[Reporting] Create reports with full state required to generate the report (#101048) (#108404)

* 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>

Co-authored-by: Jean-Louis Leysens <jloleysens@gmail.com>
This commit is contained in:
Kibana Machine 2021-08-13 04:10:14 -04:00 committed by GitHub
parent 0ec9720a63
commit a26f0048cd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
73 changed files with 1553 additions and 95 deletions

View file

@ -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

View file

@ -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;
}

View file

@ -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;
};

View file

@ -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<SharePluginSetup, SharePluginStart> {
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<SharePluginSetup, SharePluginStart> {
},
});
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<SharePluginSetup, SharePluginStart> {
),
urlGenerators: this.urlGeneratorsService.start(core),
url: this.url!,
navigate: (options: RedirectOptions) => this.redirectManager!.navigate(options),
};
}
}

View file

@ -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) {

View file

@ -7,3 +7,9 @@
export const PLUGIN_ID = 'reportingExample';
export const PLUGIN_NAME = 'reportingExample';
export {
REPORTING_EXAMPLE_LOCATOR_ID,
ReportingExampleLocatorDefinition,
ReportingExampleLocatorParams,
} from './locator';

View file

@ -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,
};
};
}

View file

@ -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"]
}

View file

@ -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<StartDeps & SetupDeps, 'developerExamples'>,
{ appBasePath, element }: AppMountParameters // FIXME: appBasePath is deprecated
{ appBasePath, element }: AppMountParameters, // FIXME: appBasePath is deprecated
forwardedParams: MyForwardableState
) => {
ReactDOM.render(<ReportingExampleApp basename={appBasePath} {...coreStart} {...deps} />, element);
ReactDOM.render(
<ReportingExampleApp
basename={appBasePath}
{...coreStart}
{...deps}
forwardedParams={forwardedParams}
/>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -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: (
<reporting.components.ReportingPanelPDF
getJobParams={getPDFJobParamsDefault}
@ -110,6 +168,26 @@ export const ReportingExampleApp = ({
/>
),
},
{
id: 4,
title: 'Default layout V2',
content: (
<reporting.components.ReportingPanelPDFV2
getJobParams={getPDFJobParamsDefaultV2}
onClose={closePopover}
/>
),
},
{
id: 5,
title: 'Default layout V2',
content: (
<reporting.components.ReportingPanelPNGV2
getJobParams={getPNGJobParamsDefaultV2}
onClose={closePopover}
/>
),
},
];
return (
@ -124,9 +202,11 @@ export const ReportingExampleApp = ({
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<EuiTitle>
<h2>Example of a Sharing menu using components from Reporting</h2>
</EuiTitle>
<EuiSpacer />
<EuiText>
<p>Example of a Sharing menu using components from Reporting</p>
<EuiPopover
id="contextMenuExample"
button={<EuiButton onClick={onButtonClick}>Share</EuiButton>}
@ -140,8 +220,29 @@ export const ReportingExampleApp = ({
<EuiHorizontalRule />
<div data-shared-items-container data-shared-items-count="4">
<div data-shared-items-container data-shared-items-count="5">
<EuiFlexGroup gutterSize="l">
<EuiFlexItem data-shared-item>
{forwardedParams ? (
<>
<EuiText>
<p>
<strong>Forwarded app state</strong>
</p>
</EuiText>
<EuiCodeBlock>{JSON.stringify(forwardedParams)}</EuiCodeBlock>
</>
) : (
<>
<EuiText>
<p>
<strong>No forwarded app state found</strong>
</p>
</EuiText>
<EuiCodeBlock>{'{}'}</EuiCodeBlock>
</>
)}
</EuiFlexItem>
{logos.map((item, index) => (
<EuiFlexItem key={index} data-shared-item>
<EuiCard

View file

@ -12,11 +12,11 @@ import {
CoreStart,
Plugin,
} from '../../../../src/core/public';
import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import { SetupDeps, StartDeps } from './types';
import { PLUGIN_ID, PLUGIN_NAME, ReportingExampleLocatorDefinition } from '../common';
import { SetupDeps, StartDeps, MyForwardableState } from './types';
export class ReportingExamplePlugin implements Plugin<void, void, {}, {}> {
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<void, void, {}, {}> {
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<void, void, {}, {}> {
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() {}

View file

@ -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<string, unknown>;

View file

@ -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',

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// 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<string, unknown> }): boolean =>
Array.isArray(sharingData.locatorParams);

View file

@ -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
@ -169,8 +176,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;

View file

@ -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';

View file

@ -19,7 +19,7 @@ interface ContextValue {
const InternalApiClientContext = createContext<undefined | ContextValue>(undefined);
export const InternalApiClientClientProvider: FunctionComponent<{
export const InternalApiClientProvider: FunctionComponent<{
apiClient: ReportingAPIClient;
}> = ({ apiClient, children }) => {
const {

View file

@ -9,4 +9,4 @@ export * from './reporting_api_client';
export * from './hooks';
export { InternalApiClientClientProvider, useInternalApiClient } from './context';
export { InternalApiClientProvider, useInternalApiClient } from './context';

View file

@ -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(
<KibanaContextProvider
services={{ http: coreSetup.http, application: coreStart.application }}
>
<InternalApiClientClientProvider apiClient={apiClient}>
<InternalApiClientProvider apiClient={apiClient}>
<IlmPolicyStatusContextProvider>
<ReportListing
toasts={coreSetup.notifications.toasts}
@ -43,7 +43,7 @@ export async function mountManagementSection(
urlService={urlService}
/>
</IlmPolicyStatusContextProvider>
</InternalApiClientClientProvider>
</InternalApiClientProvider>
</KibanaContextProvider>
</I18nProvider>,
params.element

View file

@ -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>) => (
<KibanaContextProvider services={{ http: httpService, application: applicationService }}>
<InternalApiClientClientProvider apiClient={reportingAPIClient as ReportingAPIClient}>
<InternalApiClientProvider apiClient={reportingAPIClient as ReportingAPIClient}>
<IlmPolicyStatusContextProvider>
<ReportListing
license$={license$}
@ -96,7 +96,7 @@ describe('ReportListing', () => {
{...props}
/>
</IlmPolicyStatusContextProvider>
</InternalApiClientClientProvider>
</InternalApiClientProvider>
</KibanaContextProvider>
),
{ memoryRouter: { wrapComponent: false } }

View file

@ -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(),

View file

@ -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';

View file

@ -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(
<EuiErrorBoundary>
<RedirectApp apiClient={apiClient} history={history} share={share} />
</EuiErrorBoundary>,
element
);
return () => {
unmountComponentAtNode(element);
};
};

View file

@ -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<Props> = ({ share }) => {
const [error, setError] = useState<undefined | Error>();
useEffect(() => {
try {
const locatorParams = ((window as unknown) as Record<string, LocatorParams>)[
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 ? (
<EuiCallOut title={i18nTexts.errorTitle} color="danger">
<p>{error.message}</p>
{error.stack && <EuiCodeBlock>{error.stack}</EuiCodeBlock>}
</EuiCallOut>
) : (
<EuiTitle>
<h1>{i18nTexts.redirectingTitle}</h1>
</EuiTitle>
);
};

View file

@ -24,6 +24,7 @@ export interface ExportPanelShareOpts {
export interface ReportingSharingData {
title: string;
layout: LayoutParams;
[key: string]: unknown;
}
export interface JobParamsProviderOptions {

View file

@ -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}
/>

View file

@ -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<Props, State> {
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;

View file

@ -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<PanelPropsScreenCapture, 'getJobParams' | 'layoutOption'> & IncludeOnCloseFn;
type Props = Pick<PanelPropsScreenCapture, 'getJobParams' | 'layoutOption'> & 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<PanelPropsScreenCapture, 'getJobParams' | 'layoutOption'> &
*/
export function getSharedComponents(core: CoreSetup, apiClient: ReportingAPIClient) {
return {
ReportingPanelPDF(props: PropsPDF) {
ReportingPanelPDF(props: Props) {
return (
<ScreenCapturePanelContent
layoutOption={props.layoutOption}
@ -38,5 +38,31 @@ export function getSharedComponents(core: CoreSetup, apiClient: ReportingAPIClie
/>
);
},
ReportingPanelPDFV2(props: Props) {
return (
<ScreenCapturePanelContent
layoutOption={props.layoutOption}
requiresSavedState={false}
reportType={PDF_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
{...props}
/>
);
},
ReportingPanelPNGV2(props: Props) {
return (
<ScreenCapturePanelContent
layoutOption={props.layoutOption}
requiresSavedState={false}
reportType={PNG_REPORT_TYPE_V2}
apiClient={apiClient}
toasts={core.notifications.toasts}
uiSettings={core.uiSettings}
{...props}
/>
);
},
};
}

View file

@ -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);
};

View file

@ -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<void> {
@ -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);

View file

@ -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,

View file

@ -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;

View file

@ -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,

View file

@ -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;

View file

@ -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';

View file

@ -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==`;

View file

@ -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 {

View file

@ -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,
},
};
};

View file

@ -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;
}

View file

@ -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;

View file

@ -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<

View file

@ -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<JobParamsPNGV2, TaskPayloadPNGV2>
> = 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(),
};
};
};

View file

@ -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<Writable>;
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<string, string>) => {
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);
});

View file

@ -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<TaskPayloadPNGV2>
> = 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<TaskRunResult> = 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();
};
};

View file

@ -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<JobParamsPNGV2>,
RunTaskFn<TaskPayloadPNGV2>
> => ({
...metadata,
jobType,
jobContentEncoding: 'base64',
jobContentExtension: 'PNG',
createJobFnFactory,
runTaskFnFactory,
validLicenses: [
LICENSE_TYPE_TRIAL,
LICENSE_TYPE_STANDARD,
LICENSE_TYPE_GOLD,
LICENSE_TYPE_PLATINUM,
LICENSE_TYPE_ENTERPRISE,
],
});

View file

@ -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',
};

View file

@ -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[];
}

View file

@ -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<

View file

@ -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,

View file

@ -11,6 +11,7 @@ import { BaseParams, BasePayload } from '../../types';
interface BaseParamsPDF {
layout: LayoutParams;
forceNow?: string;
// TODO: Add comment explaining this field
relativeUrls: string[];
}

View file

@ -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<JobParamsPDFV2, TaskPayloadPDFV2>
> = 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(),
};
};
};

View file

@ -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<Writable>;
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<string, string>) => {
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'));
});

View file

@ -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<TaskPayloadPDFV2>
> = 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<TaskRunResult> = 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();
};
};

View file

@ -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<JobParamsPDFV2>,
RunTaskFn<TaskPayloadPDFV2>
> => ({
...metadata,
jobType,
jobContentEncoding: 'base64',
jobContentExtension: 'pdf',
createJobFnFactory,
runTaskFnFactory,
validLicenses: [
LICENSE_TYPE_TRIAL,
LICENSE_TYPE_STANDARD,
LICENSE_TYPE_GOLD,
LICENSE_TYPE_PLATINUM,
LICENSE_TYPE_ENTERPRISE,
],
});

View file

@ -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$;
};
}

View file

@ -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();
},
};
}

View file

@ -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,
};

View file

@ -0,0 +1,11 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const metadata = {
id: 'printablePdfV2',
name: 'PDF',
};

View file

@ -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;
}

View file

@ -21,5 +21,4 @@ export {
ReportingSetupDeps as PluginSetup,
ReportingStartDeps as PluginStart,
} from './types';
export { ReportingPlugin as Plugin };

View file

@ -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());

View file

@ -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;

View file

@ -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',

View file

@ -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<ScreenshotResults[]> {
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<ScreenSetupData> = 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()
);
}),

View file

@ -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<void> => {
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(

View file

@ -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<ReportingRequestHandlerContext>();
const basePath = http.basePath;

View file

@ -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<ReturnType<typeof setupServer>>;

View file

@ -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 './';

View file

@ -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;

View file

@ -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 {