From f08005e0e7d93fa2ed28ba768e43ceb535c06768 Mon Sep 17 00:00:00 2001 From: Jean-Louis Leysens Date: Thu, 12 Aug 2021 18:40:19 +0200 Subject: [PATCH] [Reporting] Create reports with full state required to generate the report (#101048) * very wip * - Reached first iteration of reporting body value being saved with the report for **PDF** - Removed v2 of the reporting since it looks like we may be able to make a backwards compatible change on existing PDF/PNG exports * reintroduced pdfv2 export type, see https://github.com/elastic/kibana/issues/99890\#issuecomment-851527878 * fix a whol bunch of imports * mapped out a working version for pdf * refactor to tuples * added v2 pdf to export type registry * a lot of hackery to get reports generated in v2 * added png v2, png reports with locator state * wip: refactored for loading the saved object on the redirect app URL * major wip: initial stages of reporting redirect app, need to add a way to generate v2 reports! * added a way to generate a v2 pdf from the example reporting plugin * updated reporting example app to read and accept forwarded app state * added reporting locator and updated server-side route to not use Boom * removed reporting locator for now, first iteration of reports being generated using the reporting redirect app * version with PNG working * moved png/v2 -> png_v2 * moved printable_pdf/v2 -> printable_pdf_v2 * updated share public setup and start mocks * fix types after merging master * locator -> locatorParams AND added a new endpoint for getting locator params to client * fix type import * fix types * clean up bad imports * forceNow required on v2 payloads * reworked create job interface for PNG task payload and updated consumer code report example for forcenow * put locatorparams[] back onto the reportsource interface because on baseparams it conflicts with the different export type params * move getCustomLogo and generatePng to common for export types * additional import fixes * urls -> url * chore: fix and update types and fix jest import mocks * - refactored v2 behaviour to avoid client-side request for locator instead this value is injected pre-page-load so that the redirect app can use it - refactored the interface for the getScreenshot observable factory. specifically we now expect 'urlsOrUrlTuples' to be passed in. tested with new and old report types. * updated the reporting example app to use locator migration for v2 report types * added functionality for setting forceNow * added forceNow to job payload for v2 report types and fixed shared components for v2 * write the output of v2 reports to stream * fix types for forceNow * added tests for execute job * added comments, organized imports, removed selectors from report params * fix some type issues * feedback: removed duplicated PDF code, cleaned screenshot observable function and other minor tweaks * use variable (not destructured values) and remove unused import Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com> --- .eslintignore | 1 + src/plugins/screenshot_mode/server/types.ts | 3 +- src/plugins/share/public/mocks.ts | 2 + src/plugins/share/public/plugin.ts | 22 ++- .../url_service/redirect/redirect_manager.ts | 4 + .../reporting_example/common/index.ts | 6 + .../reporting_example/common/locator.ts | 30 ++++ x-pack/examples/reporting_example/kibana.json | 2 +- .../reporting_example/public/application.tsx | 15 +- .../public/components/app.tsx | 117 +++++++++++- .../reporting_example/public/plugin.ts | 15 +- .../reporting_example/public/types.ts | 4 + x-pack/plugins/reporting/common/constants.ts | 18 ++ x-pack/plugins/reporting/common/job_utils.ts | 11 ++ x-pack/plugins/reporting/common/types.ts | 20 +++ x-pack/plugins/reporting/public/constants.ts | 8 + .../lib/reporting_api_client/context.tsx | 2 +- .../public/lib/reporting_api_client/index.ts | 2 +- .../management/mount_management_section.tsx | 6 +- .../public/management/report_listing.test.tsx | 6 +- x-pack/plugins/reporting/public/plugin.ts | 10 ++ .../reporting/public/redirect/index.ts | 8 + .../public/redirect/mount_redirect_app.tsx | 33 ++++ .../public/redirect/redirect_app.tsx | 74 ++++++++ .../public/share_context_menu/index.ts | 1 + .../register_pdf_png_reporting.tsx | 35 +++- .../reporting_panel_content.tsx | 10 +- .../public/shared/get_shared_components.tsx | 32 +++- x-pack/plugins/reporting/public/utils.ts | 12 ++ .../chromium/driver/chromium_driver.ts | 27 ++- .../{png/lib => common}/generate_png.ts | 15 +- .../lib => common}/get_custom_logo.test.ts | 6 +- .../lib => common}/get_custom_logo.ts | 8 +- .../server/export_types/common/index.ts | 3 + .../lib => common}/pdf/get_doc_options.ts | 0 .../lib => common}/pdf/get_font.test.ts | 0 .../lib => common}/pdf/get_font.ts | 0 .../lib => common}/pdf/get_template.ts | 2 +- .../lib => common}/pdf/index.test.ts | 4 +- .../lib => common}/pdf/index.ts | 4 +- .../export_types/common/set_force_now.ts | 22 +++ .../export_types/common/v2/get_full_urls.ts | 34 ++++ .../png/execute_job/index.test.ts | 4 +- .../export_types/png/execute_job/index.ts | 2 +- .../server/export_types/png_v2/create_job.ts | 29 +++ .../export_types/png_v2/execute_job.test.ts | 167 ++++++++++++++++++ .../server/export_types/png_v2/execute_job.ts | 74 ++++++++ .../server/export_types/png_v2/index.ts | 39 ++++ .../server/export_types/png_v2/metadata.ts | 13 ++ .../server/export_types/png_v2/types.d.ts | 29 +++ .../printable_pdf/execute_job/index.ts | 2 +- .../printable_pdf/lib/generate_pdf.ts | 4 +- .../export_types/printable_pdf/types.d.ts | 1 + .../printable_pdf_v2/create_job.ts | 28 +++ .../printable_pdf_v2/execute_job.test.ts | 126 +++++++++++++ .../printable_pdf_v2/execute_job.ts | 88 +++++++++ .../export_types/printable_pdf_v2/index.ts | 39 ++++ .../printable_pdf_v2/lib/generate_pdf.ts | 130 ++++++++++++++ .../printable_pdf_v2/lib/tracker.ts | 88 +++++++++ .../printable_pdf_v2/lib/uri_encode.js | 32 ++++ .../export_types/printable_pdf_v2/metadata.ts | 11 ++ .../export_types/printable_pdf_v2/types.ts | 31 ++++ x-pack/plugins/reporting/server/index.ts | 1 - .../server/lib/export_types_registry.ts | 5 + .../reporting/server/lib/screenshots/index.ts | 3 +- .../server/lib/screenshots/observable.test.ts | 13 +- .../server/lib/screenshots/observable.ts | 16 +- .../server/lib/screenshots/open_url.ts | 20 ++- x-pack/plugins/reporting/server/plugin.ts | 8 +- .../routes/diagnostic/screenshot.test.ts | 4 +- .../server/routes/diagnostic/screenshot.ts | 3 +- .../plugins/reporting/server/routes/jobs.ts | 2 +- x-pack/plugins/reporting/server/types.ts | 2 +- 73 files changed, 1553 insertions(+), 95 deletions(-) create mode 100644 x-pack/examples/reporting_example/common/locator.ts create mode 100644 x-pack/plugins/reporting/common/job_utils.ts create mode 100644 x-pack/plugins/reporting/public/constants.ts create mode 100644 x-pack/plugins/reporting/public/redirect/index.ts create mode 100644 x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx create mode 100644 x-pack/plugins/reporting/public/redirect/redirect_app.tsx create mode 100644 x-pack/plugins/reporting/public/utils.ts rename x-pack/plugins/reporting/server/export_types/{png/lib => common}/generate_png.ts (85%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/get_custom_logo.test.ts (91%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/get_custom_logo.ts (78%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/pdf/get_doc_options.ts (100%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/pdf/get_font.test.ts (100%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/pdf/get_font.ts (100%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/pdf/get_template.ts (98%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/pdf/index.test.ts (97%) rename x-pack/plugins/reporting/server/export_types/{printable_pdf/lib => common}/pdf/index.ts (96%) create mode 100644 x-pack/plugins/reporting/server/export_types/common/set_force_now.ts create mode 100644 x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts create mode 100644 x-pack/plugins/reporting/server/export_types/png_v2/create_job.ts create mode 100644 x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts create mode 100644 x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts create mode 100644 x-pack/plugins/reporting/server/export_types/png_v2/index.ts create mode 100644 x-pack/plugins/reporting/server/export_types/png_v2/metadata.ts create mode 100644 x-pack/plugins/reporting/server/export_types/png_v2/types.d.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/create_job.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/index.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/uri_encode.js create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/metadata.ts create mode 100644 x-pack/plugins/reporting/server/export_types/printable_pdf_v2/types.ts diff --git a/.eslintignore b/.eslintignore index f757ed9a1bf98..66684fbcd52e6 100644 --- a/.eslintignore +++ b/.eslintignore @@ -27,6 +27,7 @@ snapshots.js /x-pack/plugins/canvas/shareable_runtime/build /x-pack/plugins/canvas/storybook/build /x-pack/plugins/reporting/server/export_types/printable_pdf/server/lib/pdf/assets/** +/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/server/lib/pdf/assets/** # package overrides /packages/elastic-eslint-config-kibana diff --git a/src/plugins/screenshot_mode/server/types.ts b/src/plugins/screenshot_mode/server/types.ts index 4347252e58fce..566ae19719454 100644 --- a/src/plugins/screenshot_mode/server/types.ts +++ b/src/plugins/screenshot_mode/server/types.ts @@ -19,7 +19,8 @@ export interface ScreenshotModePluginSetup { isScreenshotMode: IsScreenshotMode; /** - * Set the current environment to screenshot mode. Intended to run in a browser-environment. + * Set the current environment to screenshot mode. Intended to run in a browser-environment, before any other scripts + * on the page have run to ensure that screenshot mode is detected as early as possible. */ setScreenshotModeEnabled: () => void; } diff --git a/src/plugins/share/public/mocks.ts b/src/plugins/share/public/mocks.ts index 3333878676e20..d72068afa285e 100644 --- a/src/plugins/share/public/mocks.ts +++ b/src/plugins/share/public/mocks.ts @@ -27,6 +27,7 @@ const createSetupContract = (): Setup => { registerUrlGenerator: jest.fn(), }, url, + navigate: jest.fn(), }; return setupContract; }; @@ -38,6 +39,7 @@ const createStartContract = (): Start => { getUrlGenerator: jest.fn(), }, toggleShareContextMenu: jest.fn(), + navigate: jest.fn(), }; return startContract; }; diff --git a/src/plugins/share/public/plugin.ts b/src/plugins/share/public/plugin.ts index adc28556d7a3c..1382e1968192c 100644 --- a/src/plugins/share/public/plugin.ts +++ b/src/plugins/share/public/plugin.ts @@ -19,7 +19,7 @@ import { UrlGeneratorsStart, } from './url_generators/url_generator_service'; import { UrlService } from '../common/url_service'; -import { RedirectManager } from './url_service'; +import { RedirectManager, RedirectOptions } from './url_service'; export interface ShareSetupDependencies { securityOss?: SecurityOssPluginSetup; @@ -42,6 +42,12 @@ export type SharePluginSetup = ShareMenuRegistrySetup & { * Utilities to work with URL locators and short URLs. */ url: UrlService; + + /** + * Accepts serialized values for extracting a locator, migrating state from a provided version against + * the locator, then using the locator to navigate. + */ + navigate(options: RedirectOptions): void; }; /** @public */ @@ -57,12 +63,20 @@ export type SharePluginStart = ShareMenuManagerStart & { * Utilities to work with URL locators and short URLs. */ url: UrlService; + + /** + * Accepts serialized values for extracting a locator, migrating state from a provided version against + * the locator, then using the locator to navigate. + */ + navigate(options: RedirectOptions): void; }; export class SharePlugin implements Plugin { private readonly shareMenuRegistry = new ShareMenuRegistry(); private readonly shareContextMenu = new ShareMenuManager(); private readonly urlGeneratorsService = new UrlGeneratorsService(); + + private redirectManager?: RedirectManager; private url?: UrlService; public setup(core: CoreSetup, plugins: ShareSetupDependencies): SharePluginSetup { @@ -87,15 +101,16 @@ export class SharePlugin implements Plugin { }, }); - const redirectManager = new RedirectManager({ + this.redirectManager = new RedirectManager({ url: this.url, }); - redirectManager.registerRedirectApp(core); + this.redirectManager.registerRedirectApp(core); return { ...this.shareMenuRegistry.setup(), urlGenerators: this.urlGeneratorsService.setup(core), url: this.url, + navigate: (options: RedirectOptions) => this.redirectManager!.navigate(options), }; } @@ -108,6 +123,7 @@ export class SharePlugin implements Plugin { ), urlGenerators: this.urlGeneratorsService.start(core), url: this.url!, + navigate: (options: RedirectOptions) => this.redirectManager!.navigate(options), }; } } diff --git a/src/plugins/share/public/url_service/redirect/redirect_manager.ts b/src/plugins/share/public/url_service/redirect/redirect_manager.ts index 494fb623a48af..cc45e0d3126af 100644 --- a/src/plugins/share/public/url_service/redirect/redirect_manager.ts +++ b/src/plugins/share/public/url_service/redirect/redirect_manager.ts @@ -52,6 +52,10 @@ export class RedirectManager { public onMount(urlLocationSearch: string) { const options = this.parseSearchParams(urlLocationSearch); + this.navigate(options); + } + + public navigate(options: RedirectOptions) { const locator = this.deps.url.locators.get(options.id); if (!locator) { diff --git a/x-pack/examples/reporting_example/common/index.ts b/x-pack/examples/reporting_example/common/index.ts index f01f2673eff56..ba2fcd21c8c70 100644 --- a/x-pack/examples/reporting_example/common/index.ts +++ b/x-pack/examples/reporting_example/common/index.ts @@ -7,3 +7,9 @@ export const PLUGIN_ID = 'reportingExample'; export const PLUGIN_NAME = 'reportingExample'; + +export { + REPORTING_EXAMPLE_LOCATOR_ID, + ReportingExampleLocatorDefinition, + ReportingExampleLocatorParams, +} from './locator'; diff --git a/x-pack/examples/reporting_example/common/locator.ts b/x-pack/examples/reporting_example/common/locator.ts new file mode 100644 index 0000000000000..fc39ec1c52654 --- /dev/null +++ b/x-pack/examples/reporting_example/common/locator.ts @@ -0,0 +1,30 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SerializableRecord } from '@kbn/utility-types'; +import type { LocatorDefinition } from '../../../../src/plugins/share/public'; +import { PLUGIN_ID } from '../common'; + +export const REPORTING_EXAMPLE_LOCATOR_ID = 'REPORTING_EXAMPLE_LOCATOR_ID'; + +export type ReportingExampleLocatorParams = SerializableRecord; + +export class ReportingExampleLocatorDefinition implements LocatorDefinition<{}> { + public readonly id = REPORTING_EXAMPLE_LOCATOR_ID; + + migrations = { + '1.0.0': (state: {}) => ({ ...state, migrated: true }), + }; + + public readonly getLocation = async (params: {}) => { + return { + app: PLUGIN_ID, + path: '/', + state: params, + }; + }; +} diff --git a/x-pack/examples/reporting_example/kibana.json b/x-pack/examples/reporting_example/kibana.json index 64f0fcd62a220..716c6ea29c2a0 100644 --- a/x-pack/examples/reporting_example/kibana.json +++ b/x-pack/examples/reporting_example/kibana.json @@ -10,5 +10,5 @@ }, "description": "Example integration code for applications to feature reports.", "optionalPlugins": [], - "requiredPlugins": ["reporting", "developerExamples", "navigation", "screenshotMode"] + "requiredPlugins": ["reporting", "developerExamples", "navigation", "screenshotMode", "share"] } diff --git a/x-pack/examples/reporting_example/public/application.tsx b/x-pack/examples/reporting_example/public/application.tsx index 5e72a9bd8fbbc..d945048ecd73e 100644 --- a/x-pack/examples/reporting_example/public/application.tsx +++ b/x-pack/examples/reporting_example/public/application.tsx @@ -9,14 +9,23 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { AppMountParameters, CoreStart } from '../../../../src/core/public'; import { ReportingExampleApp } from './components/app'; -import { SetupDeps, StartDeps } from './types'; +import { SetupDeps, StartDeps, MyForwardableState } from './types'; export const renderApp = ( coreStart: CoreStart, deps: Omit, - { appBasePath, element }: AppMountParameters // FIXME: appBasePath is deprecated + { appBasePath, element }: AppMountParameters, // FIXME: appBasePath is deprecated + forwardedParams: MyForwardableState ) => { - ReactDOM.render(, element); + ReactDOM.render( + , + element + ); return () => ReactDOM.unmountComponentAtNode(element); }; diff --git a/x-pack/examples/reporting_example/public/components/app.tsx b/x-pack/examples/reporting_example/public/components/app.tsx index a29c5d2e018d0..a34cd0ab518de 100644 --- a/x-pack/examples/reporting_example/public/components/app.tsx +++ b/x-pack/examples/reporting_example/public/components/app.tsx @@ -21,7 +21,10 @@ import { EuiPopover, EuiText, EuiTitle, + EuiCodeBlock, + EuiSpacer, } from '@elastic/eui'; +import moment from 'moment'; import { I18nProvider } from '@kbn/i18n/react'; import React, { useEffect, useState } from 'react'; import { BrowserRouter as Router } from 'react-router-dom'; @@ -29,11 +32,18 @@ import * as Rx from 'rxjs'; import { takeWhile } from 'rxjs/operators'; import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/public'; import { constants, ReportingStart } from '../../../../../x-pack/plugins/reporting/public'; +import type { JobParamsPDFV2 } from '../../../../plugins/reporting/server/export_types/printable_pdf_v2/types'; +import type { JobParamsPNGV2 } from '../../../../plugins/reporting/server/export_types/png_v2/types'; + +import { REPORTING_EXAMPLE_LOCATOR_ID } from '../../common'; + +import { MyForwardableState } from '../types'; interface ReportingExampleAppProps { basename: string; reporting: ReportingStart; screenshotMode: ScreenshotModePluginSetup; + forwardedParams?: MyForwardableState; } const sourceLogos = ['Beats', 'Cloud', 'Logging', 'Kibana']; @@ -42,8 +52,12 @@ export const ReportingExampleApp = ({ basename, reporting, screenshotMode, + forwardedParams, }: ReportingExampleAppProps) => { - const { getDefaultLayoutSelectors } = reporting; + useEffect(() => { + // eslint-disable-next-line no-console + console.log('forwardedParams', forwardedParams); + }, [forwardedParams]); // Context Menu const [isPopoverOpen, setPopover] = useState(false); @@ -70,7 +84,6 @@ export const ReportingExampleApp = ({ return { layout: { id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, - selectors: getDefaultLayoutSelectors(), }, relativeUrls: ['/app/reportingExample#/intended-visualization'], objectType: 'develeloperExample', @@ -78,20 +91,65 @@ export const ReportingExampleApp = ({ }; }; + const getPDFJobParamsDefaultV2 = (): JobParamsPDFV2 => { + return { + version: '8.0.0', + layout: { + id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + }, + locatorParams: [ + { id: REPORTING_EXAMPLE_LOCATOR_ID, version: '0.5.0', params: { myTestState: {} } }, + ], + objectType: 'develeloperExample', + title: 'Reporting Developer Example', + browserTimezone: moment.tz.guess(), + }; + }; + + const getPNGJobParamsDefaultV2 = (): JobParamsPNGV2 => { + return { + version: '8.0.0', + layout: { + id: constants.LAYOUT_TYPES.PRESERVE_LAYOUT, + }, + locatorParams: { + id: REPORTING_EXAMPLE_LOCATOR_ID, + version: '0.5.0', + params: { myTestState: {} }, + }, + objectType: 'develeloperExample', + title: 'Reporting Developer Example', + browserTimezone: moment.tz.guess(), + }; + }; + const panels = [ - { id: 0, items: [{ name: 'PDF Reports', icon: 'document', panel: 1 }] }, + { + id: 0, + items: [ + { name: 'PDF Reports', icon: 'document', panel: 1 }, + { name: 'PNG Reports', icon: 'document', panel: 7 }, + ], + }, { id: 1, initialFocusedItemIndex: 1, title: 'PDF Reports', items: [ - { name: 'No Layout Option', icon: 'document', panel: 2 }, + { name: 'Default layout', icon: 'document', panel: 2 }, + { name: 'Default layout V2', icon: 'document', panel: 4 }, { name: 'Canvas Layout Option', icon: 'canvasApp', panel: 3 }, ], }, + { + id: 7, + initialFocusedItemIndex: 0, + title: 'PNG Reports', + items: [{ name: 'Default layout V2', icon: 'document', panel: 5 }], + }, { id: 2, - title: 'No Layout Option', + title: 'Default layout', content: ( ), }, + { + id: 4, + title: 'Default layout V2', + content: ( + + ), + }, + { + id: 5, + title: 'Default layout V2', + content: ( + + ), + }, ]; return ( @@ -124,9 +202,11 @@ export const ReportingExampleApp = ({ + +

Example of a Sharing menu using components from Reporting

+
+ -

Example of a Sharing menu using components from Reporting

- Share} @@ -140,8 +220,29 @@ export const ReportingExampleApp = ({ -
+
+ + {forwardedParams ? ( + <> + +

+ Forwarded app state +

+
+ {JSON.stringify(forwardedParams)} + + ) : ( + <> + +

+ No forwarded app state found +

+
+ {'{}'} + + )} +
{logos.map((item, index) => ( { - public setup(core: CoreSetup, { developerExamples, screenshotMode }: SetupDeps): void { + public setup(core: CoreSetup, { developerExamples, screenshotMode, share }: SetupDeps): void { core.application.register({ id: PLUGIN_ID, title: PLUGIN_NAME, @@ -30,7 +30,12 @@ export class ReportingExamplePlugin implements Plugin { unknown ]; // Render the application - return renderApp(coreStart, { ...depsStart, screenshotMode }, params); + return renderApp( + coreStart, + { ...depsStart, screenshotMode, share }, + params, + params.history.location.state as MyForwardableState + ); }, }); @@ -40,6 +45,8 @@ export class ReportingExamplePlugin implements Plugin { title: 'Reporting integration', description: 'Demonstrate how to put an Export button on a page and generate reports.', }); + + share.url.locators.create(new ReportingExampleLocatorDefinition()); } public start() {} diff --git a/x-pack/examples/reporting_example/public/types.ts b/x-pack/examples/reporting_example/public/types.ts index 55a573285e24f..fb28293ab63a3 100644 --- a/x-pack/examples/reporting_example/public/types.ts +++ b/x-pack/examples/reporting_example/public/types.ts @@ -7,6 +7,7 @@ import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/public'; +import { SharePluginSetup } from 'src/plugins/share/public'; import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public'; import { ReportingStart } from '../../../plugins/reporting/public'; @@ -17,9 +18,12 @@ export interface PluginStart {} export interface SetupDeps { developerExamples: DeveloperExamplesSetup; + share: SharePluginSetup; screenshotMode: ScreenshotModePluginSetup; } export interface StartDeps { navigation: NavigationPublicPluginStart; reporting: ReportingStart; } + +export type MyForwardableState = Record; diff --git a/x-pack/plugins/reporting/common/constants.ts b/x-pack/plugins/reporting/common/constants.ts index be543b3908b68..4ba406a14bafc 100644 --- a/x-pack/plugins/reporting/common/constants.ts +++ b/x-pack/plugins/reporting/common/constants.ts @@ -61,10 +61,14 @@ export const CSV_REPORT_TYPE = 'CSV'; export const CSV_JOB_TYPE = 'csv_searchsource'; export const PDF_REPORT_TYPE = 'printablePdf'; +export const PDF_REPORT_TYPE_V2 = 'printablePdfV2'; export const PDF_JOB_TYPE = 'printable_pdf'; +export const PDF_JOB_TYPE_V2 = 'printable_pdf_v2'; export const PNG_REPORT_TYPE = 'PNG'; +export const PNG_REPORT_TYPE_V2 = 'pngV2'; export const PNG_JOB_TYPE = 'PNG'; +export const PNG_JOB_TYPE_V2 = 'PNGV2'; export const CSV_SEARCHSOURCE_IMMEDIATE_TYPE = 'csv_searchsource_immediate'; @@ -98,6 +102,20 @@ export const ILM_POLICY_NAME = 'kibana-reporting'; // Management UI route export const REPORTING_MANAGEMENT_HOME = '/app/management/insightsAndAlerting/reporting'; +export const REPORTING_REDIRECT_LOCATOR_STORE_KEY = '__REPORTING_REDIRECT_LOCATOR_STORE_KEY__'; + +/** + * A way to get the client side route for the reporting redirect app. + * + * This route currently expects a job ID and a locator that to use from that job so that it can redirect to the + * correct page. + * + * TODO: Accommodate 'forceNow' value that some visualizations may rely on + */ +export const getRedirectAppPathHome = () => { + return '/app/management/insightsAndAlerting/reporting/r'; +}; + // Statuses export enum JOB_STATUSES { PENDING = 'pending', diff --git a/x-pack/plugins/reporting/common/job_utils.ts b/x-pack/plugins/reporting/common/job_utils.ts new file mode 100644 index 0000000000000..1a8699eeca025 --- /dev/null +++ b/x-pack/plugins/reporting/common/job_utils.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +// TODO: Remove this code once everyone is using the new PDF format, then we can also remove the legacy +// export type entirely +export const isJobV2Params = ({ sharingData }: { sharingData: Record }): boolean => + Array.isArray(sharingData.locatorParams); diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 7bcf69b564b3c..42e8e9c52719c 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -5,6 +5,8 @@ * 2.0. */ +import type { SerializableRecord } from '@kbn/utility-types'; + export interface PageSizeParams { pageMarginTop: number; pageMarginBottom: number; @@ -64,6 +66,11 @@ export interface ReportSource { created_by: string | false; // username or `false` if security is disabled. Used for ensuring users can only access the reports they've created. payload: { headers: string; // encrypted headers + /** + * PDF V2 reports will contain locators parameters (see {@link LocatorPublic}) that will be converted to {@link KibanaLocation}s when + * generating a report + */ + locatorParams?: LocatorParams[]; isDeprecated?: boolean; // set to true when the export type is being phased out } & BaseParams; meta: { objectType: string; layout?: string }; // for telemetry @@ -167,8 +174,21 @@ export type DownloadReportFn = (jobId: JobId) => DownloadLink; type ManagementLink = string; export type ManagementLinkFn = () => ManagementLink; +export interface LocatorParams< + P extends SerializableRecord = SerializableRecord & { forceNow?: string } +> { + id: string; + version: string; + params: P; +} + export type IlmPolicyMigrationStatus = 'policy-not-found' | 'indices-not-managed-by-policy' | 'ok'; export interface IlmPolicyStatusResponse { status: IlmPolicyMigrationStatus; } + +type Url = string; +type UrlLocatorTuple = [url: Url, locatorParams: LocatorParams]; + +export type UrlOrUrlLocatorTuple = Url | UrlLocatorTuple; diff --git a/x-pack/plugins/reporting/public/constants.ts b/x-pack/plugins/reporting/public/constants.ts new file mode 100644 index 0000000000000..c7e77fd44a780 --- /dev/null +++ b/x-pack/plugins/reporting/public/constants.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const REACT_ROUTER_REDIRECT_APP_PATH = '/r'; diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx index 4070f0d6d388d..d53c69ae22e7f 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/context.tsx @@ -19,7 +19,7 @@ interface ContextValue { const InternalApiClientContext = createContext(undefined); -export const InternalApiClientClientProvider: FunctionComponent<{ +export const InternalApiClientProvider: FunctionComponent<{ apiClient: ReportingAPIClient; }> = ({ apiClient, children }) => { const { diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts index b32d675a1d209..7439bf8bca900 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/index.ts @@ -9,4 +9,4 @@ export * from './reporting_api_client'; export * from './hooks'; -export { InternalApiClientClientProvider, useInternalApiClient } from './context'; +export { InternalApiClientProvider, useInternalApiClient } from './context'; diff --git a/x-pack/plugins/reporting/public/management/mount_management_section.tsx b/x-pack/plugins/reporting/public/management/mount_management_section.tsx index 0f0c06f830205..56ede79086a04 100644 --- a/x-pack/plugins/reporting/public/management/mount_management_section.tsx +++ b/x-pack/plugins/reporting/public/management/mount_management_section.tsx @@ -11,7 +11,7 @@ import { render, unmountComponentAtNode } from 'react-dom'; import { Observable } from 'rxjs'; import { CoreSetup, CoreStart } from 'src/core/public'; import { ILicense } from '../../../licensing/public'; -import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/reporting_api_client'; +import { ReportingAPIClient, InternalApiClientProvider } from '../lib/reporting_api_client'; import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; import { ClientConfigType } from '../plugin'; import type { ManagementAppMountParams, SharePluginSetup } from '../shared_imports'; @@ -32,7 +32,7 @@ export async function mountManagementSection( - + - + , params.element diff --git a/x-pack/plugins/reporting/public/management/report_listing.test.tsx b/x-pack/plugins/reporting/public/management/report_listing.test.tsx index dd8b60801066f..bae396901dc44 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.test.tsx @@ -21,7 +21,7 @@ import type { ILicense } from '../../../licensing/public'; import { IlmPolicyMigrationStatus, ReportApiJSON } from '../../common/types'; import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; import { Job } from '../lib/job'; -import { InternalApiClientClientProvider, ReportingAPIClient } from '../lib/reporting_api_client'; +import { InternalApiClientProvider, ReportingAPIClient } from '../lib/reporting_api_client'; import { KibanaContextProvider } from '../shared_imports'; import { ListingProps as Props, ReportListing } from '.'; @@ -84,7 +84,7 @@ describe('ReportListing', () => { const createTestBed = registerTestBed( (props?: Partial) => ( - + { {...props} /> - + ), { memoryRouter: { wrapComponent: false } } diff --git a/x-pack/plugins/reporting/public/plugin.ts b/x-pack/plugins/reporting/public/plugin.ts index 757f226532d95..2529681a6901f 100644 --- a/x-pack/plugins/reporting/public/plugin.ts +++ b/x-pack/plugins/reporting/public/plugin.ts @@ -42,6 +42,7 @@ import type { } from './shared_imports'; import { ReportingCsvShareProvider } from './share_context_menu/register_csv_reporting'; import { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting'; +import { isRedirectAppPath } from './utils'; export interface ClientConfigType { poll: { jobsRefresh: { interval: number; intervalErrorMultiplier: number } }; @@ -167,6 +168,15 @@ export class ReportingPublicPlugin title: this.title, order: 1, mount: async (params) => { + // The redirect app will be mounted if reporting is opened on a specific path. The redirect app expects a + // specific environment to be present so that it can navigate to a specific application. This is used by + // report generation to navigate to the correct place with full app state. + if (isRedirectAppPath(params.history.location.pathname)) { + const { mountRedirectApp } = await import('./redirect'); + return mountRedirectApp({ ...params, share, apiClient }); + } + + // Otherwise load the reporting management UI. params.setBreadcrumbs([{ text: this.breadcrumbText }]); const [[start], { mountManagementSection }] = await Promise.all([ getStartServices(), diff --git a/x-pack/plugins/reporting/public/redirect/index.ts b/x-pack/plugins/reporting/public/redirect/index.ts new file mode 100644 index 0000000000000..2cf2f0c2d11a1 --- /dev/null +++ b/x-pack/plugins/reporting/public/redirect/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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { mountRedirectApp } from './mount_redirect_app'; diff --git a/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx new file mode 100644 index 0000000000000..4bf6d40acb170 --- /dev/null +++ b/x-pack/plugins/reporting/public/redirect/mount_redirect_app.tsx @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, unmountComponentAtNode } from 'react-dom'; +import React from 'react'; +import { EuiErrorBoundary } from '@elastic/eui'; + +import type { ManagementAppMountParams, SharePluginSetup } from '../shared_imports'; +import type { ReportingAPIClient } from '../lib/reporting_api_client'; + +import { RedirectApp } from './redirect_app'; + +interface MountParams extends ManagementAppMountParams { + apiClient: ReportingAPIClient; + share: SharePluginSetup; +} + +export const mountRedirectApp = ({ element, apiClient, history, share }: MountParams) => { + render( + + + , + element + ); + + return () => { + unmountComponentAtNode(element); + }; +}; diff --git a/x-pack/plugins/reporting/public/redirect/redirect_app.tsx b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx new file mode 100644 index 0000000000000..60b51c0f07895 --- /dev/null +++ b/x-pack/plugins/reporting/public/redirect/redirect_app.tsx @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { useEffect, useState } from 'react'; +import type { FunctionComponent } from 'react'; +import { i18n } from '@kbn/i18n'; +import { EuiTitle, EuiCallOut, EuiCodeBlock } from '@elastic/eui'; + +import type { ScopedHistory } from 'src/core/public'; + +import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../common/constants'; +import { LocatorParams } from '../../common/types'; + +import { ReportingAPIClient } from '../lib/reporting_api_client'; +import type { SharePluginSetup } from '../shared_imports'; + +interface Props { + apiClient: ReportingAPIClient; + history: ScopedHistory; + share: SharePluginSetup; +} + +const i18nTexts = { + errorTitle: i18n.translate('xpack.reporting.redirectApp.errorTitle', { + defaultMessage: 'Redirect error', + }), + redirectingTitle: i18n.translate('xpack.reporting.redirectApp.redirectingMessage', { + defaultMessage: 'Redirecting...', + }), + consoleMessagePrefix: i18n.translate( + 'xpack.reporting.redirectApp.redirectConsoleErrorPrefixLabel', + { + defaultMessage: 'Redirect page error:', + } + ), +}; + +export const RedirectApp: FunctionComponent = ({ share }) => { + const [error, setError] = useState(); + + useEffect(() => { + try { + const locatorParams = ((window as unknown) as Record)[ + REPORTING_REDIRECT_LOCATOR_STORE_KEY + ]; + + if (!locatorParams) { + throw new Error('Could not find locator for report'); + } + + share.navigate(locatorParams); + } catch (e) { + setError(e); + // eslint-disable-next-line no-console + console.error(i18nTexts.consoleMessagePrefix, e.message); + throw e; + } + }, [share]); + + return error ? ( + +

{error.message}

+ {error.stack && {error.stack}} +
+ ) : ( + +

{i18nTexts.redirectingTitle}

+
+ ); +}; diff --git a/x-pack/plugins/reporting/public/share_context_menu/index.ts b/x-pack/plugins/reporting/public/share_context_menu/index.ts index 090a80d323725..b0d6f2e6a2b52 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/index.ts +++ b/x-pack/plugins/reporting/public/share_context_menu/index.ts @@ -24,6 +24,7 @@ export interface ExportPanelShareOpts { export interface ReportingSharingData { title: string; layout: LayoutParams; + [key: string]: unknown; } export interface JobParamsProviderOptions { diff --git a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx index b37e31578be6d..811d5803895db 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/register_pdf_png_reporting.tsx @@ -9,6 +9,7 @@ import { i18n } from '@kbn/i18n'; import React from 'react'; import { ShareContext } from 'src/plugins/share/public'; import { ExportPanelShareOpts, JobParamsProviderOptions, ReportingSharingData } from '.'; +import { isJobV2Params } from '../../common/job_utils'; import { checkLicense } from '../lib/license_check'; import { ReportingAPIClient } from '../lib/reporting_api_client'; import { ScreenCapturePanelContent } from './screen_capture_panel_content_lazy'; @@ -16,11 +17,11 @@ import { ScreenCapturePanelContent } from './screen_capture_panel_content_lazy'; const getJobParams = ( apiClient: ReportingAPIClient, opts: JobParamsProviderOptions, - type: 'pdf' | 'png' + type: 'png' | 'pngV2' | 'printablePdf' | 'printablePdfV2' ) => () => { const { objectType, - sharingData: { title, layout }, + sharingData: { title, layout, locatorParams }, } = opts; const baseParams = { @@ -29,6 +30,14 @@ const getJobParams = ( title, }; + if (type === 'printablePdfV2') { + // multi locator for PDF V2 + return { ...baseParams, locatorParams: [locatorParams] }; + } else if (type === 'pngV2') { + // single locator for PNG V2 + return { ...baseParams, locatorParams }; + } + // Relative URL must have URL prefix (Spaces ID prefix), but not server basePath // Replace hashes with original RISON values. const relativeUrl = opts.shareableUrl.replace( @@ -36,7 +45,7 @@ const getJobParams = ( '' ); - if (type === 'pdf') { + if (type === 'printablePdf') { // multi URL for PDF return { ...baseParams, relativeUrls: [relativeUrl] }; } @@ -111,6 +120,16 @@ export const reportingScreenshotShareProvider = ({ defaultMessage: 'PNG Reports', }); + const jobProviderOptions: JobParamsProviderOptions = { + shareableUrl, + objectType, + sharingData, + }; + + const isV2Job = isJobV2Params(jobProviderOptions); + + const pngReportType = isV2Job ? 'pngV2' : 'png'; + const panelPng = { shareMenuItem: { name: pngPanelTitle, @@ -128,10 +147,10 @@ export const reportingScreenshotShareProvider = ({ apiClient={apiClient} toasts={toasts} uiSettings={uiSettings} - reportType="png" + reportType={pngReportType} objectId={objectId} requiresSavedState={true} - getJobParams={getJobParams(apiClient, { shareableUrl, objectType, sharingData }, 'png')} + getJobParams={getJobParams(apiClient, jobProviderOptions, pngReportType)} isDirty={isDirty} onClose={onClose} /> @@ -143,6 +162,8 @@ export const reportingScreenshotShareProvider = ({ defaultMessage: 'PDF Reports', }); + const pdfReportType = isV2Job ? 'printablePdfV2' : 'printablePdf'; + const panelPdf = { shareMenuItem: { name: pdfPanelTitle, @@ -160,11 +181,11 @@ export const reportingScreenshotShareProvider = ({ apiClient={apiClient} toasts={toasts} uiSettings={uiSettings} - reportType="printablePdf" + reportType={pdfReportType} objectId={objectId} requiresSavedState={true} layoutOption={objectType === 'dashboard' ? 'print' : undefined} - getJobParams={getJobParams(apiClient, { shareableUrl, objectType, sharingData }, 'pdf')} + getJobParams={getJobParams(apiClient, jobProviderOptions, pdfReportType)} isDirty={isDirty} onClose={onClose} /> diff --git a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx index af6cd0010de09..11169dd2d2fb7 100644 --- a/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx +++ b/x-pack/plugins/reporting/public/share_context_menu/reporting_panel_content.tsx @@ -21,7 +21,13 @@ import React, { Component, ReactElement } from 'react'; import { ToastsSetup, IUiSettingsClient } from 'src/core/public'; import url from 'url'; import { toMountPoint } from '../../../../../src/plugins/kibana_react/public'; -import { CSV_REPORT_TYPE, PDF_REPORT_TYPE, PNG_REPORT_TYPE } from '../../common/constants'; +import { + CSV_REPORT_TYPE, + PDF_REPORT_TYPE, + PDF_REPORT_TYPE_V2, + PNG_REPORT_TYPE, + PNG_REPORT_TYPE_V2, +} from '../../common/constants'; import { BaseParams } from '../../common/types'; import { ReportingAPIClient } from '../lib/reporting_api_client'; @@ -200,10 +206,12 @@ class ReportingPanelContentUi extends Component { private prettyPrintReportingType = () => { switch (this.props.reportType) { case PDF_REPORT_TYPE: + case PDF_REPORT_TYPE_V2: return 'PDF'; case 'csv_searchsource': return CSV_REPORT_TYPE; case 'png': + case PNG_REPORT_TYPE_V2: return PNG_REPORT_TYPE; default: return this.props.reportType; diff --git a/x-pack/plugins/reporting/public/shared/get_shared_components.tsx b/x-pack/plugins/reporting/public/shared/get_shared_components.tsx index 659eaf2678164..623e06dd74462 100644 --- a/x-pack/plugins/reporting/public/shared/get_shared_components.tsx +++ b/x-pack/plugins/reporting/public/shared/get_shared_components.tsx @@ -8,7 +8,7 @@ import { CoreSetup } from 'kibana/public'; import React from 'react'; import { ReportingAPIClient } from '../'; -import { PDF_REPORT_TYPE } from '../../common/constants'; +import { PDF_REPORT_TYPE, PDF_REPORT_TYPE_V2, PNG_REPORT_TYPE_V2 } from '../../common/constants'; import type { Props as PanelPropsScreenCapture } from '../share_context_menu/screen_capture_panel_content'; import { ScreenCapturePanelContent } from '../share_context_menu/screen_capture_panel_content_lazy'; @@ -16,7 +16,7 @@ interface IncludeOnCloseFn { onClose: () => void; } -type PropsPDF = Pick & IncludeOnCloseFn; +type Props = Pick & IncludeOnCloseFn; /* * As of 7.14, the only shared component is a PDF report that is suited for Canvas integration. @@ -25,7 +25,7 @@ type PropsPDF = Pick & */ export function getSharedComponents(core: CoreSetup, apiClient: ReportingAPIClient) { return { - ReportingPanelPDF(props: PropsPDF) { + ReportingPanelPDF(props: Props) { return ( ); }, + ReportingPanelPDFV2(props: Props) { + return ( + + ); + }, + ReportingPanelPNGV2(props: Props) { + return ( + + ); + }, }; } diff --git a/x-pack/plugins/reporting/public/utils.ts b/x-pack/plugins/reporting/public/utils.ts new file mode 100644 index 0000000000000..f39c7ef2174ef --- /dev/null +++ b/x-pack/plugins/reporting/public/utils.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { REACT_ROUTER_REDIRECT_APP_PATH } from './constants'; + +export const isRedirectAppPath = (pathname: string) => { + return pathname.startsWith(REACT_ROUTER_REDIRECT_APP_PATH); +}; 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 30b351ff90b6f..823ccc3906e49 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 @@ -10,6 +10,8 @@ import { map, truncate } from 'lodash'; import open from 'opn'; import puppeteer, { ElementHandle, EvaluateFn, SerializableOrJSHandle } from 'puppeteer'; import { parse as parseUrl } from 'url'; +import type { LocatorParams } from '../../../../common/types'; +import { REPORTING_REDIRECT_LOCATOR_STORE_KEY } from '../../../../common/constants'; import { getDisallowedOutgoingUrlError } from '../'; import { ReportingCore } from '../../..'; import { KBN_SCREENSHOT_MODE_HEADER } from '../../../../../../../src/plugins/screenshot_mode/server'; @@ -94,10 +96,12 @@ export class HeadlessChromiumDriver { conditionalHeaders, waitForSelector: pageLoadSelector, timeout, + locator, }: { conditionalHeaders: ConditionalHeaders; waitForSelector: string; timeout: number; + locator?: LocatorParams; }, logger: LevelLogger ): Promise { @@ -106,8 +110,27 @@ export class HeadlessChromiumDriver { // Reset intercepted request count this.interceptedCount = 0; - const enableScreenshotMode = this.core.getEnableScreenshotMode(); - await this.page.evaluateOnNewDocument(enableScreenshotMode); + /** + * Integrate with the screenshot mode plugin contract by calling this function before any other + * scripts have run on the browser page. + */ + await this.page.evaluateOnNewDocument(this.core.getEnableScreenshotMode()); + + if (locator) { + await this.page.evaluateOnNewDocument( + (key: string, value: unknown) => { + Object.defineProperty(window, key, { + configurable: false, + writable: false, + enumerable: true, + value, + }); + }, + REPORTING_REDIRECT_LOCATOR_STORE_KEY, + locator + ); + } + await this.page.setRequestInterception(true); this.registerListeners(conditionalHeaders, logger); diff --git a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts similarity index 85% rename from x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts rename to x-pack/plugins/reporting/server/export_types/common/generate_png.ts index 2af56ed9881ae..1f186010eb8bb 100644 --- a/x-pack/plugins/reporting/server/export_types/png/lib/generate_png.ts +++ b/x-pack/plugins/reporting/server/export_types/common/generate_png.ts @@ -8,11 +8,12 @@ import apm from 'elastic-apm-node'; import * as Rx from 'rxjs'; import { finalize, map, tap } from 'rxjs/operators'; -import { ReportingCore } from '../../../'; -import { LevelLogger } from '../../../lib'; -import { LayoutParams, PreserveLayout } from '../../../lib/layouts'; -import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; -import { ConditionalHeaders } from '../../common'; +import { ReportingCore } from '../../'; +import { UrlOrUrlLocatorTuple } from '../../../common/types'; +import { LevelLogger } from '../../lib'; +import { LayoutParams, PreserveLayout } from '../../lib/layouts'; +import { getScreenshots$, ScreenshotResults } from '../../lib/screenshots'; +import { ConditionalHeaders } from '../common'; function getBase64DecodedSize(value: string) { // @see https://en.wikipedia.org/wiki/Base64#Output_padding @@ -30,7 +31,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { return function generatePngObservable( logger: LevelLogger, - url: string, + urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, browserTimezone: string | undefined, conditionalHeaders: ConditionalHeaders, layoutParams: LayoutParams @@ -47,7 +48,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) { let apmBuffer: typeof apm.currentSpan; const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { logger, - urls: [url], + urlsOrUrlLocatorTuples: [urlOrUrlLocatorTuple], conditionalHeaders, layout, browserTimezone, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts similarity index 91% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts rename to x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts index ebdceda0820b9..e21b7404f5ed5 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.test.ts @@ -5,14 +5,14 @@ * 2.0. */ -import { ReportingCore } from '../../../'; +import { ReportingCore } from '../..'; import { createMockConfig, createMockConfigSchema, createMockLevelLogger, createMockReportingCore, -} from '../../../test_helpers'; -import { getConditionalHeaders } from '../../common'; +} from '../../test_helpers'; +import { getConditionalHeaders } from '.'; import { getCustomLogo } from './get_custom_logo'; let mockReportingPlugin: ReportingCore; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts similarity index 78% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts rename to x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts index d829c3483c466..983f6f41af8d9 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/get_custom_logo.ts +++ b/x-pack/plugins/reporting/server/export_types/common/get_custom_logo.ts @@ -5,10 +5,10 @@ * 2.0. */ -import { ReportingCore } from '../../../'; -import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../../common/constants'; -import { LevelLogger } from '../../../lib'; -import { ConditionalHeaders } from '../../common'; +import { ReportingCore } from '../../'; +import { UI_SETTINGS_CUSTOM_PDF_LOGO } from '../../../common/constants'; +import { LevelLogger } from '../../lib'; +import { ConditionalHeaders } from '../common'; export const getCustomLogo = async ( reporting: ReportingCore, diff --git a/x-pack/plugins/reporting/server/export_types/common/index.ts b/x-pack/plugins/reporting/server/export_types/common/index.ts index 8832577281bb2..09d3236fa7b54 100644 --- a/x-pack/plugins/reporting/server/export_types/common/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/index.ts @@ -10,6 +10,9 @@ export { getConditionalHeaders } from './get_conditional_headers'; export { getFullUrls } from './get_full_urls'; export { omitBlockedHeaders } from './omit_blocked_headers'; export { validateUrls } from './validate_urls'; +export { generatePngObservableFactory } from './generate_png'; +export { getCustomLogo } from './get_custom_logo'; +export { setForceNow } from './set_force_now'; export interface TimeRangeParams { min?: Date | string | number | null; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/get_doc_options.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_doc_options.ts rename to x-pack/plugins/reporting/server/export_types/common/pdf/get_doc_options.ts diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.test.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/get_font.test.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.test.ts rename to x-pack/plugins/reporting/server/export_types/common/pdf/get_font.test.ts diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/get_font.ts similarity index 100% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_font.ts rename to x-pack/plugins/reporting/server/export_types/common/pdf/get_font.ts diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts similarity index 98% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts rename to x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts index 7813584f26e3c..58ddeb51e7a4f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/get_template.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/get_template.ts @@ -13,7 +13,7 @@ import { StyleDictionary, TDocumentDefinitions, } from 'pdfmake/interfaces'; -import { LayoutInstance } from '../../../../lib/layouts'; +import { LayoutInstance } from '../../../lib/layouts'; import { REPORTING_TABLE_LAYOUT } from './get_doc_options'; import { getFont } from './get_font'; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.test.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts similarity index 97% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.test.ts rename to x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts index 090ca995a15fc..e4c0285d2ce4f 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/index.test.ts @@ -5,8 +5,8 @@ * 2.0. */ -import { PreserveLayout, PrintLayout } from '../../../../lib/layouts'; -import { createMockConfig, createMockConfigSchema } from '../../../../test_helpers'; +import { PreserveLayout, PrintLayout } from '../../../lib/layouts'; +import { createMockConfig, createMockConfigSchema } from '../../../test_helpers'; import { PdfMaker } from './'; const imageBase64 = `iVBORw0KGgoAAAANSUhEUgAAAOEAAADhCAMAAAAJbSJIAAAAGFBMVEXy8vJpaWn7+/vY2Nj39/cAAACcnJzx8fFvt0oZAAAAi0lEQVR4nO3SSQoDIBBFwR7U3P/GQXKEIIJULXr9H3TMrHhX5Yysvj3jjM8+XRnVa9wec8QuHKv3h74Z+PNyGwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/xu3Bxy026rXu4ljdUVW395xUFfGzLo946DK+QW+bgCTFcecSAAAAABJRU5ErkJggg==`; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts b/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts similarity index 96% rename from x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts rename to x-pack/plugins/reporting/server/export_types/common/pdf/index.ts index 4056de6cbb111..3338b83321cec 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/pdf/index.ts +++ b/x-pack/plugins/reporting/server/export_types/common/pdf/index.ts @@ -12,12 +12,12 @@ import _ from 'lodash'; import path from 'path'; import Printer from 'pdfmake'; import { Content, ContentImage, ContentText } from 'pdfmake/interfaces'; -import { LayoutInstance } from '../../../../lib/layouts'; +import { LayoutInstance } from '../../../lib/layouts'; import { getDocOptions, REPORTING_TABLE_LAYOUT } from './get_doc_options'; import { getFont } from './get_font'; import { getTemplate } from './get_template'; -const assetPath = path.resolve(__dirname, '..', '..', '..', 'common', 'assets'); +const assetPath = path.resolve(__dirname, '..', '..', 'common', 'assets'); const tableBorderWidth = 1; export class PdfMaker { diff --git a/x-pack/plugins/reporting/server/export_types/common/set_force_now.ts b/x-pack/plugins/reporting/server/export_types/common/set_force_now.ts new file mode 100644 index 0000000000000..ee7d613f1b8e1 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/common/set_force_now.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LocatorParams } from '../../../common/types'; + +/** + * Add `forceNow` to {@link LocatorParams['params']} to enable clients to set the time appropriately when + * reporting navigates to the page in Chromium. + */ +export const setForceNow = (forceNow: string) => (locator: LocatorParams): LocatorParams => { + return { + ...locator, + params: { + ...locator.params, + forceNow, + }, + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts new file mode 100644 index 0000000000000..bcfb06784a6dc --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/common/v2/get_full_urls.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { parse as urlParse, UrlWithStringQuery } from 'url'; +import { ReportingConfig } from '../../../'; +import { getAbsoluteUrlFactory } from '../get_absolute_url'; +import { validateUrls } from '../validate_urls'; + +export function getFullUrls(config: ReportingConfig, relativeUrls: string[]) { + const [basePath, protocol, hostname, port] = [ + config.kbnConfig.get('server', 'basePath'), + config.get('kibanaServer', 'protocol'), + config.get('kibanaServer', 'hostname'), + config.get('kibanaServer', 'port'), + ] as string[]; + const getAbsoluteUrl = getAbsoluteUrlFactory({ basePath, protocol, hostname, port }); + + validateUrls(relativeUrls); + + const urls = relativeUrls.map((relativeUrl) => { + const parsedRelative: UrlWithStringQuery = urlParse(relativeUrl); + return getAbsoluteUrl({ + path: parsedRelative.pathname === null ? undefined : parsedRelative.pathname, + hash: parsedRelative.hash === null ? undefined : parsedRelative.hash, + search: parsedRelative.search === null ? undefined : parsedRelative.search, + }); + }); + + return urls; +} diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts index 34cfa66ddd5e1..61a987a8a8578 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.test.ts @@ -15,11 +15,11 @@ import { createMockConfigSchema, createMockReportingCore, } from '../../../test_helpers'; -import { generatePngObservableFactory } from '../lib/generate_png'; +import { generatePngObservableFactory } from '../../common'; import { TaskPayloadPNG } from '../types'; import { runTaskFnFactory } from './'; -jest.mock('../lib/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); +jest.mock('../../common/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); let content: string; let mockReporting: ReportingCore; diff --git a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts index 1027e895b2cd0..c602db61bbbc8 100644 --- a/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/png/execute_job/index.ts @@ -16,8 +16,8 @@ import { getConditionalHeaders, getFullUrls, omitBlockedHeaders, + generatePngObservableFactory, } from '../../common'; -import { generatePngObservableFactory } from '../lib/generate_png'; import { TaskPayloadPNG } from '../types'; export const runTaskFnFactory: RunTaskFnFactory< diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/create_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/create_job.ts new file mode 100644 index 0000000000000..d04a5307f22e2 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/png_v2/create_job.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cryptoFactory } from '../../lib'; +import { CreateJobFn, CreateJobFnFactory } from '../../types'; +import { JobParamsPNGV2, TaskPayloadPNGV2 } from './types'; + +export const createJobFnFactory: CreateJobFnFactory< + CreateJobFn +> = function createJobFactoryFn(reporting, logger) { + const config = reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); + + return async function createJob({ locatorParams, ...jobParams }, context, req) { + const serializedEncryptedHeaders = await crypto.encrypt(req.headers); + + return { + ...jobParams, + headers: serializedEncryptedHeaders, + spaceId: reporting.getSpaceId(req, logger), + locatorParams: [locatorParams], + forceNow: new Date().toISOString(), + }; + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts new file mode 100644 index 0000000000000..2fe0aff58069c --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.test.ts @@ -0,0 +1,167 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import * as Rx from 'rxjs'; +import { Writable } from 'stream'; +import { ReportingCore } from '../../'; +import { CancellationToken } from '../../../common'; +import { LocatorParams } from '../../../common/types'; +import { cryptoFactory, LevelLogger } from '../../lib'; +import { + createMockConfig, + createMockConfigSchema, + createMockReportingCore, +} from '../../test_helpers'; +import { generatePngObservableFactory } from '../common'; +import { runTaskFnFactory } from './execute_job'; +import { TaskPayloadPNGV2 } from './types'; + +jest.mock('../common/generate_png', () => ({ generatePngObservableFactory: jest.fn() })); + +let content: string; +let mockReporting: ReportingCore; +let stream: jest.Mocked; + +const cancellationToken = ({ + on: jest.fn(), +} as unknown) as CancellationToken; + +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'abcabcsecuresecret'; +const encryptHeaders = async (headers: Record) => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; + +const getBasePayload = (baseObj: unknown) => baseObj as TaskPayloadPNGV2; + +beforeEach(async () => { + content = ''; + stream = ({ write: jest.fn((chunk) => (content += chunk)) } as unknown) as typeof stream; + + const mockReportingConfig = createMockConfigSchema({ + index: '.reporting-2018.10.10', + encryptionKey: mockEncryptionKey, + queue: { + indexInterval: 'daily', + timeout: Infinity, + }, + }); + + mockReporting = await createMockReportingCore(mockReportingConfig); + mockReporting.setConfig(createMockConfig(mockReportingConfig)); + + (generatePngObservableFactory as jest.Mock).mockReturnValue(jest.fn()); +}); + +afterEach(() => (generatePngObservableFactory as jest.Mock).mockReset()); + +test(`passes browserTimezone to generatePng`, async () => { + const encryptedHeaders = await encryptHeaders({}); + const generatePngObservable = (await generatePngObservableFactory(mockReporting)) as jest.Mock; + generatePngObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const browserTimezone = 'UTC'; + await runTask( + 'pngJobId', + getBasePayload({ + forceNow: 'test', + locatorParams: [{ version: 'test', id: 'test', params: {} }] as LocatorParams[], + browserTimezone, + headers: encryptedHeaders, + }), + cancellationToken, + stream + ); + + expect(generatePngObservable.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + LevelLogger { + "_logger": Object { + "get": [MockFunction], + }, + "_tags": Array [ + "PNGV2", + "execute", + "pngJobId", + ], + "warning": [Function], + }, + Array [ + "localhost:80undefined/app/management/insightsAndAlerting/reporting/r", + Object { + "id": "test", + "params": Object { + "forceNow": "test", + }, + "version": "test", + }, + ], + "UTC", + Object { + "conditions": Object { + "basePath": undefined, + "hostname": "localhost", + "port": 80, + "protocol": undefined, + }, + "headers": Object {}, + }, + undefined, + ], + ] + `); +}); + +test(`returns content_type of application/png`, async () => { + const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const encryptedHeaders = await encryptHeaders({}); + + const generatePngObservable = await generatePngObservableFactory(mockReporting); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of('foo')); + + const { content_type: contentType } = await runTask( + 'pngJobId', + getBasePayload({ + locatorParams: [{ version: 'test', id: 'test' }] as LocatorParams[], + headers: encryptedHeaders, + }), + cancellationToken, + stream + ); + expect(contentType).toBe('image/png'); +}); + +test(`returns content of generatePng getBuffer base64 encoded`, async () => { + const testContent = 'raw string from get_screenhots'; + const generatePngObservable = await generatePngObservableFactory(mockReporting); + (generatePngObservable as jest.Mock).mockReturnValue(Rx.of({ base64: testContent })); + + const runTask = await runTaskFnFactory(mockReporting, getMockLogger()); + const encryptedHeaders = await encryptHeaders({}); + await runTask( + 'pngJobId', + getBasePayload({ + locatorParams: [{ version: 'test', id: 'test' }] as LocatorParams[], + headers: encryptedHeaders, + }), + cancellationToken, + stream + ); + + expect(content).toEqual(testContent); +}); diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts new file mode 100644 index 0000000000000..66e13679498aa --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/png_v2/execute_job.ts @@ -0,0 +1,74 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import apm from 'elastic-apm-node'; +import * as Rx from 'rxjs'; +import { catchError, finalize, map, mergeMap, takeUntil, tap } from 'rxjs/operators'; +import { PNG_JOB_TYPE_V2, getRedirectAppPathHome } from '../../../common/constants'; +import { TaskRunResult } from '../../lib/tasks'; +import { RunTaskFn, RunTaskFnFactory } from '../../types'; +import { + decryptJobHeaders, + getConditionalHeaders, + omitBlockedHeaders, + generatePngObservableFactory, + setForceNow, +} from '../common'; +import { getFullUrls } from '../common/v2/get_full_urls'; +import { TaskPayloadPNGV2 } from './types'; + +export const runTaskFnFactory: RunTaskFnFactory< + RunTaskFn +> = function executeJobFactoryFn(reporting, parentLogger) { + const config = reporting.getConfig(); + const encryptionKey = config.get('encryptionKey'); + + return async function runTask(jobId, job, cancellationToken, stream) { + const apmTrans = apm.startTransaction('reporting execute_job pngV2', 'reporting'); + const apmGetAssets = apmTrans?.startSpan('get_assets', 'setup'); + let apmGeneratePng: { end: () => void } | null | undefined; + + const generatePngObservable = await generatePngObservableFactory(reporting); + const jobLogger = parentLogger.clone([PNG_JOB_TYPE_V2, 'execute', jobId]); + const process$: Rx.Observable = Rx.of(1).pipe( + mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), + map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), + map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), + mergeMap((conditionalHeaders) => { + const relativeUrl = getRedirectAppPathHome(); + const [url] = getFullUrls(config, [relativeUrl]); + const [locatorParams] = job.locatorParams.map(setForceNow(job.forceNow)); + + apmGetAssets?.end(); + + apmGeneratePng = apmTrans?.startSpan('generate_png_pipeline', 'execute'); + return generatePngObservable( + jobLogger, + [url, locatorParams], + job.browserTimezone, + conditionalHeaders, + job.layout + ); + }), + tap(({ base64 }) => stream.write(base64)), + map(({ base64, warnings }) => ({ + content_type: 'image/png', + content: base64, + size: (base64 && base64.length) || 0, + warnings, + })), + catchError((err) => { + jobLogger.error(err); + return Rx.throwError(err); + }), + finalize(() => apmGeneratePng?.end()) + ); + + const stop$ = Rx.fromEventPattern(cancellationToken.on); + return process$.pipe(takeUntil(stop$)).toPromise(); + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/index.ts b/x-pack/plugins/reporting/server/export_types/png_v2/index.ts new file mode 100644 index 0000000000000..a2262be6b750b --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/png_v2/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LICENSE_TYPE_ENTERPRISE, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_STANDARD, + LICENSE_TYPE_TRIAL, + PNG_JOB_TYPE_V2 as jobType, +} from '../../../common/constants'; +import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; +import { createJobFnFactory } from './create_job'; +import { runTaskFnFactory } from './execute_job'; +import { metadata } from './metadata'; +import { JobParamsPNGV2, TaskPayloadPNGV2 } from './types'; + +export const getExportType = (): ExportTypeDefinition< + CreateJobFn, + RunTaskFn +> => ({ + ...metadata, + jobType, + jobContentEncoding: 'base64', + jobContentExtension: 'PNG', + createJobFnFactory, + runTaskFnFactory, + validLicenses: [ + LICENSE_TYPE_TRIAL, + LICENSE_TYPE_STANDARD, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_ENTERPRISE, + ], +}); diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/metadata.ts b/x-pack/plugins/reporting/server/export_types/png_v2/metadata.ts new file mode 100644 index 0000000000000..56e18b96f663a --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/png_v2/metadata.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { PNG_REPORT_TYPE_V2 } from '../../../common/constants'; + +export const metadata = { + id: PNG_REPORT_TYPE_V2, + name: 'PNG', +}; diff --git a/x-pack/plugins/reporting/server/export_types/png_v2/types.d.ts b/x-pack/plugins/reporting/server/export_types/png_v2/types.d.ts new file mode 100644 index 0000000000000..50c857b66934b --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/png_v2/types.d.ts @@ -0,0 +1,29 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { LocatorParams } from '../../../common/types'; +import type { LayoutParams } from '../../lib/layouts'; +import type { BaseParams, BasePayload } from '../../types'; + +// Job params: structure of incoming user request data +export interface JobParamsPNGV2 extends BaseParams { + layout: LayoutParams; + /** + * This value is used to re-create the same visual state as when the report was requested as well as navigate to the correct page. + */ + locatorParams: LocatorParams; +} + +// Job payload: structure of stored job data provided by create_job +export interface TaskPayloadPNGV2 extends BasePayload { + layout: LayoutParams; + forceNow: string; + /** + * Even though we only ever handle one locator for a PNG, we store it as an array for consistency with how PDFs are stored + */ + locatorParams: LocatorParams[]; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts index a878c51ba02e2..1fcd448344bcf 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/execute_job/index.ts @@ -16,9 +16,9 @@ import { getConditionalHeaders, getFullUrls, omitBlockedHeaders, + getCustomLogo, } from '../../common'; import { generatePdfObservableFactory } from '../lib/generate_pdf'; -import { getCustomLogo } from '../lib/get_custom_logo'; import { TaskPayloadPDF } from '../types'; export const runTaskFnFactory: RunTaskFnFactory< diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts index 88b9f8dc95b94..737068eaba8b6 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/lib/generate_pdf.ts @@ -13,7 +13,7 @@ import { LevelLogger } from '../../../lib'; import { createLayout, LayoutParams } from '../../../lib/layouts'; import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; import { ConditionalHeaders } from '../../common'; -import { PdfMaker } from './pdf'; +import { PdfMaker } from '../../common/pdf'; import { getTracker } from './tracker'; const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { @@ -50,7 +50,7 @@ export async function generatePdfObservableFactory(reporting: ReportingCore) { tracker.startScreenshots(); const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { logger, - urls, + urlsOrUrlLocatorTuples: urls, conditionalHeaders, layout, browserTimezone, diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts index 26cc402a8d509..5172bf300abc8 100644 --- a/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf/types.d.ts @@ -11,6 +11,7 @@ import { BaseParams, BasePayload } from '../../types'; interface BaseParamsPDF { layout: LayoutParams; forceNow?: string; + // TODO: Add comment explaining this field relativeUrls: string[]; } diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/create_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/create_job.ts new file mode 100644 index 0000000000000..b621759528e80 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/create_job.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { cryptoFactory } from '../../lib'; +import { CreateJobFn, CreateJobFnFactory } from '../../types'; +import { JobParamsPDFV2, TaskPayloadPDFV2 } from './types'; + +export const createJobFnFactory: CreateJobFnFactory< + CreateJobFn +> = function createJobFactoryFn(reporting, logger) { + const config = reporting.getConfig(); + const crypto = cryptoFactory(config.get('encryptionKey')); + + return async function createJob(jobParams, context, req) { + const serializedEncryptedHeaders = await crypto.encrypt(req.headers); + + return { + ...jobParams, + headers: serializedEncryptedHeaders, + spaceId: reporting.getSpaceId(req, logger), + forceNow: new Date().toISOString(), + }; + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts new file mode 100644 index 0000000000000..f1d1ec82cdcdd --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +jest.mock('./lib/generate_pdf', () => ({ generatePdfObservableFactory: jest.fn() })); + +import * as Rx from 'rxjs'; +import { Writable } from 'stream'; +import { ReportingCore } from '../../'; +import { CancellationToken } from '../../../common'; +import { LocatorParams } from '../../../common/types'; +import { cryptoFactory, LevelLogger } from '../../lib'; +import { createMockConfigSchema, createMockReportingCore } from '../../test_helpers'; +import { runTaskFnFactory } from './execute_job'; +import { generatePdfObservableFactory } from './lib/generate_pdf'; +import { TaskPayloadPDFV2 } from './types'; + +let content: string; +let mockReporting: ReportingCore; +let stream: jest.Mocked; + +const cancellationToken = ({ + on: jest.fn(), +} as unknown) as CancellationToken; + +const mockLoggerFactory = { + get: jest.fn().mockImplementation(() => ({ + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + })), +}; +const getMockLogger = () => new LevelLogger(mockLoggerFactory); + +const mockEncryptionKey = 'testencryptionkey'; +const encryptHeaders = async (headers: Record) => { + const crypto = cryptoFactory(mockEncryptionKey); + return await crypto.encrypt(headers); +}; + +const getBasePayload = (baseObj: any) => + ({ + params: { forceNow: 'test' }, + ...baseObj, + } as TaskPayloadPDFV2); + +beforeEach(async () => { + content = ''; + stream = ({ write: jest.fn((chunk) => (content += chunk)) } as unknown) as typeof stream; + + const reportingConfig = { + 'server.basePath': '/sbp', + index: '.reports-test', + encryptionKey: mockEncryptionKey, + 'kibanaServer.hostname': 'localhost', + 'kibanaServer.port': 5601, + 'kibanaServer.protocol': 'http', + }; + const mockSchema = createMockConfigSchema(reportingConfig); + mockReporting = await createMockReportingCore(mockSchema); + + (generatePdfObservableFactory as jest.Mock).mockReturnValue(jest.fn()); +}); + +afterEach(() => (generatePdfObservableFactory as jest.Mock).mockReset()); + +test(`passes browserTimezone to generatePdf`, async () => { + const encryptedHeaders = await encryptHeaders({}); + const generatePdfObservable = (await generatePdfObservableFactory(mockReporting)) as jest.Mock; + generatePdfObservable.mockReturnValue(Rx.of(Buffer.from(''))); + + const runTask = runTaskFnFactory(mockReporting, getMockLogger()); + const browserTimezone = 'UTC'; + await runTask( + 'pdfJobId', + getBasePayload({ + forceNow: 'test', + title: 'PDF Params Timezone Test', + locatorParams: [{ version: 'test', id: 'test' }] as LocatorParams[], + browserTimezone, + headers: encryptedHeaders, + }), + cancellationToken, + stream + ); + + const tzParam = generatePdfObservable.mock.calls[0][4]; + expect(tzParam).toBe('UTC'); +}); + +test(`returns content_type of application/pdf`, async () => { + const logger = getMockLogger(); + const runTask = runTaskFnFactory(mockReporting, logger); + const encryptedHeaders = await encryptHeaders({}); + + const generatePdfObservable = await generatePdfObservableFactory(mockReporting); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of(Buffer.from(''))); + + const { content_type: contentType } = await runTask( + 'pdfJobId', + getBasePayload({ locatorParams: [], headers: encryptedHeaders }), + cancellationToken, + stream + ); + expect(contentType).toBe('application/pdf'); +}); + +test(`returns content of generatePdf getBuffer base64 encoded`, async () => { + const testContent = 'test content'; + const generatePdfObservable = await generatePdfObservableFactory(mockReporting); + (generatePdfObservable as jest.Mock).mockReturnValue(Rx.of({ buffer: Buffer.from(testContent) })); + + const runTask = runTaskFnFactory(mockReporting, getMockLogger()); + const encryptedHeaders = await encryptHeaders({}); + await runTask( + 'pdfJobId', + getBasePayload({ locatorParams: [], headers: encryptedHeaders }), + cancellationToken, + stream + ); + + expect(content).toEqual(Buffer.from(testContent).toString('base64')); +}); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts new file mode 100644 index 0000000000000..c79f53d48e0f1 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/execute_job.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import apm from 'elastic-apm-node'; +import * as Rx from 'rxjs'; +import { catchError, map, mergeMap, takeUntil } from 'rxjs/operators'; +import { PDF_JOB_TYPE_V2 } from '../../../common/constants'; +import { TaskRunResult } from '../../lib/tasks'; +import { RunTaskFn, RunTaskFnFactory } from '../../types'; +import { + decryptJobHeaders, + getConditionalHeaders, + omitBlockedHeaders, + getCustomLogo, + setForceNow, +} from '../common'; +import { generatePdfObservableFactory } from './lib/generate_pdf'; +import { TaskPayloadPDFV2 } from './types'; + +export const runTaskFnFactory: RunTaskFnFactory< + RunTaskFn +> = function executeJobFactoryFn(reporting, parentLogger) { + const config = reporting.getConfig(); + const encryptionKey = config.get('encryptionKey'); + + return async function runTask(jobId, job, cancellationToken, stream) { + const jobLogger = parentLogger.clone([PDF_JOB_TYPE_V2, 'execute-job', jobId]); + const apmTrans = apm.startTransaction('reporting execute_job pdf_v2', 'reporting'); + const apmGetAssets = apmTrans?.startSpan('get_assets', 'setup'); + let apmGeneratePdf: { end: () => void } | null | undefined; + + const generatePdfObservable = await generatePdfObservableFactory(reporting); + + const process$: Rx.Observable = Rx.of(1).pipe( + mergeMap(() => decryptJobHeaders(encryptionKey, job.headers, jobLogger)), + map((decryptedHeaders) => omitBlockedHeaders(decryptedHeaders)), + map((filteredHeaders) => getConditionalHeaders(config, filteredHeaders)), + mergeMap((conditionalHeaders) => + getCustomLogo(reporting, conditionalHeaders, job.spaceId, jobLogger) + ), + mergeMap(({ logo, conditionalHeaders }) => { + const { browserTimezone, layout, title, locatorParams } = job; + if (apmGetAssets) apmGetAssets.end(); + + apmGeneratePdf = apmTrans?.startSpan('generate_pdf_pipeline', 'execute'); + return generatePdfObservable( + jobLogger, + jobId, + title, + locatorParams.map(setForceNow(job.forceNow)), + browserTimezone, + conditionalHeaders, + layout, + logo + ); + }), + map(({ buffer, warnings }) => { + if (apmGeneratePdf) apmGeneratePdf.end(); + + const apmEncode = apmTrans?.startSpan('encode_pdf', 'output'); + const content = buffer?.toString('base64') || null; + apmEncode?.end(); + + stream.write(content); + + return { + content_type: 'application/pdf', + content, + size: buffer?.byteLength || 0, + warnings, + }; + }), + catchError((err) => { + jobLogger.error(err); + return Rx.throwError(err); + }) + ); + + const stop$ = Rx.fromEventPattern(cancellationToken.on); + + if (apmTrans) apmTrans.end(); + return process$.pipe(takeUntil(stop$)).toPromise(); + }; +}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/index.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/index.ts new file mode 100644 index 0000000000000..ffaf0c4567147 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/index.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + LICENSE_TYPE_ENTERPRISE, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_STANDARD, + LICENSE_TYPE_TRIAL, + PDF_JOB_TYPE_V2 as jobType, +} from '../../../common/constants'; +import { CreateJobFn, ExportTypeDefinition, RunTaskFn } from '../../types'; +import { createJobFnFactory } from './create_job'; +import { runTaskFnFactory } from './execute_job'; +import { metadata } from './metadata'; +import { JobParamsPDFV2, TaskPayloadPDFV2 } from './types'; + +export const getExportType = (): ExportTypeDefinition< + CreateJobFn, + RunTaskFn +> => ({ + ...metadata, + jobType, + jobContentEncoding: 'base64', + jobContentExtension: 'pdf', + createJobFnFactory, + runTaskFnFactory, + validLicenses: [ + LICENSE_TYPE_TRIAL, + LICENSE_TYPE_STANDARD, + LICENSE_TYPE_GOLD, + LICENSE_TYPE_PLATINUM, + LICENSE_TYPE_ENTERPRISE, + ], +}); diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts new file mode 100644 index 0000000000000..ff3ee8e52e53a --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/generate_pdf.ts @@ -0,0 +1,130 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { groupBy, zip } from 'lodash'; +import * as Rx from 'rxjs'; +import { mergeMap } from 'rxjs/operators'; +import { ReportingCore } from '../../../'; +import { getRedirectAppPathHome } from '../../../../common/constants'; +import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../../common/types'; +import { LevelLogger } from '../../../lib'; +import { createLayout, LayoutParams } from '../../../lib/layouts'; +import { getScreenshots$, ScreenshotResults } from '../../../lib/screenshots'; +import { ConditionalHeaders } from '../../common'; +import { PdfMaker } from '../../common/pdf'; +import { getFullUrls } from '../../common/v2/get_full_urls'; +import { getTracker } from './tracker'; + +const getTimeRange = (urlScreenshots: ScreenshotResults[]) => { + const grouped = groupBy(urlScreenshots.map((u) => u.timeRange)); + const values = Object.values(grouped); + if (values.length === 1) { + return values[0][0]; + } + + return null; +}; + +export async function generatePdfObservableFactory(reporting: ReportingCore) { + const config = reporting.getConfig(); + const captureConfig = config.get('capture'); + const { browserDriverFactory } = await reporting.getPluginStartDeps(); + + return function generatePdfObservable( + logger: LevelLogger, + jobId: string, + title: string, + locatorParams: LocatorParams[], + browserTimezone: string | undefined, + conditionalHeaders: ConditionalHeaders, + layoutParams: LayoutParams, + logo?: string + ): Rx.Observable<{ buffer: Buffer | null; warnings: string[] }> { + const tracker = getTracker(); + tracker.startLayout(); + + const layout = createLayout(captureConfig, layoutParams); + logger.debug(`Layout: width=${layout.width} height=${layout.height}`); + tracker.endLayout(); + + tracker.startScreenshots(); + + /** + * For each locator we get the relative URL to the redirect app + */ + const relativeUrls = locatorParams.map(() => getRedirectAppPathHome()); + const urls = getFullUrls(reporting.getConfig(), relativeUrls); + + const screenshots$ = getScreenshots$(captureConfig, browserDriverFactory, { + logger, + urlsOrUrlLocatorTuples: zip(urls, locatorParams) as UrlOrUrlLocatorTuple[], + conditionalHeaders, + layout, + browserTimezone, + }).pipe( + mergeMap(async (results: ScreenshotResults[]) => { + tracker.endScreenshots(); + + tracker.startSetup(); + const pdfOutput = new PdfMaker(layout, logo); + if (title) { + const timeRange = getTimeRange(results); + title += timeRange ? ` - ${timeRange}` : ''; + pdfOutput.setTitle(title); + } + tracker.endSetup(); + + results.forEach((r) => { + r.screenshots.forEach((screenshot) => { + logger.debug(`Adding image to PDF. Image base64 size: ${screenshot.base64EncodedData?.length || 0}`); // prettier-ignore + tracker.startAddImage(); + tracker.endAddImage(); + pdfOutput.addImage(screenshot.base64EncodedData, { + title: screenshot.title, + description: screenshot.description, + }); + }); + }); + + let buffer: Buffer | null = null; + try { + tracker.startCompile(); + logger.debug(`Compiling PDF using "${layout.id}" layout...`); + pdfOutput.generate(); + tracker.endCompile(); + + tracker.startGetBuffer(); + logger.debug(`Generating PDF Buffer...`); + buffer = await pdfOutput.getBuffer(); + + const byteLength = buffer?.byteLength ?? 0; + logger.debug(`PDF buffer byte length: ${byteLength}`); + tracker.setByteLength(byteLength); + + tracker.endGetBuffer(); + } catch (err) { + logger.error(`Could not generate the PDF buffer!`); + logger.error(err); + } + + tracker.end(); + + return { + buffer, + warnings: results.reduce((found, current) => { + if (current.error) { + found.push(current.error.message); + } + return found; + }, [] as string[]), + }; + }) + ); + + return screenshots$; + }; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts new file mode 100644 index 0000000000000..4b5a0a7bdade7 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/tracker.ts @@ -0,0 +1,88 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import apm from 'elastic-apm-node'; + +interface PdfTracker { + setByteLength: (byteLength: number) => void; + startLayout: () => void; + endLayout: () => void; + startScreenshots: () => void; + endScreenshots: () => void; + startSetup: () => void; + endSetup: () => void; + startAddImage: () => void; + endAddImage: () => void; + startCompile: () => void; + endCompile: () => void; + startGetBuffer: () => void; + endGetBuffer: () => void; + end: () => void; +} + +const SPANTYPE_SETUP = 'setup'; +const SPANTYPE_OUTPUT = 'output'; + +interface ApmSpan { + end: () => void; +} + +export function getTracker(): PdfTracker { + const apmTrans = apm.startTransaction('reporting generate_pdf', 'reporting'); + + let apmLayout: ApmSpan | null = null; + let apmScreenshots: ApmSpan | null = null; + let apmSetup: ApmSpan | null = null; + let apmAddImage: ApmSpan | null = null; + let apmCompilePdf: ApmSpan | null = null; + let apmGetBuffer: ApmSpan | null = null; + + return { + startLayout() { + apmLayout = apmTrans?.startSpan('create_layout', SPANTYPE_SETUP) || null; + }, + endLayout() { + if (apmLayout) apmLayout.end(); + }, + startScreenshots() { + apmScreenshots = apmTrans?.startSpan('screenshots_pipeline', SPANTYPE_SETUP) || null; + }, + endScreenshots() { + if (apmScreenshots) apmScreenshots.end(); + }, + startSetup() { + apmSetup = apmTrans?.startSpan('setup_pdf', SPANTYPE_SETUP) || null; + }, + endSetup() { + if (apmSetup) apmSetup.end(); + }, + startAddImage() { + apmAddImage = apmTrans?.startSpan('add_pdf_image', SPANTYPE_OUTPUT) || null; + }, + endAddImage() { + if (apmAddImage) apmAddImage.end(); + }, + startCompile() { + apmCompilePdf = apmTrans?.startSpan('compile_pdf', SPANTYPE_OUTPUT) || null; + }, + endCompile() { + if (apmCompilePdf) apmCompilePdf.end(); + }, + startGetBuffer() { + apmGetBuffer = apmTrans?.startSpan('get_buffer', SPANTYPE_OUTPUT) || null; + }, + endGetBuffer() { + if (apmGetBuffer) apmGetBuffer.end(); + }, + setByteLength(byteLength: number) { + apmTrans?.setLabel('byte_length', byteLength, false); + }, + end() { + if (apmTrans) apmTrans.end(); + }, + }; +} diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/uri_encode.js b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/uri_encode.js new file mode 100644 index 0000000000000..c2170b0aaf897 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/lib/uri_encode.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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { forEach, isArray } from 'lodash'; +import { url } from '../../../../../../../src/plugins/kibana_utils/server'; + +function toKeyValue(obj) { + const parts = []; + forEach(obj, function (value, key) { + if (isArray(value)) { + forEach(value, function (arrayValue) { + const keyStr = url.encodeUriQuery(key, true); + const valStr = arrayValue === true ? '' : '=' + url.encodeUriQuery(arrayValue, true); + parts.push(keyStr + valStr); + }); + } else { + const keyStr = url.encodeUriQuery(key, true); + const valStr = value === true ? '' : '=' + url.encodeUriQuery(value, true); + parts.push(keyStr + valStr); + } + }); + return parts.length ? parts.join('&') : ''; +} + +export const uriEncode = { + stringify: toKeyValue, + string: url.encodeUriQuery, +}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/metadata.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/metadata.ts new file mode 100644 index 0000000000000..f4fc93a86821b --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/metadata.ts @@ -0,0 +1,11 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const metadata = { + id: 'printablePdfV2', + name: 'PDF', +}; diff --git a/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/types.ts b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/types.ts new file mode 100644 index 0000000000000..a629eea9f21f7 --- /dev/null +++ b/x-pack/plugins/reporting/server/export_types/printable_pdf_v2/types.ts @@ -0,0 +1,31 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { LocatorParams } from '../../../common/types'; +import { LayoutParams } from '../../lib/layouts'; +import { BaseParams, BasePayload } from '../../types'; + +interface BaseParamsPDFV2 { + layout: LayoutParams; + + /** + * This value is used to re-create the same visual state as when the report was requested as well as navigate to the correct page. + */ + locatorParams: LocatorParams[]; +} + +// Job params: structure of incoming user request data, after being parsed from RISON +export type JobParamsPDFV2 = BaseParamsPDFV2 & BaseParams; + +// Job payload: structure of stored job data provided by create_job +export interface TaskPayloadPDFV2 extends BasePayload, BaseParamsPDFV2 { + layout: LayoutParams; + /** + * The value of forceNow is injected server-side every time a given report is generated. + */ + forceNow: string; +} diff --git a/x-pack/plugins/reporting/server/index.ts b/x-pack/plugins/reporting/server/index.ts index 999311b9ae17b..bc6529eb90782 100644 --- a/x-pack/plugins/reporting/server/index.ts +++ b/x-pack/plugins/reporting/server/index.ts @@ -21,5 +21,4 @@ export { ReportingSetupDeps as PluginSetup, ReportingStartDeps as PluginStart, } from './types'; - export { ReportingPlugin as Plugin }; diff --git a/x-pack/plugins/reporting/server/lib/export_types_registry.ts b/x-pack/plugins/reporting/server/lib/export_types_registry.ts index 890af43297751..314d50e131565 100644 --- a/x-pack/plugins/reporting/server/lib/export_types_registry.ts +++ b/x-pack/plugins/reporting/server/lib/export_types_registry.ts @@ -10,7 +10,10 @@ import { getExportType as getTypeCsvDeprecated } from '../export_types/csv'; import { getExportType as getTypeCsvFromSavedObject } from '../export_types/csv_searchsource_immediate'; import { getExportType as getTypeCsv } from '../export_types/csv_searchsource'; import { getExportType as getTypePng } from '../export_types/png'; +import { getExportType as getTypePngV2 } from '../export_types/png_v2'; import { getExportType as getTypePrintablePdf } from '../export_types/printable_pdf'; +import { getExportType as getTypePrintablePdfV2 } from '../export_types/printable_pdf_v2'; + import { CreateJobFn, ExportTypeDefinition } from '../types'; type GetCallbackFn = (item: ExportTypeDefinition) => boolean; @@ -88,7 +91,9 @@ export function getExportTypesRegistry(): ExportTypesRegistry { getTypeCsvDeprecated, getTypeCsvFromSavedObject, getTypePng, + getTypePngV2, getTypePrintablePdf, + getTypePrintablePdfV2, ]; getTypeFns.forEach((getType) => { registry.register(getType()); diff --git a/x-pack/plugins/reporting/server/lib/screenshots/index.ts b/x-pack/plugins/reporting/server/lib/screenshots/index.ts index d5ef52d627c6b..d5920605a8be6 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/index.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/index.ts @@ -6,6 +6,7 @@ */ import { LevelLogger } from '../'; +import { UrlOrUrlLocatorTuple } from '../../../common/types'; import { ConditionalHeaders } from '../../export_types/common'; import { LayoutInstance } from '../layouts'; @@ -13,7 +14,7 @@ export { getScreenshots$ } from './observable'; export interface ScreenshotObservableOpts { logger: LevelLogger; - urls: string[]; + urlsOrUrlLocatorTuples: UrlOrUrlLocatorTuple[]; conditionalHeaders: ConditionalHeaders; layout: LayoutInstance; browserTimezone?: string; diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts index 7458340a4a52f..e5caa2490153a 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.test.ts @@ -69,7 +69,7 @@ describe('Screenshot Observable Pipeline', () => { it('pipelines a single url into screenshot and timeRange', async () => { const result = await getScreenshots$(captureConfig, mockBrowserDriverFactory, { logger, - urls: ['/welcome/home/start/index.htm'], + urlsOrUrlLocatorTuples: ['/welcome/home/start/index.htm'], conditionalHeaders: {} as ConditionalHeaders, layout: mockLayout, browserTimezone: 'UTC', @@ -129,7 +129,10 @@ describe('Screenshot Observable Pipeline', () => { // test const result = await getScreenshots$(captureConfig, mockBrowserDriverFactory, { logger, - urls: ['/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php'], + urlsOrUrlLocatorTuples: [ + '/welcome/home/start/index2.htm', + '/welcome/home/start/index.php3?page=./home.php', + ], conditionalHeaders: {} as ConditionalHeaders, layout: mockLayout, browserTimezone: 'UTC', @@ -228,7 +231,7 @@ describe('Screenshot Observable Pipeline', () => { const getScreenshot = async () => { return await getScreenshots$(captureConfig, mockBrowserDriverFactory, { logger, - urls: [ + urlsOrUrlLocatorTuples: [ '/welcome/home/start/index2.htm', '/welcome/home/start/index.php3?page=./home.php3', ], @@ -322,7 +325,7 @@ describe('Screenshot Observable Pipeline', () => { const getScreenshot = async () => { return await getScreenshots$(captureConfig, mockBrowserDriverFactory, { logger, - urls: ['/welcome/home/start/index.php3?page=./home.php3'], + urlsOrUrlLocatorTuples: ['/welcome/home/start/index.php3?page=./home.php3'], conditionalHeaders: {} as ConditionalHeaders, layout: mockLayout, browserTimezone: 'UTC', @@ -352,7 +355,7 @@ describe('Screenshot Observable Pipeline', () => { const screenshots = await getScreenshots$(captureConfig, mockBrowserDriverFactory, { logger, - urls: ['/welcome/home/start/index.php3?page=./home.php3'], + urlsOrUrlLocatorTuples: ['/welcome/home/start/index.php3?page=./home.php3'], conditionalHeaders: {} as ConditionalHeaders, layout: mockLayout, browserTimezone: 'UTC', diff --git a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts index baaf8a4fb38ee..e833a0dfcaf60 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/observable.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/observable.ts @@ -34,7 +34,13 @@ interface ScreenSetupData { export function getScreenshots$( captureConfig: CaptureConfig, browserDriverFactory: HeadlessChromiumDriverFactory, - { logger, urls, conditionalHeaders, layout, browserTimezone }: ScreenshotObservableOpts + { + logger, + urlsOrUrlLocatorTuples, + conditionalHeaders, + layout, + browserTimezone, + }: ScreenshotObservableOpts ): Rx.Observable { const apmTrans = apm.startTransaction(`reporting screenshot pipeline`, 'reporting'); @@ -49,8 +55,8 @@ export function getScreenshots$( apmCreatePage?.end(); exit$.subscribe({ error: () => apmTrans?.end() }); - return Rx.from(urls).pipe( - concatMap((url, index) => { + return Rx.from(urlsOrUrlLocatorTuples).pipe( + concatMap((urlOrUrlLocatorTuple, index) => { const setup$: Rx.Observable = Rx.of(1).pipe( mergeMap(() => { // If we're moving to another page in the app, we'll want to wait for the app to tell us @@ -62,7 +68,7 @@ export function getScreenshots$( return openUrl( captureConfig, driver, - url, + urlOrUrlLocatorTuple, pageLoadSelector, conditionalHeaders, logger @@ -129,7 +135,7 @@ export function getScreenshots$( ) ); }), - take(urls.length), + take(urlsOrUrlLocatorTuples.length), toArray() ); }), diff --git a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts index 377897bcc381f..588cd792bdf06 100644 --- a/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts +++ b/x-pack/plugins/reporting/server/lib/screenshots/open_url.ts @@ -6,6 +6,7 @@ */ import { i18n } from '@kbn/i18n'; +import { LocatorParams, UrlOrUrlLocatorTuple } from '../../../common/types'; import { LevelLogger, startTrace } from '../'; import { durationToNumber } from '../../../common/schema_utils'; import { HeadlessChromiumDriver } from '../../browsers'; @@ -15,19 +16,24 @@ import { CaptureConfig } from '../../types'; export const openUrl = async ( captureConfig: CaptureConfig, browser: HeadlessChromiumDriver, - url: string, - pageLoadSelector: string, + urlOrUrlLocatorTuple: UrlOrUrlLocatorTuple, + waitForSelector: string, conditionalHeaders: ConditionalHeaders, logger: LevelLogger ): Promise => { const endTrace = startTrace('open_url', 'wait'); + let url: string; + let locator: undefined | LocatorParams; + + if (typeof urlOrUrlLocatorTuple === 'string') { + url = urlOrUrlLocatorTuple; + } else { + [url, locator] = urlOrUrlLocatorTuple; + } + try { const timeout = durationToNumber(captureConfig.timeouts.openUrl); - await browser.open( - url, - { conditionalHeaders, waitForSelector: pageLoadSelector, timeout }, - logger - ); + await browser.open(url, { conditionalHeaders, waitForSelector, timeout, locator }, logger); } catch (err) { logger.error(err); throw new Error( diff --git a/x-pack/plugins/reporting/server/plugin.ts b/x-pack/plugins/reporting/server/plugin.ts index 185b47a980bfe..0090afb855ee9 100644 --- a/x-pack/plugins/reporting/server/plugin.ts +++ b/x-pack/plugins/reporting/server/plugin.ts @@ -33,11 +33,14 @@ export class ReportingPlugin } public setup(core: CoreSetup, plugins: ReportingSetupDeps) { + const { http } = core; + const { screenshotMode, features, licensing, security, spaces, taskManager } = plugins; + const reportingCore = new ReportingCore(this.logger, this.initContext); // prevent throwing errors in route handlers about async deps not being initialized // @ts-expect-error null is not assignable to object. use a boolean property to ensure reporting API is enabled. - core.http.registerRouteHandlerContext(PLUGIN_ID, () => { + http.registerRouteHandlerContext(PLUGIN_ID, () => { if (reportingCore.pluginIsStarted()) { return reportingCore.getContract(); } else { @@ -46,9 +49,6 @@ export class ReportingPlugin } }); - const { http } = core; - const { screenshotMode, features, licensing, security, spaces, taskManager } = plugins; - const router = http.createRouter(); const basePath = http.basePath; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts index 9b3260cb31da7..4dfedef93f291 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.test.ts @@ -18,9 +18,9 @@ import { import { registerDiagnoseScreenshot } from './screenshot'; import type { ReportingRequestHandlerContext } from '../../types'; -jest.mock('../../export_types/png/lib/generate_png'); +jest.mock('../../export_types/common/generate_png'); -import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png'; +import { generatePngObservableFactory } from '../../export_types/common'; type SetupServerReturn = UnwrapPromise>; diff --git a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts index 765e0a2a4e8a2..3a89c869542b4 100644 --- a/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts +++ b/x-pack/plugins/reporting/server/routes/diagnostic/screenshot.ts @@ -9,9 +9,8 @@ import { i18n } from '@kbn/i18n'; import { ReportingCore } from '../..'; import { APP_WRAPPER_CLASS } from '../../../../../../src/core/server'; import { API_DIAGNOSE_URL } from '../../../common/constants'; -import { omitBlockedHeaders } from '../../export_types/common'; +import { omitBlockedHeaders, generatePngObservableFactory } from '../../export_types/common'; import { getAbsoluteUrlFactory } from '../../export_types/common/get_absolute_url'; -import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png'; import { LevelLogger as Logger } from '../../lib'; import { authorizedUserPreRouting } from '../lib/authorized_user_pre_routing'; import { DiagnosticResponse } from './'; diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index ad0aac121106c..6086c1b9eb872 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -112,7 +112,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { const result = await jobsQuery.get(user, docId); if (!result) { - throw Boom.notFound(); + return res.notFound(); } const { jobtype: jobType } = result; diff --git a/x-pack/plugins/reporting/server/types.ts b/x-pack/plugins/reporting/server/types.ts index 16cd247b4d00e..0d1520f3c4d0b 100644 --- a/x-pack/plugins/reporting/server/types.ts +++ b/x-pack/plugins/reporting/server/types.ts @@ -30,11 +30,11 @@ import { ReportTaskParams } from './lib/tasks'; export interface ReportingSetupDeps { licensing: LicensingPluginSetup; features: FeaturesPluginSetup; + screenshotMode: ScreenshotModePluginSetup; security?: SecurityPluginSetup; spaces?: SpacesPluginSetup; taskManager: TaskManagerSetupContract; usageCollection?: UsageCollectionSetup; - screenshotMode: ScreenshotModePluginSetup; } export interface ReportingStartDeps {