[Reporting] Allow reports to be deleted in Management > Kibana > Reporting (#60077)

* [Reporting] Feature Delete Button in Job Listing

* refactor listing buttons

* multi-delete

* confirm modal

* remove unused

* fix test

* mock the id generator for snapshotting

* simplify

* add search bar above table

* fix types errors
This commit is contained in:
Tim Sullivan 2020-03-19 12:36:19 -07:00 committed by GitHub
parent 915b784cd6
commit ce2e3fd621
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1225 additions and 193 deletions

View file

@ -4,6 +4,8 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import Boom from 'boom';
import { errors as elasticsearchErrors } from 'elasticsearch';
import { ElasticsearchServiceSetup } from 'kibana/server';
import { get } from 'lodash';
@ -152,5 +154,21 @@ export function jobsQueryFactory(server: ServerFacade, elasticsearch: Elasticsea
return hits[0];
});
},
async delete(deleteIndex: string, id: string) {
try {
const query = { id, index: deleteIndex };
return callAsInternalUser('delete', query);
} catch (error) {
const wrappedError = new Error(
i18n.translate('xpack.reporting.jobsQuery.deleteError', {
defaultMessage: 'Could not delete the report: {error}',
values: { error: error.message },
})
);
throw Boom.boomify(wrappedError, { statusCode: error.status });
}
},
};
}

View file

