[Screenshot mode] Create plugin to provide "screenshot mode" awareness (#99627)

* initial version of the screenshot mode service

* First iteration of client side of screenshot mode plugin

Also hooked it up to the chromium browser imitating the preload
functionality of electron to set up the environment before
code runs.

* First implementation of server-side logic for detecting
screenshot mode

* fix some type issues and do a small refactor

* fix size limits, docs and ts issues

* fixed types issues and made sure screenshot mode is correctly detected on the client

* Moved the screenshot mode header definition to common
Added a server-side example for screenshot mode
Export the screenshot mode header in both public and server

* move require() to screenshotMode plugin

* Update chromium_driver.ts

* cleaned up some comments, minor refactor in ReportingCore and
changed the screenshotmode detection function to check for a
specific value.

* fix export

* Expanded server-side screenshot mode contract with function that
checks a kibana request to determine whether we in screenshot
mode

* added comments to explain use of literal value rather than external reference

* updated comment

* update reporting example

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Timothy Sullivan <tsullivan@elastic.co>
Co-authored-by: Tim Sullivan <tsullivan@users.noreply.github.com>
This commit is contained in:
Jean-Louis Leysens 2021-05-19 16:03:27 +02:00 committed by GitHub
parent 8f1bf66a7b
commit f97aad30f4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
52 changed files with 907 additions and 49 deletions

View file

@ -181,6 +181,10 @@ Content is fetched from the remote (https://feeds.elastic.co and https://feeds-s
oss plugins.
|{kib-repo}blob/{branch}/src/plugins/screenshot_mode/README.md[screenshotMode]
|The service exposed by this plugin informs consumers whether they should optimize for non-interactivity. In this way plugins can avoid loading unnecessary code, data or other services.
|{kib-repo}blob/{branch}/src/plugins/security_oss/README.md[securityOss]
|securityOss is responsible for educating users about Elastic's free security features,
so they can properly protect the data within their clusters.

View file

@ -0,0 +1,7 @@
{
"prefix": "screenshotModeExample",
"paths": {
"screenshotModeExample": "."
},
"translations": ["translations/ja-JP.json"]
}

View file

@ -0,0 +1,9 @@
# screenshotModeExample
A Kibana plugin
---
## Development
See the [kibana contributing guide](https://github.com/elastic/kibana/blob/master/CONTRIBUTING.md) for instructions setting up your development environment.

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const PLUGIN_NAME = 'Screenshot mode example app';
export const BASE_API_ROUTE = '/api/screenshot_mode_example';

View file

@ -0,0 +1,9 @@
{
"id": "screenshotModeExample",
"version": "1.0.0",
"kibanaVersion": "kibana",
"server": true,
"ui": true,
"requiredPlugins": ["navigation", "screenshotMode", "usageCollection"],
"optionalPlugins": []
}

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, CoreStart } from '../../../src/core/public';
import { AppPluginSetupDependencies, AppPluginStartDependencies } from './types';
import { ScreenshotModeExampleApp } from './components/app';
export const renderApp = (
{ notifications, http }: CoreStart,
{ screenshotMode }: AppPluginSetupDependencies,
{ navigation }: AppPluginStartDependencies,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(
<ScreenshotModeExampleApp
basename={appBasePath}
notifications={notifications}
http={http}
navigation={navigation}
screenshotMode={screenshotMode}
/>,
element
);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -0,0 +1,122 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import { FormattedMessage, I18nProvider } from '@kbn/i18n/react';
import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageContentHeader,
EuiPageHeader,
EuiTitle,
EuiText,
} from '@elastic/eui';
import { CoreStart } from '../../../../src/core/public';
import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
import {
ScreenshotModePluginSetup,
KBN_SCREENSHOT_MODE_HEADER,
} from '../../../../src/plugins/screenshot_mode/public';
import { PLUGIN_NAME, BASE_API_ROUTE } from '../../common';
interface ScreenshotModeExampleAppDeps {
basename: string;
notifications: CoreStart['notifications'];
http: CoreStart['http'];
navigation: NavigationPublicPluginStart;
screenshotMode: ScreenshotModePluginSetup;
}
export const ScreenshotModeExampleApp = ({
basename,
notifications,
http,
navigation,
screenshotMode,
}: ScreenshotModeExampleAppDeps) => {
const isScreenshotMode = screenshotMode.isScreenshotMode();
useEffect(() => {
// fire and forget
http.get(`${BASE_API_ROUTE}/check_is_screenshot`, {
headers: isScreenshotMode ? { [KBN_SCREENSHOT_MODE_HEADER]: 'true' } : undefined,
});
notifications.toasts.addInfo({
title: 'Welcome to the screenshot example app!',
text: isScreenshotMode
? 'In screenshot mode we want this to remain visible'
: 'In normal mode this toast will disappear eventually',
toastLifeTimeMs: isScreenshotMode ? 360000 : 3000,
});
}, [isScreenshotMode, notifications, http]);
return (
<Router basename={basename}>
<I18nProvider>
<>
<navigation.ui.TopNavMenu
appName={PLUGIN_NAME}
showSearchBar={true}
useDefaultBehaviors={true}
/>
<EuiPage restrictWidth="1000px">
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>
<FormattedMessage
id="screenshotModeExample.helloWorldText"
defaultMessage="{name}"
values={{ name: PLUGIN_NAME }}
/>
</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentHeader>
<EuiTitle>
<h2>
{isScreenshotMode ? (
<FormattedMessage
id="screenshotModeExample.screenshotModeTitle"
defaultMessage="We are in screenshot mode!"
/>
) : (
<FormattedMessage
id="screenshotModeExample.normalModeTitle"
defaultMessage="We are not in screenshot mode!"
/>
)}
</h2>
</EuiTitle>
</EuiPageContentHeader>
<EuiPageContentBody>
<EuiText>
{isScreenshotMode ? (
<p>We detected screenshot mode. The chrome navbar should be hidden.</p>
) : (
<p>
This is how the app looks in normal mode. The chrome navbar should be
visible.
</p>
)}
</EuiText>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
</EuiPage>
</>
</I18nProvider>
</Router>
);
};

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import './index.scss';
import { ScreenshotModeExamplePlugin } from './plugin';
// This exports static code and TypeScript types,
// as well as, Kibana Platform `plugin()` initializer.
export function plugin() {
return new ScreenshotModeExamplePlugin();
}

View file

@ -0,0 +1,48 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '../../../src/core/public';
import { AppPluginSetupDependencies, AppPluginStartDependencies } from './types';
import { MetricsTracking } from './services';
import { PLUGIN_NAME } from '../common';
export class ScreenshotModeExamplePlugin implements Plugin<void, void> {
uiTracking = new MetricsTracking();
public setup(core: CoreSetup, depsSetup: AppPluginSetupDependencies): void {
const { screenshotMode, usageCollection } = depsSetup;
const isScreenshotMode = screenshotMode.isScreenshotMode();
this.uiTracking.setup({
disableTracking: isScreenshotMode, // In screenshot mode there will be no user interactions to track
usageCollection,
});
// Register an application into the side navigation menu
core.application.register({
id: 'screenshotModeExample',
title: PLUGIN_NAME,
async mount(params: AppMountParameters) {
// Load application bundle
const { renderApp } = await import('./application');
// Get start services as specified in kibana.json
const [coreStart, depsStart] = await core.getStartServices();
// For screenshots we don't need to have the top bar visible
coreStart.chrome.setIsVisible(!isScreenshotMode);
// Render the application
return renderApp(coreStart, depsSetup, depsStart as AppPluginStartDependencies, params);
},
});
}
public start(core: CoreStart): void {}
public stop() {}
}

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { MetricsTracking } from './metrics_tracking';

View file

@ -0,0 +1,37 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { UiCounterMetricType, METRIC_TYPE } from '@kbn/analytics';
import { PLUGIN_NAME } from '../../common';
import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/public';
export class MetricsTracking {
private trackingDisabled = false;
private usageCollection?: UsageCollectionSetup;
private track(eventName: string, type: UiCounterMetricType) {
if (this.trackingDisabled) return;
this.usageCollection?.reportUiCounter(PLUGIN_NAME, type, eventName);
}
public setup({
disableTracking,
usageCollection,
}: {
disableTracking?: boolean;
usageCollection: UsageCollectionSetup;
}) {
this.usageCollection = usageCollection;
if (disableTracking) this.trackingDisabled = true;
}
public trackInit() {
this.track('init', METRIC_TYPE.LOADED);
}
}

View file

@ -0,0 +1,20 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { NavigationPublicPluginStart } from '../../../src/plugins/navigation/public';
import { ScreenshotModePluginSetup } from '../../../src/plugins/screenshot_mode/public';
import { UsageCollectionSetup } from '../../../src/plugins/usage_collection/public';
export interface AppPluginSetupDependencies {
usageCollection: UsageCollectionSetup;
screenshotMode: ScreenshotModePluginSetup;
}
export interface AppPluginStartDependencies {
navigation: NavigationPublicPluginStart;
}

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PluginInitializerContext } from 'kibana/server';
import { ScreenshotModeExamplePlugin } from './plugin';
export const plugin = (ctx: PluginInitializerContext) => new ScreenshotModeExamplePlugin(ctx);

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Plugin, PluginInitializerContext, CoreSetup, Logger } from 'kibana/server';
import { ScreenshotModePluginSetup } from '../../../src/plugins/screenshot_mode/server';
import { RouteDependencies } from './types';
import { registerRoutes } from './routes';
export class ScreenshotModeExamplePlugin implements Plugin<void, void> {
log: Logger;
constructor(ctx: PluginInitializerContext) {
this.log = ctx.logger.get();
}
setup(core: CoreSetup, { screenshotMode }: { screenshotMode: ScreenshotModePluginSetup }): void {
const deps: RouteDependencies = {
screenshotMode,
router: core.http.createRouter(),
log: this.log,
};
registerRoutes(deps);
}
start() {}
stop() {}
}

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RouteDependencies } from './types';
import { BASE_API_ROUTE } from '../common';
export const registerRoutes = ({ router, log, screenshotMode }: RouteDependencies) => {
router.get(
{ path: `${BASE_API_ROUTE}/check_is_screenshot`, validate: false },
async (ctx, req, res) => {
log.info(`Reading screenshot mode from a request: ${screenshotMode.isScreenshotMode(req)}`);
log.info(`Reading is screenshot mode from ctx: ${ctx.screenshotMode.isScreenshot}`);
return res.ok();
}
);
};

