Reporting/diagnostics (#74314) (#77124)

* WIP: Adding in new reporting diag tool

* WIP: chrome-binary test + log capturing/error handling

* More wip on diagnostic tool

* More work adding in diagnose routes

* Alter link in description + minor rename of chrome => browser

* Wiring UI to API + some polish on UI flow

* WIP: Add in screenshot diag route

* Adding in screenshot diag route, hooking up client to it

* Add missing lib check + memory check

* Working screenshot test + config check for RAM

* Small test helper consolidation + screenshot diag test

* Delete old i18n translations

* PR feedback, browser tests, rename, re-organize import statements and lite fixes

* Lite rename for consistency

* Remove old validate check i18n

* Add config check

* i18n all the things!

* Docs on diagnostics tool

* Fixes, better readability, spelling and more for diagnostic tool

* Translate a few error messages

* Rename of test => start_logs for clarity. Move to observables

* Gathering logs even during process exit or crash

* Adds a test case for the browser exiting during the diag check

* Tap into browser logs for checking output

* Rename asciidoc diag id

* Remove duplicate shared object message

* Add better comment as to why we merge events + wait for a period of time

* Cloning logger for mirroring browser stderr to kibana output

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Joel Griffith 2020-09-09 19:34:01 -07:00 committed by GitHub
parent 9d69007b82
commit 94c13f610f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 1295 additions and 253 deletions

View file

@ -7,6 +7,7 @@
Having trouble? Here are solutions to common problems you might encounter while using Reporting.
* <<reporting-diagnostics>>
* <<reporting-troubleshooting-system-dependencies>>
* <<reporting-troubleshooting-text-incorrect>>
* <<reporting-troubleshooting-missing-data>>
@ -15,6 +16,11 @@ Having trouble? Here are solutions to common problems you might encounter while
* <<reporting-troubleshooting-puppeteer-debug-logs>>
* <<reporting-troubleshooting-system-requirements>>
[float]
[[reporting-diagnostics]]
=== Reporting Diagnostics
Reporting comes with a built-in utility to try to automatically find common issues. When Kibana is running, navigate to the Report Listing page, and click the "Run reporting diagnostics..." button. This will open up a diagnostic tool that checks various parts of the Kibana deployment to come up with any relevant recommendations.
[float]
[[reporting-troubleshooting-system-dependencies]]
=== System dependencies

View file

@ -16,6 +16,7 @@ export const API_BASE_URL_V1 = '/api/reporting/v1'; //
export const API_BASE_GENERATE_V1 = `${API_BASE_URL_V1}/generate`;
export const API_LIST_URL = '/api/reporting/jobs';
export const API_GENERATE_IMMEDIATE = `${API_BASE_URL_V1}/generate/immediate/csv/saved-object`;
export const API_DIAGNOSE_URL = `${API_BASE_URL}/diagnose`;
export const CONTENT_TYPE_CSV = 'text/csv';
export const CSV_REPORTING_ACTION = 'downloadCsvReport';

View file

@ -0,0 +1,281 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import React, { useState, Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButton,
EuiButtonEmpty,
EuiCallOut,
EuiCodeBlock,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiSpacer,
EuiSteps,
EuiText,
EuiTitle,
} from '@elastic/eui';
import { ReportingAPIClient, DiagnoseResponse } from '../lib/reporting_api_client';
interface Props {
apiClient: ReportingAPIClient;
}
type ResultStatus = 'danger' | 'incomplete' | 'complete';
enum statuses {
configStatus = 'configStatus',
chromeStatus = 'chromeStatus',
screenshotStatus = 'screenshotStatus',
}
interface State {
isFlyoutVisible: boolean;
configStatus: ResultStatus;
chromeStatus: ResultStatus;
screenshotStatus: ResultStatus;
help: string[];
logs: string;
isBusy: boolean;
success: boolean;
}
const initialState: State = {
[statuses.configStatus]: 'incomplete',
[statuses.chromeStatus]: 'incomplete',
[statuses.screenshotStatus]: 'incomplete',
isFlyoutVisible: false,
help: [],
logs: '',
isBusy: false,
success: true,
};
export const ReportDiagnostic = ({ apiClient }: Props) => {
const [state, setStateBase] = useState(initialState);
const setState = (s: Partial<typeof state>) =>
setStateBase({
...state,
...s,
});
const {
configStatus,
isBusy,
screenshotStatus,
chromeStatus,
isFlyoutVisible,
help,
logs,
success,
} = state;
const closeFlyout = () => setState({ ...initialState, isFlyoutVisible: false });
const showFlyout = () => setState({ isFlyoutVisible: true });
const apiWrapper = (apiMethod: () => Promise<DiagnoseResponse>, statusProp: statuses) => () => {
setState({ isBusy: true, [statusProp]: 'incomplete' });
apiMethod()
.then((response) => {
setState({
isBusy: false,
help: response.help,
logs: response.logs,
success: response.success,
[statusProp]: response.success ? 'complete' : 'danger',
});
})
.catch((error) => {
setState({
isBusy: false,
help: [
i18n.translate('xpack.reporting.listing.diagnosticApiCallFailure', {
defaultMessage: `There was a problem running the diagnostic: {error}`,
values: { error },
}),
],
logs: `${error.message}`,
success: false,
[statusProp]: 'danger',
});
});
};
const steps = [
{
title: i18n.translate('xpack.reporting.listing.diagnosticConfigTitle', {
defaultMessage: 'Verify Kibana Configuration',
}),
children: (
<Fragment>
<FormattedMessage
id="xpack.reporting.listing.diagnosticConfigMessage"
defaultMessage="This check ensures your Kibana configuration is setup properly for reports."
/>
<EuiSpacer />
<EuiButton
disabled={isBusy || configStatus === 'complete'}
isLoading={isBusy && configStatus === 'incomplete'}
onClick={apiWrapper(apiClient.verifyConfig, statuses.configStatus)}
iconType={configStatus === 'complete' ? 'check' : undefined}
>
<FormattedMessage
id="xpack.reporting.listing.diagnosticConfigButton"
defaultMessage="Verify Configuration"
/>
</EuiButton>
</Fragment>
),
status: !success && configStatus !== 'complete' ? 'danger' : configStatus,
},
];
if (configStatus === 'complete') {
steps.push({
title: i18n.translate('xpack.reporting.listing.diagnosticBrowserTitle', {
defaultMessage: 'Check Browser',
}),
children: (
<Fragment>
<FormattedMessage
id="xpack.reporting.listing.diagnosticBrowserMessage"
defaultMessage="Reporting utilizes a headless browser to generate PDF and PNGS. This check validates
that the browser can launch successfully."
/>
<EuiSpacer />
<EuiButton
disabled={isBusy || chromeStatus === 'complete'}
onClick={apiWrapper(apiClient.verifyBrowser, statuses.chromeStatus)}
isLoading={isBusy && chromeStatus === 'incomplete'}
iconType={chromeStatus === 'complete' ? 'check' : undefined}
>
<FormattedMessage
id="xpack.reporting.listing.diagnosticBrowserButton"
defaultMessage="Check Browser"
/>
</EuiButton>
</Fragment>
),
status: !success && chromeStatus !== 'complete' ? 'danger' : chromeStatus,
});
}
if (chromeStatus === 'complete') {
steps.push({
title: i18n.translate('xpack.reporting.listing.diagnosticScreenshotTitle', {
defaultMessage: 'Check Screen Capture Capabilities',
}),
children: (
<Fragment>
<FormattedMessage
id="xpack.reporting.listing.diagnosticScreenshotMessage"
defaultMessage="This check ensures the headless browser can capture a screenshot of a page."
/>
<EuiSpacer />
<EuiButton
disabled={isBusy || screenshotStatus === 'complete'}
onClick={apiWrapper(apiClient.verifyScreenCapture, statuses.screenshotStatus)}
isLoading={isBusy && screenshotStatus === 'incomplete'}
iconType={screenshotStatus === 'complete' ? 'check' : undefined}
>
<FormattedMessage
id="xpack.reporting.listing.diagnosticScreenshotButton"
defaultMessage="Capture Screenshot"
/>
</EuiButton>
</Fragment>
),
status: !success && screenshotStatus !== 'complete' ? 'danger' : screenshotStatus,
});
}
if (screenshotStatus === 'complete') {
steps.push({
title: i18n.translate('xpack.reporting.listing.diagnosticSuccessTitle', {
defaultMessage: 'All set!',
}),
children: (
<Fragment>
<FormattedMessage
id="xpack.reporting.listing.diagnosticSuccessMessage"
defaultMessage="Excellent! Everything looks like shipshape for reporting to function!"
/>
</Fragment>
),
status: !success ? 'danger' : screenshotStatus,
});
}
if (!success) {
steps.push({
title: i18n.translate('xpack.reporting.listing.diagnosticFailureTitle', {
defaultMessage: "Whoops! Looks like something isn't working properly.",
}),
children: (
<Fragment>
{help.length ? (
<Fragment>
<EuiCallOut color="danger" iconType="alert">
<p>{help.join('\n')}</p>
</EuiCallOut>
</Fragment>
) : null}
{logs.length ? (
<Fragment>
<EuiSpacer />
<FormattedMessage
id="xpack.reporting.listing.diagnosticFailureDescription"
defaultMessage="Here are some more details about the issue:"
/>
<EuiSpacer />
<EuiCodeBlock>{logs}</EuiCodeBlock>
</Fragment>
) : null}
</Fragment>
),
status: 'danger',
});
}
let flyout;
if (isFlyoutVisible) {
flyout = (
<EuiFlyout onClose={closeFlyout} aria-labelledby="reportingHelperTitle" size="m">
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
<FormattedMessage
id="xpack.reporting.listing.diagnosticTitle"
defaultMessage="Reporting Diagnostics"
/>
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText color="subdued">
<FormattedMessage
id="xpack.reporting.listing.diagnosticDescription"
defaultMessage="Automatically run a series of diagnostics to troubleshoot common reporting problems."
/>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiSteps steps={steps} />
</EuiFlyoutBody>
</EuiFlyout>
);
}
return (
<div>
{flyout}
<EuiButtonEmpty size="xs" flush="left" onClick={showFlyout}>
<FormattedMessage
id="xpack.reporting.listing.diagnosticButton"
defaultMessage="Run reporting diagnostics..."
/>
</EuiButtonEmpty>
</div>
);
};

View file

@ -6,6 +6,8 @@
import {
EuiBasicTable,
EuiFlexItem,
EuiFlexGroup,
EuiPageContent,
EuiSpacer,
EuiText,
@ -31,6 +33,7 @@ import {
ReportErrorButton,
ReportInfoButton,
} from './buttons';
import { ReportDiagnostic } from './report_diagnostic';
export interface Job {
id: string;
@ -134,23 +137,38 @@ class ReportListingUi extends Component<Props, State> {
public render() {
return (
<EuiPageContent horizontalPosition="center" className="euiPageBody--restrictWidth-default">
<EuiTitle>
<h1>
<FormattedMessage id="xpack.reporting.listing.reportstitle" defaultMessage="Reports" />
</h1>
</EuiTitle>
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.reporting.listing.reports.subtitle"
defaultMessage="Find reports generated in Kibana applications here"
/>
</p>
</EuiText>
<EuiSpacer />
{this.renderTable()}
</EuiPageContent>
<div>
<EuiPageContent horizontalPosition="center" className="euiPageBody--restrictWidth-default">
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiTitle>
<h1>
<FormattedMessage
id="xpack.reporting.listing.reportstitle"
defaultMessage="Reports"
/>
</h1>
</EuiTitle>
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="xpack.reporting.listing.reports.subtitle"
defaultMessage="Find reports generated in Kibana applications here"
/>
</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
{this.renderTable()}
</EuiPageContent>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween" direction="rowReverse">
<EuiFlexItem grow={false}>
<ReportDiagnostic apiClient={this.props.apiClient} />
</EuiFlexItem>
</EuiFlexGroup>
</div>
);
}

View file

@ -8,7 +8,12 @@ import { stringify } from 'query-string';
import rison from 'rison-node';
import { HttpSetup } from 'src/core/public';
import { JobId, SourceJob } from '../../common/types';
import { API_BASE_GENERATE, API_LIST_URL, REPORTING_MANAGEMENT_HOME } from '../../constants';
import {
API_BASE_URL,
API_BASE_GENERATE,
API_LIST_URL,
REPORTING_MANAGEMENT_HOME,
} from '../../constants';
import { add } from './job_completion_notifications';
export interface JobQueueEntry {
@ -59,6 +64,12 @@ interface JobParams {
[paramName: string]: any;
}
export interface DiagnoseResponse {
help: string[];
success: boolean;
logs: string;
}
export class ReportingAPIClient {
private http: HttpSetup;
@ -157,4 +168,28 @@ export class ReportingAPIClient {
* provides the raw server basePath to allow it to be stripped out from relativeUrls in job params
*/
public getServerBasePath = () => this.http.basePath.serverBasePath;
/*
* Diagnostic-related API calls
*/
public verifyConfig = (): Promise<DiagnoseResponse> =>
this.http.post(`${API_BASE_URL}/diagnose/config`, {
asSystemRequest: true,
});
/*
* Diagnostic-related API calls
*/
public verifyBrowser = (): Promise<DiagnoseResponse> =>
this.http.post(`${API_BASE_URL}/diagnose/browser`, {
asSystemRequest: true,
});
/*
* Diagnostic-related API calls
*/
public verifyScreenCapture = (): Promise<DiagnoseResponse> =>
this.http.post(`${API_BASE_URL}/diagnose/screenshot`, {
asSystemRequest: true,
});
}

View file

@ -59,28 +59,6 @@ export class HeadlessChromiumDriverFactory {
type = BROWSER_TYPE;
test(logger: LevelLogger) {
const chromiumArgs = args({
userDataDir: this.userDataDir,
viewport: { width: 800, height: 600 },
disableSandbox: this.browserConfig.disableSandbox,
proxy: this.browserConfig.proxy,
});
return puppeteerLaunch({
userDataDir: this.userDataDir,
executablePath: this.binaryPath,
ignoreHTTPSErrors: true,
args: chromiumArgs,
} as LaunchOptions).catch((error: Error) => {
logger.error(
`The Reporting plugin encountered issues launching Chromium in a self-test. You may have trouble generating reports.`
);
logger.error(error);
return null;
});
}
/*
* Return an observable to objects which will drive screenshot capture for a page
*/

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { spawn } from 'child_process';
import del from 'del';
import { mkdtempSync } from 'fs';
import { uniq } from 'lodash';
import { tmpdir } from 'os';
import { join } from 'path';
import { createInterface } from 'readline';
import { fromEvent, timer, merge, of } from 'rxjs';
import { takeUntil, map, reduce, tap, catchError } from 'rxjs/operators';
import { ReportingCore } from '../../..';
import { LevelLogger } from '../../../lib';
import { getBinaryPath } from '../../install';
import { args } from './args';
const browserLaunchTimeToWait = 5 * 1000;
// Default args used by pptr
// https://github.com/puppeteer/puppeteer/blob/13ea347/src/node/Launcher.ts#L168
const defaultArgs = [
'--disable-background-networking',
'--enable-features=NetworkService,NetworkServiceInProcess',
'--disable-background-timer-throttling',
'--disable-backgrounding-occluded-windows',
'--disable-breakpad',
'--disable-client-side-phishing-detection',
'--disable-component-extensions-with-background-pages',
'--disable-default-apps',
'--disable-dev-shm-usage',
'--disable-extensions',
'--disable-features=TranslateUI',
'--disable-hang-monitor',
'--disable-ipc-flooding-protection',
'--disable-popup-blocking',
'--disable-prompt-on-repost',
'--disable-renderer-backgrounding',
'--disable-sync',
'--force-color-profile=srgb',
'--metrics-recording-only',
'--no-first-run',
'--enable-automation',
'--password-store=basic',
'--use-mock-keychain',
'--remote-debugging-port=0',
'--headless',
];
export const browserStartLogs = (
core: ReportingCore,
logger: LevelLogger,
overrideFlags: string[] = []
) => {
const config = core.getConfig();
const proxy = config.get('capture', 'browser', 'chromium', 'proxy');
const disableSandbox = config.get('capture', 'browser', 'chromium', 'disableSandbox');
const userDataDir = mkdtempSync(join(tmpdir(), 'chromium-'));
const binaryPath = getBinaryPath();
const kbnArgs = args({
userDataDir,
viewport: { width: 800, height: 600 },
disableSandbox,
proxy,
});
const finalArgs = uniq([...defaultArgs, ...kbnArgs, ...overrideFlags]);
// On non-windows platforms, `detached: true` makes child process a
// leader of a new process group, making it possible to kill child
// process tree with `.kill(-pid)` command. @see
// https://nodejs.org/api/child_process.html#child_process_options_detached
const browserProcess = spawn(binaryPath, finalArgs, {
detached: process.platform !== 'win32',
});
const rl = createInterface({ input: browserProcess.stderr });
const exit$ = fromEvent(browserProcess, 'exit').pipe(
map((code) => {
logger.error(`Browser exited abnormally, received code: ${code}`);
return i18n.translate('xpack.reporting.diagnostic.browserCrashed', {
defaultMessage: `Browser exited abnormally during startup`,
});
})
);
const error$ = fromEvent(browserProcess, 'error').pipe(
map(() => {
logger.error(`Browser process threw an error on startup`);
return i18n.translate('xpack.reporting.diagnostic.browserErrored', {
defaultMessage: `Browser process threw an error on startup`,
});
})
);
const browserProcessLogger = logger.clone(['chromium-stderr']);
const log$ = fromEvent(rl, 'line').pipe(
tap((message: unknown) => {
if (typeof message === 'string') {
browserProcessLogger.info(message);
}
})
);
// Collect all events (exit, error and on log-lines), but let chromium keep spitting out
// logs as sometimes it's "bind" successfully for remote connections, but later emit
// a log indicative of an issue (for example, no default font found).
return merge(exit$, error$, log$).pipe(
takeUntil(timer(browserLaunchTimeToWait)),
reduce((acc, curr) => `${acc}${curr}\n`, ''),
tap(() => {
if (browserProcess && browserProcess.pid && !browserProcess.killed) {
browserProcess.kill('SIGKILL');
logger.info(`Successfully sent 'SIGKILL' to browser process (PID: ${browserProcess.pid})`);
}
browserProcess.removeAllListeners();
rl.removeAllListeners();
rl.close();
del(userDataDir, { force: true }).catch((error) => {
logger.error(`Error deleting user data directory at [${userDataDir}]!`);
logger.error(error);
});
}),
catchError((error) => {
logger.error(error);
return of(error);
})
);
};

View file

@ -4,24 +4,43 @@
* you may not use this file except in compliance with the Elastic License.
*/
import del from 'del';
import os from 'os';
import path from 'path';
import del from 'del';
import * as Rx from 'rxjs';
import { LevelLogger } from '../lib';
import { paths } from './chromium/paths';
import { ensureBrowserDownloaded } from './download';
// @ts-ignore
import { md5 } from './download/checksum';
// @ts-ignore
import { extract } from './extract';
import { paths } from './chromium/paths';
interface Package {
platforms: string[];
architecture: string;
}
/**
* Small helper util to resolve where chromium is installed
*/
export const getBinaryPath = (
chromiumPath: string = path.resolve(__dirname, '../../chromium'),
platform: string = process.platform,
architecture: string = os.arch()
) => {
const pkg = paths.packages.find((p: Package) => {
return p.platforms.includes(platform) && p.architecture === architecture;
});
if (!pkg) {
// TODO: validate this
throw new Error(`Unsupported platform: ${platform}-${architecture}`);
}
return path.join(chromiumPath, pkg.binaryRelativePath);
};
/**
* "install" a browser by type into installs path by extracting the downloaded
* archive. If there is an error extracting the archive an `ExtractError` is thrown
@ -43,7 +62,7 @@ export function installBrowser(
throw new Error(`Unsupported platform: ${platform}-${architecture}`);
}
const binaryPath = path.join(chromiumPath, pkg.binaryRelativePath);
const binaryPath = getBinaryPath(chromiumPath, platform, architecture);
const binaryChecksum = await md5(binaryPath).catch(() => '');
if (binaryChecksum !== pkg.binaryChecksum) {

View file

@ -28,7 +28,7 @@ export async function generatePngObservableFactory(reporting: ReportingCore) {
if (!layoutParams || !layoutParams.dimensions) {
throw new Error(`LayoutParams.Dimensions is undefined.`);
}
const layout = new PreserveLayout(layoutParams.dimensions);
const layout = new PreserveLayout(layoutParams.dimensions, layoutParams.selectors);
if (apmLayout) apmLayout.end();
const apmScreenshots = apmTrans?.startSpan('screenshots_pipeline', 'setup');

View file

@ -13,4 +13,3 @@ export { LevelLogger } from './level_logger';
export { statuses } from './statuses';
export { ReportingStore } from './store';
export { startTrace } from './trace';
export { runValidations } from './validate';

View file

@ -54,6 +54,7 @@ export interface Size {
export interface LayoutParams {
id: string;
dimensions: Size;
selectors?: LayoutSelectorDictionary;
}
interface LayoutSelectors {

View file

@ -25,12 +25,16 @@ export class PreserveLayout extends Layout {
private readonly scaledHeight: number;
private readonly scaledWidth: number;
constructor(size: Size) {
constructor(size: Size, layoutSelectors?: LayoutSelectorDictionary) {
super(LayoutTypes.PRESERVE_LAYOUT);
this.height = size.height;
this.width = size.width;
this.scaledHeight = size.height * ZOOM;
this.scaledWidth = size.width * ZOOM;
if (layoutSelectors) {
this.selectors = layoutSelectors;
}
}
public getCssOverridesPath() {

View file

@ -7,8 +7,7 @@
import sinon from 'sinon';
import { ElasticsearchServiceSetup } from 'src/core/server';
import { ReportingConfig, ReportingCore } from '../..';
import { createMockReportingCore } from '../../test_helpers';
import { createMockLevelLogger } from '../../test_helpers/create_mock_levellogger';
import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers';
import { Report } from './report';
import { ReportingStore } from './store';

View file

@ -1,43 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { ElasticsearchServiceSetup } from 'kibana/server';
import { ReportingConfig } from '../../';
import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory';
import { LevelLogger } from '../';
import { validateBrowser } from './validate_browser';
import { validateMaxContentLength } from './validate_max_content_length';
export async function runValidations(
config: ReportingConfig,
elasticsearch: ElasticsearchServiceSetup,
browserFactory: HeadlessChromiumDriverFactory,
parentLogger: LevelLogger
) {
const logger = parentLogger.clone(['validations']);
try {
await Promise.all([
validateBrowser(browserFactory, logger),
validateMaxContentLength(config, elasticsearch, logger),
]);
logger.debug(
i18n.translate('xpack.reporting.selfCheck.ok', {
defaultMessage: `Reporting plugin self-check ok!`,
})
);
} catch (err) {
logger.error(err);
logger.warning(
i18n.translate('xpack.reporting.selfCheck.warning', {
defaultMessage: `Reporting plugin self-check generated a warning: {err}`,
values: {
err,
},
})
);
}
}

View file

@ -1,29 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Browser } from 'puppeteer';
import { BROWSER_TYPE } from '../../../common/constants';
import { HeadlessChromiumDriverFactory } from '../../browsers/chromium/driver_factory';
import { LevelLogger } from '../';
/*
* Validate the Reporting headless browser can launch, and that it can connect
* to the locally running Kibana instance.
*/
export const validateBrowser = async (
browserFactory: HeadlessChromiumDriverFactory,
logger: LevelLogger
) => {
if (browserFactory.type === BROWSER_TYPE) {
return browserFactory.test(logger).then((browser: Browser | null) => {
if (browser && browser.close) {
browser.close();
} else {
throw new Error('Could not close browser client handle!');
}
});
}
};

View file

@ -1,80 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import sinon from 'sinon';
import { validateMaxContentLength } from './validate_max_content_length';
const FIVE_HUNDRED_MEGABYTES = 524288000;
const ONE_HUNDRED_MEGABYTES = 104857600;
describe('Reporting: Validate Max Content Length', () => {
const elasticsearch = {
legacy: {
client: {
callAsInternalUser: () => ({
defaults: {
http: {
max_content_length: '100mb',
},
},
}),
},
},
};
const logger = {
warning: sinon.spy(),
};
beforeEach(() => {
logger.warning.resetHistory();
});
it('should log warning messages when reporting has a higher max-size than elasticsearch', async () => {
const config = { get: sinon.stub().returns(FIVE_HUNDRED_MEGABYTES) };
const elasticsearch = {
legacy: {
client: {
callAsInternalUser: () => ({
defaults: {
http: {
max_content_length: '100mb',
},
},
}),
},
},
};
await validateMaxContentLength(config, elasticsearch, logger);
sinon.assert.calledWithMatch(
logger.warning,
`xpack.reporting.csv.maxSizeBytes (524288000) is higher`
);
sinon.assert.calledWithMatch(
logger.warning,
`than ElasticSearch's http.max_content_length (104857600)`
);
sinon.assert.calledWithMatch(
logger.warning,
'Please set http.max_content_length in ElasticSearch to match'
);
sinon.assert.calledWithMatch(
logger.warning,
'or lower your xpack.reporting.csv.maxSizeBytes in Kibana'
);
});
it('should do nothing when reporting has the same max-size as elasticsearch', async () => {
const config = { get: sinon.stub().returns(ONE_HUNDRED_MEGABYTES) };
expect(
async () => await validateMaxContentLength(config, elasticsearch, logger.warning)
).not.toThrow();
sinon.assert.notCalled(logger.warning);
});
});

View file

@ -1,40 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import numeral from '@elastic/numeral';
import { ElasticsearchServiceSetup } from 'kibana/server';
import { defaults, get } from 'lodash';
import { ReportingConfig } from '../../';
import { LevelLogger } from '../';
const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes';
const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length';
export async function validateMaxContentLength(
config: ReportingConfig,
elasticsearch: ElasticsearchServiceSetup,
logger: LevelLogger
) {
const { callAsInternalUser } = elasticsearch.legacy.client;
const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', {
includeDefaults: true,
});
const { persistent, transient, defaults: defaultSettings } = elasticClusterSettingsResponse;
const elasticClusterSettings = defaults({}, persistent, transient, defaultSettings);
const elasticSearchMaxContent = get(elasticClusterSettings, 'http.max_content_length', '100mb');
const elasticSearchMaxContentBytes = numeral().unformat(elasticSearchMaxContent.toUpperCase());
const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes');
if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) {
// TODO this should simply throw an error and let the handler conver it to a warning mesasge. See validateServerHost.
logger.warning(
`xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} (${kibanaMaxContentBytes}) is higher than ElasticSearch's ${ES_MAX_SIZE_BYTES_PATH} (${elasticSearchMaxContentBytes}). ` +
`Please set ${ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.${KIBANA_MAX_SIZE_BYTES_PATH} in Kibana to avoid this warning.`
);
}
}

View file

@ -11,7 +11,7 @@ import { PLUGIN_ID, UI_SETTINGS_CUSTOM_PDF_LOGO } from '../common/constants';
import { ReportingCore } from './';
import { initializeBrowserDriverFactory } from './browsers';
import { buildConfig, ReportingConfigType } from './config';
import { createQueueFactory, LevelLogger, ReportingStore, runValidations } from './lib';
import { createQueueFactory, LevelLogger, ReportingStore } from './lib';
import { registerRoutes } from './routes';
import { setFieldFormats } from './services';
import { ReportingSetup, ReportingSetupDeps, ReportingStart, ReportingStartDeps } from './types';
@ -105,7 +105,6 @@ export class ReportingPlugin
setFieldFormats(plugins.data.fieldFormats);
const { logger, reportingCore } = this;
const { elasticsearch } = reportingCore.getPluginSetupDeps();
// async background start
(async () => {
@ -124,9 +123,6 @@ export class ReportingPlugin
store,
});
// run self-check validations
runValidations(config, elasticsearch, browserDriverFactory, this.logger);
this.logger.debug('Start complete');
})().catch((e) => {
this.logger.error(`Error in Reporting start, reporting may not function properly`);

View file

@ -0,0 +1,250 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { UnwrapPromise } from '@kbn/utility-types';
import { spawn } from 'child_process';
import { createInterface } from 'readline';
import { setupServer } from 'src/core/server/test_utils';
import supertest from 'supertest';
import { ReportingCore } from '../..';
import { createMockLevelLogger, createMockReportingCore } from '../../test_helpers';
import { registerDiagnoseBrowser } from './browser';
jest.mock('child_process');
jest.mock('readline');
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
const devtoolMessage = 'DevTools listening on (ws://localhost:4000)';
const fontNotFoundMessage = 'Could not find the default font';
describe('POST /diagnose/browser', () => {
jest.setTimeout(6000);
const reportingSymbol = Symbol('reporting');
const mockLogger = createMockLevelLogger();
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
let core: ReportingCore;
const mockedSpawn: any = spawn;
const mockedCreateInterface: any = createInterface;
const config = {
get: jest.fn().mockImplementation(() => ({})),
kbnConfig: { get: jest.fn() },
};
beforeEach(async () => {
({ server, httpSetup } = await setupServer(reportingSymbol));
httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({}));
const mockSetupDeps = ({
elasticsearch: {
legacy: { client: { callAsInternalUser: jest.fn() } },
},
router: httpSetup.createRouter(''),
} as unknown) as any;
core = await createMockReportingCore(config, mockSetupDeps);
mockedSpawn.mockImplementation(() => ({
removeAllListeners: jest.fn(),
kill: jest.fn(),
pid: 123,
stderr: 'stderr',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
mockedCreateInterface.mockImplementation(() => ({
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
removeAllListeners: jest.fn(),
close: jest.fn(),
}));
});
afterEach(async () => {
await server.stop();
});
it('returns a 200 when successful', async () => {
registerDiagnoseBrowser(core, mockLogger);
await server.start();
mockedCreateInterface.mockImplementation(() => ({
addEventListener: (e: string, cb: any) => setTimeout(() => cb(devtoolMessage), 0),
removeEventListener: jest.fn(),
removeAllListeners: jest.fn(),
close: jest.fn(),
}));
return supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/browser')
.expect(200)
.then(({ body }) => {
expect(body.success).toEqual(true);
expect(body.help).toEqual([]);
});
});
it('returns logs when browser crashes + helpful links', async () => {
const logs = `Could not find the default font`;
registerDiagnoseBrowser(core, mockLogger);
await server.start();
mockedCreateInterface.mockImplementation(() => ({
addEventListener: (e: string, cb: any) => setTimeout(() => cb(logs), 0),
removeEventListener: jest.fn(),
removeAllListeners: jest.fn(),
close: jest.fn(),
}));
mockedSpawn.mockImplementation(() => ({
removeAllListeners: jest.fn(),
kill: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
return supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/browser')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [
"The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.",
],
"logs": "Could not find the default font
",
"success": false,
}
`);
});
});
it('logs a message when the browser starts, but then has problems later', async () => {
registerDiagnoseBrowser(core, mockLogger);
await server.start();
mockedCreateInterface.mockImplementation(() => ({
addEventListener: (e: string, cb: any) => {
setTimeout(() => cb(devtoolMessage), 0);
setTimeout(() => cb(fontNotFoundMessage), 0);
},
removeEventListener: jest.fn(),
removeAllListeners: jest.fn(),
close: jest.fn(),
}));
mockedSpawn.mockImplementation(() => ({
removeAllListeners: jest.fn(),
kill: jest.fn(),
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
return supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/browser')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [
"The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.",
],
"logs": "DevTools listening on (ws://localhost:4000)
Could not find the default font
",
"success": false,
}
`);
});
});
it('logs a message when the browser starts, but then crashes', async () => {
registerDiagnoseBrowser(core, mockLogger);
await server.start();
mockedCreateInterface.mockImplementation(() => ({
addEventListener: (e: string, cb: any) => {
setTimeout(() => cb(fontNotFoundMessage), 0);
},
removeEventListener: jest.fn(),
removeAllListeners: jest.fn(),
close: jest.fn(),
}));
mockedSpawn.mockImplementation(() => ({
removeAllListeners: jest.fn(),
kill: jest.fn(),
addEventListener: (e: string, cb: any) => {
if (e === 'exit') {
setTimeout(() => cb(), 5);
}
},
removeEventListener: jest.fn(),
}));
return supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/browser')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [
"The browser couldn't locate a default font. Please see https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies to fix this issue.",
],
"logs": "Could not find the default font
Browser exited abnormally during startup
",
"success": false,
}
`);
});
});
it('cleans up process and subscribers', async () => {
registerDiagnoseBrowser(core, mockLogger);
await server.start();
const killMock = jest.fn();
const spawnListenersMock = jest.fn();
const createInterfaceListenersMock = jest.fn();
const createInterfaceCloseMock = jest.fn();
mockedSpawn.mockImplementation(() => ({
removeAllListeners: spawnListenersMock,
kill: killMock,
pid: 123,
stderr: 'stderr',
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
}));
mockedCreateInterface.mockImplementation(() => ({
addEventListener: (e: string, cb: any) => setTimeout(() => cb(devtoolMessage), 0),
removeEventListener: jest.fn(),
removeAllListeners: createInterfaceListenersMock,
close: createInterfaceCloseMock,
}));
return supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/browser')
.expect(200)
.then(() => {
expect(killMock.mock.calls.length).toBe(1);
expect(spawnListenersMock.mock.calls.length).toBe(1);
expect(createInterfaceListenersMock.mock.calls.length).toBe(1);
expect(createInterfaceCloseMock.mock.calls.length).toBe(1);
});
});
});

View file

@ -0,0 +1,78 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { ReportingCore } from '../..';
import { API_DIAGNOSE_URL } from '../../../common/constants';
import { browserStartLogs } from '../../browsers/chromium/driver_factory/start_logs';
import { LevelLogger as Logger } from '../../lib';
import { DiagnosticResponse } from '../../types';
import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing';
const logsToHelpMap = {
'error while loading shared libraries': i18n.translate(
'xpack.reporting.diagnostic.browserMissingDependency',
{
defaultMessage: `The browser couldn't start properly due to missing system dependencies. Please see {url}`,
values: {
url:
'https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies',
},
}
),
'Could not find the default font': i18n.translate(
'xpack.reporting.diagnostic.browserMissingFonts',
{
defaultMessage: `The browser couldn't locate a default font. Please see {url} to fix this issue.`,
values: {
url:
'https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-system-dependencies',
},
}
),
'No usable sandbox': i18n.translate('xpack.reporting.diagnostic.noUsableSandbox', {
defaultMessage: `Unable to use Chromium sandbox. This can be disabled at your own risk with 'xpack.reporting.capture.browser.chromium.disableSandbox'. Please see {url}`,
values: {
url:
'https://www.elastic.co/guide/en/kibana/current/reporting-troubleshooting.html#reporting-troubleshooting-sandbox-dependency',
},
}),
};
export const registerDiagnoseBrowser = (reporting: ReportingCore, logger: Logger) => {
const { router } = reporting.getPluginSetupDeps();
const userHandler = authorizedUserPreRoutingFactory(reporting);
router.post(
{
path: `${API_DIAGNOSE_URL}/browser`,
validate: {},
},
userHandler(async (user, context, req, res) => {
const logs = await browserStartLogs(reporting, logger).toPromise();
const knownIssues = Object.keys(logsToHelpMap) as Array<keyof typeof logsToHelpMap>;
const boundSuccessfully = logs.includes(`DevTools listening on`);
const help = knownIssues.reduce((helpTexts: string[], knownIssue) => {
const helpText = logsToHelpMap[knownIssue];
if (logs.includes(knownIssue)) {
helpTexts.push(helpText);
}
return helpTexts;
}, []);
const response: DiagnosticResponse = {
success: boundSuccessfully && !help.length,
help,
logs,
};
return res.ok({ body: response });
})
);
};

View file

@ -0,0 +1,107 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { UnwrapPromise } from '@kbn/utility-types';
import { setupServer } from 'src/core/server/test_utils';
import supertest from 'supertest';
import { ReportingCore } from '../..';
import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers';
import { registerDiagnoseConfig } from './config';
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
describe('POST /diagnose/config', () => {
const reportingSymbol = Symbol('reporting');
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
let core: ReportingCore;
let mockSetupDeps: any;
let config: any;
const mockLogger = createMockLevelLogger();
beforeEach(async () => {
({ server, httpSetup } = await setupServer(reportingSymbol));
httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({}));
mockSetupDeps = ({
elasticsearch: {
legacy: { client: { callAsInternalUser: jest.fn() } },
},
router: httpSetup.createRouter(''),
} as unknown) as any;
config = {
get: jest.fn(),
kbnConfig: { get: jest.fn() },
};
core = await createMockReportingCore(config, mockSetupDeps);
});
afterEach(async () => {
await server.stop();
});
it('returns a 200 by default when configured properly', async () => {
mockSetupDeps.elasticsearch.legacy.client.callAsInternalUser.mockImplementation(() =>
Promise.resolve({
defaults: {
http: {
max_content_length: '100mb',
},
},
})
);
registerDiagnoseConfig(core, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/config')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [],
"logs": "",
"success": true,
}
`);
});
});
it('returns a 200 with help text when not configured properly', async () => {
config.get.mockImplementation(() => 10485760);
mockSetupDeps.elasticsearch.legacy.client.callAsInternalUser.mockImplementation(() =>
Promise.resolve({
defaults: {
http: {
max_content_length: '5mb',
},
},
})
);
registerDiagnoseConfig(core, mockLogger);
await server.start();
await supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/config')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [
"xpack.reporting.csv.maxSizeBytes (10485760) is higher than ElasticSearch's http.max_content_length (5242880). Please set http.max_content_length in ElasticSearch to match, or lower your xpack.reporting.csv.maxSizeBytes in Kibana.",
],
"logs": "xpack.reporting.csv.maxSizeBytes (10485760) is higher than ElasticSearch's http.max_content_length (5242880). Please set http.max_content_length in ElasticSearch to match, or lower your xpack.reporting.csv.maxSizeBytes in Kibana.",
"success": false,
}
`);
});
});
});

