From 35fc0bfbd2e69d8d3e8d960b276a2f66b90fff78 Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Wed, 14 Jul 2021 17:48:30 -0700 Subject: [PATCH] [Reporting] Clean up types for internal APIs needed for UI (#105508) * [Reporting] Refactor types for API response data * fix api test * Update x-pack/plugins/reporting/common/types.ts Co-authored-by: Jean-Louis Leysens * more verbose comments * set comments in interface type definition * Update x-pack/plugins/reporting/common/types.ts Co-authored-by: Michael Dokolin * more strict * Update x-pack/plugins/reporting/public/management/report_info_button.tsx Co-authored-by: Michael Dokolin * Update x-pack/plugins/reporting/public/management/report_info_button.tsx Co-authored-by: Michael Dokolin * fix suggestion * fix accidental declaration of interface methods as function properties * fix info panel Co-authored-by: Jean-Louis Leysens Co-authored-by: Michael Dokolin --- x-pack/plugins/reporting/common/types.ts | 120 +- x-pack/plugins/reporting/public/lib/job.ts | 75 + .../reporting_api_client.ts | 143 +- .../public/lib/stream_handler.test.ts | 53 +- .../reporting/public/lib/stream_handler.ts | 26 +- .../report_info_button.test.tsx.snap | 2 + .../report_listing.test.tsx.snap | 3874 +++++++++++++---- .../management/report_delete_button.tsx | 5 +- .../management/report_download_button.tsx | 3 +- .../public/management/report_error_button.tsx | 4 +- .../public/management/report_info_button.tsx | 203 +- .../public/management/report_listing.test.tsx | 45 +- .../public/management/report_listing.tsx | 67 +- .../reporting/server/lib/enqueue_job.ts | 2 - .../reporting/server/lib/store/report.test.ts | 6 +- .../reporting/server/lib/store/report.ts | 53 +- .../reporting/server/lib/store/store.ts | 1 + .../server/lib/tasks/execute_report.ts | 13 +- .../reporting/server/lib/tasks/index.ts | 1 - .../reporting/server/routes/jobs.test.ts | 16 +- .../plugins/reporting/server/routes/jobs.ts | 21 +- .../server/routes/lib/get_document_payload.ts | 17 +- .../server/routes/lib/job_response_handler.ts | 14 +- .../reporting/server/routes/lib/jobs_query.ts | 107 +- .../job_apis_csv.ts | 97 +- .../job_apis_csv_deprecated.ts | 87 +- 26 files changed, 3641 insertions(+), 1414 deletions(-) create mode 100644 x-pack/plugins/reporting/public/lib/job.ts diff --git a/x-pack/plugins/reporting/common/types.ts b/x-pack/plugins/reporting/common/types.ts index 71bcf143a7f85f..43ae839ad1c2a0 100644 --- a/x-pack/plugins/reporting/common/types.ts +++ b/x-pack/plugins/reporting/common/types.ts @@ -55,31 +55,39 @@ export interface TaskRunResult { } export interface ReportSource { - jobtype: string; - kibana_name: string; - kibana_id: string; - created_by: string | false; + /* + * Required fields: populated in enqueue_job when the request comes in to + * generate the report + */ + jobtype: string; // refers to `ExportTypeDefinition.jobType` + created_by: string | false; // username or `false` if security is disabled. Used for ensuring users can only access the reports they've created. payload: { headers: string; // encrypted headers - browserTimezone?: string; // may use timezone from advanced settings - objectType: string; - title: string; - layout?: LayoutParams; - isDeprecated?: boolean; - }; - meta: { objectType: string; layout?: string }; - browser_type: string; - migration_version: string; - max_attempts: number; - timeout: number; - + isDeprecated?: boolean; // set to true when the export type is being phased out + } & BaseParams; + meta: { objectType: string; layout?: string }; // for telemetry + migration_version: string; // for reminding the user to update their POST URL + attempts: number; // initially populated as 0 + created_at: string; // timestamp in UTC status: JobStatus; - attempts: number; + + /* + * `output` is only populated if the report job is completed or failed. + */ output: TaskRunResult | null; - started_at?: string; - completed_at?: string; - created_at: string; - process_expiration?: string | null; // must be set to null to clear the expiration + + /* + * Optional fields: populated when the job is claimed to execute, and after + * execution has finished + */ + kibana_name?: string; // for troubleshooting + kibana_id?: string; // for troubleshooting + browser_type?: string; // no longer used since chromium is the only option (used to allow phantomjs) + timeout?: number; // for troubleshooting: the actual comparison uses the config setting xpack.reporting.queue.timeout + max_attempts?: number; // for troubleshooting: the actual comparison uses the config setting xpack.reporting.capture.maxAttempts + started_at?: string; // timestamp in UTC + completed_at?: string; // timestamp in UTC + process_expiration?: string | null; // timestamp in UTC - is overwritten with `null` when the job needs a retry } /* @@ -99,48 +107,41 @@ export interface BaseParams { } export type JobId = string; + +/* + * JobStatus: + * - Begins as 'pending' + * - Changes to 'processing` when the job is claimed + * - Then 'completed' | 'failed' when execution is done + * If the job needs a retry, it reverts back to 'pending'. + */ export type JobStatus = - | 'completed' - | 'completed_with_warnings' - | 'pending' - | 'processing' - | 'failed'; + | 'completed' // Report was successful + | 'completed_with_warnings' // The download available for troubleshooting - it **should** show a meaningful error + | 'pending' // Report job is waiting to be claimed + | 'processing' // Report job has been claimed and is executing + | 'failed'; // Report was not successful, and all retries are done. Nothing to download. export interface JobContent { content: string; } -export interface ReportApiJSON { +/* + * Info API response: to avoid unnecessary large payloads on a network, the + * report query results do not include `payload.headers` or `output.content`, + * which can be long strings of meaningless text + */ +interface ReportSimple extends Omit { + payload: Omit; + output?: Omit; // is undefined for report jobs that are not completed +} + +/* + * The response format for all of the report job APIs + */ +export interface ReportApiJSON extends ReportSimple { id: string; index: string; - kibana_name: string; - kibana_id: string; - browser_type: string | undefined; - created_at: string; - jobtype: string; - created_by: string | false; - timeout?: number; - output?: { - content_type: string; - size: number; - warnings?: string[]; - }; - process_expiration?: string; - completed_at: string | undefined; - payload: { - layout?: LayoutParams; - title: string; - browserTimezone?: string; - isDeprecated?: boolean; - }; - meta: { - layout?: string; - objectType: string; - }; - max_attempts: number; - started_at: string | undefined; - attempts: number; - status: string; } export interface LicenseCheckResults { @@ -149,13 +150,14 @@ export interface LicenseCheckResults { message: string; } +/* Notifier Toasts */ export interface JobSummary { id: JobId; status: JobStatus; - title: string; - jobtype: string; - maxSizeReached?: boolean; - csvContainsFormulas?: boolean; + jobtype: ReportSource['jobtype']; + title: ReportSource['payload']['title']; + maxSizeReached: TaskRunResult['max_size_reached']; + csvContainsFormulas: TaskRunResult['csv_contains_formulas']; } export interface JobSummarySet { diff --git a/x-pack/plugins/reporting/public/lib/job.ts b/x-pack/plugins/reporting/public/lib/job.ts new file mode 100644 index 00000000000000..c882e8b92986bc --- /dev/null +++ b/x-pack/plugins/reporting/public/lib/job.ts @@ -0,0 +1,75 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { JobId, ReportApiJSON, ReportSource, TaskRunResult } from '../../common/types'; + +type ReportPayload = ReportSource['payload']; + +/* + * This class represents a report job for the UI + * It can be instantiated with ReportApiJSON: the response data format for the report job APIs + */ +export class Job { + public id: JobId; + public index: string; + + public objectType: ReportPayload['objectType']; + public title: ReportPayload['title']; + public isDeprecated: ReportPayload['isDeprecated']; + public browserTimezone?: ReportPayload['browserTimezone']; + public layout: ReportPayload['layout']; + + public jobtype: ReportSource['jobtype']; + public created_by: ReportSource['created_by']; + public created_at: ReportSource['created_at']; + public started_at: ReportSource['started_at']; + public completed_at: ReportSource['completed_at']; + public status: ReportSource['status']; + public attempts: ReportSource['attempts']; + public max_attempts: ReportSource['max_attempts']; + + public timeout: ReportSource['timeout']; + public kibana_name: ReportSource['kibana_name']; + public kibana_id: ReportSource['kibana_id']; + public browser_type: ReportSource['browser_type']; + + public size?: TaskRunResult['size']; + public content_type?: TaskRunResult['content_type']; + public csv_contains_formulas?: TaskRunResult['csv_contains_formulas']; + public max_size_reached?: TaskRunResult['max_size_reached']; + public warnings?: TaskRunResult['warnings']; + + constructor(report: ReportApiJSON) { + this.id = report.id; + this.index = report.index; + + this.jobtype = report.jobtype; + this.objectType = report.payload.objectType; + this.title = report.payload.title; + this.layout = report.payload.layout; + this.created_by = report.created_by; + this.created_at = report.created_at; + this.started_at = report.started_at; + this.completed_at = report.completed_at; + this.status = report.status; + this.attempts = report.attempts; + this.max_attempts = report.max_attempts; + + this.timeout = report.timeout; + this.kibana_name = report.kibana_name; + this.kibana_id = report.kibana_id; + this.browser_type = report.browser_type; + this.browserTimezone = report.payload.browserTimezone; + this.size = report.output?.size; + this.content_type = report.output?.content_type; + + this.isDeprecated = report.payload.isDeprecated || false; + this.csv_contains_formulas = report.output?.csv_contains_formulas; + this.max_size_reached = report.output?.max_size_reached; + this.warnings = report.output?.warnings; + } +} diff --git a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts index 64caac0e27bddc..90411884332c83 100644 --- a/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts +++ b/x-pack/plugins/reporting/public/lib/reporting_api_client/reporting_api_client.ts @@ -15,37 +15,52 @@ import { API_MIGRATE_ILM_POLICY_URL, REPORTING_MANAGEMENT_HOME, } from '../../../common/constants'; -import { - DownloadReportFn, - JobId, - ManagementLinkFn, - ReportApiJSON, - ReportDocument, - ReportSource, -} from '../../../common/types'; +import { DownloadReportFn, JobId, ManagementLinkFn, ReportApiJSON } from '../../../common/types'; import { add } from '../../notifier/job_completion_notifications'; - -export interface JobQueueEntry { - _id: string; - _source: ReportSource; -} +import { Job } from '../job'; export interface JobContent { content: string; content_type: boolean; } -interface JobParams { - [paramName: string]: any; -} - export interface DiagnoseResponse { help: string[]; success: boolean; logs: string; } -export class ReportingAPIClient { +interface JobParams { + [paramName: string]: any; +} + +interface IReportingAPI { + // Helpers + getReportURL(jobId: string): string; + getReportingJobPath(exportType: string, jobParams: JobParams): string; // Return a URL to queue a job, with the job params encoded in the query string of the URL. Used for copying POST URL + createReportingJob(exportType: string, jobParams: any): Promise; // Sends a request to queue a job, with the job params in the POST body + getServerBasePath(): string; // Provides the raw server basePath to allow it to be stripped out from relativeUrls in job params + + // CRUD + downloadReport(jobId: string): void; + deleteReport(jobId: string): Promise; + list(page: number, jobIds: string[]): Promise; // gets the first 10 report of the page + total(): Promise; + getError(jobId: string): Promise; + getInfo(jobId: string): Promise; + findForJobIds(jobIds: string[]): Promise; + + // Function props + getManagementLink: ManagementLinkFn; + getDownloadLink: DownloadReportFn; + + // Diagnostic-related API calls + verifyConfig(): Promise; + verifyBrowser(): Promise; + verifyScreenCapture(): Promise; +} + +export class ReportingAPIClient implements IReportingAPI { private http: HttpSetup; constructor(http: HttpSetup) { @@ -71,68 +86,69 @@ export class ReportingAPIClient { }); } - public list = (page = 0, jobIds: string[] = []): Promise => { + public async list(page = 0, jobIds: string[] = []) { const query = { page } as any; if (jobIds.length > 0) { // Only getting the first 10, to prevent URL overflows query.ids = jobIds.slice(0, 10).join(','); } - return this.http.get(`${API_LIST_URL}/list`, { + const jobQueueEntries: ReportApiJSON[] = await this.http.get(`${API_LIST_URL}/list`, { query, asSystemRequest: true, }); - }; - public total(): Promise { - return this.http.get(`${API_LIST_URL}/count`, { + return jobQueueEntries.map((report) => new Job(report)); + } + + public async total() { + return await this.http.get(`${API_LIST_URL}/count`, { asSystemRequest: true, }); } - public getContent(jobId: string): Promise { - return this.http.get(`${API_LIST_URL}/output/${jobId}`, { + public async getError(jobId: string) { + return await this.http.get(`${API_LIST_URL}/output/${jobId}`, { asSystemRequest: true, }); } - public getInfo(jobId: string): Promise { - return this.http.get(`${API_LIST_URL}/info/${jobId}`, { + public async getInfo(jobId: string) { + const report: ReportApiJSON = await this.http.get(`${API_LIST_URL}/info/${jobId}`, { asSystemRequest: true, }); + return new Job(report); } - public findForJobIds = (jobIds: JobId[]): Promise => { - return this.http.fetch(`${API_LIST_URL}/list`, { + public async findForJobIds(jobIds: JobId[]) { + const reports: ReportApiJSON[] = await this.http.fetch(`${API_LIST_URL}/list`, { query: { page: 0, ids: jobIds.join(',') }, method: 'GET', }); - }; + return reports.map((report) => new Job(report)); + } - /* - * Return a URL to queue a job, with the job params encoded in the query string of the URL. Used for copying POST URL - */ - public getReportingJobPath = (exportType: string, jobParams: JobParams) => { + public getReportingJobPath(exportType: string, jobParams: JobParams) { const params = stringify({ jobParams: rison.encode(jobParams) }); return `${this.http.basePath.prepend(API_BASE_GENERATE)}/${exportType}?${params}`; - }; + } - /* - * Sends a request to queue a job, with the job params in the POST body - */ - public createReportingJob = async (exportType: string, jobParams: any) => { + public async createReportingJob(exportType: string, jobParams: any) { const jobParamsRison = rison.encode(jobParams); - const resp = await this.http.post(`${API_BASE_GENERATE}/${exportType}`, { - method: 'POST', - body: JSON.stringify({ - jobParams: jobParamsRison, - }), - }); + const resp: { job: ReportApiJSON } = await this.http.post( + `${API_BASE_GENERATE}/${exportType}`, + { + method: 'POST', + body: JSON.stringify({ + jobParams: jobParamsRison, + }), + } + ); add(resp.job.id); - return resp; - }; + return new Job(resp.job); + } public getManagementLink: ManagementLinkFn = () => this.http.basePath.prepend(REPORTING_MANAGEMENT_HOME); @@ -140,36 +156,27 @@ export class ReportingAPIClient { public getDownloadLink: DownloadReportFn = (jobId: JobId) => this.http.basePath.prepend(`${API_LIST_URL}/download/${jobId}`); - /* - * 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 => - this.http.post(`${API_BASE_URL}/diagnose/config`, { + public async verifyConfig() { + return await this.http.post(`${API_BASE_URL}/diagnose/config`, { asSystemRequest: true, }); + } - /* - * Diagnostic-related API calls - */ - public verifyBrowser = (): Promise => - this.http.post(`${API_BASE_URL}/diagnose/browser`, { + public async verifyBrowser() { + return await this.http.post(`${API_BASE_URL}/diagnose/browser`, { asSystemRequest: true, }); + } - /* - * Diagnostic-related API calls - */ - public verifyScreenCapture = (): Promise => - this.http.post(`${API_BASE_URL}/diagnose/screenshot`, { + public async verifyScreenCapture() { + return await this.http.post(`${API_BASE_URL}/diagnose/screenshot`, { asSystemRequest: true, }); + } - public migrateReportingIndicesIlmPolicy = (): Promise => { - return this.http.put(`${API_MIGRATE_ILM_POLICY_URL}`); - }; + public async migrateReportingIndicesIlmPolicy() { + return await this.http.put(`${API_MIGRATE_ILM_POLICY_URL}`); + } } diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts index 7c3837486ad1d8..58fde5cbd83adc 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.test.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.test.ts @@ -7,7 +7,9 @@ import sinon, { stub } from 'sinon'; import { NotificationsStart } from 'src/core/public'; -import { JobSummary, ReportDocument } from '../../common/types'; +import { coreMock } from '../../../../../src/core/public/mocks'; +import { JobSummary, ReportApiJSON } from '../../common/types'; +import { Job } from './job'; import { ReportingAPIClient } from './reporting_api_client'; import { ReportingNotifierStreamHandler } from './stream_handler'; @@ -18,43 +20,20 @@ Object.defineProperty(window, 'sessionStorage', { writable: true, }); -const mockJobsFound = [ - { - _id: 'job-source-mock1', - _source: { - status: 'completed', - output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { title: 'specimen' }, - }, - }, - { - _id: 'job-source-mock2', - _source: { - status: 'failed', - output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { title: 'specimen' }, - }, - }, - { - _id: 'job-source-mock3', - _source: { - status: 'pending', - output: { max_size_reached: false, csv_contains_formulas: false }, - payload: { title: 'specimen' }, - }, - }, -]; +const mockJobsFound: Job[] = [ + { id: 'job-source-mock1', status: 'completed', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, + { id: 'job-source-mock2', status: 'failed', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, + { id: 'job-source-mock3', status: 'pending', output: { csv_contains_formulas: false, max_size_reached: false }, payload: { title: 'specimen' } }, +].map((j) => new Job(j as ReportApiJSON)); // prettier-ignore -const jobQueueClientMock: ReportingAPIClient = { - findForJobIds: async (jobIds: string[]) => { - return mockJobsFound as ReportDocument[]; - }, - getContent: (): Promise => { - return Promise.resolve({ content: 'this is the completed report data' }); - }, - getManagementLink: () => '/#management', - getDownloadLink: () => '/reporting/download/job-123', -} as any; +const jobQueueClientMock = new ReportingAPIClient(coreMock.createSetup().http); +jobQueueClientMock.findForJobIds = async (jobIds: string[]) => mockJobsFound; +jobQueueClientMock.getInfo = () => + Promise.resolve(({ content: 'this is the completed report data' } as unknown) as Job); +jobQueueClientMock.getError = () => + Promise.resolve({ content: 'this is the completed report data' }); +jobQueueClientMock.getManagementLink = () => '/#management'; +jobQueueClientMock.getDownloadLink = () => '/reporting/download/job-123'; const mockShowDanger = stub(); const mockShowSuccess = stub(); diff --git a/x-pack/plugins/reporting/public/lib/stream_handler.ts b/x-pack/plugins/reporting/public/lib/stream_handler.ts index 53191cacb5ba1a..8e41d34d054ecb 100644 --- a/x-pack/plugins/reporting/public/lib/stream_handler.ts +++ b/x-pack/plugins/reporting/public/lib/stream_handler.ts @@ -10,7 +10,7 @@ import * as Rx from 'rxjs'; import { catchError, map } from 'rxjs/operators'; import { NotificationsSetup } from 'src/core/public'; import { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JOB_STATUSES } from '../../common/constants'; -import { JobId, JobSummary, JobSummarySet, ReportDocument } from '../../common/types'; +import { JobId, JobSummary, JobSummarySet } from '../../common/types'; import { getFailureToast, getGeneralErrorToast, @@ -18,20 +18,21 @@ import { getWarningFormulasToast, getWarningMaxSizeToast, } from '../notifier'; +import { Job } from './job'; import { ReportingAPIClient } from './reporting_api_client'; function updateStored(jobIds: JobId[]): void { sessionStorage.setItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, JSON.stringify(jobIds)); } -function getReportStatus(src: ReportDocument): JobSummary { +function getReportStatus(src: Job): JobSummary { return { - id: src._id, - status: src._source.status, - title: src._source.payload.title, - jobtype: src._source.jobtype, - maxSizeReached: src._source.output?.max_size_reached, - csvContainsFormulas: src._source.output?.csv_contains_formulas, + id: src.id, + status: src.status, + title: src.title, + jobtype: src.jobtype, + maxSizeReached: src.max_size_reached, + csvContainsFormulas: src.csv_contains_formulas, }; } @@ -73,7 +74,7 @@ export class ReportingNotifierStreamHandler { // no download link available for (const job of failedJobs) { - const { content } = await this.apiClient.getContent(job.id); + const { content } = await this.apiClient.getError(job.id); this.notifications.toasts.addDanger( getFailureToast(content, job, this.apiClient.getManagementLink) ); @@ -90,17 +91,14 @@ export class ReportingNotifierStreamHandler { */ public findChangedStatusJobs(storedJobs: JobId[]): Rx.Observable { return Rx.from(this.apiClient.findForJobIds(storedJobs)).pipe( - map((jobs: ReportDocument[]) => { + map((jobs) => { 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; + const { id: jobId, status: jobStatus } = job; if (storedJobs.includes(jobId)) { if (jobStatus === JOB_STATUSES.COMPLETED || jobStatus === JOB_STATUSES.WARNINGS) { completedJobs.push(getReportStatus(job)); diff --git a/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap b/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap index cb365849608677..3417aa59f9d725 100644 --- a/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap +++ b/x-pack/plugins/reporting/public/management/__snapshots__/report_info_button.test.tsx.snap @@ -6,7 +6,9 @@ exports[`ReportInfoButton handles button click flyout on click 1`] = ` className="euiButtonIcon euiButtonIcon--primary euiButtonIcon--empty euiButtonIcon--xSmall" data-test-subj="reportInfoButton" disabled={false} + onBlur={[Function]} onClick={[Function]} + onFocus={[Function]} type="button" > - - - - - + + + + + + + @@ -1404,7 +1654,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` - - - - - - - - - - - - - - - - - - -
- - -
- + + + + + + + + + +
+
+ + + + +
+ + + + + + +
+ + +
+ - + - - - - + + + + + + +
@@ -3493,7 +4243,7 @@ exports[`ReportListing Report job listing with some items 1`] = `
- + - - - - + + + + + + + @@ -4594,7 +5594,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` - + - - - - + + + + + + + @@ -5662,7 +6912,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` - + - - - - + + + + + + + @@ -6730,7 +8230,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` - + - - - - + + + + + + + @@ -7798,7 +9548,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` - + - - - - + + + + + + + @@ -8866,7 +10866,7 @@ exports[`ReportListing Report job listing with some items 1`] = ` - - - - - + + + + + + + diff --git a/x-pack/plugins/reporting/public/management/report_delete_button.tsx b/x-pack/plugins/reporting/public/management/report_delete_button.tsx index dfb411fc195e8a..5200191184972f 100644 --- a/x-pack/plugins/reporting/public/management/report_delete_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_delete_button.tsx @@ -7,7 +7,8 @@ import { EuiButton, EuiConfirmModal } from '@elastic/eui'; import React, { Fragment, PureComponent } from 'react'; -import { Job, Props as ListingProps } from './report_listing'; +import { Job } from '../lib/job'; +import { Props as ListingProps } from './report_listing'; type DeleteFn = () => Promise; type Props = { jobsToDelete: Job[]; performDelete: DeleteFn } & ListingProps; @@ -46,7 +47,7 @@ export class ReportDeleteButton extends PureComponent { id: 'xpack.reporting.listing.table.deleteConfirmTitle', defaultMessage: `Delete the "{name}" report?`, }, - { name: jobsToDelete[0].object_title } + { name: jobsToDelete[0].title } ); const message = intl.formatMessage({ id: 'xpack.reporting.listing.table.deleteConfirmMessage', diff --git a/x-pack/plugins/reporting/public/management/report_download_button.tsx b/x-pack/plugins/reporting/public/management/report_download_button.tsx index 78022b85e2ff86..b4212710377224 100644 --- a/x-pack/plugins/reporting/public/management/report_download_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_download_button.tsx @@ -8,7 +8,8 @@ import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; import React, { FunctionComponent } from 'react'; import { JOB_STATUSES } from '../../common/constants'; -import { Job as ListingJob, Props as ListingProps } from './report_listing'; +import { Job as ListingJob } from '../lib/job'; +import { Props as ListingProps } from './report_listing'; type Props = { record: ListingJob } & ListingProps; diff --git a/x-pack/plugins/reporting/public/management/report_error_button.tsx b/x-pack/plugins/reporting/public/management/report_error_button.tsx index 0ebdf5ca60b5a0..ee0c0e162cb7d0 100644 --- a/x-pack/plugins/reporting/public/management/report_error_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_error_button.tsx @@ -9,8 +9,8 @@ import { EuiButtonIcon, EuiCallOut, EuiPopover } from '@elastic/eui'; import { InjectedIntl, injectI18n } from '@kbn/i18n/react'; import React, { Component } from 'react'; import { JOB_STATUSES } from '../../common/constants'; +import { Job as ListingJob } from '../lib/job'; import { JobContent, ReportingAPIClient } from '../lib/reporting_api_client'; -import { Job as ListingJob } from './report_listing'; interface Props { intl: InjectedIntl; @@ -102,7 +102,7 @@ class ReportErrorButtonUi extends Component { this.setState({ isLoading: true }); try { - const reportContent: JobContent = await apiClient.getContent(record.id); + const reportContent: JobContent = await apiClient.getError(record.id); if (this.mounted) { this.setState({ isLoading: false, error: reportContent.content }); } diff --git a/x-pack/plugins/reporting/public/management/report_info_button.tsx b/x-pack/plugins/reporting/public/management/report_info_button.tsx index 719f1ff341daf8..92acaa386bd568 100644 --- a/x-pack/plugins/reporting/public/management/report_info_button.tsx +++ b/x-pack/plugins/reporting/public/management/report_info_button.tsx @@ -15,14 +15,16 @@ import { EuiSpacer, EuiText, EuiTitle, + EuiToolTip, } from '@elastic/eui'; -import { get } from 'lodash'; -import React, { Component, Fragment } from 'react'; +import { injectI18n } from '@kbn/i18n/react'; +import React, { Component } from 'react'; import { USES_HEADLESS_JOB_TYPES } from '../../common/constants'; -import { ReportApiJSON } from '../../common/types'; +import { Job } from '../lib/job'; import { ReportingAPIClient } from '../lib/reporting_api_client'; +import { Props as ListingProps } from './report_listing'; -interface Props { +interface Props extends Pick { jobId: string; apiClient: ReportingAPIClient; } @@ -31,23 +33,23 @@ interface State { isLoading: boolean; isFlyoutVisible: boolean; calloutTitle: string; - info: ReportApiJSON | null; + info: Job | null; error: Error | null; } const NA = 'n/a'; const UNKNOWN = 'unknown'; -const getDimensions = (info: ReportApiJSON): string => { +const getDimensions = (info: Job): string => { const defaultDimensions = { width: null, height: null }; - const { width, height } = get(info, 'payload.layout.dimensions', defaultDimensions); + const { width, height } = info.layout?.dimensions || defaultDimensions; if (width && height) { return `Width: ${width} x Height: ${height}`; } - return NA; + return UNKNOWN; }; -export class ReportInfoButton extends Component { +class ReportInfoButtonUi extends Component { private mounted?: boolean; constructor(props: Props) { @@ -75,133 +77,60 @@ export class ReportInfoButton extends Component { } const jobType = info.jobtype || NA; - - interface JobInfo { - title: string; - description: string; - } - - interface JobInfoMap { - [thing: string]: JobInfo[]; - } - const attempts = info.attempts ? info.attempts.toString() : NA; const maxAttempts = info.max_attempts ? info.max_attempts.toString() : NA; const timeout = info.timeout ? info.timeout.toString() : NA; - const warnings = info.output && info.output.warnings ? info.output.warnings.join(',') : null; + const warnings = info.warnings?.join(',') ?? null; + + const jobInfo = [ + { title: 'Title', description: info.title || NA }, + { title: 'Created By', description: info.created_by || NA }, + { title: 'Created At', description: info.created_at || NA }, + { title: 'Timezone', description: info.browserTimezone || NA }, + { title: 'Status', description: info.status || NA }, + ]; - const jobInfoDateTimes: JobInfo[] = [ - { - title: 'Created By', - description: info.created_by || NA, - }, - { - title: 'Created At', - description: info.created_at || NA, - }, - { - title: 'Started At', - description: info.started_at || NA, - }, - { - title: 'Completed At', - description: info.completed_at || NA, - }, + const processingInfo = [ + { title: 'Started At', description: info.started_at || NA }, + { title: 'Completed At', description: info.completed_at || NA }, { title: 'Processed By', description: - info.kibana_name && info.kibana_id ? `${info.kibana_name} (${info.kibana_id})` : UNKNOWN, - }, - { - title: 'Browser Timezone', - description: get(info, 'payload.browserTimezone') || NA, - }, - ]; - const jobInfoPayload: JobInfo[] = [ - { - title: 'Title', - description: get(info, 'payload.title') || NA, - }, - { - title: 'Layout', - description: get(info, 'meta.layout') || NA, - }, - { - title: 'Dimensions', - description: getDimensions(info), - }, - { - title: 'Job Type', - description: jobType, - }, - { - title: 'Content Type', - description: get(info, 'output.content_type') || NA, - }, - { - title: 'Size in Bytes', - description: get(info, 'output.size') || NA, - }, - ]; - const jobInfoStatus: JobInfo[] = [ - { - title: 'Attempts', - description: attempts, - }, - { - title: 'Max Attempts', - description: maxAttempts, - }, - { - title: 'Timeout', - description: timeout, - }, - { - title: 'Status', - description: info.status || NA, - }, - { - title: 'Browser Type', - description: USES_HEADLESS_JOB_TYPES.includes(jobType) ? info.browser_type || UNKNOWN : NA, + info.kibana_name && info.kibana_id ? `${info.kibana_name} (${info.kibana_id})` : NA, }, + { title: 'Content Type', description: info.content_type || NA }, + { title: 'Size in Bytes', description: info.size?.toString() || NA }, + { title: 'Attempts', description: attempts }, + { title: 'Max Attempts', description: maxAttempts }, + { title: 'Timeout', description: timeout }, ]; - if (warnings) { - jobInfoStatus.push({ - title: 'Errors', - description: warnings, - }); - } + const jobScreenshot = [ + { title: 'Dimensions', description: getDimensions(info) }, + { title: 'Layout', description: info.layout?.id || UNKNOWN }, + { title: 'Browser Type', description: info.browser_type || NA }, + ]; - const jobInfoParts: JobInfoMap = { - datetimes: jobInfoDateTimes, - payload: jobInfoPayload, - status: jobInfoStatus, - }; + const warningInfo = warnings && [{ title: 'Errors', description: warnings }]; return ( - - + <> + - - - - + + {USES_HEADLESS_JOB_TYPES.includes(jobType) ? ( + <> + + + + ) : null} + {warningInfo ? ( + <> + + + + ) : null} + ); } @@ -240,23 +169,31 @@ export class ReportInfoButton extends Component { } return ( - - + <> + + + {flyout} - + ); } private loadInfo = async () => { this.setState({ isLoading: true }); try { - const info: ReportApiJSON = await this.props.apiClient.getInfo(this.props.jobId); + const info = await this.props.apiClient.getInfo(this.props.jobId); if (this.mounted) { this.setState({ isLoading: false, info }); } @@ -287,3 +224,5 @@ export class ReportInfoButton extends Component { } }; } + +export const ReportInfoButton = injectI18n(ReportInfoButtonUi); diff --git a/x-pack/plugins/reporting/public/management/report_listing.test.tsx b/x-pack/plugins/reporting/public/management/report_listing.test.tsx index 0b278cbaa0449f..0c9b85c2f8cbb0 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.test.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.test.tsx @@ -5,24 +5,20 @@ * 2.0. */ -import React from 'react'; -import { Observable } from 'rxjs'; +import { registerTestBed } from '@kbn/test/jest'; import { UnwrapPromise } from '@kbn/utility-types'; - +import React from 'react'; import { act } from 'react-dom/test-utils'; - -import { registerTestBed } from '@kbn/test/jest'; - -import type { SharePluginSetup, LocatorPublic } from '../../../../../src/plugins/share/public'; +import { Observable } from 'rxjs'; import type { NotificationsSetup } from '../../../../../src/core/public'; import { httpServiceMock, notificationServiceMock } from '../../../../../src/core/public/mocks'; - +import type { LocatorPublic, SharePluginSetup } from '../../../../../src/plugins/share/public'; import type { ILicense } from '../../../licensing/public'; - -import { IlmPolicyMigrationStatus } from '../../common/types'; - -import { ReportingAPIClient, InternalApiClientClientProvider } from '../lib/reporting_api_client'; +import { IlmPolicyMigrationStatus, ReportApiJSON } from '../../common/types'; import { IlmPolicyStatusContextProvider } from '../lib/ilm_policy_status_context'; +import { Job } from '../lib/job'; +import { InternalApiClientClientProvider, ReportingAPIClient } from '../lib/reporting_api_client'; +import { Props, ReportListing } from './report_listing'; jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { return { @@ -30,21 +26,20 @@ jest.mock('@elastic/eui/lib/services/accessibility/html_id_generator', () => { }; }); -import { ReportListing, Props } from './report_listing'; +const mockJobs: ReportApiJSON[] = [ + { id: 'k90e51pk1ieucbae0c3t8wo2', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 0, browser_type: 'chromium', created_at: '2020-04-14T21:01:13.064Z', created_by: 'elastic', jobtype: 'printable_pdf', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '1970-01-01T00:00:00.000Z', status: 'pending', timeout: 300000 }, // prettier-ignore + { id: 'k90e51pk1ieucbae0c3t8wo1', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', created_at: '2020-04-14T21:01:13.064Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T21:06:14.526Z', started_at: '2020-04-14T21:01:14.526Z', status: 'processing', timeout: 300000 }, + { id: 'k90cmthd1gv8cbae0c2le8bo', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T20:19:14.748Z', created_at: '2020-04-14T20:19:02.977Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, output: { content_type: 'application/pdf', size: 80262 }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T20:24:04.073Z', started_at: '2020-04-14T20:19:04.073Z', status: 'completed', timeout: 300000 }, + { id: 'k906958e1d4wcbae0c9hip1a', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:21:08.223Z', created_at: '2020-04-14T17:20:27.326Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, output: { content_type: 'application/pdf', size: 49468, warnings: [ 'An error occurred when trying to read the page for visualization panel info. You may need to increase \'xpack.reporting.capture.timeouts.waitForElements\'. TimeoutError: waiting for selector "[data-shared-item],[data-shared-items-count]" failed: timeout 30000ms exceeded', ] }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T17:25:29.444Z', started_at: '2020-04-14T17:20:29.444Z', status: 'completed_with_warnings', timeout: 300000 }, + { id: 'k9067y2a1d4wcbae0cad38n0', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:19:53.244Z', created_at: '2020-04-14T17:19:31.379Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, output: { content_type: 'application/pdf', size: 80262 }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T17:24:39.883Z', started_at: '2020-04-14T17:19:39.883Z', status: 'completed', timeout: 300000 }, + { id: 'k9067s1m1d4wcbae0cdnvcms', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:19:36.822Z', created_at: '2020-04-14T17:19:23.578Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, output: { content_type: 'application/pdf', size: 80262 }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T17:24:25.247Z', started_at: '2020-04-14T17:19:25.247Z', status: 'completed', timeout: 300000 }, + { id: 'k9065q3s1d4wcbae0c00fxlh', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:18:03.910Z', created_at: '2020-04-14T17:17:47.752Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, output: { content_type: 'application/pdf', size: 80262 }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T17:22:50.379Z', started_at: '2020-04-14T17:17:50.379Z', status: 'completed', timeout: 300000 }, + { id: 'k905zdw11d34cbae0c3y6tzh', index: '.reporting-2020.04.12', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:13:03.719Z', created_at: '2020-04-14T17:12:51.985Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad' }, output: { content_type: 'application/pdf', size: 80262 }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 720, width: 1080 }, id: 'preserve_layout' }, objectType: 'canvas workpad', title: 'My Canvas Workpad' }, process_expiration: '2020-04-14T17:17:52.431Z', started_at: '2020-04-14T17:12:52.431Z', status: 'completed', timeout: 300000 }, + { id: 'k8t4ylcb07mi9d006214ifyg', index: '.reporting-2020.04.05', migration_version: '7.15.0', attempts: 1, browser_type: 'chromium', completed_at: '2020-04-09T19:10:10.049Z', created_at: '2020-04-09T19:09:52.139Z', created_by: 'elastic', jobtype: 'PNG', kibana_id: 'f2e59b4e-f79b-4a48-8a7d-6d50a3c1d914', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'png', objectType: 'visualization' }, output: { content_type: 'image/png', size: 123456789 }, payload: { browserTimezone: 'America/Phoenix', layout: { dimensions: { height: 1575, width: 1423 }, id: 'png' }, objectType: 'visualization', title: 'count' }, process_expiration: '2020-04-09T19:14:54.570Z', started_at: '2020-04-09T19:09:54.570Z', status: 'completed', timeout: 300000 }, +]; // prettier-ignore const reportingAPIClient = { - list: () => - Promise.resolve([ - { _id: 'k90e51pk1ieucbae0c3t8wo2', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 0, browser_type: 'chromium', created_at: '2020-04-14T21:01:13.064Z', created_by: 'elastic', jobtype: 'printable_pdf', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T21:01:13.062Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '1970-01-01T00:00:00.000Z', status: 'pending', timeout: 300000, }, sort: [1586898073064], }, // prettier-ignore - { _id: 'k90e51pk1ieucbae0c3t8wo1', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', created_at: '2020-04-14T21:01:13.064Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T21:01:13.062Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T21:06:14.526Z', started_at: '2020-04-14T21:01:14.526Z', status: 'processing', timeout: 300000, }, sort: [1586898073064], }, - { _id: 'k90cmthd1gv8cbae0c2le8bo', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T20:19:14.748Z', created_at: '2020-04-14T20:19:02.977Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T20:19:02.976Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T20:24:04.073Z', started_at: '2020-04-14T20:19:04.073Z', status: 'completed', timeout: 300000, }, sort: [1586895542977], }, - { _id: 'k906958e1d4wcbae0c9hip1a', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:21:08.223Z', created_at: '2020-04-14T17:20:27.326Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 49468, warnings: [ 'An error occurred when trying to read the page for visualization panel info. You may need to increase \'xpack.reporting.capture.timeouts.waitForElements\'. TimeoutError: waiting for selector "[data-shared-item],[data-shared-items-count]" failed: timeout 30000ms exceeded', ], }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:20:27.326Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e8-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:25:29.444Z', started_at: '2020-04-14T17:20:29.444Z', status: 'completed_with_warnings', timeout: 300000, }, sort: [1586884827326], }, - { _id: 'k9067y2a1d4wcbae0cad38n0', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:19:53.244Z', created_at: '2020-04-14T17:19:31.379Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:19:31.378Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:24:39.883Z', started_at: '2020-04-14T17:19:39.883Z', status: 'completed', timeout: 300000, }, sort: [1586884771379], }, - { _id: 'k9067s1m1d4wcbae0cdnvcms', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:19:36.822Z', created_at: '2020-04-14T17:19:23.578Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:19:23.578Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:24:25.247Z', started_at: '2020-04-14T17:19:25.247Z', status: 'completed', timeout: 300000, }, sort: [1586884763578], }, - { _id: 'k9065q3s1d4wcbae0c00fxlh', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:18:03.910Z', created_at: '2020-04-14T17:17:47.752Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:17:47.750Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:22:50.379Z', started_at: '2020-04-14T17:17:50.379Z', status: 'completed', timeout: 300000, }, sort: [1586884667752], }, - { _id: 'k905zdw11d34cbae0c3y6tzh', _index: '.reporting-2020.04.12', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-14T17:13:03.719Z', created_at: '2020-04-14T17:12:51.985Z', created_by: 'elastic', jobtype: 'printable_pdf', kibana_id: '5b2de169-2785-441b-ae8c-186a1936b17d', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'preserve_layout', objectType: 'canvas workpad', }, output: { content_type: 'application/pdf', size: 80262, }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-14T17:12:51.984Z', layout: { dimensions: { height: 720, width: 1080, }, id: 'preserve_layout', }, objectType: 'canvas workpad', relativeUrls: [ '/s/hsyjklk/app/canvas#/export/workpad/pdf/workpad-53d38306-eda4-410f-b5e7-efbeca1a8c63/page/1', ], title: 'My Canvas Workpad', }, priority: 10, process_expiration: '2020-04-14T17:17:52.431Z', started_at: '2020-04-14T17:12:52.431Z', status: 'completed', timeout: 300000, }, sort: [1586884371985], }, - { _id: 'k8t4ylcb07mi9d006214ifyg', _index: '.reporting-2020.04.05', _score: null, _source: { attempts: 1, browser_type: 'chromium', completed_at: '2020-04-09T19:10:10.049Z', created_at: '2020-04-09T19:09:52.139Z', created_by: 'elastic', jobtype: 'PNG', kibana_id: 'f2e59b4e-f79b-4a48-8a7d-6d50a3c1d914', kibana_name: 'spicy.local', max_attempts: 1, meta: { layout: 'png', objectType: 'visualization', }, output: { content_type: 'image/png', }, payload: { basePath: '/kbn', browserTimezone: 'America/Phoenix', forceNow: '2020-04-09T19:09:52.137Z', layout: { dimensions: { height: 1575, width: 1423, }, id: 'png', }, objectType: 'visualization', relativeUrl: "/s/hsyjklk/app/visualize#/edit/94d1fe40-7a94-11ea-b373-0749f92ad295?_a=(filters:!(),linked:!f,query:(language:kuery,query:''),uiState:(),vis:(aggs:!((enabled:!t,id:'1',params:(),schema:metric,type:count)),params:(addLegend:!f,addTooltip:!t,metric:(colorSchema:'Green%20to%20Red',colorsRange:!((from:0,to:10000)),invertColors:!f,labels:(show:!t),metricColorMode:None,percentageMode:!f,style:(bgColor:!f,bgFill:%23000,fontSize:60,labelColor:!f,subText:''),useRanges:!f),type:metric),title:count,type:metric))&_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15y,to:now))&indexPattern=d81752b0-7434-11ea-be36-1f978cda44d4&type=metric", title: 'count', }, priority: 10, process_expiration: '2020-04-09T19:14:54.570Z', started_at: '2020-04-09T19:09:54.570Z', status: 'completed', timeout: 300000, }, sort: [1586459392139], }, - ]), // prettier-ignore + list: () => Promise.resolve(mockJobs.map((j) => new Job(j))), total: () => Promise.resolve(18), migrateReportingIndicesIlmPolicy: jest.fn(), } as any; diff --git a/x-pack/plugins/reporting/public/management/report_listing.tsx b/x-pack/plugins/reporting/public/management/report_listing.tsx index dd41314b4883fa..30c9325a0f34f5 100644 --- a/x-pack/plugins/reporting/public/management/report_listing.tsx +++ b/x-pack/plugins/reporting/public/management/report_listing.tsx @@ -9,15 +9,14 @@ import { EuiBasicTable, EuiFlexGroup, EuiFlexItem, + EuiLoadingSpinner, EuiPageHeader, EuiSpacer, EuiText, EuiTextColor, - EuiLoadingSpinner, } from '@elastic/eui'; 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, Fragment } from 'react'; import { Subscription } from 'rxjs'; @@ -26,37 +25,16 @@ import { ILicense, LicensingPluginSetup } from '../../../licensing/public'; import { JOB_STATUSES as JobStatuses } from '../../common/constants'; import { Poller } from '../../common/poller'; import { durationToNumber } from '../../common/schema_utils'; -import { checkLicense } from '../lib/license_check'; -import { - JobQueueEntry, - ReportingAPIClient, - useInternalApiClient, -} from '../lib/reporting_api_client'; import { useIlmPolicyStatus, UseIlmPolicyStatusReturn } from '../lib/ilm_policy_status_context'; -import type { SharePluginSetup } from '../shared_imports'; +import { Job } from '../lib/job'; +import { checkLicense } from '../lib/license_check'; +import { ReportingAPIClient, useInternalApiClient } from '../lib/reporting_api_client'; import { ClientConfigType } from '../plugin'; +import type { SharePluginSetup } from '../shared_imports'; import { ReportDeleteButton, ReportDownloadButton, ReportErrorButton, ReportInfoButton } from './'; -import { ReportDiagnostic } from './report_diagnostic'; -import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; import { IlmPolicyLink } from './ilm_policy_link'; - -export interface Job { - id: string; - type: string; - object_type: string; - object_title: string; - created_by?: string | false; - created_at: string; - started_at?: string; - completed_at?: string; - status: string; - statusLabel: string; - max_size_reached?: boolean; - attempts: number; - max_attempts: number; - csv_contains_formulas: boolean; - warnings?: string[]; -} +import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; +import { ReportDiagnostic } from './report_diagnostic'; export interface Props { intl: InjectedIntl; @@ -251,7 +229,7 @@ class ReportListingUi extends Component { id: 'xpack.reporting.listing.table.deleteConfim', defaultMessage: `The {reportTitle} report was deleted`, }, - { reportTitle: record.object_title } + { reportTitle: record.title } ) ); } catch (error) { @@ -293,7 +271,7 @@ class ReportListingUi extends Component { this.setState(() => ({ isLoading: true })); } - let jobs: JobQueueEntry[]; + let jobs: Job[]; let total: number; try { jobs = await this.props.apiClient.list(this.state.page); @@ -325,28 +303,7 @@ class ReportListingUi extends Component { 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, - }; - } - ), + jobs, })); } }; @@ -369,7 +326,7 @@ class ReportListingUi extends Component { const tableColumns = [ { - field: 'object_title', + field: 'title', name: intl.formatMessage({ id: 'xpack.reporting.listing.tableColumns.reportTitle', defaultMessage: 'Report', @@ -379,7 +336,7 @@ class ReportListingUi extends Component {
{objectTitle}
- {record.object_type} + {record.objectType}
); diff --git a/x-pack/plugins/reporting/server/lib/enqueue_job.ts b/x-pack/plugins/reporting/server/lib/enqueue_job.ts index 70492b415f961d..ec2e443d86c809 100644 --- a/x-pack/plugins/reporting/server/lib/enqueue_job.ts +++ b/x-pack/plugins/reporting/server/lib/enqueue_job.ts @@ -47,7 +47,6 @@ export function enqueueJobFactory( reporting.getStore(), ]); - const config = reporting.getConfig(); const job = await createJob!(jobParams, context, request); // 1. Add the report to ReportingStore to show as pending @@ -55,7 +54,6 @@ export function enqueueJobFactory( new Report({ jobtype: exportType.jobType, created_by: user ? user.username : false, - max_attempts: config.get('capture', 'maxAttempts'), // NOTE: since max attempts is stored in the document, changing the capture.maxAttempts setting does not affect existing pending reports payload: job, meta: { objectType: jobParams.objectType, diff --git a/x-pack/plugins/reporting/server/lib/store/report.test.ts b/x-pack/plugins/reporting/server/lib/store/report.test.ts index a8d14e12a738be..4bc45fd745a567 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.test.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.test.ts @@ -48,7 +48,7 @@ describe('Class Report', () => { index: '.reporting-test-index-12345', jobtype: 'test-report', max_attempts: 50, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, + payload: { objectType: 'testOt' }, meta: { objectType: 'test' }, status: 'pending', timeout: 30000, @@ -109,7 +109,7 @@ describe('Class Report', () => { jobtype: 'test-report', max_attempts: 50, meta: { objectType: 'stange' }, - payload: { headers: 'payload_test_field', objectType: 'testOt' }, + payload: { objectType: 'testOt' }, started_at: undefined, status: 'pending', timeout: 30000, @@ -117,7 +117,7 @@ describe('Class Report', () => { }); it('throws error if converted to task JSON before being synced with ES storage', () => { - const report = new Report({} as any); + const report = new Report({ jobtype: 'spam', payload: {} } as any); expect(() => report.updateWithEsDoc(report)).toThrowErrorMatchingInlineSnapshot( `"Report object from ES has missing fields!"` ); diff --git a/x-pack/plugins/reporting/server/lib/store/report.ts b/x-pack/plugins/reporting/server/lib/store/report.ts index fa5b91527ccc47..0f970ead7c75cb 100644 --- a/x-pack/plugins/reporting/server/lib/store/report.ts +++ b/x-pack/plugins/reporting/server/lib/store/report.ts @@ -5,6 +5,7 @@ * 2.0. */ +import { omit } from 'lodash'; import moment from 'moment'; // @ts-ignore no module definition import Puid from 'puid'; @@ -33,23 +34,25 @@ export class Report implements Partial { public _primary_term?: number; // set by ES public _seq_no?: number; // set by ES - public readonly kibana_name: ReportSource['kibana_name']; - public readonly kibana_id: ReportSource['kibana_id']; public readonly jobtype: ReportSource['jobtype']; public readonly created_at: ReportSource['created_at']; public readonly created_by: ReportSource['created_by']; public readonly payload: ReportSource['payload']; public readonly meta: ReportSource['meta']; - public readonly max_attempts: ReportSource['max_attempts']; - public readonly browser_type?: ReportSource['browser_type']; + public readonly browser_type: ReportSource['browser_type']; public readonly status: ReportSource['status']; public readonly attempts: ReportSource['attempts']; - public readonly output?: ReportSource['output']; - public readonly started_at?: ReportSource['started_at']; - public readonly completed_at?: ReportSource['completed_at']; - public readonly timeout?: ReportSource['timeout']; + + // fields with undefined values exist in report jobs that have not been claimed + public readonly kibana_name: ReportSource['kibana_name']; + public readonly kibana_id: ReportSource['kibana_id']; + public readonly output: ReportSource['output']; + public readonly started_at: ReportSource['started_at']; + public readonly completed_at: ReportSource['completed_at']; + public readonly timeout: ReportSource['timeout']; + public readonly max_attempts: ReportSource['max_attempts']; public process_expiration?: ReportSource['process_expiration']; public migration_version: string; @@ -66,20 +69,29 @@ export class Report implements Partial { this.migration_version = MIGRATION_VERSION; - this.payload = opts.payload!; - this.kibana_name = opts.kibana_name!; - this.kibana_id = opts.kibana_id!; - this.jobtype = opts.jobtype!; - this.max_attempts = opts.max_attempts!; - this.attempts = opts.attempts || 0; + // see enqueue_job for all the fields that are expected to exist when adding a report + if (opts.jobtype == null) { + throw new Error(`jobtype is expected!`); + } + if (opts.payload == null) { + throw new Error(`payload is expected!`); + } - this.process_expiration = opts.process_expiration; + this.payload = opts.payload; + this.kibana_id = opts.kibana_id; + this.kibana_name = opts.kibana_name; + this.jobtype = opts.jobtype; + this.max_attempts = opts.max_attempts; + this.attempts = opts.attempts || 0; this.timeout = opts.timeout; + this.browser_type = opts.browser_type; + this.process_expiration = opts.process_expiration; + this.started_at = opts.started_at; + this.completed_at = opts.completed_at; this.created_at = opts.created_at || moment.utc().toISOString(); this.created_by = opts.created_by || false; this.meta = opts.meta || { objectType: 'unknown' }; - this.browser_type = opts.browser_type; this.status = opts.status || JOB_STATUSES.PENDING; this.output = opts.output || null; @@ -113,9 +125,9 @@ export class Report implements Partial { created_by: this.created_by, payload: this.payload, meta: this.meta, - timeout: this.timeout!, + timeout: this.timeout, max_attempts: this.max_attempts, - browser_type: this.browser_type!, + browser_type: this.browser_type, status: this.status, attempts: this.attempts, started_at: this.started_at, @@ -142,7 +154,6 @@ export class Report implements Partial { payload: this.payload, meta: this.meta, attempts: this.attempts, - max_attempts: this.max_attempts, }; } @@ -158,7 +169,6 @@ export class Report implements Partial { jobtype: this.jobtype, created_at: this.created_at, created_by: this.created_by, - payload: this.payload, meta: this.meta, timeout: this.timeout, max_attempts: this.max_attempts, @@ -167,6 +177,9 @@ export class Report implements Partial { attempts: this.attempts, started_at: this.started_at, completed_at: this.completed_at, + migration_version: this.migration_version, + payload: omit(this.payload, 'headers'), + output: omit(this.output, 'content'), }; } } diff --git a/x-pack/plugins/reporting/server/lib/store/store.ts b/x-pack/plugins/reporting/server/lib/store/store.ts index 7a7dd20e1b25cf..40a73f294c5a97 100644 --- a/x-pack/plugins/reporting/server/lib/store/store.ts +++ b/x-pack/plugins/reporting/server/lib/store/store.ts @@ -29,6 +29,7 @@ export type ReportProcessingFields = Required<{ browser_type: Report['browser_type']; attempts: Report['attempts']; started_at: Report['started_at']; + max_attempts: Report['max_attempts']; timeout: Report['timeout']; process_expiration: Report['process_expiration']; }>; diff --git a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts index f9e2cd82b0805c..a52f452436d6db 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/execute_report.ts @@ -135,9 +135,8 @@ export class ExecuteReportTask implements ReportingTask { const m = moment(); - // check if job has exceeded maxAttempts (stored in job params) and somehow hasn't been marked as failed yet - // NOTE: the max attempts value comes from the stored document, so changing the capture.maxAttempts config setting does not affect existing pending reports - const maxAttempts = task.max_attempts; + // check if job has exceeded the configured maxAttempts + const maxAttempts = this.config.capture.maxAttempts; if (report.attempts >= maxAttempts) { const err = new Error(`Max attempts reached (${maxAttempts}). Queue timeout reached.`); await this._failJob(report, err); @@ -153,6 +152,7 @@ export class ExecuteReportTask implements ReportingTask { kibana_name: this.kibanaName, browser_type: this.config.capture.browser.type, attempts: report.attempts + 1, + max_attempts: maxAttempts, started_at: startTime, timeout: queueTimeout, process_expiration: expirationTime, @@ -195,7 +195,7 @@ export class ExecuteReportTask implements ReportingTask { const completedTime = moment().toISOString(); const doc: ReportFailedFields = { completed_at: completedTime, - output: docOutput, + output: docOutput ?? null, }; return await store.setReportFailed(report, doc); @@ -306,11 +306,14 @@ export class ExecuteReportTask implements ReportingTask { } if (!report) { + this.reporting.untrackReport(jobId); errorLogger(this.logger, `Job ${jobId} could not be claimed. Exiting...`); return; } - const { jobtype: jobType, attempts, max_attempts: maxAttempts } = report; + const { jobtype: jobType, attempts } = report; + const maxAttempts = this.config.capture.maxAttempts; + this.logger.debug( `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` ); diff --git a/x-pack/plugins/reporting/server/lib/tasks/index.ts b/x-pack/plugins/reporting/server/lib/tasks/index.ts index c02b06d97adc7d..662528124e6c02 100644 --- a/x-pack/plugins/reporting/server/lib/tasks/index.ts +++ b/x-pack/plugins/reporting/server/lib/tasks/index.ts @@ -27,7 +27,6 @@ export interface ReportTaskParams { created_at: ReportSource['created_at']; created_by: ReportSource['created_by']; jobtype: ReportSource['jobtype']; - max_attempts: ReportSource['max_attempts']; attempts: ReportSource['attempts']; meta: ReportSource['meta']; } diff --git a/x-pack/plugins/reporting/server/routes/jobs.test.ts b/x-pack/plugins/reporting/server/routes/jobs.test.ts index 3f913dfd1f32f3..3040ea351f7d04 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.test.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.test.ts @@ -152,7 +152,10 @@ describe('GET /api/reporting/jobs/download', () => { it('returns a 401 if not a valid job type', async () => { mockEsClient.search.mockResolvedValueOnce({ - body: getHits({ jobtype: 'invalidJobType' }), + body: getHits({ + jobtype: 'invalidJobType', + payload: { title: 'invalid!' }, + }), } as any); registerJobInfoRoutes(core); @@ -163,7 +166,11 @@ describe('GET /api/reporting/jobs/download', () => { it('when a job is incomplete', async () => { mockEsClient.search.mockResolvedValueOnce({ - body: getHits({ jobtype: 'unencodedJobType', status: 'pending' }), + body: getHits({ + jobtype: 'unencodedJobType', + status: 'pending', + payload: { title: 'incomplete!' }, + }), } as any); registerJobInfoRoutes(core); @@ -182,6 +189,7 @@ describe('GET /api/reporting/jobs/download', () => { jobtype: 'unencodedJobType', status: 'failed', output: { content: 'job failure message' }, + payload: { title: 'failing job!' }, }), } as any); registerJobInfoRoutes(core); @@ -207,9 +215,7 @@ describe('GET /api/reporting/jobs/download', () => { jobtype: jobType, status: 'completed', output: { content: outputContent, content_type: outputContentType }, - payload: { - title, - }, + payload: { title }, }); }; diff --git a/x-pack/plugins/reporting/server/routes/jobs.ts b/x-pack/plugins/reporting/server/routes/jobs.ts index 3f2a95a34224ce..0d0332983d6bc3 100644 --- a/x-pack/plugins/reporting/server/routes/jobs.ts +++ b/x-pack/plugins/reporting/server/routes/jobs.ts @@ -71,7 +71,7 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { path: `${MAIN_ENTRY}/count`, validate: false, }, - userHandler(async (user, context, req, res) => { + userHandler(async (user, context, _req, res) => { // ensure the async dependencies are loaded if (!context.reporting) { return handleUnavailable(res); @@ -115,22 +115,20 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { } = await reporting.getLicenseInfo(); const jobsQuery = jobsQueryFactory(reporting); - const result = await jobsQuery.get(user, docId, { includeContent: true }); + const result = await jobsQuery.getContent(user, docId); if (!result) { throw Boom.notFound(); } - const { - _source: { jobtype: jobType, output: jobOutput }, - } = result; + const { jobtype: jobType, output } = result; if (!jobTypes.includes(jobType)) { throw Boom.unauthorized(`Sorry, you are not authorized to download ${jobType} reports`); } return res.ok({ - body: jobOutput || {}, + body: output?.content ?? {}, headers: { 'content-type': 'application/json', }, @@ -166,21 +164,14 @@ export function registerJobInfoRoutes(reporting: ReportingCore) { throw Boom.notFound(); } - const { _source: job } = result; - const { jobtype: jobType, payload: jobPayload } = job; + const { jobtype: jobType } = result; if (!jobTypes.includes(jobType)) { throw Boom.unauthorized(`Sorry, you are not authorized to view ${jobType} info`); } return res.ok({ - body: { - ...job, - payload: { - ...jobPayload, - headers: undefined, - }, - }, + body: result, headers: { 'content-type': 'application/json', }, diff --git a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts index 4e8e888e4e2665..2141252c70bfa0 100644 --- a/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts +++ b/x-pack/plugins/reporting/server/routes/lib/get_document_payload.ts @@ -7,12 +7,11 @@ // @ts-ignore import contentDisposition from 'content-disposition'; -import { get } from 'lodash'; import { CSV_JOB_TYPE, CSV_JOB_TYPE_DEPRECATED } from '../../../common/constants'; import { ExportTypesRegistry, statuses } from '../../lib'; -import { ReportDocument } from '../../lib/store'; import { TaskRunResult } from '../../lib/tasks'; import { ExportTypeDefinition } from '../../types'; +import { ReportContent } from './jobs_query'; export interface ErrorFromPayload { message: string; @@ -35,8 +34,8 @@ const getReportingHeaders = (output: TaskRunResult, exportType: ExportTypeDefini const metaDataHeaders: Record = {}; if (exportType.jobType === CSV_JOB_TYPE || exportType.jobType === CSV_JOB_TYPE_DEPRECATED) { - const csvContainsFormulas = get(output, 'csv_contains_formulas', false); - const maxSizedReach = get(output, 'max_size_reached', false); + const csvContainsFormulas = output.csv_contains_formulas ?? false; + const maxSizedReach = output.max_size_reached ?? false; metaDataHeaders['kbn-csv-contains-formulas'] = csvContainsFormulas; metaDataHeaders['kbn-max-size-reached'] = maxSizedReach; @@ -98,10 +97,12 @@ export function getDocumentPayloadFactory(exportTypesRegistry: ExportTypesRegist }; } - return function getDocumentPayload(doc: ReportDocument): Payload { - const { status, jobtype: jobType, payload: { title } = { title: '' } } = doc._source; - const { output } = doc._source; - + return function getDocumentPayload({ + status, + jobtype: jobType, + payload: { title } = { title: 'unknown' }, + output, + }: ReportContent): Payload { if (output) { if (status === statuses.JOB_STATUS_COMPLETED || status === statuses.JOB_STATUS_WARNINGS) { return getCompleted(output, jobType, title); diff --git a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts index 8ffefa9c8a98cf..f9519f74060f91 100644 --- a/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts +++ b/x-pack/plugins/reporting/server/routes/lib/job_response_handler.ts @@ -35,16 +35,14 @@ export function downloadJobResponseHandlerFactory(reporting: ReportingCore) { try { const { docId } = params; - const doc = await jobsQuery.get(user, docId, { includeContent: !opts.excludeContent }); + const doc = await jobsQuery.getContent(user, docId); if (!doc) { return res.notFound(); } - const { jobtype: jobType } = doc._source; - - if (!validJobTypes.includes(jobType)) { + if (!validJobTypes.includes(doc.jobtype)) { return res.unauthorized({ - body: `Sorry, you are not authorized to download ${jobType} reports`, + body: `Sorry, you are not authorized to download ${doc.jobtype} reports`, }); } @@ -81,13 +79,13 @@ export function deleteJobResponseHandlerFactory(reporting: ReportingCore) { params: JobResponseHandlerParams ) { const { docId } = params; - const doc = await jobsQuery.get(user, docId, { includeContent: false }); + const doc = await jobsQuery.get(user, docId); if (!doc) { return res.notFound(); } - const { jobtype: jobType } = doc._source; + const { jobtype: jobType } = doc; if (!validJobTypes.includes(jobType)) { return res.unauthorized({ @@ -96,7 +94,7 @@ export function deleteJobResponseHandlerFactory(reporting: ReportingCore) { } try { - const docIndex = doc._index; + const docIndex = doc.index; await jobsQuery.delete(docIndex, docId); return res.ok({ body: { deleted: true }, diff --git a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts index 2fec34470ff1f1..76896a7472d59d 100644 --- a/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts +++ b/x-pack/plugins/reporting/server/routes/lib/jobs_query.ts @@ -5,20 +5,19 @@ * 2.0. */ +import { ApiResponse } from '@elastic/elasticsearch'; +import { DeleteResponse, SearchHit, SearchResponse } from '@elastic/elasticsearch/api/types'; import { ResponseError } from '@elastic/elasticsearch/lib/errors'; import { i18n } from '@kbn/i18n'; import { UnwrapPromise } from '@kbn/utility-types'; import { ElasticsearchClient } from 'src/core/server'; import { ReportingCore } from '../../'; -import { ReportDocument } from '../../lib/store'; +import { ReportApiJSON, ReportDocument, ReportSource } from '../../../common/types'; +import { Report } from '../../lib/store'; import { ReportingUser } from '../../types'; type SearchRequest = Required>[0]; -interface GetOpts { - includeContent?: boolean; -} - const defaultSize = 10; const getUsername = (user: ReportingUser) => (user ? user.username : false); @@ -33,7 +32,25 @@ function getSearchBody(body: SearchRequest['body']): SearchRequest['body'] { }; } -export function jobsQueryFactory(reportingCore: ReportingCore) { +export type ReportContent = Pick & { + payload?: Pick; +}; + +interface JobsQueryFactory { + list( + jobTypes: string[], + user: ReportingUser, + page: number, + size: number, + jobIds: string[] | null + ): Promise; + count(jobTypes: string[], user: ReportingUser): Promise; + get(user: ReportingUser, id: string): Promise; + getContent(user: ReportingUser, id: string): Promise; + delete(deleteIndex: string, id: string): Promise>; +} + +export function jobsQueryFactory(reportingCore: ReportingCore): JobsQueryFactory { function getIndex() { const config = reportingCore.getConfig(); @@ -57,13 +74,7 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { } return { - async list( - jobTypes: string[], - user: ReportingUser, - page = 0, - size = defaultSize, - jobIds: string[] | null - ) { + async list(jobTypes, user, page = 0, size = defaultSize, jobIds) { const username = getUsername(user); const body = getSearchBody({ size, @@ -83,15 +94,23 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { }, }); - const response = await execQuery((elasticsearchClient) => + const response = (await execQuery((elasticsearchClient) => elasticsearchClient.search({ body, index: getIndex() }) + )) as ApiResponse>; + + return ( + response?.body.hits?.hits.map((report: SearchHit) => { + const { _source: reportSource, ...reportHead } = report; + if (reportSource) { + const reportInstance = new Report({ ...reportSource, ...reportHead }); + return reportInstance.toApiJSON(); + } + throw new Error(`Search hit did not include _source!`); + }) ?? [] ); - - // FIXME: return the info in ReportApiJSON format; - return response?.body.hits?.hits ?? []; }, - async count(jobTypes: string[], user: ReportingUser) { + async count(jobTypes, user) { const username = getUsername(user); const body = { query: { @@ -112,14 +131,50 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { return response?.body.count ?? 0; }, - async get(user: ReportingUser, id: string, opts: GetOpts = {}): Promise { + async get(user, id) { + const { logger } = reportingCore.getPluginSetupDeps(); + if (!id) { + logger.warning(`No ID provided for GET`); + return; + } + + const username = getUsername(user); + + const body = getSearchBody({ + query: { + constant_score: { + filter: { + bool: { + must: [{ term: { _id: id } }, { term: { created_by: username } }], + }, + }, + }, + }, + size: 1, + }); + + const response = await execQuery((elasticsearchClient) => + elasticsearchClient.search({ body, index: getIndex() }) + ); + + const result = response?.body.hits.hits[0] as SearchHit | undefined; + if (!result || !result._source) { + logger.warning(`No hits resulted in search`); + return; + } + + const report = new Report({ ...result, ...result._source }); + return report.toApiJSON(); + }, + + async getContent(user, id) { if (!id) { return; } const username = getUsername(user); const body: SearchRequest['body'] = { - ...(opts.includeContent ? { _source: { excludes: [] } } : {}), + _source: { excludes: ['payload.headers'] }, query: { constant_score: { filter: { @@ -140,11 +195,17 @@ export function jobsQueryFactory(reportingCore: ReportingCore) { return; } - // FIXME: return the info in ReportApiJSON format; - return response.body.hits.hits[0] as ReportDocument; + const report = response.body.hits.hits[0] as ReportDocument; + + return { + status: report._source.status, + jobtype: report._source.jobtype, + output: report._source.output, + payload: report._source.payload, + }; }, - async delete(deleteIndex: string, id: string) { + async delete(deleteIndex, id) { try { const { asInternalUser: elasticsearchClient } = await reportingCore.getEsClient(); const query = { id, index: deleteIndex, refresh: true }; diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv.ts b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv.ts index 577afb200d4a1a..06f3756593d767 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv.ts @@ -7,18 +7,13 @@ import expect from '@kbn/expect'; import { pick } from 'lodash'; -import { - ReportApiJSON, - ReportDocument, - ReportSource, -} from '../../../plugins/reporting/common/types'; +import { ReportApiJSON } from '../../../plugins/reporting/common/types'; import { FtrProviderContext } from '../ftr_provider_context'; const apiResponseFields = [ 'attempts', 'created_by', 'jobtype', - 'max_attempts', 'meta', 'payload.isDeprecated', 'payload.title', @@ -26,26 +21,13 @@ const apiResponseFields = [ 'status', ]; -// TODO: clean up the /list and /info endpoints to return ReportApiJSON interface data -const documentResponseFields = [ - '_source.attempts', - '_source.created_by', - '_source.jobtype', - '_source.max_attempts', - '_source.meta', - '_source.payload.isDeprecated', - '_source.payload.title', - '_source.payload.type', - '_source.status', -]; - // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { const esArchiver = getService('esArchiver'); const supertestNoAuth = getService('supertestWithoutAuth'); const reportingAPI = getService('reportingAPI'); - const postJobCSV = async () => { + const postJobCSV = async (): Promise<{ job: ReportApiJSON; path: string }> => { const jobParams = `(browserTimezone:UTC,columns:!('@timestamp',clientip,extension),` + `objectType:search,searchSource:(fields:!((field:'*',include_unmapped:true)),filter:!((meta:(index:'logstash-*',params:()),` + @@ -81,13 +63,12 @@ export default function ({ getService }: FtrProviderContext) { }); it('Posted CSV job is visible in the job count', async () => { - const { job, path }: { job: ReportApiJSON; path: string } = await postJobCSV(); + const { job, path } = await postJobCSV(); expectSnapshot(pick(job, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv_searchsource", - "max_attempts": 1, "meta": Object { "objectType": "search", }, @@ -110,13 +91,12 @@ export default function ({ getService }: FtrProviderContext) { it('Posted CSV job is visible in the status check', async () => { // post a job - const { job, path }: { job: ReportApiJSON; path: string } = await postJobCSV(); + const { job, path } = await postJobCSV(); expectSnapshot(pick(job, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv_searchsource", - "max_attempts": 1, "meta": Object { "objectType": "search", }, @@ -133,24 +113,21 @@ export default function ({ getService }: FtrProviderContext) { .set('kbn-xsrf', 'xxx'); // verify the top item in the list - const listingJobs: ReportDocument[] = JSON.parse(listText); - expect(listingJobs[0]._id).to.be(job.id); - expectSnapshot(listingJobs.map((j) => pick(j, documentResponseFields))).toMatchInline(` + const listingJobs: ReportApiJSON[] = JSON.parse(listText); + expect(listingJobs[0].id).to.be(job.id); + expectSnapshot(listingJobs.map((j) => pick(j, apiResponseFields))).toMatchInline(` Array [ Object { - "_source": Object { - "attempts": 0, - "created_by": false, - "jobtype": "csv_searchsource", - "max_attempts": 1, - "meta": Object { - "objectType": "search", - }, - "payload": Object { - "title": "A Saved Search With a DATE FILTER", - }, - "status": "pending", + "attempts": 0, + "created_by": false, + "jobtype": "csv_searchsource", + "meta": Object { + "objectType": "search", + }, + "payload": Object { + "title": "A Saved Search With a DATE FILTER", }, + "status": "pending", }, ] `); @@ -161,13 +138,12 @@ export default function ({ getService }: FtrProviderContext) { it('Posted CSV job is visible in the first page of jobs listing', async () => { // post a job - const { job, path }: { job: ReportApiJSON; path: string } = await postJobCSV(); + const { job, path } = await postJobCSV(); expectSnapshot(pick(job, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv_searchsource", - "max_attempts": 1, "meta": Object { "objectType": "search", }, @@ -179,29 +155,27 @@ export default function ({ getService }: FtrProviderContext) { `); // call the listing api - const { text: listText } = await supertestNoAuth + const { text: listText, status } = await supertestNoAuth .get(`/api/reporting/jobs/list?page=0`) .set('kbn-xsrf', 'xxx'); + expect(status).to.be(200); // verify the top item in the list - const listingJobs: ReportDocument[] = JSON.parse(listText); - expect(listingJobs[0]._id).to.be(job.id); - expectSnapshot(listingJobs.map((j) => pick(j, documentResponseFields))).toMatchInline(` + const listingJobs: ReportApiJSON[] = JSON.parse(listText); + expect(listingJobs[0].id).to.be(job.id); + expectSnapshot(listingJobs.map((j) => pick(j, apiResponseFields))).toMatchInline(` Array [ Object { - "_source": Object { - "attempts": 0, - "created_by": false, - "jobtype": "csv_searchsource", - "max_attempts": 1, - "meta": Object { - "objectType": "search", - }, - "payload": Object { - "title": "A Saved Search With a DATE FILTER", - }, - "status": "pending", + "attempts": 0, + "created_by": false, + "jobtype": "csv_searchsource", + "meta": Object { + "objectType": "search", + }, + "payload": Object { + "title": "A Saved Search With a DATE FILTER", }, + "status": "pending", }, ] `); @@ -212,13 +186,12 @@ export default function ({ getService }: FtrProviderContext) { it('Posted CSV job details are visible in the info API', async () => { // post a job - const { job, path }: { job: ReportApiJSON; path: string } = await postJobCSV(); + const { job, path } = await postJobCSV(); expectSnapshot(pick(job, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv_searchsource", - "max_attempts": 1, "meta": Object { "objectType": "search", }, @@ -229,17 +202,17 @@ export default function ({ getService }: FtrProviderContext) { } `); - const { text: infoText } = await supertestNoAuth + const { text: infoText, status } = await supertestNoAuth .get(`/api/reporting/jobs/info/${job.id}`) .set('kbn-xsrf', 'xxx'); + expect(status).to.be(200); - const info: ReportSource = JSON.parse(infoText); + const info = JSON.parse(infoText); expectSnapshot(pick(info, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv_searchsource", - "max_attempts": 1, "meta": Object { "objectType": "search", }, diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv_deprecated.ts b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv_deprecated.ts index 5aafcfb9d29a1a..2d62725e23989a 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv_deprecated.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/job_apis_csv_deprecated.ts @@ -7,11 +7,7 @@ import expect from '@kbn/expect'; import { pick } from 'lodash'; -import { - ReportApiJSON, - ReportDocument, - ReportSource, -} from '../../../plugins/reporting/common/types'; +import { ReportApiJSON } from '../../../plugins/reporting/common/types'; import { FtrProviderContext } from '../ftr_provider_context'; import { JOB_PARAMS_RISON_CSV_DEPRECATED } from '../services/fixtures'; @@ -19,7 +15,6 @@ const apiResponseFields = [ 'attempts', 'created_by', 'jobtype', - 'max_attempts', 'meta', 'payload.isDeprecated', 'payload.title', @@ -27,18 +22,8 @@ const apiResponseFields = [ 'status', ]; -// TODO: clean up the /list and /info endpoints to return ReportApiJSON interface data -const documentResponseFields = [ - '_source.attempts', - '_source.created_by', - '_source.jobtype', - '_source.max_attempts', - '_source.meta', - '_source.payload.isDeprecated', - '_source.payload.title', - '_source.payload.type', - '_source.status', -]; +const parseApiJSON = (apiResponseText: string): { job: ReportApiJSON; path: string } => + JSON.parse(apiResponseText); // eslint-disable-next-line import/no-default-export export default function ({ getService }: FtrProviderContext) { @@ -68,13 +53,12 @@ export default function ({ getService }: FtrProviderContext) { .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); expect(resStatus).to.be(200); - const { job, path }: { job: ReportApiJSON; path: string } = JSON.parse(resText); + const { job, path } = parseApiJSON(resText); expectSnapshot(pick(job, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv", - "max_attempts": 1, "meta": Object {}, "payload": Object { "isDeprecated": true, @@ -103,30 +87,27 @@ export default function ({ getService }: FtrProviderContext) { .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); expect(resStatus).to.be(200); - const { job, path }: { job: ReportApiJSON; path: string } = JSON.parse(resText); + const { job, path } = parseApiJSON(resText); // call the single job listing api (status check) const { text: listText } = await supertestNoAuth .get(`/api/reporting/jobs/list?page=0&ids=${job.id}`) .set('kbn-xsrf', 'xxx'); - const listingJobs: ReportDocument[] = JSON.parse(listText); - expect(listingJobs[0]._id).to.be(job.id); - expectSnapshot(listingJobs.map((j) => pick(j, documentResponseFields))).toMatchInline(` + const listingJobs: ReportApiJSON[] = JSON.parse(listText); + expect(listingJobs[0].id).to.be(job.id); + expectSnapshot(listingJobs.map((j) => pick(j, apiResponseFields))).toMatchInline(` Array [ Object { - "_source": Object { - "attempts": 0, - "created_by": false, - "jobtype": "csv", - "max_attempts": 1, - "meta": Object {}, - "payload": Object { - "isDeprecated": true, - "title": "A Saved Search With a DATE FILTER", - "type": "search", - }, - "status": "pending", + "attempts": 0, + "created_by": false, + "jobtype": "csv", + "meta": Object {}, + "payload": Object { + "isDeprecated": true, + "title": "A Saved Search With a DATE FILTER", + "type": "search", }, + "status": "pending", }, ] `); @@ -141,30 +122,27 @@ export default function ({ getService }: FtrProviderContext) { .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); expect(resStatus).to.be(200); - const { job, path }: { job: ReportApiJSON; path: string } = JSON.parse(resText); + const { job, path } = parseApiJSON(resText); // call the ALL job listing api const { text: listText } = await supertestNoAuth .get(`/api/reporting/jobs/list?page=0`) .set('kbn-xsrf', 'xxx'); - const listingJobs: ReportDocument[] = JSON.parse(listText); - expect(listingJobs[0]._id).to.eql(job.id); - expectSnapshot(listingJobs.map((j) => pick(j, documentResponseFields))).toMatchInline(` + const listingJobs: ReportApiJSON[] = JSON.parse(listText); + expect(listingJobs[0].id).to.eql(job.id); + expectSnapshot(listingJobs.map((j) => pick(j, apiResponseFields))).toMatchInline(` Array [ Object { - "_source": Object { - "attempts": 0, - "created_by": false, - "jobtype": "csv", - "max_attempts": 1, - "meta": Object {}, - "payload": Object { - "isDeprecated": true, - "title": "A Saved Search With a DATE FILTER", - "type": "search", - }, - "status": "pending", + "attempts": 0, + "created_by": false, + "jobtype": "csv", + "meta": Object {}, + "payload": Object { + "isDeprecated": true, + "title": "A Saved Search With a DATE FILTER", + "type": "search", }, + "status": "pending", }, ] `); @@ -179,18 +157,17 @@ export default function ({ getService }: FtrProviderContext) { .send({ jobParams: JOB_PARAMS_RISON_CSV_DEPRECATED }); expect(resStatus).to.be(200); - const { job, path }: { job: ReportApiJSON; path: string } = JSON.parse(resText); + const { job, path } = parseApiJSON(resText); const { text: infoText } = await supertestNoAuth .get(`/api/reporting/jobs/info/${job.id}`) .set('kbn-xsrf', 'xxx'); - const info: ReportSource = JSON.parse(infoText); + const info = JSON.parse(infoText); expectSnapshot(pick(info, apiResponseFields)).toMatchInline(` Object { "attempts": 0, "created_by": false, "jobtype": "csv", - "max_attempts": 1, "meta": Object {}, "payload": Object { "isDeprecated": true,