View file

@ -0,0 +1,19 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { IRouter, Logger } from 'kibana/server';
import { ScreenshotModeRequestHandlerContext } from '../../../src/plugins/screenshot_mode/server';
import { ScreenshotModePluginSetup } from '../../../src/plugins/screenshot_mode/server';
export type ScreenshotModeExampleRouter = IRouter<ScreenshotModeRequestHandlerContext>;
export interface RouteDependencies {
screenshotMode: ScreenshotModePluginSetup;
router: ScreenshotModeExampleRouter;
log: Logger;
}

View file

@ -0,0 +1,17 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./target",
"skipLibCheck": true
},
"include": [
"index.ts",
"public/**/*.ts",
"public/**/*.tsx",
"common/**/*.ts",
"server/**/*.ts",
"../../typings/**/*"
],
"exclude": [],
"references": [{ "path": "../../src/core/tsconfig.json" }]
}

View file

@ -110,3 +110,4 @@ pageLoadAssetSize:
mapsEms: 26072
timelines: 28613
cases: 162385
screenshotMode: 17856

View file

@ -0,0 +1,7 @@
{
"prefix": "screenshotMode",
"paths": {
"screenshotMode": "."
},
"translations": ["translations/ja-JP.json"]
}

View file

@ -0,0 +1,27 @@
# Screenshot Mode
The service exposed by this plugin informs consumers whether they should optimize for non-interactivity. In this way plugins can avoid loading unnecessary code, data or other services.
The primary intention is to inform other lower-level plugins (plugins that don't depend on other plugins) that we do not expect an actual user to interact with browser. In this way we can avoid loading unnecessary resources (code and data).
**NB** This plugin should have no other dependencies to avoid any possibility of circular dependencies.
---
## Development
### How to test in screenshot mode
Please note: the following information is subject to change over time.
In order to test whether we are correctly detecting screenshot mode, developers can run the following JS snippet:
```js
window.localStorage.setItem('__KBN_SCREENSHOT_MODE_ENABLED_KEY__', true);
```
To get out of screenshot mode, run the following snippet:
```js
window.localStorage.removeItem('__KBN_SCREENSHOT_MODE_ENABLED_KEY__');
```

View file

@ -0,0 +1,9 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const KBN_SCREENSHOT_MODE_HEADER = 'x-kbn-screenshot-mode'.toLowerCase();

View file

@ -0,0 +1,63 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
// **PLEASE NOTE**
// The functionality in this file targets a browser environment and is intended to be used both in public and server.
// For instance, reporting uses these functions when starting puppeteer to set the current browser into "screenshot" mode.
export const KBN_SCREENSHOT_MODE_ENABLED_KEY = '__KBN_SCREENSHOT_MODE_ENABLED_KEY__';
/**
* This function is responsible for detecting whether we are currently in screenshot mode.
*
* We check in the current window context whether screenshot mode is enabled, otherwise we check
* localStorage. The ability to set a value in localStorage enables more convenient development and testing
* in functionality that needs to detect screenshot mode.
*/
export const getScreenshotMode = (): boolean => {
return (
((window as unknown) as Record<string, unknown>)[KBN_SCREENSHOT_MODE_ENABLED_KEY] === true ||
window.localStorage.getItem(KBN_SCREENSHOT_MODE_ENABLED_KEY) === 'true'
);
};
/**
* Use this function to set the current browser to screenshot mode.
*
* This function should be called as early as possible to ensure that screenshot mode is
* correctly detected for the first page load. It is not suitable for use inside any plugin
* code unless the plugin code is guaranteed to, somehow, load before any other code.
*
* Additionally, we don't know what environment this code will run in so we remove as many external
* references as possible to make it portable. For instance, running inside puppeteer.
*/
export const setScreenshotModeEnabled = () => {
Object.defineProperty(
window,
'__KBN_SCREENSHOT_MODE_ENABLED_KEY__', // Literal value to prevent adding an external reference
{
enumerable: true,
writable: true,
configurable: false,
value: true,
}
);
};
export const setScreenshotModeDisabled = () => {
Object.defineProperty(
window,
'__KBN_SCREENSHOT_MODE_ENABLED_KEY__', // Literal value to prevent adding an external reference
{
enumerable: true,
writable: true,
configurable: false,
value: undefined,
}
);
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export {
getScreenshotMode,
setScreenshotModeEnabled,
setScreenshotModeDisabled,
} from './get_set_browser_screenshot_mode';
export { KBN_SCREENSHOT_MODE_HEADER } from './constants';

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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/screenshot_mode'],
};

View file

@ -0,0 +1,9 @@
{
"id": "screenshotMode",
"version": "1.0.0",
"kibanaVersion": "kibana",
"ui": true,
"server": true,
"requiredPlugins": [],
"optionalPlugins": []
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ScreenshotModePlugin } from './plugin';
export function plugin() {
return new ScreenshotModePlugin();
}
export { KBN_SCREENSHOT_MODE_HEADER, setScreenshotModeEnabled } from '../common';
export { ScreenshotModePluginSetup } from './types';

View file

@ -0,0 +1,43 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { coreMock } from '../../../../src/core/public/mocks';
import { ScreenshotModePlugin } from './plugin';
import { setScreenshotModeEnabled, setScreenshotModeDisabled } from '../common';
describe('Screenshot mode public', () => {
let plugin: ScreenshotModePlugin;
beforeEach(() => {
plugin = new ScreenshotModePlugin();
});
afterAll(() => {
setScreenshotModeDisabled();
});
describe('setup contract', () => {
it('detects screenshot mode "true"', () => {
setScreenshotModeEnabled();
const screenshotMode = plugin.setup(coreMock.createSetup());
expect(screenshotMode.isScreenshotMode()).toBe(true);
});
it('detects screenshot mode "false"', () => {
setScreenshotModeDisabled();
const screenshotMode = plugin.setup(coreMock.createSetup());
expect(screenshotMode.isScreenshotMode()).toBe(false);
});
});
describe('start contract', () => {
it('returns nothing', () => {
expect(plugin.start(coreMock.createStart())).toBe(undefined);
});
});
});

View file

@ -0,0 +1,25 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CoreSetup, CoreStart, Plugin } from '../../../core/public';
import { ScreenshotModePluginSetup } from './types';
import { getScreenshotMode } from '../common';
export class ScreenshotModePlugin implements Plugin<ScreenshotModePluginSetup> {
public setup(core: CoreSetup): ScreenshotModePluginSetup {
return {
isScreenshotMode: () => getScreenshotMode() === true,
};
}
public start(core: CoreStart) {}
public stop() {}
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export interface IScreenshotModeService {
/**
* Returns a boolean indicating whether the current user agent (browser) would like to view UI optimized for
* screenshots or printing.
*/
isScreenshotMode: () => boolean;
}
export type ScreenshotModePluginSetup = IScreenshotModeService;

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { ScreenshotModePlugin } from './plugin';
export { setScreenshotModeEnabled, KBN_SCREENSHOT_MODE_HEADER } from '../common';
export {
ScreenshotModeRequestHandlerContext,
ScreenshotModePluginSetup,
ScreenshotModePluginStart,
} from './types';
export function plugin() {
return new ScreenshotModePlugin();
}

View file

@ -0,0 +1,25 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { httpServerMock } from 'src/core/server/mocks';
import { KBN_SCREENSHOT_MODE_HEADER } from '../common';
import { isScreenshotMode } from './is_screenshot_mode';
const { createKibanaRequest } = httpServerMock;
describe('isScreenshotMode', () => {
test('screenshot headers are present', () => {
expect(
isScreenshotMode(createKibanaRequest({ headers: { [KBN_SCREENSHOT_MODE_HEADER]: 'true' } }))
).toBe(true);
});
test('screenshot headers are not present', () => {
expect(isScreenshotMode(createKibanaRequest())).toBe(false);
});
});

View file

@ -0,0 +1,16 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { KibanaRequest } from 'src/core/server';
import { KBN_SCREENSHOT_MODE_HEADER } from '../common';
export const isScreenshotMode = (request: KibanaRequest): boolean => {
return Object.keys(request.headers).some((header) => {
return header.toLowerCase() === KBN_SCREENSHOT_MODE_HEADER;
});
};

View file

@ -0,0 +1,47 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Plugin, CoreSetup } from '../../../core/server';
import {
ScreenshotModeRequestHandlerContext,
ScreenshotModePluginSetup,
ScreenshotModePluginStart,
} from './types';
import { isScreenshotMode } from './is_screenshot_mode';
export class ScreenshotModePlugin
implements Plugin<ScreenshotModePluginSetup, ScreenshotModePluginStart> {
public setup(core: CoreSetup): ScreenshotModePluginSetup {
core.http.registerRouteHandlerContext<ScreenshotModeRequestHandlerContext, 'screenshotMode'>(
'screenshotMode',
(ctx, req) => {
return {
isScreenshot: isScreenshotMode(req),
};
}
);
// We use "require" here to ensure the import does not have external references due to code bundling that
// commonly happens during transpiling. External references would be missing in the environment puppeteer creates.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const { setScreenshotModeEnabled } = require('../common');
return {
setScreenshotModeEnabled,
isScreenshotMode,
};
}
public start(): ScreenshotModePluginStart {
return {
isScreenshotMode,
};
}
public stop() {}
}