View file

@ -0,0 +1,81 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import numeral from '@elastic/numeral';
import { defaults, get } from 'lodash';
import { ReportingCore } from '../..';
import { API_DIAGNOSE_URL } from '../../../common/constants';
import { LevelLogger as Logger } from '../../lib';
import { DiagnosticResponse } from '../../types';
import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing';
const KIBANA_MAX_SIZE_BYTES_PATH = 'csv.maxSizeBytes';
const ES_MAX_SIZE_BYTES_PATH = 'http.max_content_length';
export const registerDiagnoseConfig = (reporting: ReportingCore, logger: Logger) => {
const setupDeps = reporting.getPluginSetupDeps();
const userHandler = authorizedUserPreRoutingFactory(reporting);
const { router, elasticsearch } = setupDeps;
router.post(
{
path: `${API_DIAGNOSE_URL}/config`,
validate: {},
},
userHandler(async (user, context, req, res) => {
const warnings = [];
const { callAsInternalUser } = elasticsearch.legacy.client;
const config = reporting.getConfig();
const elasticClusterSettingsResponse = await callAsInternalUser('cluster.getSettings', {
includeDefaults: true,
});
const { persistent, transient, defaults: defaultSettings } = elasticClusterSettingsResponse;
const elasticClusterSettings = defaults({}, persistent, transient, defaultSettings);
const elasticSearchMaxContent = get(
elasticClusterSettings,
'http.max_content_length',
'100mb'
);
const elasticSearchMaxContentBytes = numeral().unformat(
elasticSearchMaxContent.toUpperCase()
);
const kibanaMaxContentBytes = config.get('csv', 'maxSizeBytes');
if (kibanaMaxContentBytes > elasticSearchMaxContentBytes) {
const maxContentSizeWarning = i18n.translate(
'xpack.reporting.diagnostic.configSizeMismatch',
{
defaultMessage:
`xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} ({kibanaMaxContentBytes}) is higher than ElasticSearch's {ES_MAX_SIZE_BYTES_PATH} ({elasticSearchMaxContentBytes}). ` +
`Please set {ES_MAX_SIZE_BYTES_PATH} in ElasticSearch to match, or lower your xpack.reporting.{KIBANA_MAX_SIZE_BYTES_PATH} in Kibana.`,
values: {
kibanaMaxContentBytes,
elasticSearchMaxContentBytes,
KIBANA_MAX_SIZE_BYTES_PATH,
ES_MAX_SIZE_BYTES_PATH,
},
}
);
warnings.push(maxContentSizeWarning);
}
if (warnings.length) {
warnings.forEach((warn) => logger.warn(warn));
}
const body: DiagnosticResponse = {
help: warnings,
success: !warnings.length,
logs: warnings.join('\n'),
};
return res.ok({ body });
})
);
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
import { registerDiagnoseBrowser } from './browser';
import { registerDiagnoseConfig } from './config';
import { registerDiagnoseScreenshot } from './screenshot';
import { LevelLogger as Logger } from '../../lib';
import { ReportingCore } from '../../core';
export const registerDiagnosticRoutes = (reporting: ReportingCore, logger: Logger) => {
registerDiagnoseBrowser(reporting, logger);
registerDiagnoseConfig(reporting, logger);
registerDiagnoseScreenshot(reporting, logger);
};

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { UnwrapPromise } from '@kbn/utility-types';
import { setupServer } from 'src/core/server/test_utils';
import supertest from 'supertest';
import { ReportingCore } from '../..';
import { createMockReportingCore, createMockLevelLogger } from '../../test_helpers';
import { registerDiagnoseScreenshot } from './screenshot';
jest.mock('../../export_types/png/lib/generate_png');
import { generatePngObservableFactory } from '../../export_types/png/lib/generate_png';
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;
describe('POST /diagnose/screenshot', () => {
const reportingSymbol = Symbol('reporting');
let server: SetupServerReturn['server'];
let httpSetup: SetupServerReturn['httpSetup'];
let core: ReportingCore;
const setScreenshotResponse = (resp: object | Error) => {
const generateMock = Promise.resolve(() => ({
pipe: () => ({
toPromise: () => (resp instanceof Error ? Promise.reject(resp) : Promise.resolve(resp)),
}),
}));
(generatePngObservableFactory as any).mockResolvedValue(generateMock);
};
const config = {
get: jest.fn(),
kbnConfig: { get: jest.fn() },
};
const mockLogger = createMockLevelLogger();
beforeEach(async () => {
({ server, httpSetup } = await setupServer(reportingSymbol));
httpSetup.registerRouteHandlerContext(reportingSymbol, 'reporting', () => ({}));
const mockSetupDeps = ({
elasticsearch: {
legacy: { client: { callAsInternalUser: jest.fn() } },
},
router: httpSetup.createRouter(''),
} as unknown) as any;
core = await createMockReportingCore(config, mockSetupDeps);
});
afterEach(async () => {
await server.stop();
});
it('returns a 200 by default', async () => {
registerDiagnoseScreenshot(core, mockLogger);
setScreenshotResponse({ warnings: [] });
await server.start();
await supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/screenshot')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [],
"logs": "",
"success": true,
}
`);
});
});
it('returns a 200 when it fails and sets success to false', async () => {
registerDiagnoseScreenshot(core, mockLogger);
setScreenshotResponse({ warnings: [`Timeout waiting for .dank to load`] });
await server.start();
await supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/screenshot')
.expect(200)
.then(({ body }) => {
expect(body).toMatchInlineSnapshot(`
Object {
"help": Array [],
"logs": Array [
"Timeout waiting for .dank to load",
],
"success": false,
}
`);
});
});
it('catches errors and returns a well formed response', async () => {
registerDiagnoseScreenshot(core, mockLogger);
setScreenshotResponse(new Error('Failure to start chromium!'));
await server.start();
await supertest(httpSetup.server.listener)
.post('/api/reporting/diagnose/screenshot')
.expect(200)
.then(({ body }) => {
expect(body.help).toContain(`We couldn't screenshot your Kibana install.`);
expect(body.logs).toContain(`Failure to start chromium!`);
});
});
});