@ -18,9 +18,13 @@ import {
} from '../../types';
import { jobsQueryFactory } from '../lib/jobs_query';
import { ReportingSetupDeps, ReportingCore } from '../types';
import { jobResponseHandlerFactory } from './lib/job_response_handler';
import {
deleteJobResponseHandlerFactory,
downloadJobResponseHandlerFactory,
} from './lib/job_response_handler';
import { makeRequestFacade } from './lib/make_request_facade';
import {
getRouteConfigFactoryDeletePre,
getRouteConfigFactoryDownloadPre,
getRouteConfigFactoryManagementPre,
} from './lib/route_config_factories';
@ -40,7 +44,6 @@ export function registerJobInfoRoutes(
const { elasticsearch } = plugins;
const jobsQuery = jobsQueryFactory(server, elasticsearch);
const getRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger);
const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger);
// list jobs in the queue, paginated
server.route({
@ -138,7 +141,8 @@ export function registerJobInfoRoutes(
// trigger a download of the output from a job
const exportTypesRegistry = reporting.getExportTypesRegistry();
const jobResponseHandler = jobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry);
const getRouteConfigDownload = getRouteConfigFactoryDownloadPre(server, plugins, logger);
const downloadResponseHandler = downloadJobResponseHandlerFactory(server, elasticsearch, exportTypesRegistry); // prettier-ignore
server.route({
path: `${MAIN_ENTRY}/download/{docId}`,
method: 'GET',
@ -147,7 +151,47 @@ export function registerJobInfoRoutes(
const request = makeRequestFacade(legacyRequest);
const { docId } = request.params;
let response = await jobResponseHandler(
let response = await downloadResponseHandler(
request.pre.management.jobTypes,
request.pre.user,
h,
{ docId }
);
if (isResponse(response)) {
const { statusCode } = response;
if (statusCode !== 200) {
if (statusCode === 500) {
logger.error(`Report ${docId} has failed: ${JSON.stringify(response.source)}`);
} else {
logger.debug(
`Report ${docId} has non-OK status: [${statusCode}] Reason: [${JSON.stringify(
response.source
)}]`
);
}
}
response = response.header('accept-ranges', 'none');
}
return response;
},
});
// allow a report to be deleted
const getRouteConfigDelete = getRouteConfigFactoryDeletePre(server, plugins, logger);
const deleteResponseHandler = deleteJobResponseHandlerFactory(server, elasticsearch);
server.route({
path: `${MAIN_ENTRY}/delete/{docId}`,
method: 'DELETE',
options: getRouteConfigDelete(),
handler: async (legacyRequest: Legacy.Request, h: ReportingResponseToolkit) => {
const request = makeRequestFacade(legacyRequest);
const { docId } = request.params;
let response = await deleteResponseHandler(
request.pre.management.jobTypes,
request.pre.user,
h,

View file

@ -20,7 +20,7 @@ interface JobResponseHandlerOpts {
excludeContent?: boolean;
}
export function jobResponseHandlerFactory(
export function downloadJobResponseHandlerFactory(
server: ServerFacade,
elasticsearch: ElasticsearchServiceSetup,
exportTypesRegistry: ExportTypesRegistry
@ -36,6 +36,7 @@ export function jobResponseHandlerFactory(
opts: JobResponseHandlerOpts = {}
) {
const { docId } = params;
// TODO: async/await
return jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }).then(doc => {
if (!doc) return Boom.notFound();
@ -67,3 +68,34 @@ export function jobResponseHandlerFactory(
});
};
}
export function deleteJobResponseHandlerFactory(
server: ServerFacade,
elasticsearch: ElasticsearchServiceSetup
) {
const jobsQuery = jobsQueryFactory(server, elasticsearch);
return async function deleteJobResponseHander(
validJobTypes: string[],
user: any,
h: ResponseToolkit,
params: JobResponseHandlerParams
) {
const { docId } = params;
const doc = await jobsQuery.get(user, docId, { includeContent: false });
if (!doc) return Boom.notFound();
const { jobtype: jobType } = doc._source;
if (!validJobTypes.includes(jobType)) {
return Boom.unauthorized(`Sorry, you are not authorized to delete ${jobType} reports`);
}
try {
const docIndex = doc._index;
await jobsQuery.delete(docIndex, docId);
return h.response({ deleted: true });
} catch (error) {
return Boom.boomify(error, { statusCode: error.statusCode });
}
};
}

View file

@ -106,7 +106,22 @@ export function getRouteConfigFactoryDownloadPre(
const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger);
return (): RouteConfigFactory => ({
...getManagementRouteConfig(),
tags: [API_TAG],
tags: [API_TAG, 'download'],
response: {
ranges: false,
},
});
}
export function getRouteConfigFactoryDeletePre(
server: ServerFacade,
plugins: ReportingSetupDeps,
logger: Logger
): GetRouteConfigFactoryFn {
const getManagementRouteConfig = getRouteConfigFactoryManagementPre(server, plugins, logger);
return (): RouteConfigFactory => ({
...getManagementRouteConfig(),
tags: [API_TAG, 'delete'],
response: {
ranges: false,
},

View file

@ -197,6 +197,7 @@ export interface JobDocPayload<JobParamsType> {
export interface JobSource<JobParamsType> {
_id: string;
_index: string;
_source: {
jobtype: string;
output: JobDocOutput;

View file

@ -2,6 +2,526 @@
exports[`ReportListing Report job listing with some items 1`] = `
Array [
<EuiInMemoryTable
columns={
Array [
Object {
"field": "object_title",
"name": "Report",
"render": [Function],
},
Object {
"field": "created_at",
"name": "Created at",
"render": [Function],
},
Object {
"field": "status",
"name": "Status",
"render": [Function],
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
data-test-subj="reportJobListing"
isSelectable={true}
itemId="id"
items={Array []}
loading={true}
message="Loading reports"
onChange={[Function]}
pagination={
Object {
"hidePerPageOptions": true,
"pageIndex": 0,
"pageSize": 10,
"totalItemCount": 0,
}
}
responsive={true}
search={
Object {
"toolsRight": null,
}
}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
tableLayout="fixed"
>
<div>
<EuiSearchBar
onChange={[Function]}
toolsRight={null}
>
<EuiFlexGroup
alignItems="center"
gutterSize="m"
wrap={true}
>
<div
className="euiFlexGroup euiFlexGroup--gutterMedium euiFlexGroup--alignItemsCenter euiFlexGroup--directionRow euiFlexGroup--responsive euiFlexGroup--wrap"
>
<EuiFlexItem
className="euiSearchBar__searchHolder"
grow={true}
>
<div
className="euiFlexItem euiSearchBar__searchHolder"
>
<EuiSearchBox
incremental={false}
isInvalid={false}
onSearch={[Function]}
placeholder="Search..."
query=""
>
<EuiFieldSearch
aria-label="This is a search bar. After typing your query, hit enter to filter the results lower in the page."
compressed={false}
defaultValue=""
fullWidth={true}
incremental={false}
inputRef={[Function]}
isClearable={true}
isInvalid={false}
isLoading={false}
onSearch={[Function]}
placeholder="Search..."
>
<EuiFormControlLayout
compressed={false}
fullWidth={true}
icon="search"
isLoading={false}
>
<div
className="euiFormControlLayout euiFormControlLayout--fullWidth"
>
<div
className="euiFormControlLayout__childrenWrapper"
>
<EuiValidatableControl
isInvalid={false}
>
<input
aria-label="This is a search bar. After typing your query, hit enter to filter the results lower in the page."
className="euiFieldSearch euiFieldSearch--fullWidth"
defaultValue=""
onKeyUp={[Function]}
placeholder="Search..."
type="search"
/>
</EuiValidatableControl>
<EuiFormControlLayoutIcons
icon="search"
isLoading={false}
>
<div
className="euiFormControlLayoutIcons"
>
<EuiFormControlLayoutCustomIcon
type="search"
>
<span
className="euiFormControlLayoutCustomIcon"
>
<EuiIcon
aria-hidden="true"
className="euiFormControlLayoutCustomIcon__icon"
type="search"
>
<div
aria-hidden="true"
className="euiFormControlLayoutCustomIcon__icon"
data-euiicon-type="search"
/>
</EuiIcon>
</span>
</EuiFormControlLayoutCustomIcon>
</div>
</EuiFormControlLayoutIcons>
</div>
</div>
</EuiFormControlLayout>
</EuiFieldSearch>
</EuiSearchBox>
</div>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</EuiSearchBar>
<EuiSpacer
size="l"
>
<div
className="euiSpacer euiSpacer--l"
/>
</EuiSpacer>
<EuiBasicTable
columns={
Array [
Object {
"field": "object_title",
"name": "Report",
"render": [Function],
},
Object {
"field": "created_at",
"name": "Created at",
"render": [Function],
},
Object {
"field": "status",
"name": "Status",
"render": [Function],
},
Object {
"actions": Array [
Object {
"render": [Function],
},
],
"name": "Actions",
},
]
}
data-test-subj="reportJobListing"
isSelectable={true}
itemId="id"
items={Array []}
loading={true}
noItemsMessage="Loading reports"
onChange={[Function]}
pagination={
Object {
"hidePerPageOptions": true,
"pageIndex": 0,
"pageSize": 10,
"pageSizeOptions": Array [
10,
25,
50,
],
"totalItemCount": 0,
}
}
responsive={true}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
tableLayout="fixed"
>
<div
className="euiBasicTable euiBasicTable-loading"
data-test-subj="reportJobListing"
>
<div>
<EuiTableHeaderMobile>
<div
className="euiTableHeaderMobile"
>
<EuiFlexGroup
alignItems="baseline"
justifyContent="spaceBetween"
responsive={false}
>
<div
className="euiFlexGroup euiFlexGroup--gutterLarge euiFlexGroup--alignItemsBaseline euiFlexGroup--justifyContentSpaceBetween euiFlexGroup--directionRow"
>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
>
<EuiI18n
default="Select all rows"
token="euiBasicTable.selectAllRows"
>
<EuiCheckbox
aria-label="Select all rows"
checked={false}
compressed={false}
disabled={true}
id="_selection_column-checkbox_generated-id"
indeterminate={false}
label="Select all rows"
onChange={[Function]}
>
<div
className="euiCheckbox"
>
<input
aria-label="Select all rows"
checked={false}
className="euiCheckbox__input"
disabled={true}
id="_selection_column-checkbox_generated-id"
onChange={[Function]}
type="checkbox"
/>
<div
className="euiCheckbox__square"
/>
<label
className="euiCheckbox__label"
htmlFor="_selection_column-checkbox_generated-id"
>
Select all rows
</label>
</div>
</EuiCheckbox>
</EuiI18n>
</div>
</EuiFlexItem>
<EuiFlexItem
grow={false}
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
</EuiFlexItem>
</div>
</EuiFlexGroup>
</div>
</EuiTableHeaderMobile>
<EuiTable
responsive={true}
tableLayout="fixed"
>
<table
className="euiTable euiTable--responsive"
>
<EuiScreenReaderOnly>
<caption
className="euiScreenReaderOnly euiTableCaption"
>
<EuiDelayRender
delay={500}
/>
</caption>
</EuiScreenReaderOnly>
<EuiTableHeader>
<thead>
<tr>
<EuiTableHeaderCellCheckbox
key="_selection_column_h"
>
<th
className="euiTableHeaderCellCheckbox"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent"
>
<EuiI18n
default="Select all rows"
token="euiBasicTable.selectAllRows"
>
<EuiCheckbox
aria-label="Select all rows"
checked={false}
compressed={false}
data-test-subj="checkboxSelectAll"
disabled={true}
id="_selection_column-checkbox_generated-id"
indeterminate={false}
label={null}
onChange={[Function]}
type="inList"
>
<div
className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel"
>
<input
aria-label="Select all rows"
checked={false}
className="euiCheckbox__input"
data-test-subj="checkboxSelectAll"
disabled={true}
id="_selection_column-checkbox_generated-id"
onChange={[Function]}
type="checkbox"
/>
<div
className="euiCheckbox__square"
/>
</div>
</EuiCheckbox>
</EuiI18n>
</div>
</th>
</EuiTableHeaderCellCheckbox>
<EuiTableHeaderCell
align="left"
data-test-subj="tableHeaderCell_object_title_0"
key="_data_h_object_title_0"
>
<th
className="euiTableHeaderCell"
data-test-subj="tableHeaderCell_object_title_0"
role="columnheader"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent"
>
<span
className="euiTableCellContent__text"
>
Report
</span>
</div>
</th>
</EuiTableHeaderCell>
<EuiTableHeaderCell
align="left"
data-test-subj="tableHeaderCell_created_at_1"
key="_data_h_created_at_1"
>
<th
className="euiTableHeaderCell"
data-test-subj="tableHeaderCell_created_at_1"
role="columnheader"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent"
>
<span
className="euiTableCellContent__text"
>
Created at
</span>
</div>
</th>
</EuiTableHeaderCell>
<EuiTableHeaderCell
align="left"
data-test-subj="tableHeaderCell_status_2"
key="_data_h_status_2"
>
<th
className="euiTableHeaderCell"
data-test-subj="tableHeaderCell_status_2"
role="columnheader"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent"
>
<span
className="euiTableCellContent__text"
>
Status
</span>
</div>
</th>
</EuiTableHeaderCell>
<EuiTableHeaderCell
align="right"
key="_actions_h_3"
>
<th
className="euiTableHeaderCell"
role="columnheader"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent euiTableCellContent--alignRight"
>
<span
className="euiTableCellContent__text"
>
Actions
</span>
</div>
</th>
</EuiTableHeaderCell>
</tr>
</thead>
</EuiTableHeader>
<EuiTableBody>
<tbody>
<EuiTableRow>
<tr
className="euiTableRow"
>
<EuiTableRowCell
align="center"
colSpan={5}
isMobileFullWidth={true}
>
<td
className="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colSpan={5}
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent euiTableCellContent--alignCenter"
>
<span
className="euiTableCellContent__text"
>
Loading reports
</span>
</div>
</td>
</EuiTableRowCell>
</tr>
</EuiTableRow>
</tbody>
</EuiTableBody>
</table>
</EuiTable>
</div>
</div>
</EuiBasicTable>
</div>
</EuiInMemoryTable>,
<EuiBasicTable
columns={
Array [
@ -31,6 +551,7 @@ Array [
]
}
data-test-subj="reportJobListing"
isSelectable={true}
itemId="id"
items={Array []}
loading={true}
@ -41,10 +562,21 @@ Array [
"hidePerPageOptions": true,
"pageIndex": 0,
"pageSize": 10,
"pageSizeOptions": Array [
10,
25,
50,
],
"totalItemCount": 0,
}
}
responsive={true}
selection={
Object {
"itemId": "id",
"onSelectionChange": [Function],
}
}
tableLayout="fixed"
>
<div
@ -69,7 +601,46 @@ Array [
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
>
<EuiI18n
default="Select all rows"
token="euiBasicTable.selectAllRows"
>
<EuiCheckbox
aria-label="Select all rows"
checked={false}
compressed={false}
disabled={true}
id="_selection_column-checkbox_generated-id"
indeterminate={false}
label="Select all rows"
onChange={[Function]}
>
<div
className="euiCheckbox"
>
<input
aria-label="Select all rows"
checked={false}
className="euiCheckbox__input"
disabled={true}
id="_selection_column-checkbox_generated-id"
onChange={[Function]}
type="checkbox"
/>
<div
className="euiCheckbox__square"
/>
<label
className="euiCheckbox__label"
htmlFor="_selection_column-checkbox_generated-id"
>
Select all rows
</label>
</div>
</EuiCheckbox>
</EuiI18n>
</div>
</EuiFlexItem>
<EuiFlexItem
grow={false}
@ -101,6 +672,59 @@ Array [
<EuiTableHeader>
<thead>
<tr>
<EuiTableHeaderCellCheckbox
key="_selection_column_h"
>
<th
className="euiTableHeaderCellCheckbox"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent"
>
<EuiI18n
default="Select all rows"
token="euiBasicTable.selectAllRows"
>
<EuiCheckbox
aria-label="Select all rows"
checked={false}
compressed={false}
data-test-subj="checkboxSelectAll"
disabled={true}
id="_selection_column-checkbox_generated-id"
indeterminate={false}
label={null}
onChange={[Function]}
type="inList"
>
<div
className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel"
>
<input
aria-label="Select all rows"
checked={false}
className="euiCheckbox__input"
data-test-subj="checkboxSelectAll"
disabled={true}
id="_selection_column-checkbox_generated-id"
onChange={[Function]}
type="checkbox"
/>
<div
className="euiCheckbox__square"
/>
</div>
</EuiCheckbox>
</EuiI18n>
</div>
</th>
</EuiTableHeaderCellCheckbox>
<EuiTableHeaderCell
align="left"
data-test-subj="tableHeaderCell_object_title_0"
@ -218,12 +842,12 @@ Array [
>
<EuiTableRowCell
align="center"
colSpan={4}
colSpan={5}
isMobileFullWidth={true}
>
<td
className="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colSpan={4}
colSpan={5}
style={
Object {
"width": undefined,
@ -272,7 +896,46 @@ Array [
>
<div
className="euiFlexItem euiFlexItem--flexGrowZero"
/>
>
<EuiI18n
default="Select all rows"
token="euiBasicTable.selectAllRows"
>
<EuiCheckbox
aria-label="Select all rows"
checked={false}
compressed={false}
disabled={true}
id="_selection_column-checkbox_generated-id"
indeterminate={false}
label="Select all rows"
onChange={[Function]}
>
<div
className="euiCheckbox"
>
<input
aria-label="Select all rows"
checked={false}
className="euiCheckbox__input"
disabled={true}
id="_selection_column-checkbox_generated-id"
onChange={[Function]}
type="checkbox"
/>
<div
className="euiCheckbox__square"
/>
<label
className="euiCheckbox__label"
htmlFor="_selection_column-checkbox_generated-id"
>
Select all rows
</label>
</div>
</EuiCheckbox>
</EuiI18n>
</div>
</EuiFlexItem>
<EuiFlexItem
grow={false}
@ -304,6 +967,59 @@ Array [
<EuiTableHeader>
<thead>
<tr>
<EuiTableHeaderCellCheckbox
key="_selection_column_h"
>
<th
className="euiTableHeaderCellCheckbox"
scope="col"
style={
Object {
"width": undefined,
}
}
>
<div
className="euiTableCellContent"
>
<EuiI18n
default="Select all rows"
token="euiBasicTable.selectAllRows"
>
<EuiCheckbox
aria-label="Select all rows"
checked={false}
compressed={false}
data-test-subj="checkboxSelectAll"
disabled={true}
id="_selection_column-checkbox_generated-id"
indeterminate={false}
label={null}
onChange={[Function]}
type="inList"
>
<div
className="euiCheckbox euiCheckbox--inList euiCheckbox--noLabel"
>
<input
aria-label="Select all rows"
checked={false}
className="euiCheckbox__input"
data-test-subj="checkboxSelectAll"
disabled={true}
id="_selection_column-checkbox_generated-id"
onChange={[Function]}
type="checkbox"
/>
<div
className="euiCheckbox__square"
/>
</div>
</EuiCheckbox>
</EuiI18n>
</div>
</th>
</EuiTableHeaderCellCheckbox>
<EuiTableHeaderCell
align="left"
data-test-subj="tableHeaderCell_object_title_0"
@ -421,12 +1137,12 @@ Array [
>
<EuiTableRowCell
align="center"
colSpan={4}
colSpan={5}
isMobileFullWidth={true}
>
<td
className="euiTableRowCell euiTableRowCell--isMobileFullWidth"
colSpan={4}
colSpan={5}
style={
Object {
"width": undefined,

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 { ReportErrorButton } from './report_error_button';
export { ReportDeleteButton } from './report_delete_button';
export { ReportDownloadButton } from './report_download_button';
export { ReportInfoButton } from './report_info_button';

View file

@ -0,0 +1,99 @@
/*
* 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 { EuiConfirmModal, EuiOverlayMask, EuiButton } from '@elastic/eui';
import React, { PureComponent, Fragment } from 'react';
import { Job, Props as ListingProps } from '../report_listing';
type DeleteFn = () => Promise<void>;
type Props = { jobsToDelete: Job[]; performDelete: DeleteFn } & ListingProps;
interface State {
showConfirm: boolean;
}
export class ReportDeleteButton extends PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = { showConfirm: false };
}
private hideConfirm() {
this.setState({ showConfirm: false });
}
private showConfirm() {
this.setState({ showConfirm: true });
}
private renderConfirm() {
const { intl, jobsToDelete } = this.props;
const title =
jobsToDelete.length > 1
? intl.formatMessage(
{
id: 'xpack.reporting.listing.table.deleteNumConfirmTitle',
defaultMessage: `Delete {num} reports?`,
},
{ num: jobsToDelete.length }
)
: intl.formatMessage(
{
id: 'xpack.reporting.listing.table.deleteConfirmTitle',
defaultMessage: `Delete the "{name}" report?`,
},
{ name: jobsToDelete[0].object_title }
);
const message = intl.formatMessage({
id: 'xpack.reporting.listing.table.deleteConfirmMessage',
defaultMessage: `You can't recover deleted reports.`,
});
const confirmButtonText = intl.formatMessage({
id: 'xpack.reporting.listing.table.deleteConfirmButton',
defaultMessage: `Delete`,
});
const cancelButtonText = intl.formatMessage({
id: 'xpack.reporting.listing.table.deleteCancelButton',
defaultMessage: `Cancel`,
});
return (
<EuiOverlayMask>
<EuiConfirmModal
title={title}
onCancel={() => this.hideConfirm()}
onConfirm={() => this.props.performDelete()}
confirmButtonText={confirmButtonText}
cancelButtonText={cancelButtonText}
defaultFocusedButton="confirm"
buttonColor="danger"
>
{message}
</EuiConfirmModal>
</EuiOverlayMask>
);
}
public render() {
const { jobsToDelete, intl } = this.props;
if (jobsToDelete.length === 0) return null;
return (
<Fragment>
<EuiButton onClick={() => this.showConfirm()} iconType="trash" color={'danger'}>
{intl.formatMessage(
{
id: 'xpack.reporting.listing.table.deleteReportButton',
defaultMessage: `Delete ({num})`,
},
{ num: jobsToDelete.length }
)}
</EuiButton>
{this.state.showConfirm ? this.renderConfirm() : null}
</Fragment>
);
}
}

View file

@ -0,0 +1,72 @@
/*
* 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 { EuiButtonIcon, EuiToolTip } from '@elastic/eui';
import React, { FunctionComponent } from 'react';
import { JobStatuses } from '../../../constants';
import { Job as ListingJob, Props as ListingProps } from '../report_listing';
type Props = { record: ListingJob } & ListingProps;
export const ReportDownloadButton: FunctionComponent<Props> = (props: Props) => {
const { record, apiClient, intl } = props;
if (record.status !== JobStatuses.COMPLETED) {
return null;
}
const button = (
<EuiButtonIcon
onClick={() => apiClient.downloadReport(record.id)}
iconType="importAction"
aria-label={intl.formatMessage({
id: 'xpack.reporting.listing.table.downloadReportAriaLabel',
defaultMessage: 'Download report',
})}
/>
);
if (record.csv_contains_formulas) {
return (
<EuiToolTip
position="top"
content={intl.formatMessage({
id: 'xpack.reporting.listing.table.csvContainsFormulas',
defaultMessage:
'Your CSV contains characters which spreadsheet applications can interpret as formulas.',
})}
>
{button}
</EuiToolTip>
);
}
if (record.max_size_reached) {
return (
<EuiToolTip
position="top"
content={intl.formatMessage({
id: 'xpack.reporting.listing.table.maxSizeReachedTooltip',
defaultMessage: 'Max size reached, contains partial data.',
})}
>
{button}
</EuiToolTip>
);
}
return (
<EuiToolTip
position="top"
content={intl.formatMessage({
id: 'xpack.reporting.listing.table.downloadReport',
defaultMessage: 'Download report',
})}
>
{button}
</EuiToolTip>
);
};

View file

@ -7,12 +7,14 @@
import { EuiButtonIcon, EuiCallOut, EuiPopover } from '@elastic/eui';
import { InjectedIntl, injectI18n } from '@kbn/i18n/react';
import React, { Component } from 'react';
import { JobContent, ReportingAPIClient } from '../lib/reporting_api_client';
import { JobStatuses } from '../../../constants';
import { JobContent, ReportingAPIClient } from '../../lib/reporting_api_client';
import { Job as ListingJob } from '../report_listing';
interface Props {
jobId: string;
intl: InjectedIntl;
apiClient: ReportingAPIClient;
record: ListingJob;
}
interface State {
@ -39,12 +41,18 @@ class ReportErrorButtonUi extends Component<Props, State> {
}
public render() {
const { record, intl } = this.props;
if (record.status !== JobStatuses.FAILED) {
return null;
}
const button = (
<EuiButtonIcon
onClick={this.togglePopover}
iconType="alert"
color={'danger'}
aria-label={this.props.intl.formatMessage({
aria-label={intl.formatMessage({
id: 'xpack.reporting.errorButton.showReportErrorAriaLabel',
defaultMessage: 'Show report error',
})}
@ -89,9 +97,11 @@ class ReportErrorButtonUi extends Component<Props, State> {
};
private loadError = async () => {
const { record, apiClient, intl } = this.props;
this.setState({ isLoading: true });
try {
const reportContent: JobContent = await this.props.apiClient.getContent(this.props.jobId);
const reportContent: JobContent = await apiClient.getContent(record.id);
if (this.mounted) {
this.setState({ isLoading: false, error: reportContent.content });
}
@ -99,7 +109,7 @@ class ReportErrorButtonUi extends Component<Props, State> {
if (this.mounted) {
this.setState({
isLoading: false,
calloutTitle: this.props.intl.formatMessage({
calloutTitle: intl.formatMessage({
id: 'xpack.reporting.errorButton.unableToFetchReportContentTitle',
defaultMessage: 'Unable to fetch report content',
}),

View file

@ -7,9 +7,10 @@
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { ReportInfoButton } from './report_info_button';
import { ReportingAPIClient } from '../lib/reporting_api_client';
jest.mock('../lib/reporting_api_client');
jest.mock('../../lib/reporting_api_client');
import { ReportingAPIClient } from '../../lib/reporting_api_client';
const httpSetup = {} as any;
const apiClient = new ReportingAPIClient(httpSetup);

View file

@ -17,8 +17,8 @@ import {
} from '@elastic/eui';
import React, { Component, Fragment } from 'react';
import { get } from 'lodash';
import { USES_HEADLESS_JOB_TYPES } from '../../constants';
import { JobInfo, ReportingAPIClient } from '../lib/reporting_api_client';
import { USES_HEADLESS_JOB_TYPES } from '../../../constants';
import { JobInfo, ReportingAPIClient } from '../../lib/reporting_api_client';
interface Props {
jobId: string;

View file

@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { ToastInput } from 'src/core/public';
import { toMountPoint } from '../../../../../src/plugins/kibana_react/public';
import { JobId, JobSummary } from '../../index.d';
import { DownloadButton } from './job_download_button';
import { ReportLink } from './report_link';
import { DownloadButton } from './download_button';
export const getSuccessToast = (
job: JobSummary,

View file

@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { ToastInput } from 'src/core/public';
import { toMountPoint } from '../../../../../src/plugins/kibana_react/public';
import { JobId, JobSummary } from '../../index.d';
import { DownloadButton } from './job_download_button';
import { ReportLink } from './report_link';
import { DownloadButton } from './download_button';
export const getWarningFormulasToast = (
job: JobSummary,

View file

@ -9,8 +9,8 @@ import { FormattedMessage } from '@kbn/i18n/react';
import { ToastInput } from 'src/core/public';
import { toMountPoint } from '../../../../../src/plugins/kibana_react/public';
import { JobId, JobSummary } from '../../index.d';
import { DownloadButton } from './job_download_button';
import { ReportLink } from './report_link';
import { DownloadButton } from './download_button';
export const getWarningMaxSizeToast = (
job: JobSummary,

View file

@ -5,12 +5,15 @@
*/
import React from 'react';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { ReportListing } from './report_listing';
import { Observable } from 'rxjs';
import { mountWithIntl } from 'test_utils/enzyme_helpers';
import { ILicense } from '../../../licensing/public';
import { ReportingAPIClient } from '../lib/reporting_api_client';
jest.mock('@elastic/eui/lib/components/form/form_row/make_id', () => () => 'generated-id');
import { ReportListing } from './report_listing';
const reportingAPIClient = {
list: () =>
Promise.resolve([

View file

@ -4,34 +4,34 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { get } from 'lodash';
import moment from 'moment';
import React, { Component } from 'react';
import { Subscription } from 'rxjs';
import {
EuiBasicTable,
EuiButtonIcon,
EuiInMemoryTable,
EuiPageContent,
EuiSpacer,
EuiText,
EuiTextColor,
EuiTitle,
EuiToolTip,
} from '@elastic/eui';
import { ToastsSetup, ApplicationStart } from 'src/core/public';
import { LicensingPluginSetup, ILicense } from '../../../licensing/public';
import { i18n } from '@kbn/i18n';
import { FormattedMessage, InjectedIntl, injectI18n } from '@kbn/i18n/react';
import { get } from 'lodash';
import moment from 'moment';
import { Component, default as React } from 'react';
import { Subscription } from 'rxjs';
import { ApplicationStart, ToastsSetup } from 'src/core/public';
import { ILicense, LicensingPluginSetup } from '../../../licensing/public';
import { Poller } from '../../common/poller';
import { JobStatuses, JOB_COMPLETION_NOTIFICATIONS_POLLER_CONFIG } from '../../constants';
import { ReportingAPIClient, JobQueueEntry } from '../lib/reporting_api_client';
import { checkLicense } from '../lib/license_check';
import { ReportErrorButton } from './report_error_button';
import { ReportInfoButton } from './report_info_button';
import { JobQueueEntry, ReportingAPIClient } from '../lib/reporting_api_client';
import {
ReportDeleteButton,
ReportDownloadButton,
ReportErrorButton,
ReportInfoButton,
} from './buttons';
interface Job {
export interface Job {
id: string;
type: string;
object_type: string;
@ -49,7 +49,7 @@ interface Job {
warnings: string[];
}
interface Props {
export interface Props {
intl: InjectedIntl;
apiClient: ReportingAPIClient;
license$: LicensingPluginSetup['license$'];
@ -61,6 +61,7 @@ interface State {
page: number;
total: number;
jobs: Job[];
selectedJobs: Job[];
isLoading: boolean;
showLinks: boolean;
enableLinks: boolean;
@ -113,6 +114,7 @@ class ReportListingUi extends Component<Props, State> {
page: 0,
total: 0,
jobs: [],
selectedJobs: [],
isLoading: false,
showLinks: false,
enableLinks: false,
@ -182,6 +184,140 @@ class ReportListingUi extends Component<Props, State> {
});
};
private onSelectionChange = (jobs: Job[]) => {
this.setState(current => ({ ...current, selectedJobs: jobs }));
};
private removeRecord = (record: Job) => {
const { jobs } = this.state;
const filtered = jobs.filter(j => j.id !== record.id);
this.setState(current => ({ ...current, jobs: filtered }));
};
private renderDeleteButton = () => {
const { selectedJobs } = this.state;
if (selectedJobs.length === 0) return null;
const performDelete = async () => {
for (const record of selectedJobs) {
try {
await this.props.apiClient.deleteReport(record.id);
this.removeRecord(record);
this.props.toasts.addSuccess(
this.props.intl.formatMessage(
{
id: 'xpack.reporting.listing.table.deleteConfim',
defaultMessage: `The {reportTitle} report was deleted`,
},
{ reportTitle: record.object_title }
)
);
} catch (error) {
this.props.toasts.addDanger(
this.props.intl.formatMessage(
{
id: 'xpack.reporting.listing.table.deleteFailedErrorMessage',
defaultMessage: `The report was not deleted: {error}`,
},
{ error }
)
);
throw error;
}
}
};
return (
<ReportDeleteButton
jobsToDelete={selectedJobs}
performDelete={performDelete}
{...this.props}
/>
);
};
private onTableChange = ({ page }: { page: { index: number } }) => {
const { index: pageIndex } = page;
this.setState(() => ({ page: pageIndex }), this.fetchJobs);
};
private fetchJobs = async () => {
// avoid page flicker when poller is updating table - only display loading screen on first load
if (this.isInitialJobsFetch) {
this.setState(() => ({ isLoading: true }));
}
let jobs: JobQueueEntry[];
let total: number;
try {
jobs = await this.props.apiClient.list(this.state.page);
total = await this.props.apiClient.total();
this.isInitialJobsFetch = false;
} catch (fetchError) {
if (!this.licenseAllowsToShowThisPage()) {
this.props.toasts.addDanger(this.state.badLicenseMessage);
this.props.redirect('kibana#/management');
return;
}
if (fetchError.message === 'Failed to fetch') {
this.props.toasts.addDanger(
fetchError.message ||
this.props.intl.formatMessage({
id: 'xpack.reporting.listing.table.requestFailedErrorMessage',
defaultMessage: 'Request failed',
})
);
}
if (this.mounted) {
this.setState(() => ({ isLoading: false, jobs: [], total: 0 }));
}
return;
}
if (this.mounted) {
this.setState(() => ({
isLoading: false,
total,
jobs: jobs.map(
(job: JobQueueEntry): Job => {
const { _source: source } = job;
return {
id: job._id,
type: source.jobtype,
object_type: source.payload.objectType,
object_title: source.payload.title,
created_by: source.created_by,
created_at: source.created_at,
started_at: source.started_at,
completed_at: source.completed_at,
status: source.status,
statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status,
max_size_reached: source.output ? source.output.max_size_reached : false,
attempts: source.attempts,
max_attempts: source.max_attempts,
csv_contains_formulas: get(source, 'output.csv_contains_formulas'),
warnings: source.output ? source.output.warnings : undefined,
};
}
),
}));
}
};
private licenseAllowsToShowThisPage = () => {
return this.state.showLinks && this.state.enableLinks;
};
private formatDate(timestamp: string) {
try {
return moment(timestamp).format('YYYY-MM-DD @ hh:mm A');
} catch (error) {
// ignore parse error and display unformatted value
return timestamp;
}
}
private renderTable() {
const { intl } = this.props;
@ -317,9 +453,9 @@ class ReportListingUi extends Component<Props, State> {
render: (record: Job) => {
return (
<div>
{this.renderDownloadButton(record)}
{this.renderReportErrorButton(record)}
{this.renderInfoButton(record)}
<ReportDownloadButton {...this.props} record={record} />
<ReportErrorButton {...this.props} record={record} />
<ReportInfoButton {...this.props} jobId={record.id} />
</div>
);
},
@ -335,13 +471,22 @@ class ReportListingUi extends Component<Props, State> {
hidePerPageOptions: true,
};
const selection = {
itemId: 'id',
onSelectionChange: this.onSelectionChange,
};
const search = {
toolsRight: this.renderDeleteButton(),
};
return (
<EuiBasicTable
itemId={'id'}
<EuiInMemoryTable
itemId="id"
items={this.state.jobs}
loading={this.state.isLoading}
columns={tableColumns}
noItemsMessage={
message={
this.state.isLoading
? intl.formatMessage({
id: 'xpack.reporting.listing.table.loadingReportsDescription',
@ -353,154 +498,14 @@ class ReportListingUi extends Component<Props, State> {
})
}
pagination={pagination}
selection={selection}
search={search}
isSelectable={true}
onChange={this.onTableChange}
data-test-subj="reportJobListing"
/>
);
}
private renderDownloadButton = (record: Job) => {
if (record.status !== JobStatuses.COMPLETED) {
return;
}
const { intl } = this.props;
const button = (
<EuiButtonIcon
onClick={() => this.props.apiClient.downloadReport(record.id)}
iconType="importAction"
aria-label={intl.formatMessage({
id: 'xpack.reporting.listing.table.downloadReportAriaLabel',
defaultMessage: 'Download report',
})}
/>
);
if (record.csv_contains_formulas) {
return (
<EuiToolTip
position="top"
content={intl.formatMessage({
id: 'xpack.reporting.listing.table.csvContainsFormulas',
defaultMessage:
'Your CSV contains characters which spreadsheet applications can interpret as formulas.',
})}
>
{button}
</EuiToolTip>
);
}
if (record.max_size_reached) {
return (
<EuiToolTip
position="top"
content={intl.formatMessage({
id: 'xpack.reporting.listing.table.maxSizeReachedTooltip',
defaultMessage: 'Max size reached, contains partial data.',
})}
>
{button}
</EuiToolTip>
);
}
return button;
};
private renderReportErrorButton = (record: Job) => {
if (record.status !== JobStatuses.FAILED) {
return;
}
return <ReportErrorButton apiClient={this.props.apiClient} jobId={record.id} />;
};
private renderInfoButton = (record: Job) => {
return <ReportInfoButton apiClient={this.props.apiClient} jobId={record.id} />;
};
private onTableChange = ({ page }: { page: { index: number } }) => {
const { index: pageIndex } = page;
this.setState(() => ({ page: pageIndex }), this.fetchJobs);
};
private fetchJobs = async () => {
// avoid page flicker when poller is updating table - only display loading screen on first load
if (this.isInitialJobsFetch) {
this.setState(() => ({ isLoading: true }));
}
let jobs: JobQueueEntry[];
let total: number;
try {
jobs = await this.props.apiClient.list(this.state.page);
total = await this.props.apiClient.total();
this.isInitialJobsFetch = false;
} catch (fetchError) {
if (!this.licenseAllowsToShowThisPage()) {
this.props.toasts.addDanger(this.state.badLicenseMessage);
this.props.redirect('kibana#/management');
return;
}
if (fetchError.message === 'Failed to fetch') {
this.props.toasts.addDanger(
fetchError.message ||
this.props.intl.formatMessage({
id: 'xpack.reporting.listing.table.requestFailedErrorMessage',
defaultMessage: 'Request failed',
})
);
}
if (this.mounted) {
this.setState(() => ({ isLoading: false, jobs: [], total: 0 }));
}
return;
}
if (this.mounted) {
this.setState(() => ({
isLoading: false,
total,
jobs: jobs.map(
(job: JobQueueEntry): Job => {
const { _source: source } = job;
return {
id: job._id,
type: source.jobtype,
object_type: source.payload.objectType,
object_title: source.payload.title,
created_by: source.created_by,
created_at: source.created_at,
started_at: source.started_at,
completed_at: source.completed_at,
status: source.status,
statusLabel: jobStatusLabelsMap.get(source.status as JobStatuses) || source.status,
max_size_reached: source.output ? source.output.max_size_reached : false,
attempts: source.attempts,
max_attempts: source.max_attempts,
csv_contains_formulas: get(source, 'output.csv_contains_formulas'),
warnings: source.output ? source.output.warnings : undefined,
};
}
),
}));
}
};
private licenseAllowsToShowThisPage = () => {
return this.state.showLinks && this.state.enableLinks;
};
private formatDate(timestamp: string) {
try {
return moment(timestamp).format('YYYY-MM-DD @ hh:mm A');
} catch (error) {
// ignore parse error and display unformatted value
return timestamp;
}
}
}
export const ReportListing = injectI18n(ReportListingUi);

View file

@ -85,6 +85,12 @@ export class ReportingAPIClient {
window.open(location);
}
public async deleteReport(jobId: string) {
return await this.http.delete(`${API_LIST_URL}/delete/${jobId}`, {
asSystemRequest: true,
});
}
public list = (page = 0, jobIds: string[] = []): Promise<JobQueueEntry[]> => {
const query = { page } as any;
if (jobIds.length > 0) {