View file

@ -0,0 +1,35 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RequestHandlerContext, KibanaRequest } from 'src/core/server';
/**
* Any context that requires access to the screenshot mode flag but does not have access
* to request context {@link ScreenshotModeRequestHandlerContext}, for instance if they are pre-context,
* can use this function to check whether the request originates from a client that is in screenshot mode.
*/
type IsScreenshotMode = (request: KibanaRequest) => boolean;
export interface ScreenshotModePluginSetup {
isScreenshotMode: IsScreenshotMode;
/**
* Set the current environment to screenshot mode. Intended to run in a browser-environment.
*/
setScreenshotModeEnabled: () => void;
}
export interface ScreenshotModePluginStart {
isScreenshotMode: IsScreenshotMode;
}
export interface ScreenshotModeRequestHandlerContext extends RequestHandlerContext {
screenshotMode: {
isScreenshot: boolean;
};
}

View file

@ -0,0 +1,18 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"composite": true,
"outDir": "./target/types",
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true
},
"include": [
"common/**/*",
"public/**/*",
"server/**/*"
],
"references": [
{ "path": "../../core/tsconfig.json" },
]
}

View file

@ -5,5 +5,5 @@
"server": false,
"ui": true,
"optionalPlugins": [],
"requiredPlugins": ["reporting", "developerExamples", "navigation"]
"requiredPlugins": ["reporting", "developerExamples", "navigation", "screenshotMode"]
}