View file

@ -0,0 +1,116 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { ReportingCore } from '../..';
import { API_DIAGNOSE_URL } from '../../../common/constants';
import { omitBlacklistedHeaders } 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 { DiagnosticResponse } from '../../types';
import { authorizedUserPreRoutingFactory } from '../lib/authorized_user_pre_routing';
export const registerDiagnoseScreenshot = (reporting: ReportingCore, logger: Logger) => {
const setupDeps = reporting.getPluginSetupDeps();
const userHandler = authorizedUserPreRoutingFactory(reporting);
const { router } = setupDeps;
router.post(
{
path: `${API_DIAGNOSE_URL}/screenshot`,
validate: {},
},
userHandler(async (user, context, req, res) => {
const generatePngObservable = await generatePngObservableFactory(reporting);
const config = reporting.getConfig();
const decryptedHeaders = req.headers as Record<string, 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({
defaultBasePath: basePath,
protocol,
hostname,
port,
});
const hashUrl = getAbsoluteUrl({
basePath,
path: '/',
hash: '',
search: '',
});
// Hack the layout to make the base/login page work
const layout = {
id: 'png',
dimensions: {
width: 1440,
height: 2024,
},
selectors: {
screenshot: '.application',
renderComplete: '.application',
itemsCountAttribute: 'data-test-subj="kibanaChrome"',
timefilterDurationAttribute: 'data-test-subj="kibanaChrome"',
},
};
const headers = {
headers: omitBlacklistedHeaders({
job: null,
decryptedHeaders,
}),
conditions: {
hostname,
port: +port,
basePath,
protocol,
},
};
return generatePngObservable(logger, hashUrl, 'America/Los_Angeles', headers, layout)
.pipe()
.toPromise()
.then((screenshot) => {
if (screenshot.warnings.length) {
return res.ok({
body: {
success: false,
help: [],
logs: screenshot.warnings,
},
});
}
return res.ok({
body: {
success: true,
help: [],
logs: '',
} as DiagnosticResponse,
});
})
.catch((error) =>
res.ok({
body: {
success: false,
help: [
i18n.translate('xpack.reporting.diagnostic.screenshotFailureMessage', {
defaultMessage: `We couldn't screenshot your Kibana install.`,
}),
],
logs: error.message,
} as DiagnosticResponse,
})
);
})
);
};

View file

@ -11,8 +11,7 @@ import { setupServer } from 'src/core/server/test_utils';
import supertest from 'supertest';
import { ReportingCore } from '..';
import { ExportTypesRegistry } from '../lib/export_types_registry';
import { createMockReportingCore } from '../test_helpers';
import { createMockLevelLogger } from '../test_helpers/create_mock_levellogger';
import { createMockReportingCore, createMockLevelLogger } from '../test_helpers';
import { registerJobGenerationRoutes } from './generation';
type SetupServerReturn = UnwrapPromise<ReturnType<typeof setupServer>>;

View file

@ -8,8 +8,10 @@ import { LevelLogger as Logger } from '../lib';
import { registerJobGenerationRoutes } from './generation';
import { registerJobInfoRoutes } from './jobs';
import { ReportingCore } from '../core';
import { registerDiagnosticRoutes } from './diagnostic';
export function registerRoutes(reporting: ReportingCore, logger: Logger) {
registerJobGenerationRoutes(reporting, logger);
registerJobInfoRoutes(reporting);
registerDiagnosticRoutes(reporting, logger);
}

View file

@ -8,7 +8,6 @@ jest.mock('../routes');
jest.mock('../usage');
jest.mock('../browsers');
jest.mock('../lib/create_queue');
jest.mock('../lib/validate');
import * as Rx from 'rxjs';
import { ReportingConfig, ReportingCore } from '../';

View file

@ -8,3 +8,4 @@ export { createMockServer } from './create_mock_server';
export { createMockReportingCore, createMockConfigSchema } from './create_mock_reportingplugin';
export { createMockBrowserDriverFactory } from './create_mock_browserdriverfactory';
export { createMockLayoutInstance } from './create_mock_layoutinstance';
export { createMockLevelLogger } from './create_mock_levellogger';

View file

@ -162,3 +162,9 @@ export interface ExportTypeDefinition<
runTaskFnFactory: RunTaskFnFactory<RunTaskFnType>;
validLicenses: string[];
}
export interface DiagnosticResponse {
help: string[];
success: boolean;
logs: string;
}

View file

@ -14089,8 +14089,6 @@
"xpack.reporting.screencapture.waitingForRenderComplete": "レンダリングの完了を待っています",
"xpack.reporting.screencapture.waitingForRenderedElements": "レンダリングされた {itemsCount} 個の要素が DOM に入るのを待っています",
"xpack.reporting.screenCapturePanelContent.optimizeForPrintingLabel": "印刷用に最適化",
"xpack.reporting.selfCheck.ok": "レポートプラグイン自己チェックOK!",
"xpack.reporting.selfCheck.warning": "レポートプラグイン自己チェックで警告が発生しました: {err}",
"xpack.reporting.serverConfig.autoSet.sandboxDisabled": "Chromiumサンドボックスは保護が強化されていますが、{osName} OSではサポートされていません。自動的に'{configKey}: true'を設定しています。",
"xpack.reporting.serverConfig.autoSet.sandboxEnabled": "Chromiumサンドボックスは保護が強化され、{osName} OSでサポートされています。自動的にChromiumサンドボックスを有効にしています。",
"xpack.reporting.serverConfig.invalidServerHostname": "Kibana構成で「server.host:\"0\"」が見つかりました。これはReportingと互換性がありません。レポートが動作するように、「{configKey}:0.0.0.0」が自動的に構成になります。設定を「server.host:0.0.0.0」に変更するか、kibana.ymlに「{configKey}:0.0.0.0'」を追加して、このメッセージが表示されないようにすることができます。",

View file

@ -14098,8 +14098,6 @@
"xpack.reporting.screencapture.waitingForRenderComplete": "正在等候渲染完成",
"xpack.reporting.screencapture.waitingForRenderedElements": "正在等候 {itemsCount} 个已渲染元素进入 DOM",
"xpack.reporting.screenCapturePanelContent.optimizeForPrintingLabel": "打印优化",
"xpack.reporting.selfCheck.ok": "Reporting 插件自检正常!",
"xpack.reporting.selfCheck.warning": "Reporting 插件自检生成警告:{err}",
"xpack.reporting.serverConfig.autoSet.sandboxDisabled": "Chromium 沙盒提供附加保护层,但不受 {osName} OS 支持。自动设置“{configKey}: true”。",
"xpack.reporting.serverConfig.autoSet.sandboxEnabled": "Chromium 沙盒提供附加保护层,受 {osName} OS 支持。自动启用 Chromium 沙盒。",
"xpack.reporting.serverConfig.invalidServerHostname": "在 Kibana 配置中找到“server.host:\"0\"”。其不与 Reporting 兼容。要使 Reporting 运行,“{configKey}:0.0.0.0”将自动添加到配置中。可以将该设置更改为“server.host:0.0.0.0”或在 kibana.yml 中添加“{configKey}:0.0.0.0”,以阻止此消息。",