[Reporting] Replace Job Completion Notifier as NP Plugin (#47283) (#48703)

* [Reporting] Replace Job Completion Notifier as NP Plugin

* beautiful toasts

* unit test

* showNotifications returns observable

* fix kibana.json

* depends on links

* no prettier ignore

* Update content per feedback

* Remove unnecessary wrapper

* remove type annos and condense

* use an observable of the stop method

* rename the new platform plugin to match legacy

* fix i18n config

* additional plugin rename

* i18n strings prefix change

* try something with x-pack/.i18nrc.json

* remove a few more notifier phrasing from plugin definition
This commit is contained in:
Tim Sullivan 2019-10-21 12:25:47 -07:00 committed by GitHub
parent 620ad37c71
commit af952997c5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 1130 additions and 250 deletions

View file

@ -27,7 +27,7 @@
"xpack.main": "legacy/plugins/xpack_main",
"xpack.monitoring": "legacy/plugins/monitoring",
"xpack.remoteClusters": "legacy/plugins/remote_clusters",
"xpack.reporting": "legacy/plugins/reporting",
"xpack.reporting": [ "plugins/reporting", "legacy/plugins/reporting" ],
"xpack.rollupJobs": "legacy/plugins/rollup",
"xpack.searchProfiler": "legacy/plugins/searchprofiler",
"xpack.siem": "legacy/plugins/siem",

View file

@ -45,7 +45,6 @@ export const reporting = (kibana) => {
embeddableActions: [
'plugins/reporting/panel_actions/get_csv_panel_action',
],
hacks: ['plugins/reporting/hacks/job_completion_notifier'],
home: ['plugins/reporting/register_feature'],
managementSections: ['plugins/reporting/views/management'],
injectDefaultVars(server, options) {

View file

@ -1,21 +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 { uiModules } from 'ui/modules';
uiModules.get('kibana')
// disable stat reporting while running tests,
// MockInjector used in these tests is not impacted
.constant('reportingPollConfig', {
jobCompletionNotifier: {
interval: 0,
intervalErrorMultiplier: 0
},
jobsRefresh: {
interval: 0,
intervalErrorMultiplier: 0
}
});

View file

@ -1,207 +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 React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { toastNotifications } from 'ui/notify';
import { uiModules } from 'ui/modules';
import { get } from 'lodash';
import { jobQueueClient } from 'plugins/reporting/lib/job_queue_client';
import { jobCompletionNotifications } from 'plugins/reporting/lib/job_completion_notifications';
import { JobStatuses } from '../constants/job_statuses';
import { Path } from 'plugins/xpack_main/services/path';
import { xpackInfo } from 'plugins/xpack_main/services/xpack_info';
import { Poller } from '../../../../common/poller';
import {
EuiButton,
} from '@elastic/eui';
import { downloadReport } from '../lib/download_report';
import { npStart } from 'ui/new_platform';
/**
* Poll for changes to reports. Inform the user of changes when the license is active.
*/
uiModules.get('kibana')
.run(reportingPollConfig => {
// Don't show users any reporting toasts until they're logged in.
if (Path.isUnauthenticated()) {
return;
}
// We assume that all license types offer Reporting, and that we only need to check if the
// license is active or expired.
const isLicenseActive = xpackInfo.getLicense().isActive;
async function showCompletionNotification(job) {
const reportObjectTitle = job._source.payload.title;
const reportObjectType = job._source.payload.type;
const isJobSuccessful = get(job, '_source.status') === JobStatuses.COMPLETED;
if (!isJobSuccessful) {
const errorDoc = await jobQueueClient.getContent(job._id);
const text = errorDoc.content;
return toastNotifications.addDanger({
title: (
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.couldNotCreateReportTitle"
defaultMessage="Couldn't create report for {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType, reportObjectTitle }}
/>
),
text,
});
}
let seeReportLink;
const { chrome } = npStart.core;
// In-case the license expired/changed between the time they queued the job and the time that
// the job completes, that way we don't give the user a toast to download their report if they can't.
// NOTE: this should be looking at configuration rather than the existence of a navLink
if (chrome.navLinks.has('kibana:management')) {
const { baseUrl } = chrome.navLinks.get('kibana:management');
const reportingSectionUrl = `${baseUrl}/kibana/reporting`;
seeReportLink = (
<p>
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.reportLink.pickItUpFromPathDescription"
defaultMessage="Pick it up from {path}."
values={{ path: (
<a href={reportingSectionUrl}>
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.reportLink.reportingSectionUrlLinkLabel"
defaultMessage="Management &gt; Kibana &gt; Reporting"
/>
</a>
)
}}
/>
</p>
);
}
const downloadReportButton = (
<EuiButton
size="s"
data-test-subj="downloadCompletedReportButton"
onClick={() => { downloadReport(job._id); }}
>
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.downloadReportButtonLabel"
defaultMessage="Download report"
/>
</EuiButton>
);
const maxSizeReached = get(job, '_source.output.max_size_reached');
const csvContainsFormulas = get(job, '_source.output.csv_contains_formulas');
if (csvContainsFormulas) {
return toastNotifications.addWarning({
title: (
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.csvContainsFormulas.formulaReportTitle"
defaultMessage="Report may contain formulas {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType, reportObjectTitle }}
/>
),
text: (
<div>
<p>
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.csvContainsFormulas.formulaReportMessage"
defaultMessage="The report contains characters which spreadsheet applications can interpret as formulas."
/>
</p>
{seeReportLink}
{downloadReportButton}
</div>
),
'data-test-subj': 'completeReportSuccess',
});
}
if (maxSizeReached) {
return toastNotifications.addWarning({
title: (
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.maxSizeReached.partialReportTitle"
defaultMessage="Created partial report for {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType, reportObjectTitle }}
/>
),
text: (
<div>
<p>
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.maxSizeReached.partialReportDescription"
defaultMessage="The report reached the max size and contains partial data."
/>
</p>
{seeReportLink}
{downloadReportButton}
</div>
),
'data-test-subj': 'completeReportSuccess',
});
}
toastNotifications.addSuccess({
title: (
<FormattedMessage
id="xpack.reporting.jobCompletionNotifier.successfullyCreatedReportNotificationTitle"
defaultMessage="Created report for {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType, reportObjectTitle }}
/>
),
text: (
<div>
{seeReportLink}
{downloadReportButton}
</div>
),
'data-test-subj': 'completeReportSuccess',
});
}
const { jobCompletionNotifier } = reportingPollConfig;
const poller = new Poller({
functionToPoll: async () => {
if (!isLicenseActive) {
return;
}
const jobIds = jobCompletionNotifications.getAll();
if (!jobIds.length) {
return;
}
const jobs = await jobQueueClient.list(0, jobIds);
jobIds.forEach(async jobId => {
const job = jobs.find(j => j._id === jobId);
if (!job) {
jobCompletionNotifications.remove(jobId);
return;
}
if (job._source.status === JobStatuses.COMPLETED || job._source.status === JobStatuses.FAILED) {
await showCompletionNotification(job);
jobCompletionNotifications.remove(job.id);
return;
}
});
},
pollFrequencyInMillis: jobCompletionNotifier.interval,
trailing: true,
continuePollingOnError: true,
pollFrequencyErrorMultiplier: jobCompletionNotifier.intervalErrorMultiplier
});
poller.start();
});

View file

@ -5,6 +5,7 @@
*/
import boom from 'boom';
import { RequestQuery } from 'hapi';
import { Request, ResponseToolkit } from 'hapi';
import { API_BASE_URL } from '../../common/constants';
import { JobDoc, KbnServer } from '../../types';
@ -19,6 +20,12 @@ import {
const MAIN_ENTRY = `${API_BASE_URL}/jobs`;
type ListQuery = RequestQuery & {
page: string;
size: string;
ids?: string; // optional field forbids us from extending RequestQuery
};
export function registerJobs(server: KbnServer) {
const jobsQuery = jobsQueryFactory(server);
const getRouteConfig = getRouteConfigFactoryManagementPre(server);
@ -30,12 +37,10 @@ export function registerJobs(server: KbnServer) {
method: 'GET',
config: getRouteConfig(),
handler: (request: Request) => {
// @ts-ignore
const page = parseInt(request.query.page, 10) || 0;
// @ts-ignore
const size = Math.min(100, parseInt(request.query.size, 10) || 10);
// @ts-ignore
const jobIds = request.query.ids ? request.query.ids.split(',') : null;
const { page: queryPage, size: querySize, ids: queryIds } = request.query as ListQuery;
const page = parseInt(queryPage, 10) || 0;
const size = Math.min(100, parseInt(querySize, 10) || 10);
const jobIds = queryIds ? queryIds.split(',') : null;
const results = jobsQuery.list(
request.pre.management.jobTypes,

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const reportingPollConfig = {
jobCompletionNotifier: { interval: 10000, intervalErrorMultiplier: 5 },
jobsRefresh: { interval: 5000, intervalErrorMultiplier: 5 },
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
export const JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY =
'xpack.reporting.jobCompletionNotifications';
export const JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG = {
jobCompletionNotifier: {
interval: 10000,
intervalErrorMultiplier: 5,
},
};
export const API_BASE_URL = '/api/reporting/jobs';
export const REPORTING_MANAGEMENT_HOME = '/app/kibana#/management/kibana/reporting';
export const JOB_STATUS_FAILED = 'failed';
export const JOB_STATUS_COMPLETED = 'completed';

59
x-pack/plugins/reporting/index.d.ts vendored Normal file
View file

@ -0,0 +1,59 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
CoreSetup,
CoreStart,
HttpServiceBase,
Plugin,
PluginInitializerContext,
NotificationsStart,
} from '../../../src/core/public';
export type JobId = string;
export type JobStatus = 'completed' | 'pending' | 'processing' | 'failed';
export type HttpService = HttpServiceBase;
export type NotificationsService = NotificationsStart;
export interface SourceJob {
_id: JobId;
_source: {
status: JobStatus;
output: {
max_size_reached: boolean;
csv_contains_formulas: boolean;
};
payload: {
type: string;
title: string;
};
};
}
export interface JobContent {
content: string;
}
export interface JobSummary {
id: JobId;
status: JobStatus;
title: string;
type: string;
maxSizeReached: boolean;
csvContainsFormulas: boolean;
}
export interface JobStatusBuckets {
completed: JobSummary[];
failed: JobSummary[];
}
type DownloadLink = string;
export type DownloadReportFn = (jobId: JobId) => DownloadLink;
type ManagementLink = string;
export type ManagementLinkFn = () => ManagementLink;

View file

@ -0,0 +1,8 @@
{
"id": "reporting",
"version": "8.0.0",
"kibanaVersion": "kibana",
"requiredPlugins": [],
"server": false,
"ui": true
}

View file

@ -0,0 +1,36 @@
/*
* 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 React from 'react';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { JobId, JobSummary } from '../../index.d';
interface Props {
getUrl: (jobId: JobId) => string;
job: JobSummary;
}
export const DownloadButton = ({ getUrl, job }: Props) => {
const downloadReport = () => {
window.open(getUrl(job.id));
};
return (
<EuiButton
size="s"
data-test-subj="downloadCompletedReportButton"
onClick={() => {
downloadReport();
}}
>
<FormattedMessage
id="xpack.reporting.publicNotifier.downloadReportButtonLabel"
defaultMessage="Download report"
/>
</EuiButton>
);
};

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { ToastInput } from '../../../../../src/core/public';
export const getGeneralErrorToast = (errorText: string, err: Error): ToastInput => ({
text: (
<Fragment>
<EuiCallOut title={errorText} color="danger" iconType="alert">
{err.toString()}
</EuiCallOut>
<EuiSpacer />
<FormattedMessage
id="xpack.reporting.publicNotifier.error.tryRefresh"
defaultMessage="Try refreshing the page."
></FormattedMessage>
</Fragment>
),
iconType: undefined,
});

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;
* you may not use this file except in compliance with the Elastic License.
*/
export { getSuccessToast } from './job_success';
export { getFailureToast } from './job_failure';
export { getWarningFormulasToast } from './job_warning_formulas';
export { getWarningMaxSizeToast } from './job_warning_max_size';
export { getGeneralErrorToast } from './general_error';

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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { ToastInput } from '../../../../../src/core/public';
import { JobSummary, ManagementLinkFn } from '../../index.d';
export const getFailureToast = (
errorText: string,
job: JobSummary,
getManagmenetLink: ManagementLinkFn
): ToastInput => {
return {
title: (
<FormattedMessage
id="xpack.reporting.publicNotifier.error.couldNotCreateReportTitle"
defaultMessage="Could not create report for {reportObjectType} '{reportObjectTitle}'."
values={{ reportObjectType: job.type, reportObjectTitle: job.title }}
/>
),
text: (
<Fragment>
<EuiCallOut
size="m"
title={i18n.translate('xpack.reporting.publicNotifier.error.calloutTitle', {
defaultMessage: 'The reporting job failed',
})}
color="danger"
iconType="alert"
>
{errorText}
</EuiCallOut>
<EuiSpacer />
<p>
<FormattedMessage
id="xpack.reporting.publicNotifier.error.checkManagement"
defaultMessage="More information is available at {path}."
values={{
path: (
<a href={getManagmenetLink()}>
<FormattedMessage
id="xpack.reporting.publicNotifier.error.reportingSectionUrlLinkLabel"
defaultMessage="Management &gt; Kibana &gt; Reporting"
/>
</a>
),
}}
/>
</p>
</Fragment>
),
iconType: undefined,
'data-test-subj': 'completeReportFailure',
};
};

View file

@ -0,0 +1,36 @@
/*
* 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 React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { ToastInput } from '../../../../../src/core/public';
import { JobId, JobSummary } from '../../index.d';
import { ReportLink } from './report_link';
import { DownloadButton } from './download_button';
export const getSuccessToast = (
job: JobSummary,
getReportLink: () => string,
getDownloadLink: (jobId: JobId) => string
): ToastInput => ({
title: (
<FormattedMessage
id="xpack.reporting.publicNotifier.successfullyCreatedReportNotificationTitle"
defaultMessage="Created report for {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType: job.type, reportObjectTitle: job.title }}
/>
),
color: 'success',
text: (
<Fragment>
<p>
<ReportLink getUrl={getReportLink} />
</p>
<DownloadButton getUrl={getDownloadLink} job={job} />
</Fragment>
),
'data-test-subj': 'completeReportSuccess',
});

View file

@ -0,0 +1,41 @@
/*
* 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 React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { ToastInput } from '../../../../../src/core/public';
import { JobId, JobSummary } from '../../index.d';
import { ReportLink } from './report_link';
import { DownloadButton } from './download_button';
export const getWarningFormulasToast = (
job: JobSummary,
getReportLink: () => string,
getDownloadLink: (jobId: JobId) => string
): ToastInput => ({
title: (
<FormattedMessage
id="xpack.reporting.publicNotifier.csvContainsFormulas.formulaReportTitle"
defaultMessage="Report may contain formulas {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType: job.type, reportObjectTitle: job.title }}
/>
),
text: (
<Fragment>
<p>
<FormattedMessage
id="xpack.reporting.publicNotifier.csvContainsFormulas.formulaReportMessage"
defaultMessage="The report contains characters which spreadsheet applications can interpret as formulas."
/>
</p>
<p>
<ReportLink getUrl={getReportLink} />
</p>
<DownloadButton getUrl={getDownloadLink} job={job} />
</Fragment>
),
'data-test-subj': 'completeReportCsvFormulasWarning',
});

View file

@ -0,0 +1,41 @@
/*
* 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 React, { Fragment } from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { ToastInput } from '../../../../../src/core/public';
import { JobId, JobSummary } from '../../index.d';
import { ReportLink } from './report_link';
import { DownloadButton } from './download_button';
export const getWarningMaxSizeToast = (
job: JobSummary,
getReportLink: () => string,
getDownloadLink: (jobId: JobId) => string
): ToastInput => ({
title: (
<FormattedMessage
id="xpack.reporting.publicNotifier.maxSizeReached.partialReportTitle"
defaultMessage="Created partial report for {reportObjectType} '{reportObjectTitle}'"
values={{ reportObjectType: job.type, reportObjectTitle: job.title }}
/>
),
text: (
<Fragment>
<p>
<FormattedMessage
id="xpack.reporting.publicNotifier.maxSizeReached.partialReportDescription"
defaultMessage="The report reached the max size and contains partial data."
/>
</p>
<p>
<ReportLink getUrl={getReportLink} />
</p>
<DownloadButton getUrl={getDownloadLink} job={job} />
</Fragment>
),
'data-test-subj': 'completeReportMaxSizeWarning',
});

View file

@ -0,0 +1,29 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
interface Props {
getUrl: () => string;
}
export const ReportLink = ({ getUrl }: Props) => (
<FormattedMessage
id="xpack.reporting.publicNotifier.reportLink.pickItUpFromPathDescription"
defaultMessage="Pick it up from {path}."
values={{
path: (
<a href={getUrl()}>
<FormattedMessage
id="xpack.reporting.publicNotifier.reportLink.reportingSectionUrlLinkLabel"
defaultMessage="Management &gt; Kibana &gt; Reporting"
/>
</a>
),
}}
/>
);

View file

@ -0,0 +1,14 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { PluginInitializerContext } from '../../../../src/core/public';
import { ReportingPublicPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new ReportingPublicPlugin(initializerContext);
}
export { ReportingPublicPlugin as Plugin };

View file

@ -0,0 +1,198 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`stream handler findChangedStatusJobs finds changed status jobs 1`] = `
Object {
"completed": Array [
Object {
"csvContainsFormulas": false,
"id": "job-source-mock1",
"maxSizeReached": false,
"status": "completed",
"title": "specimen",
"type": "spectacular",
},
],
"failed": Array [
Object {
"csvContainsFormulas": false,
"id": "job-source-mock2",
"maxSizeReached": false,
"status": "failed",
"title": "specimen",
"type": "spectacular",
},
],
}
`;
exports[`stream handler showNotifications show csv formulas warning 1`] = `
Array [
Object {
"data-test-subj": "completeReportCsvFormulasWarning",
"text": <React.Fragment>
<p>
<FormattedMessage
defaultMessage="The report contains characters which spreadsheet applications can interpret as formulas."
id="xpack.reporting.publicNotifier.csvContainsFormulas.formulaReportMessage"
values={Object {}}
/>
</p>
<p>
<ReportLink
getUrl={[Function]}
/>
</p>
<DownloadButton
getUrl={[Function]}
job={
Object {
"csvContainsFormulas": true,
"id": "yas3",
"status": "completed",
"title": "Yas",
"type": "yas",
}
}
/>
</React.Fragment>,
"title": <FormattedMessage
defaultMessage="Report may contain formulas {reportObjectType} '{reportObjectTitle}'"
id="xpack.reporting.publicNotifier.csvContainsFormulas.formulaReportTitle"
values={
Object {
"reportObjectTitle": "Yas",
"reportObjectType": "yas",
}
}
/>,
},
]
`;
exports[`stream handler showNotifications show failed job toast 1`] = `
Array [
Object {
"data-test-subj": "completeReportFailure",
"iconType": undefined,
"text": <React.Fragment>
<EuiCallOut
color="danger"
iconType="alert"
size="m"
title="The reporting job failed"
>
this is the completed report data
</EuiCallOut>
<EuiSpacer />
<p>
<FormattedMessage
defaultMessage="More information is available at {path}."
id="xpack.reporting.publicNotifier.error.checkManagement"
values={
Object {
"path": <a>
<FormattedMessage
defaultMessage="Management > Kibana > Reporting"
id="xpack.reporting.publicNotifier.error.reportingSectionUrlLinkLabel"
values={Object {}}
/>
</a>,
}
}
/>
</p>
</React.Fragment>,
"title": <FormattedMessage
defaultMessage="Could not create report for {reportObjectType} '{reportObjectTitle}'."
id="xpack.reporting.publicNotifier.error.couldNotCreateReportTitle"
values={
Object {
"reportObjectTitle": "Yas 7",
"reportObjectType": "yas",
}
}
/>,
},
]
`;
exports[`stream handler showNotifications show max length warning 1`] = `
Array [
Object {
"data-test-subj": "completeReportMaxSizeWarning",
"text": <React.Fragment>
<p>
<FormattedMessage
defaultMessage="The report reached the max size and contains partial data."
id="xpack.reporting.publicNotifier.maxSizeReached.partialReportDescription"
values={Object {}}
/>
</p>
<p>
<ReportLink
getUrl={[Function]}
/>
</p>
<DownloadButton
getUrl={[Function]}
job={
Object {
"id": "yas2",
"maxSizeReached": true,
"status": "completed",
"title": "Yas",
"type": "yas",
}
}
/>
</React.Fragment>,
"title": <FormattedMessage
defaultMessage="Created partial report for {reportObjectType} '{reportObjectTitle}'"
id="xpack.reporting.publicNotifier.maxSizeReached.partialReportTitle"
values={
Object {
"reportObjectTitle": "Yas",
"reportObjectType": "yas",
}
}
/>,
},
]
`;
exports[`stream handler showNotifications show success 1`] = `
Array [
Object {
"color": "success",
"data-test-subj": "completeReportSuccess",
"text": <React.Fragment>
<p>
<ReportLink
getUrl={[Function]}
/>
</p>
<DownloadButton
getUrl={[Function]}
job={
Object {
"id": "yas1",
"status": "completed",
"title": "Yas",
"type": "yas",
}
}
/>
</React.Fragment>,
"title": <FormattedMessage
defaultMessage="Created report for {reportObjectType} '{reportObjectTitle}'"
id="xpack.reporting.publicNotifier.successfullyCreatedReportNotificationTitle"
values={
Object {
"reportObjectTitle": "Yas",
"reportObjectType": "yas",
}
}
/>,
},
]
`;

View file

@ -0,0 +1,27 @@
/*
* 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 { HttpService, JobId, JobContent, SourceJob } from '../../index.d';
import { API_BASE_URL } from '../../constants';
export class JobQueue {
public findForJobIds = (http: HttpService, jobIds: JobId[]): Promise<SourceJob[]> => {
return http.fetch(`${API_BASE_URL}/list`, {
query: { page: 0, ids: jobIds.join(',') },
method: 'GET',
});
};
public getContent(http: HttpService, jobId: JobId): Promise<string> {
return http
.fetch(`${API_BASE_URL}/output/${jobId}`, {
method: 'GET',
})
.then((data: JobContent) => data.content);
}
}
export const jobQueueClient = new JobQueue();

View file

@ -0,0 +1,264 @@
/*
* 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, { stub } from 'sinon';
import { HttpServiceBase, NotificationsStart } from '../../../../../src/core/public';
import { SourceJob, JobSummary, HttpService } from '../../index.d';
import { JobQueue } from './job_queue';
import { ReportingNotifierStreamHandler } from './stream_handler';
Object.defineProperty(window, 'sessionStorage', {
value: {
setItem: jest.fn(() => null),
},
writable: true,
});
const mockJobsFound = [
{
_id: 'job-source-mock1',
_source: {
status: 'completed',
output: { max_size_reached: false, csv_contains_formulas: false },
payload: { type: 'spectacular', title: 'specimen' },
},
},
{
_id: 'job-source-mock2',
_source: {
status: 'failed',
output: { max_size_reached: false, csv_contains_formulas: false },
payload: { type: 'spectacular', title: 'specimen' },
},
},
{
_id: 'job-source-mock3',
_source: {
status: 'pending',
output: { max_size_reached: false, csv_contains_formulas: false },
payload: { type: 'spectacular', title: 'specimen' },
},
},
];
const jobQueueClientMock: JobQueue = {
findForJobIds: async (http: HttpService, jobIds: string[]) => {
return mockJobsFound as SourceJob[];
},
getContent: () => {
return Promise.resolve('this is the completed report data');
},
};
const httpMock: HttpService = ({
basePath: {
prepend: stub(),
},
} as unknown) as HttpServiceBase;
const mockShowDanger = stub();
const mockShowSuccess = stub();
const mockShowWarning = stub();
const notificationsMock = ({
toasts: {
addDanger: mockShowDanger,
addSuccess: mockShowSuccess,
addWarning: mockShowWarning,
},
} as unknown) as NotificationsStart;
describe('stream handler', () => {
afterEach(() => {
sinon.reset();
});
it('constructs', () => {
const sh = new ReportingNotifierStreamHandler(httpMock, notificationsMock, jobQueueClientMock);
expect(sh).not.toBe(null);
});
describe('findChangedStatusJobs', () => {
it('finds no changed status jobs from empty', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
const findJobs = sh.findChangedStatusJobs([]);
findJobs.subscribe(data => {
expect(data).toEqual({ completed: [], failed: [] });
done();
});
});
it('finds changed status jobs', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
const findJobs = sh.findChangedStatusJobs([
'job-source-mock1',
'job-source-mock2',
'job-source-mock3',
]);
findJobs.subscribe(data => {
expect(data).toMatchSnapshot();
done();
});
});
});
describe('showNotifications', () => {
it('show success', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
sh.showNotifications({
completed: [
{
id: 'yas1',
title: 'Yas',
type: 'yas',
status: 'completed',
} as JobSummary,
],
failed: [],
}).subscribe(() => {
expect(mockShowDanger.callCount).toBe(0);
expect(mockShowSuccess.callCount).toBe(1);
expect(mockShowWarning.callCount).toBe(0);
expect(mockShowSuccess.args[0]).toMatchSnapshot();
done();
});
});
it('show max length warning', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
sh.showNotifications({
completed: [
{
id: 'yas2',
title: 'Yas',
type: 'yas',
status: 'completed',
maxSizeReached: true,
} as JobSummary,
],
failed: [],
}).subscribe(() => {
expect(mockShowDanger.callCount).toBe(0);
expect(mockShowSuccess.callCount).toBe(0);
expect(mockShowWarning.callCount).toBe(1);
expect(mockShowWarning.args[0]).toMatchSnapshot();
done();
});
});
it('show csv formulas warning', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
sh.showNotifications({
completed: [
{
id: 'yas3',
title: 'Yas',
type: 'yas',
status: 'completed',
csvContainsFormulas: true,
} as JobSummary,
],
failed: [],
}).subscribe(() => {
expect(mockShowDanger.callCount).toBe(0);
expect(mockShowSuccess.callCount).toBe(0);
expect(mockShowWarning.callCount).toBe(1);
expect(mockShowWarning.args[0]).toMatchSnapshot();
done();
});
});
it('show failed job toast', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
sh.showNotifications({
completed: [],
failed: [
{
id: 'yas7',
title: 'Yas 7',
type: 'yas',
status: 'failed',
} as JobSummary,
],
}).subscribe(() => {
expect(mockShowSuccess.callCount).toBe(0);
expect(mockShowWarning.callCount).toBe(0);
expect(mockShowDanger.callCount).toBe(1);
expect(mockShowDanger.args[0]).toMatchSnapshot();
done();
});
});
it('show multiple toast', done => {
const sh = new ReportingNotifierStreamHandler(
httpMock,
notificationsMock,
jobQueueClientMock
);
sh.showNotifications({
completed: [
{
id: 'yas8',
title: 'Yas 8',
type: 'yas',
status: 'completed',
} as JobSummary,
{
id: 'yas9',
title: 'Yas 9',
type: 'yas',
status: 'completed',
csvContainsFormulas: true,
} as JobSummary,
{
id: 'yas10',
title: 'Yas 10',
type: 'yas',
status: 'completed',
maxSizeReached: true,
} as JobSummary,
],
failed: [
{
id: 'yas13',
title: 'Yas 13',
type: 'yas',
status: 'failed',
} as JobSummary,
],
}).subscribe(() => {
expect(mockShowSuccess.callCount).toBe(1);
expect(mockShowWarning.callCount).toBe(2);
expect(mockShowDanger.callCount).toBe(1);
done();
});
});
});
});

View file

@ -0,0 +1,150 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import * as Rx from 'rxjs';
import { catchError, map } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import {
JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY,
JOB_STATUS_COMPLETED,
JOB_STATUS_FAILED,
API_BASE_URL,
REPORTING_MANAGEMENT_HOME,
} from '../../constants';
import {
JobId,
JobSummary,
JobStatusBuckets,
HttpService,
NotificationsService,
SourceJob,
DownloadReportFn,
ManagementLinkFn,
} from '../../index.d';
import {
getSuccessToast,
getFailureToast,
getWarningFormulasToast,
getWarningMaxSizeToast,
getGeneralErrorToast,
} from '../components';
import { jobQueueClient as defaultJobQueueClient } from './job_queue';
function updateStored(jobIds: JobId[]): void {
sessionStorage.setItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JSON.stringify(jobIds));
}
function summarizeJob(src: SourceJob): JobSummary {
return {
id: src._id,
status: src._source.status,
title: src._source.payload.title,
type: src._source.payload.type,
maxSizeReached: src._source.output.max_size_reached,
csvContainsFormulas: src._source.output.csv_contains_formulas,
};
}
export class ReportingNotifierStreamHandler {
private getManagementLink: ManagementLinkFn;
private getDownloadLink: DownloadReportFn;
constructor(
private http: HttpService,
private notifications: NotificationsService,
private jobQueueClient = defaultJobQueueClient
) {
this.getManagementLink = () => {
return http.basePath.prepend(REPORTING_MANAGEMENT_HOME);
};
this.getDownloadLink = (jobId: JobId) => {
return http.basePath.prepend(`${API_BASE_URL}/download/${jobId}`);
};
}
/*
* Use Kibana Toast API to show our messages
*/
public showNotifications({
completed: completedJobs,
failed: failedJobs,
}: JobStatusBuckets): Rx.Observable<JobStatusBuckets> {
const showNotificationsAsync = async () => {
// notifications with download link
for (const job of completedJobs) {
if (job.csvContainsFormulas) {
this.notifications.toasts.addWarning(
getWarningFormulasToast(job, this.getManagementLink, this.getDownloadLink)
);
} else if (job.maxSizeReached) {
this.notifications.toasts.addWarning(
getWarningMaxSizeToast(job, this.getManagementLink, this.getDownloadLink)
);
} else {
this.notifications.toasts.addSuccess(
getSuccessToast(job, this.getManagementLink, this.getDownloadLink)
);
}
}
// no download link available
for (const job of failedJobs) {
const content = await this.jobQueueClient.getContent(this.http, job.id);
this.notifications.toasts.addDanger(getFailureToast(content, job, this.getManagementLink));
}
return { completed: completedJobs, failed: failedJobs };
};
return Rx.from(showNotificationsAsync()); // convert Promise to Observable, for the convenience of the main stream
}
/*
* An observable that finds jobs that are known to be "processing" (stored in
* session storage) but have non-processing job status on the server
*/
public findChangedStatusJobs(storedJobs: JobId[]): Rx.Observable<JobStatusBuckets> {
return Rx.from(this.jobQueueClient.findForJobIds(this.http, storedJobs)).pipe(
map((jobs: SourceJob[]) => {
const completedJobs: JobSummary[] = [];
const failedJobs: JobSummary[] = [];
const pending: JobId[] = [];
// add side effects to storage
for (const job of jobs) {
const {
_id: jobId,
_source: { status: jobStatus },
} = job;
if (storedJobs.includes(jobId)) {
if (jobStatus === JOB_STATUS_COMPLETED) {
completedJobs.push(summarizeJob(job));
} else if (jobStatus === JOB_STATUS_FAILED) {
failedJobs.push(summarizeJob(job));
} else {
pending.push(jobId);
}
}
}
updateStored(pending); // refresh the storage of pending job IDs, minus completed and failed job IDs
return { completed: completedJobs, failed: failedJobs };
}),
catchError(err => {
// show connection refused toast
this.notifications.toasts.addDanger(
getGeneralErrorToast(
i18n.translate('xpack.reporting.publicNotifier.httpErrorMessage', {
defaultMessage: 'Could not check Reporting job status!',
}),
err
)
); // prettier-ignore
window.console.error(err);
return Rx.of({ completed: [], failed: [] }); // log the error and resume
})
);
}
}

View file

@ -0,0 +1,82 @@
/*
* 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 * as Rx from 'rxjs';
import { catchError, filter, map, mergeMap, takeUntil } from 'rxjs/operators';
import { i18n } from '@kbn/i18n';
import {
CoreSetup,
CoreStart,
Plugin,
PluginInitializerContext,
} from '../../../../src/core/public';
import {
JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG,
JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY,
} from '../constants';
import { JobId, JobStatusBuckets, NotificationsService } from '../index.d';
import { getGeneralErrorToast } from './components';
import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler';
const {
jobCompletionNotifier: { interval: JOBS_REFRESH_INTERVAL },
} = JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG;
function getStored(): JobId[] {
const sessionValue = sessionStorage.getItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY);
return sessionValue ? JSON.parse(sessionValue) : [];
}
function handleError(
notifications: NotificationsService,
err: Error
): Rx.Observable<JobStatusBuckets> {
notifications.toasts.addDanger(
getGeneralErrorToast(
i18n.translate('xpack.reporting.publicNotifier.pollingErrorMessage', {
defaultMessage: 'Reporting notifier error!',
}),
err
)
);
window.console.error(err);
return Rx.of({ completed: [], failed: [] });
}
export class ReportingPublicPlugin implements Plugin<any, any> {
private readonly stop$ = new Rx.ReplaySubject(1);
// FIXME: License checking: only active, non-expired licenses allowed
// Depends on https://github.com/elastic/kibana/pull/44922
constructor(initializerContext: PluginInitializerContext) {}
public setup(core: CoreSetup) {}
// FIXME: only perform these actions for authenticated routes
// Depends on https://github.com/elastic/kibana/pull/39477
public start(core: CoreStart) {
const { http, notifications } = core;
const streamHandler = new StreamHandler(http, notifications);
Rx.timer(0, JOBS_REFRESH_INTERVAL)
.pipe(
takeUntil(this.stop$), // stop the interval when stop method is called
map(() => getStored()), // read all pending job IDs from session storage
filter(storedJobs => storedJobs.length > 0), // stop the pipeline here if there are none pending
mergeMap(storedJobs => streamHandler.findChangedStatusJobs(storedJobs)), // look up the latest status of all pending jobs on the server
mergeMap(({ completed, failed }) => streamHandler.showNotifications({ completed, failed })),
catchError(err => handleError(notifications, err))
)
.subscribe();
}
public stop() {
this.stop$.next();
}
}
export type Setup = ReturnType<ReportingPublicPlugin['setup']>;
export type Start = ReturnType<ReportingPublicPlugin['start']>;

View file

@ -8251,13 +8251,6 @@
"xpack.reporting.exportTypes.printablePdf.logoDescription": "Elastic 提供",
"xpack.reporting.exportTypes.printablePdf.pagingDescription": "{pageCount} ページ中 {currentPage} ページ目",
"xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage": "ページで予期せぬメッセージが発生しました: {toastHeaderText}",
"xpack.reporting.jobCompletionNotifier.couldNotCreateReportTitle": "{reportObjectType}「{reportObjectTitle}」のレポートを作成できませんでした",
"xpack.reporting.jobCompletionNotifier.downloadReportButtonLabel": "レポートをダウンロード",
"xpack.reporting.jobCompletionNotifier.maxSizeReached.partialReportDescription": "レポートが最大サイズに達し、部分データが含まれています。",
"xpack.reporting.jobCompletionNotifier.maxSizeReached.partialReportTitle": "{reportObjectType}「{reportObjectTitle}」の部分レポートが作成されました",
"xpack.reporting.jobCompletionNotifier.reportLink.pickItUpFromPathDescription": "{path} から開始します。",
"xpack.reporting.jobCompletionNotifier.reportLink.reportingSectionUrlLinkLabel": "管理 > Kibana > レポート",
"xpack.reporting.jobCompletionNotifier.successfullyCreatedReportNotificationTitle": "{reportObjectType}「{reportObjectTitle}」のレポートが作成されました",
"xpack.reporting.jobStatuses.cancelledText": "キャンセル済み",
"xpack.reporting.jobStatuses.completedText": "完了",
"xpack.reporting.jobStatuses.failedText": "失敗",

View file

@ -8408,13 +8408,6 @@
"xpack.reporting.exportTypes.printablePdf.logoDescription": "由 Elastic 提供支持",
"xpack.reporting.exportTypes.printablePdf.pagingDescription": "第 {currentPage} 页,共 {pageCount} 页",
"xpack.reporting.exportTypes.printablePdf.screenshots.unexpectedErrorMessage": "在页面上出现意外消息:{toastHeaderText}",
"xpack.reporting.jobCompletionNotifier.couldNotCreateReportTitle": "无法为 {reportObjectType} “{reportObjectTitle}” 创建报告",
"xpack.reporting.jobCompletionNotifier.downloadReportButtonLabel": "下载报告",
"xpack.reporting.jobCompletionNotifier.maxSizeReached.partialReportDescription": "报告已达到最大大小,仅包含部分数据。",
"xpack.reporting.jobCompletionNotifier.maxSizeReached.partialReportTitle": "已为 {reportObjectType} “{reportObjectTitle}” 创建部分报告",
"xpack.reporting.jobCompletionNotifier.reportLink.pickItUpFromPathDescription": "从 {path} 收集。",
"xpack.reporting.jobCompletionNotifier.reportLink.reportingSectionUrlLinkLabel": "管理 > Kibana > Reporting",
"xpack.reporting.jobCompletionNotifier.successfullyCreatedReportNotificationTitle": "已为 {reportObjectType} “{reportObjectTitle}” 创建报告",
"xpack.reporting.jobStatuses.cancelledText": "已取消",
"xpack.reporting.jobStatuses.completedText": "已完成",
"xpack.reporting.jobStatuses.failedText": "失败",