View file

@ -8,18 +8,15 @@
import React from 'react';
import ReactDOM from 'react-dom';
import { AppMountParameters, CoreStart } from '../../../../src/core/public';
import { StartDeps } from './types';
import { SetupDeps, StartDeps } from './types';
import { ReportingExampleApp } from './components/app';
export const renderApp = (
coreStart: CoreStart,
startDeps: StartDeps,
deps: Omit<StartDeps & SetupDeps, 'developerExamples'>,
{ appBasePath, element }: AppMountParameters
) => {
ReactDOM.render(
<ReportingExampleApp basename={appBasePath} {...coreStart} {...startDeps} />,
element
);
ReactDOM.render(<ReportingExampleApp basename={appBasePath} {...coreStart} {...deps} />, element);
return () => ReactDOM.unmountComponentAtNode(element);
};

View file

@ -26,6 +26,7 @@ import React, { useEffect, useState } from 'react';
import { BrowserRouter as Router } from 'react-router-dom';
import * as Rx from 'rxjs';
import { takeWhile } from 'rxjs/operators';
import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/public';
import { CoreStart } from '../../../../../src/core/public';
import { NavigationPublicPluginStart } from '../../../../../src/plugins/navigation/public';
import { constants, ReportingStart } from '../../../../../x-pack/plugins/reporting/public';
@ -37,6 +38,7 @@ interface ReportingExampleAppDeps {
http: CoreStart['http'];
navigation: NavigationPublicPluginStart;
reporting: ReportingStart;
screenshotMode: ScreenshotModePluginSetup;
}
const sourceLogos = ['Beats', 'Cloud', 'Logging', 'Kibana'];
@ -46,6 +48,7 @@ export const ReportingExampleApp = ({
notifications,
http,
reporting,
screenshotMode,
}: ReportingExampleAppDeps) => {
const { getDefaultLayoutSelectors, ReportingAPIClient } = reporting;
const [logos, setLogos] = useState<string[]>([]);
@ -125,6 +128,8 @@ export const ReportingExampleApp = ({
</EuiFlexItem>
))}
</EuiFlexGroup>
<p>Screenshot Mode is {screenshotMode.isScreenshotMode() ? 'ON' : 'OFF'}!</p>
</div>
</EuiText>
</EuiPageContentBody>

View file

@ -16,7 +16,7 @@ import { PLUGIN_ID, PLUGIN_NAME } from '../common';
import { SetupDeps, StartDeps } from './types';
export class ReportingExamplePlugin implements Plugin<void, void, {}, {}> {
public setup(core: CoreSetup, { developerExamples, ...depsSetup }: SetupDeps): void {
public setup(core: CoreSetup, { developerExamples, screenshotMode }: SetupDeps): void {
core.application.register({
id: PLUGIN_ID,
title: PLUGIN_NAME,
@ -30,7 +30,7 @@ export class ReportingExamplePlugin implements Plugin<void, void, {}, {}> {
unknown
];
// Render the application
return renderApp(coreStart, { ...depsSetup, ...depsStart }, params);
return renderApp(coreStart, { ...depsStart, screenshotMode }, params);
},
});

View file

@ -5,8 +5,9 @@
* 2.0.
*/
import { NavigationPublicPluginStart } from 'src/plugins/navigation/public';
import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/public';
import { DeveloperExamplesSetup } from '../../../../examples/developer_examples/public';
import { NavigationPublicPluginStart } from '../../../../src/plugins/navigation/public';
import { ReportingStart } from '../../../plugins/reporting/public';
// eslint-disable-next-line @typescript-eslint/no-empty-interface
@ -16,6 +17,7 @@ export interface PluginStart {}
export interface SetupDeps {
developerExamples: DeveloperExamplesSetup;
screenshotMode: ScreenshotModePluginSetup;
}
export interface StartDeps {
navigation: NavigationPublicPluginStart;

View file

@ -2,11 +2,7 @@
"id": "reporting",
"version": "8.0.0",
"kibanaVersion": "kibana",
"optionalPlugins": [
"security",
"spaces",
"usageCollection"
],
"optionalPlugins": ["security", "spaces", "usageCollection"],
"configPath": ["xpack", "reporting"],
"requiredPlugins": [
"data",
@ -16,13 +12,11 @@
"uiActions",
"taskManager",
"embeddable",
"screenshotMode",
"share",
"features"
],
"server": true,
"ui": true,
"requiredBundles": [
"kibanaReact",
"discover"
]
"requiredBundles": ["kibanaReact", "discover"]
}

View file

@ -11,6 +11,8 @@ import open from 'opn';
import puppeteer, { ElementHandle, EvaluateFn, SerializableOrJSHandle } from 'puppeteer';
import { parse as parseUrl } from 'url';
import { getDisallowedOutgoingUrlError } from '../';
import { ReportingCore } from '../../..';
import { KBN_SCREENSHOT_MODE_HEADER } from '../../../../../../../src/plugins/screenshot_mode/server';
import { ConditionalHeaders, ConditionalHeadersConditions } from '../../../export_types/common';
import { LevelLogger } from '../../../lib';
import { ViewZoomWidthHeight } from '../../../lib/layouts/layout';
@ -59,8 +61,14 @@ export class HeadlessChromiumDriver {
private listenersAttached = false;
private interceptedCount = 0;
private core: ReportingCore;
constructor(page: puppeteer.Page, { inspect, networkPolicy }: ChromiumDriverOptions) {
constructor(
core: ReportingCore,
page: puppeteer.Page,
{ inspect, networkPolicy }: ChromiumDriverOptions
) {
this.core = core;
this.page = page;
this.inspect = inspect;
this.networkPolicy = networkPolicy;
@ -98,6 +106,8 @@ export class HeadlessChromiumDriver {
// Reset intercepted request count
this.interceptedCount = 0;
const enableScreenshotMode = this.core.getEnableScreenshotMode();
await this.page.evaluateOnNewDocument(enableScreenshotMode);
await this.page.setRequestInterception(true);
this.registerListeners(conditionalHeaders, logger);
@ -261,6 +271,7 @@ export class HeadlessChromiumDriver {
{
...interceptedRequest.request.headers,
...conditionalHeaders.headers,
[KBN_SCREENSHOT_MODE_HEADER]: 'true',
},
(value, name) => ({
name,

View file

@ -15,6 +15,7 @@ import * as Rx from 'rxjs';
import { InnerSubscriber } from 'rxjs/internal/InnerSubscriber';
import { ignoreElements, map, mergeMap, tap } from 'rxjs/operators';
import { getChromiumDisconnectedError } from '../';
import { ReportingCore } from '../../..';
import { BROWSER_TYPE } from '../../../../common/constants';
import { durationToNumber } from '../../../../common/schema_utils';
import { CaptureConfig } from '../../../../server/types';
@ -32,11 +33,14 @@ export class HeadlessChromiumDriverFactory {
private browserConfig: BrowserConfig;
private userDataDir: string;
private getChromiumArgs: (viewport: ViewportConfig) => string[];
private core: ReportingCore;
constructor(binaryPath: string, captureConfig: CaptureConfig, logger: LevelLogger) {
constructor(core: ReportingCore, binaryPath: string, logger: LevelLogger) {
this.core = core;
this.binaryPath = binaryPath;
this.captureConfig = captureConfig;
this.browserConfig = captureConfig.browser.chromium;
const config = core.getConfig();
this.captureConfig = config.get('capture');
this.browserConfig = this.captureConfig.browser.chromium;
if (this.browserConfig.disableSandbox) {
logger.warning(`Enabling the Chromium sandbox provides an additional layer of protection.`);
@ -138,7 +142,7 @@ export class HeadlessChromiumDriverFactory {
this.getProcessLogger(browser, logger).subscribe();
// HeadlessChromiumDriver: object to "drive" a browser page
const driver = new HeadlessChromiumDriver(page, {
const driver = new HeadlessChromiumDriver(this.core, page, {
inspect: !!this.browserConfig.inspect,
networkPolicy: this.captureConfig.networkPolicy,
});

View file

@ -7,15 +7,15 @@
import { i18n } from '@kbn/i18n';
import { BrowserDownload } from '../';
import { CaptureConfig } from '../../../server/types';
import { ReportingCore } from '../../../server';
import { LevelLogger } from '../../lib';
import { HeadlessChromiumDriverFactory } from './driver_factory';
import { ChromiumArchivePaths } from './paths';
export const chromium: BrowserDownload = {
paths: new ChromiumArchivePaths(),
createDriverFactory: (binaryPath: string, captureConfig: CaptureConfig, logger: LevelLogger) =>
new HeadlessChromiumDriverFactory(binaryPath, captureConfig, logger),
createDriverFactory: (core: ReportingCore, binaryPath: string, logger: LevelLogger) =>
new HeadlessChromiumDriverFactory(core, binaryPath, logger),
};
export const getChromiumDisconnectedError = () =>

View file

@ -6,9 +6,8 @@
*/
import { first } from 'rxjs/operators';
import { ReportingConfig } from '../';
import { ReportingCore } from '../';
import { LevelLogger } from '../lib';
import { CaptureConfig } from '../types';
import { chromium, ChromiumArchivePaths } from './chromium';
import { HeadlessChromiumDriverFactory } from './chromium/driver_factory';
import { installBrowser } from './install';
@ -18,8 +17,8 @@ export { HeadlessChromiumDriver } from './chromium/driver';
export { HeadlessChromiumDriverFactory } from './chromium/driver_factory';
type CreateDriverFactory = (
core: ReportingCore,
binaryPath: string,
captureConfig: CaptureConfig,
logger: LevelLogger
) => HeadlessChromiumDriverFactory;
@ -28,12 +27,8 @@ export interface BrowserDownload {
paths: ChromiumArchivePaths;
}
export const initializeBrowserDriverFactory = async (
config: ReportingConfig,
logger: LevelLogger
) => {
export const initializeBrowserDriverFactory = async (core: ReportingCore, logger: LevelLogger) => {
const { binaryPath$ } = installBrowser(logger);
const binaryPath = await binaryPath$.pipe(first()).toPromise();
const captureConfig = config.get('capture');
return chromium.createDriverFactory(binaryPath, captureConfig, logger);
return chromium.createDriverFactory(core, binaryPath, logger);
};

View file

@ -8,6 +8,7 @@
import Hapi from '@hapi/hapi';
import * as Rx from 'rxjs';
import { first, map, take } from 'rxjs/operators';
import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server';
import {
BasePath,
IClusterClient,
@ -41,6 +42,7 @@ export interface ReportingInternalSetup {
security?: SecurityPluginSetup;
spaces?: SpacesPluginSetup;
taskManager: TaskManagerSetupContract;
screenshotMode: ScreenshotModePluginSetup;
logger: LevelLogger;
}
@ -237,6 +239,11 @@ export class ReportingCore {
return screenshotsObservableFactory(config.get('capture'), browserDriverFactory);
}
public getEnableScreenshotMode() {
const { screenshotMode } = this.getPluginSetupDeps();
return screenshotMode.setScreenshotModeEnabled;
}
/*
* Gives synchronous access to the setupDeps
*/

View file

@ -19,6 +19,7 @@ jest.mock('puppeteer', () => ({
import moment from 'moment';
import * as Rx from 'rxjs';
import { ReportingCore } from '../..';
import { HeadlessChromiumDriver } from '../../browsers';
import { ConditionalHeaders } from '../../export_types/common';
import {
@ -27,6 +28,7 @@ import {
createMockConfigSchema,
createMockLayoutInstance,
createMockLevelLogger,
createMockReportingCore,
} from '../../test_helpers';
import { ElementsPositionAndAttribute } from './';
import * as contexts from './constants';
@ -37,7 +39,7 @@ import { screenshotsObservableFactory } from './observable';
*/
const logger = createMockLevelLogger();
const reportingConfig = {
const mockSchema = createMockConfigSchema({
capture: {
loadDelay: moment.duration(2, 's'),
timeouts: {
@ -46,12 +48,13 @@ const reportingConfig = {
renderComplete: moment.duration(10, 's'),
},
},
};
const mockSchema = createMockConfigSchema(reportingConfig);
});
const mockConfig = createMockConfig(mockSchema);
const captureConfig = mockConfig.get('capture');
const mockLayout = createMockLayoutInstance(captureConfig);
let core: ReportingCore;
/*
* Tests
*/
@ -59,7 +62,8 @@ describe('Screenshot Observable Pipeline', () => {
let mockBrowserDriverFactory: any;
beforeEach(async () => {
mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {});
core = await createMockReportingCore(mockSchema);
mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, {});
});
it('pipelines a single url into screenshot and timeRange', async () => {
@ -118,7 +122,7 @@ describe('Screenshot Observable Pipeline', () => {
const mockOpen = jest.fn();
// mocks
mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, {
screenshot: mockScreenshot,
open: mockOpen,
});
@ -218,7 +222,7 @@ describe('Screenshot Observable Pipeline', () => {
});
// mocks
mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, {
waitForSelector: mockWaitForSelector,
});
@ -312,7 +316,7 @@ describe('Screenshot Observable Pipeline', () => {
return Rx.never().toPromise();
});
mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, {
getCreatePage: mockGetCreatePage,
waitForSelector: mockWaitForSelector,
});
@ -345,7 +349,7 @@ describe('Screenshot Observable Pipeline', () => {
return Promise.resolve();
}
});
mockBrowserDriverFactory = await createMockBrowserDriverFactory(logger, {
mockBrowserDriverFactory = await createMockBrowserDriverFactory(core, logger, {
evaluate: mockBrowserEvaluate,
});
mockLayout.getViewport = () => null;

View file

@ -48,12 +48,13 @@ export class ReportingPlugin
registerUiSettings(core);
const { http } = core;
const { features, licensing, security, spaces, taskManager } = plugins;
const { screenshotMode, features, licensing, security, spaces, taskManager } = plugins;
const router = http.createRouter<ReportingRequestHandlerContext>();
const basePath = http.basePath;
reportingCore.pluginSetup({
screenshotMode,
features,
licensing,
basePath,
@ -91,9 +92,8 @@ export class ReportingPlugin
// async background start
(async () => {
await reportingCore.pluginSetsUp();
const config = reportingCore.getConfig();
const browserDriverFactory = await initializeBrowserDriverFactory(config, this.logger);
const browserDriverFactory = await initializeBrowserDriverFactory(reportingCore, this.logger);
const store = new ReportingStore(reportingCore, this.logger);
await reportingCore.pluginStart({

View file

@ -8,6 +8,7 @@
import moment from 'moment';
import { Page } from 'puppeteer';
import * as Rx from 'rxjs';
import { ReportingCore } from '..';
import { chromium, HeadlessChromiumDriver, HeadlessChromiumDriverFactory } from '../browsers';
import { LevelLogger } from '../lib';
import { ElementsPositionAndAttribute } from '../lib/screenshots';
@ -96,6 +97,7 @@ const defaultOpts: CreateMockBrowserDriverFactoryOpts = {
};
export const createMockBrowserDriverFactory = async (
core: ReportingCore,
logger: LevelLogger,
opts: Partial<CreateMockBrowserDriverFactoryOpts> = {}
): Promise<HeadlessChromiumDriverFactory> => {
@ -122,9 +124,9 @@ export const createMockBrowserDriverFactory = async (
};
const binaryPath = '/usr/local/share/common/secure/super_awesome_binary';
const mockBrowserDriverFactory = chromium.createDriverFactory(binaryPath, captureConfig, logger);
const mockBrowserDriverFactory = chromium.createDriverFactory(core, binaryPath, logger);
const mockPage = ({ setViewport: () => {} } as unknown) as Page;
const mockBrowserDriver = new HeadlessChromiumDriver(mockPage, {
const mockBrowserDriver = new HeadlessChromiumDriver(core, mockPage, {
inspect: true,
networkPolicy: captureConfig.networkPolicy,
});

View file

@ -8,6 +8,7 @@
import type { IRouter, KibanaRequest, RequestHandlerContext } from 'src/core/server';
// eslint-disable-next-line @kbn/eslint/no-restricted-paths
import { DataPluginStart } from 'src/plugins/data/server/plugin';
import { ScreenshotModePluginSetup } from 'src/plugins/screenshot_mode/server';
import { UsageCollectionSetup } from 'src/plugins/usage_collection/server';
import { PluginSetupContract as FeaturesPluginSetup } from '../../features/server';
import { LicensingPluginSetup } from '../../licensing/server';
@ -32,6 +33,7 @@ export interface ReportingSetupDeps {
spaces?: SpacesPluginSetup;
taskManager: TaskManagerSetupContract;
usageCollection?: UsageCollectionSetup;
screenshotMode: ScreenshotModePluginSetup;
}
export interface ReportingStartDeps {

View file

@ -20,6 +20,7 @@
{ "path": "../../../src/plugins/embeddable/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/management/tsconfig.json" },
{ "path": "../../../src/plugins/screenshot_mode/tsconfig.json" },
{ "path": "../../../src/plugins/share/tsconfig.json" },
{ "path": "../../../src/plugins/ui_actions/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },