diff --git a/oas_docs/output/kibana.serverless.yaml b/oas_docs/output/kibana.serverless.yaml index 004130ea49e26..8542aa70ac115 100644 --- a/oas_docs/output/kibana.serverless.yaml +++ b/oas_docs/output/kibana.serverless.yaml @@ -61483,6 +61483,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61501,6 +61507,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61532,6 +61544,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61624,6 +61642,12 @@ components: $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61647,6 +61671,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61667,6 +61697,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -61685,6 +61721,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: diff --git a/oas_docs/output/kibana.yaml b/oas_docs/output/kibana.yaml index a0d5276d63f95..1572228a4217f 100644 --- a/oas_docs/output/kibana.yaml +++ b/oas_docs/output/kibana.yaml @@ -70856,6 +70856,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -70874,6 +70880,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -70905,6 +70917,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -70997,6 +71015,12 @@ components: $ref: '#/components/schemas/Security_Detections_API_BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -71020,6 +71044,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -71040,6 +71070,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: @@ -71058,6 +71094,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. items: diff --git a/packages/kbn-check-mappings-update-cli/current_fields.json b/packages/kbn-check-mappings-update-cli/current_fields.json index 5d7aa4d9d82d0..c6619b39403bb 100644 --- a/packages/kbn-check-mappings-update-cli/current_fields.json +++ b/packages/kbn-check-mappings-update-cli/current_fields.json @@ -958,6 +958,9 @@ "installCount", "unInstallCount" ], + "scheduled_report": [ + "createdBy" + ], "search": [ "description", "title" diff --git a/packages/kbn-check-mappings-update-cli/current_mappings.json b/packages/kbn-check-mappings-update-cli/current_mappings.json index 78f008fa99a1b..1279924049f00 100644 --- a/packages/kbn-check-mappings-update-cli/current_mappings.json +++ b/packages/kbn-check-mappings-update-cli/current_mappings.json @@ -3119,6 +3119,14 @@ } } }, + "scheduled_report": { + "dynamic": false, + "properties": { + "createdBy": { + "type": "keyword" + } + } + }, "search": { "dynamic": false, "properties": { diff --git a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts index 03b33a3e404d0..e53a427715607 100644 --- a/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts +++ b/src/core/server/integration_tests/saved_objects/registration/type_registrations.test.ts @@ -126,6 +126,7 @@ const previouslyRegisteredTypes = [ 'query', 'rules-settings', 'sample-data-telemetry', + 'scheduled_report', 'search', 'search-session', 'search-telemetry', diff --git a/src/platform/packages/private/kbn-reporting/common/errors.ts b/src/platform/packages/private/kbn-reporting/common/errors.ts index 45a299115bf1b..3700602bd6f27 100644 --- a/src/platform/packages/private/kbn-reporting/common/errors.ts +++ b/src/platform/packages/private/kbn-reporting/common/errors.ts @@ -72,6 +72,13 @@ export class AuthenticationExpiredError extends ReportingError { } } +export class MissingAuthenticationError extends ReportingError { + static code = 'missing_authentication_header_error' as const; + public get code(): string { + return MissingAuthenticationError.code; + } +} + export class QueueTimeoutError extends ReportingError { static code = 'queue_timeout_error' as const; public get code(): string { diff --git a/src/platform/packages/private/kbn-reporting/common/routes.ts b/src/platform/packages/private/kbn-reporting/common/routes.ts index 4fb56700cda28..9b289877567fb 100644 --- a/src/platform/packages/private/kbn-reporting/common/routes.ts +++ b/src/platform/packages/private/kbn-reporting/common/routes.ts @@ -24,8 +24,13 @@ export const INTERNAL_ROUTES = { DELETE_PREFIX: prefixInternalPath + '/jobs/delete', // docId is added to the final path DOWNLOAD_PREFIX: prefixInternalPath + '/jobs/download', // docId is added to the final path }, + SCHEDULED: { + LIST: prefixInternalPath + '/scheduled/list', + BULK_DISABLE: prefixInternalPath + '/scheduled/bulk_disable', + }, HEALTH: prefixInternalPath + '/_health', GENERATE_PREFIX: prefixInternalPath + '/generate', // exportTypeId is added to the final path + SCHEDULE_PREFIX: prefixInternalPath + '/schedule', // exportTypeId is added to the final path }; const prefixPublicPath = '/api/reporting'; diff --git a/src/platform/packages/private/kbn-reporting/common/types.ts b/src/platform/packages/private/kbn-reporting/common/types.ts index 8eed3f9e1de70..a51e8566ab52f 100644 --- a/src/platform/packages/private/kbn-reporting/common/types.ts +++ b/src/platform/packages/private/kbn-reporting/common/types.ts @@ -11,7 +11,9 @@ import type { LayoutParams, PerformanceMetrics as ScreenshotMetrics, } from '@kbn/screenshotting-plugin/common'; +import type { Rrule } from '@kbn/task-manager-plugin/server/task'; import type { ConcreteTaskInstance } from '@kbn/task-manager-plugin/server'; +import { Rrule } from '@kbn/task-manager-plugin/server/task'; import { JOB_STATUS } from './constants'; import type { LocatorParams } from './url'; @@ -66,6 +68,7 @@ export interface BaseParams { objectType: string; title: string; version: string; // to handle any state migrations + forceNow?: string; layout?: LayoutParams; // png & pdf only pagingStrategy?: CsvPagingStrategy; // csv only } @@ -152,6 +155,7 @@ export interface ReportSource { created_at: string; // timestamp in UTC '@timestamp'?: string; // creation timestamp, only used for data streams compatibility status: JOB_STATUS; + scheduled_report_id?: string; /* * `output` is only populated if the report job is completed or failed. @@ -209,3 +213,25 @@ export interface LicenseCheckResults { showLinks: boolean; message: string; } + +export interface ScheduledReport { + id: string; + createdAt: string; + createdBy: string; + enabled: boolean; + jobtype: string; + lastRun: string | undefined; + nextRun: string | undefined; + objectType: string; + notification?: { + email?: { + to?: string[]; + bcc?: string[]; + cc?: string[]; + }; + }; + schedule: { + rrule: Rrule; + }; + title: string; +} diff --git a/src/platform/packages/private/kbn-reporting/public/job.tsx b/src/platform/packages/private/kbn-reporting/public/job.tsx index 3b4822363733e..bb300d574938a 100644 --- a/src/platform/packages/private/kbn-reporting/public/job.tsx +++ b/src/platform/packages/private/kbn-reporting/public/job.tsx @@ -10,7 +10,14 @@ import moment from 'moment'; import React from 'react'; -import { EuiText, EuiTextColor } from '@elastic/eui'; +import { + EuiBadge, + EuiFlexGroup, + EuiFlexItem, + EuiIconTip, + EuiText, + EuiTextColor, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { JOB_STATUS } from '@kbn/reporting-common'; import type { @@ -79,6 +86,7 @@ export class Job { public readonly queue_time_ms?: Required['queue_time_ms'][number]; public readonly execution_time_ms?: Required['execution_time_ms'][number]; + public readonly scheduled_report_id?: ReportSource['scheduled_report_id']; constructor(report: ReportApiJSON) { this.id = report.id; @@ -117,6 +125,7 @@ export class Job { this.metrics = report.metrics; this.queue_time_ms = report.queue_time_ms; this.execution_time_ms = report.execution_time_ms; + this.scheduled_report_id = report.scheduled_report_id; } public isSearch() { @@ -254,6 +263,27 @@ export class Job { return this.formatDate(this.created_at); } + getExportType(): React.ReactElement { + const exportType = this.scheduled_report_id + ? i18n.translate('reporting.jobExportType.scheduled', { + defaultMessage: 'Scheduled', + }) + : i18n.translate('reporting.jobExportType.single', { + defaultMessage: 'Single', + }); + + return ( + + + + + + {exportType} + + + ); + } + /* * We use `output.warnings` to show the error of a failed report job, * and to show warnings of a job that completed with warnings. diff --git a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/helpers.ts b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/helpers.ts index 21d13655421e0..4b0df91dead5f 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/helpers.ts +++ b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/helpers.ts @@ -16,7 +16,16 @@ import { getColumnByAccessor, getFormatByAccessor } from '@kbn/visualizations-pl import { getFormatService, getPaletteService } from '../services'; import { getDataBoundsForPalette } from '../utils'; -function enhanceFieldFormat(serializedFieldFormat: SerializedFieldFormat | undefined) { +export interface FormatOverrides { + number?: { alwaysShowSign?: boolean }; + percent?: { alwaysShowSign?: boolean }; + bytes?: { alwaysShowSign?: boolean }; +} + +function enhanceFieldFormat( + serializedFieldFormat: SerializedFieldFormat | undefined, + formatOverrides: FormatOverrides | undefined +): SerializedFieldFormat { const formatId = serializedFieldFormat?.id || 'number'; if (formatId === 'duration' && !serializedFieldFormat?.params?.formatOverride) { return { @@ -31,17 +40,28 @@ function enhanceFieldFormat(serializedFieldFormat: SerializedFieldFormat | undef }, }; } + if (formatOverrides && formatId in formatOverrides) { + return { + ...serializedFieldFormat, + params: { + ...serializedFieldFormat?.params, + ...formatOverrides[formatId as keyof FormatOverrides], + }, + }; + } + return serializedFieldFormat ?? { id: formatId }; } export const getMetricFormatter = ( accessor: ExpressionValueVisDimension | string, - columns: Datatable['columns'] + columns: Datatable['columns'], + formatOverrides?: FormatOverrides | undefined ) => { const type = getColumnByAccessor(accessor, columns)?.meta.type; const defaultFormat = type ? { id: type } : undefined; const serializedFieldFormat = getFormatByAccessor(accessor, columns, defaultFormat); - const enhancedFieldFormat = enhanceFieldFormat(serializedFieldFormat); + const enhancedFieldFormat = enhanceFieldFormat(serializedFieldFormat, formatOverrides); return getFormatService().deserialize(enhancedFieldFormat).getConverterFor('text'); }; diff --git a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.test.tsx b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.test.tsx index bee7ea66d16f7..3ad2782776881 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.test.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.test.tsx @@ -9,7 +9,7 @@ import React from 'react'; import userEvent from '@testing-library/user-event'; -import { render, screen, waitFor } from '@testing-library/react'; +import { act, render, screen, waitFor } from '@testing-library/react'; import { Datatable, DatatableColumn } from '@kbn/expressions-plugin/common'; import { MetricVis, MetricVisComponentProps } from './metric_vis'; import { MetricWTrend } from '@elastic/charts'; @@ -238,8 +238,12 @@ describe('MetricVisComponent', function () { }); async function waitForChartToRender(renderComplete: MetricVisComponentProps['renderComplete']) { - // wait for 1 rAF tick (~16ms) - jest.advanceTimersByTime(30); + // Interestingly we have to wrap this into an act() call to avoid + // issues with the React scheduling when testing + await act(async () => { + // wait for 1 rAF tick (~16ms) + jest.advanceTimersByTime(30); + }); // wait for render complete callback await waitFor(() => expect(renderComplete).toHaveBeenCalled()); } diff --git a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.tsx b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.tsx index f1870105a26d2..0a58231b2b517 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/metric_vis.tsx @@ -184,14 +184,13 @@ export const MetricVis = ({ title: String(title), subtitle, icon: config.metric?.icon ? getIcon(config.metric?.icon) : undefined, - extra: ({ fontSize, color }) => ( + extra: ({ color }) => ( ( + extra: ({ color }) => ( = {}) { metric: { secondaryPrefix: '' }, }} getMetricFormatter={jest.fn(() => () => formattedValue)} - fontSize={16} {...props} /> ); @@ -257,9 +256,10 @@ describe('Secondary metric', () => { it.each(trendCombinationCompareToPrimary)( '[Compare to primary] should render a badge with the trend icon "$icon" and the formatted value (rawValue: $valueFinite, baseline: $baselineFinite)', ({ value, baseline, color, icon }) => { + const getMetricFormatterMock = jest.fn(() => (v: unknown) => String(v)); renderSecondaryMetric({ row: { [id]: value }, - getMetricFormatter: jest.fn(() => (v: unknown) => String(v)), + getMetricFormatter: getMetricFormatterMock, trendConfig: { icon: showIcon, value: showValue, @@ -280,6 +280,13 @@ describe('Secondary metric', () => { const el = screen.getByTitle(badgeText); expect(el).toBeInTheDocument(); expect(el).toHaveStyle(`--euiBadgeBackgroundColor: ${color}`); + expect(getMetricFormatterMock).toHaveBeenCalledWith( + expect.any(String), + expect.any(Object), + expect.objectContaining({ + number: expect.objectContaining({ alwaysShowSign: true }), + }) + ); } if (showValue) { expect( diff --git a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/secondary_metric.tsx b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/secondary_metric.tsx index 07f9b55055ab8..55bae8bbf5bde 100644 --- a/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/secondary_metric.tsx +++ b/src/platform/plugins/shared/chart_expressions/expression_metric/public/components/secondary_metric.tsx @@ -15,6 +15,7 @@ import type { DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/comm import { type FieldFormatConvertFunction } from '@kbn/field-formats-plugin/common'; import { type VisParams } from '@kbn/visualizations-plugin/common'; import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils'; +import { FormatOverrides } from './helpers'; export interface TrendConfig { icon: boolean; @@ -119,14 +120,12 @@ function SecondaryMetricValue({ formattedValue, trendConfig, color, - fontSize, formatter, }: { rawValue?: number | string; formattedValue?: string; trendConfig?: TrendConfig; color?: string; - fontSize: number; formatter?: FieldFormatConvertFunction; }) { const safeFormattedValue = formattedValue ?? notAvailable; @@ -152,6 +151,15 @@ function SecondaryMetricValue({ formatter, trendConfig.compareToPrimary ); + // If no value is shown and no icon should be shown (i.e. N/A) then do not render the badge at all + if (trendConfig.icon && !trendConfig.value && !icon) { + return ( + + ); + } return ( ; trendConfig?: TrendConfig; color?: string; - fontSize: number; - getMetricFormatter: (accessor: string, columns: DatatableColumn[]) => FieldFormatConvertFunction; + getMetricFormatter: ( + accessor: string, + columns: DatatableColumn[], + formatOverrides?: FormatOverrides | undefined + ) => FieldFormatConvertFunction; } function getMetricColumnAndFormatter( columns: SecondaryMetricProps['columns'], config: SecondaryMetricProps['config'], - getMetricFormatter: SecondaryMetricProps['getMetricFormatter'] + getMetricFormatter: SecondaryMetricProps['getMetricFormatter'], + formatOverrides: FormatOverrides | undefined ) { if (!config.dimensions.secondaryMetric) { return; } return { metricColumn: getColumnByAccessor(config.dimensions.secondaryMetric, columns), - metricFormatter: getMetricFormatter(config.dimensions.secondaryMetric, columns), + metricFormatter: getMetricFormatter( + config.dimensions.secondaryMetric, + columns, + formatOverrides + ), + }; +} + +function getEnhancedNumberSignFormatter( + trendConfig: TrendConfig | undefined +): FormatOverrides | undefined { + if (!trendConfig?.compareToPrimary) { + return; + } + const paramsOverride = { alwaysShowSign: true }; + return { + number: paramsOverride, + percent: paramsOverride, + bytes: paramsOverride, }; } @@ -214,10 +244,14 @@ export function SecondaryMetric({ getMetricFormatter, trendConfig, color, - fontSize, }: SecondaryMetricProps) { const { metricFormatter, metricColumn } = - getMetricColumnAndFormatter(columns, config, getMetricFormatter) || {}; + getMetricColumnAndFormatter( + columns, + config, + getMetricFormatter, + getEnhancedNumberSignFormatter(trendConfig) + ) || {}; const prefix = config.metric.secondaryPrefix ?? metricColumn?.name; const value = metricColumn ? row[metricColumn.id] : undefined; @@ -230,7 +264,6 @@ export function SecondaryMetric({ formattedValue={metricFormatter?.(value)} trendConfig={color ? undefined : trendConfig} color={color} - fontSize={fontSize} formatter={metricFormatter} /> diff --git a/src/platform/plugins/shared/field_formats/common/converters/numeral.ts b/src/platform/plugins/shared/field_formats/common/converters/numeral.ts index 100898fd558b6..6c74153fca36b 100644 --- a/src/platform/plugins/shared/field_formats/common/converters/numeral.ts +++ b/src/platform/plugins/shared/field_formats/common/converters/numeral.ts @@ -30,7 +30,9 @@ export abstract class NumeralFormat extends FieldFormat { abstract title: string; getParamDefaults = () => ({ - pattern: this.getConfig!(`format:${this.id}:defaultPattern`), + // While this should be always defined, it is not guaranteed in testing that the function is available + pattern: this.getConfig?.(`format:${this.id}:defaultPattern`), + alwaysShowSign: false, }); protected getConvertedValue(val: number | string | object): string { @@ -50,7 +52,12 @@ export abstract class NumeralFormat extends FieldFormat { (this.getConfig && this.getConfig(FORMATS_UI_SETTINGS.FORMAT_NUMBER_DEFAULT_LOCALE)) || 'en'; numeral.language(defaultLocale); - const formatted = numeralInst.set(val).format(this.param('pattern')); + let pattern: string = this.param('pattern'); + if (pattern && this.param('alwaysShowSign')) { + pattern = pattern.startsWith('+') || val === 0 ? pattern : `+ ${pattern}`; + } + + const formatted = numeralInst.set(val).format(pattern); numeral.language(previousLocale); diff --git a/src/platform/plugins/shared/field_formats/common/converters/percent.ts b/src/platform/plugins/shared/field_formats/common/converters/percent.ts index c091ba1866d5d..43cbd5e1216e3 100644 --- a/src/platform/plugins/shared/field_formats/common/converters/percent.ts +++ b/src/platform/plugins/shared/field_formats/common/converters/percent.ts @@ -26,6 +26,7 @@ export class PercentFormat extends NumeralFormat { getParamDefaults = () => ({ pattern: this.getConfig!(FORMATS_UI_SETTINGS.FORMAT_PERCENT_DEFAULT_PATTERN), fractional: true, + alwaysShowSign: false, }); textConvert: TextContextTypeConvert = (val: string | number) => { diff --git a/x-pack/platform/plugins/private/canvas/server/feature.test.ts b/x-pack/platform/plugins/private/canvas/server/feature.test.ts index 7f71bafd41700..6fde4788a9c89 100644 --- a/x-pack/platform/plugins/private/canvas/server/feature.test.ts +++ b/x-pack/platform/plugins/private/canvas/server/feature.test.ts @@ -107,7 +107,9 @@ it('Provides a feature declaration ', () => { "minimumLicense": "gold", "name": "Generate PDF reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -216,7 +218,9 @@ it(`Calls on Reporting whether to include Generate PDF as a sub-feature`, () => "minimumLicense": "gold", "name": "Generate PDF reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ diff --git a/x-pack/platform/plugins/private/canvas/server/feature.ts b/x-pack/platform/plugins/private/canvas/server/feature.ts index daa8a8fc4aa4f..4aa03b73425f4 100644 --- a/x-pack/platform/plugins/private/canvas/server/feature.ts +++ b/x-pack/platform/plugins/private/canvas/server/feature.ts @@ -68,7 +68,7 @@ export function getCanvasFeature(plugins: { reporting?: ReportingStart }): Kiban includeIn: 'all', management: { insightsAndAlerting: ['reporting'] }, minimumLicense: 'gold', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, api: ['generateReport'], ui: ['generatePdf'], }, diff --git a/x-pack/platform/plugins/private/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/unit_field.tsx b/x-pack/platform/plugins/private/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/unit_field.tsx index 51fffcda5c02b..8dc626437041e 100644 --- a/x-pack/platform/plugins/private/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/unit_field.tsx +++ b/x-pack/platform/plugins/private/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/unit_field.tsx @@ -6,7 +6,7 @@ */ import React, { FunctionComponent, useState } from 'react'; -import { EuiFilterSelectItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; +import { EuiPopover, EuiButtonEmpty, EuiSelectable } from '@elastic/eui'; import { UseField } from '../../../form'; interface Props { @@ -49,16 +49,28 @@ export const UnitField: FunctionComponent = ({ path, options, euiFieldPro closePopover={() => setOpen(false)} {...euiFieldProps} > - {options.map((item) => ( - onSelect(item.value)} - data-test-subj={`filter-option-${item.value}`} - > - {item.text} - - ))} + ({ + key: item.value, + label: item.text, + checked: field.value === item.value ? 'on' : undefined, + 'data-test-subj': `filter-option-${item.value}`, + }))} + onChange={(newOptions, event, changedOption) => { + if (changedOption) { + onSelect(changedOption.key); + } + }} + > + {(list) => list} + ); }} diff --git a/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts b/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts index 823785c4eb273..8c492e77e1c93 100644 --- a/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts +++ b/x-pack/platform/plugins/private/reporting/common/test/fixtures.ts @@ -5,8 +5,10 @@ * 2.0. */ +import { Frequency } from '@kbn/rrule'; import { JOB_STATUS } from '@kbn/reporting-common'; import { ReportApiJSON } from '@kbn/reporting-common/types'; +import { ListScheduledReportApiJSON } from '../../server/types'; import type { ReportMock } from './types'; const buildMockReport = (baseObj: ReportMock): ReportApiJSON => ({ @@ -173,3 +175,51 @@ export const mockJobs: ReportApiJSON[] = [ status: JOB_STATUS.COMPLETED, }), ]; + +export const mockScheduledReports: ListScheduledReportApiJSON[] = [ + { + created_at: '2025-06-10T12:41:45.136Z', + created_by: 'Foo Bar', + enabled: true, + id: 'scheduled-report-1', + jobtype: 'printable_pdf_v2', + last_run: '2025-05-10T12:41:46.959Z', + next_run: '2025-06-16T13:56:07.123Z', + object_type: 'dashboard', + schedule: { + rrule: { freq: Frequency.WEEKLY, tzid: 'UTC', interval: 1 }, + }, + title: 'Scheduled report 1', + notification: {}, + }, + { + created_at: '2025-06-16T12:41:45.136Z', + created_by: 'Test abc', + enabled: true, + id: 'scheduled-report-2', + jobtype: 'printable_pdf_v2', + last_run: '2025-06-16T12:41:46.959Z', + next_run: '2025-06-16T13:56:07.123Z', + object_type: 'discover', + schedule: { + rrule: { freq: Frequency.DAILY, tzid: 'UTC', interval: 1 }, + }, + title: 'Scheduled report 2', + notification: {}, + }, + { + created_at: '2025-06-12T12:41:45.136Z', + created_by: 'New', + enabled: false, + id: 'scheduled-report-3', + jobtype: 'printable_pdf_v2', + last_run: '2025-06-16T12:41:46.959Z', + next_run: '2025-06-16T13:56:07.123Z', + object_type: 'discover', + schedule: { + rrule: { freq: Frequency.MONTHLY, tzid: 'UTC', interval: 2 }, + }, + title: 'Scheduled report 3', + notification: {}, + }, +]; diff --git a/x-pack/platform/plugins/private/reporting/kibana.jsonc b/x-pack/platform/plugins/private/reporting/kibana.jsonc index a27970e2cec8d..1a3b40c96ca46 100644 --- a/x-pack/platform/plugins/private/reporting/kibana.jsonc +++ b/x-pack/platform/plugins/private/reporting/kibana.jsonc @@ -16,6 +16,7 @@ "reporting" ], "requiredPlugins": [ + "actions", "data", "discover", "encryptedSavedObjects", diff --git a/x-pack/platform/plugins/private/reporting/public/constants.ts b/x-pack/platform/plugins/private/reporting/public/constants.ts new file mode 100644 index 0000000000000..193e9cf234af3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/constants.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const APP_PATH = '/app/management/insightsAndAlerting/reporting' as const; +export const HOME_PATH = `/`; +export const REPORTING_EXPORTS_PATH = '/exports' as const; +export const REPORTING_SCHEDULES_PATH = '/schedules' as const; +export const EXPORTS_TAB_ID = 'exports' as const; +export const SCHEDULES_TAB_ID = 'schedules' as const; + +export type Section = 'exports' | 'schedules'; diff --git a/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx b/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx index 793b49979cb6f..883a1f19b637c 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/__test__/report_listing.test.helpers.tsx @@ -31,10 +31,13 @@ import { act } from 'react-dom/test-utils'; import { Observable } from 'rxjs'; import { EuiThemeProvider } from '@elastic/eui'; -import { ListingProps as Props, ReportListing } from '..'; +import { RouteComponentProps } from 'react-router-dom'; +import { createLocation, createMemoryHistory } from 'history'; +import { ListingProps as Props, ReportingTabs } from '..'; import { mockJobs } from '../../../common/test'; import { IlmPolicyStatusContextProvider } from '../../lib/ilm_policy_status_context'; import { ReportDiagnostic } from '../components'; +import { MatchParams } from '../components/reporting_tabs'; export interface TestDependencies { http: ReturnType; @@ -90,6 +93,21 @@ const license$ = { }, } as Observable; +const routeProps: RouteComponentProps = { + history: createMemoryHistory({ + initialEntries: ['/exports'], + }), + location: createLocation('/exports'), + match: { + isExact: true, + path: `/exports`, + url: '', + params: { + section: 'exports', + }, + }, +}; + export const createTestBed = registerTestBed( ({ http, @@ -107,14 +125,16 @@ export const createTestBed = registerTestBed( - diff --git a/x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts b/x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts new file mode 100644 index 0000000000000..c86cdde56b0d7 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/bulk_disable_scheduled_reports.ts @@ -0,0 +1,25 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpSetup } from '@kbn/core/public'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; + +export const bulkDisableScheduledReports = async ({ + http, + ids = [], +}: { + http: HttpSetup; + ids: string[]; +}): Promise<{ + scheduled_report_ids: string[]; + errors: Array<{ message: string; status?: number; id: string }>; + total: number; +}> => { + return await http.patch(INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE, { + body: JSON.stringify({ ids }), + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts b/x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts new file mode 100644 index 0000000000000..d6152936ff554 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/apis/get_scheduled_reports_list.ts @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { HttpFetchQuery, HttpSetup } from '@kbn/core/public'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { type ListScheduledReportApiJSON } from '../../../server/types'; + +export interface Pagination { + index: number; + size: number; +} + +export const getScheduledReportsList = async ({ + http, + index, + size, +}: { + http: HttpSetup; + index?: number; + size?: number; +}): Promise<{ + page: number; + size: number; + total: number; + data: ListScheduledReportApiJSON[]; +}> => { + const query: HttpFetchQuery = { page: index, size }; + + const res = await http.get<{ + page: number; + per_page: number; + total: number; + data: ListScheduledReportApiJSON[]; + }>(INTERNAL_ROUTES.SCHEDULED.LIST, { + query, + }); + + return { + page: res.page, + size: res.per_page, + total: res.total, + data: res.data, + }; +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx index 8bb72cddd6c76..bb5c3a572e17e 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/ilm_policy_link.tsx @@ -22,7 +22,7 @@ interface Props { const i18nTexts = { buttonLabel: i18n.translate('xpack.reporting.listing.reports.ilmPolicyLinkText', { - defaultMessage: 'Edit reporting ILM policy', + defaultMessage: 'Edit ILM policy', }), }; @@ -30,7 +30,8 @@ export const IlmPolicyLink: FunctionComponent = ({ locator, navigateToUrl return ( { const url = locator.getRedirectUrl({ page: 'policy_edit', diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx index 90139a56ead28..c6ea3e874e322 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_diagnostic.tsx @@ -10,7 +10,6 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; import { EuiButton, - EuiButtonEmpty, EuiCallOut, EuiFlyout, EuiFlyoutBody, @@ -182,17 +181,12 @@ export const ReportDiagnostic = ({ apiClient, clientConfig }: Props) => { {configAllowsImageReports && (
{flyout} - + - +
)} diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx new file mode 100644 index 0000000000000..7d381f3921957 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.test.tsx @@ -0,0 +1,106 @@ +/* + * 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 { + applicationServiceMock, + coreMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { ReportExportsTable } from './report_exports_table'; +import { render, screen } from '@testing-library/react'; +import { Job, ReportingAPIClient } from '@kbn/reporting-public'; +import { Observable } from 'rxjs'; +import { ILicense } from '@kbn/licensing-plugin/public'; +import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { mockConfig } from '../__test__/report_listing.test.helpers'; +import React from 'react'; +import { REPORT_TABLE_ID, REPORT_TABLE_ROW_ID } from '@kbn/reporting-common'; +import { mockJobs } from '../../../common/test'; +import { RecursivePartial, UseEuiTheme } from '@elastic/eui'; +import { ThemeProvider } from '@emotion/react'; + +const coreStart = coreMock.createStart(); +const http = httpServiceMock.createSetupContract(); +const uiSettingsClient = coreMock.createSetup().uiSettings; +const httpService = httpServiceMock.createSetupContract(); +const application = applicationServiceMock.createStartContract(); +const reportingAPIClient = new ReportingAPIClient(httpService, uiSettingsClient, 'x.x.x'); +const validCheck = { + check: () => ({ + state: 'VALID', + message: '', + }), +}; +const license$ = { + subscribe: (handler: unknown) => { + return (handler as Function)(validCheck); + }, +} as Observable; + +export const getMockTheme = (partialTheme: RecursivePartial): UseEuiTheme => + partialTheme as UseEuiTheme; + +const defaultProps = { + coreStart, + http, + application, + apiClient: reportingAPIClient, + config: mockConfig, + license$, + urlService: {} as unknown as SharePluginSetup['url'], + toasts: notificationServiceMock.createSetupContract().toasts, + capabilities: application.capabilities, + redirect: application.navigateToApp, + navigateToUrl: application.navigateToUrl, +}; + +describe('ReportExportsTable', () => { + const mockTheme = getMockTheme({ euiTheme: { size: { s: '' } } }); + beforeEach(() => { + jest.clearAllMocks(); + jest + .spyOn(reportingAPIClient, 'list') + .mockImplementation(() => Promise.resolve(mockJobs.map((j) => new Job(j)))); + jest.spyOn(reportingAPIClient, 'total').mockImplementation(() => Promise.resolve(18)); + }); + + it('renders table correctly', async () => { + render( + + + + ); + + expect(await screen.findByTestId(REPORT_TABLE_ID)).toBeInTheDocument(); + }); + + it('renders empty state correctly', async () => { + jest.spyOn(reportingAPIClient, 'list').mockImplementation(() => Promise.resolve([])); + jest.spyOn(reportingAPIClient, 'total').mockImplementation(() => Promise.resolve(0)); + render( + + + + ); + + expect(await screen.findByText('No reports have been created')).toBeInTheDocument(); + }); + + it('renders data correctly', async () => { + render( + + + + ); + + expect(await screen.findAllByTestId(REPORT_TABLE_ROW_ID)).toHaveLength(mockJobs.length); + + expect(await screen.findByTestId(`viewReportingLink-${mockJobs[0].id}`)).toBeInTheDocument(); + expect(await screen.findByTestId(`reportDownloadLink-${mockJobs[0].id}`)).toBeInTheDocument(); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_listing_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx similarity index 83% rename from x-pack/platform/plugins/private/reporting/public/management/report_listing_table.tsx rename to x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx index 848c25a4ba278..9ea704d92bb48 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/report_listing_table.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_exports_table.tsx @@ -24,11 +24,12 @@ import { ILicense } from '@kbn/licensing-plugin/public'; import { durationToNumber, REPORT_TABLE_ID, REPORT_TABLE_ROW_ID } from '@kbn/reporting-common'; import { checkLicense, Job } from '@kbn/reporting-public'; -import { ListingPropsInternal } from '.'; -import { prettyPrintJobType } from '../../common/job_utils'; -import { Poller } from '../../common/poller'; -import { ReportDeleteButton, ReportInfoFlyout, ReportStatusIndicator } from './components'; -import { guessAppIconTypeFromObjectType, getDisplayNameFromObjectType } from './utils'; +import { ListingPropsInternal } from '..'; +import { prettyPrintJobType } from '../../../common/job_utils'; +import { Poller } from '../../../common/poller'; +import { ReportDeleteButton, ReportInfoFlyout, ReportStatusIndicator } from '.'; +import { guessAppIconTypeFromObjectType, getDisplayNameFromObjectType } from '../utils'; +import { NO_CREATED_REPORTS_DESCRIPTION } from '../../translations'; type TableColumn = EuiBasicTableColumn; @@ -44,7 +45,7 @@ interface State { selectedJob: undefined | Job; } -export class ReportListingTable extends Component { +export class ReportExportsTable extends Component { private isInitialJobsFetch: boolean; private licenseSubscription?: Subscription; private mounted?: boolean; @@ -128,7 +129,7 @@ export class ReportListingTable extends Component { await this.props.apiClient.deleteReport(job.id); this.removeJob(job); this.props.toasts.addSuccess( - i18n.translate('xpack.reporting.listing.table.deleteConfim', { + i18n.translate('xpack.reporting.exports.table.deleteConfirm', { defaultMessage: `The {reportTitle} report was deleted`, values: { reportTitle: job.title, @@ -137,7 +138,7 @@ export class ReportListingTable extends Component { ); } catch (error) { this.props.toasts.addDanger( - i18n.translate('xpack.reporting.listing.table.deleteFailedErrorMessage', { + i18n.translate('xpack.reporting.exports.table.deleteFailedErrorMessage', { defaultMessage: `The report was not deleted: {error}`, values: { error }, }) @@ -172,6 +173,7 @@ export class ReportListingTable extends Component { try { jobs = await this.props.apiClient.list(this.state.page); total = await this.props.apiClient.total(); + this.isInitialJobsFetch = false; } catch (fetchError) { if (!this.licenseAllowsToShowThisPage()) { @@ -183,7 +185,7 @@ export class ReportListingTable extends Component { if (fetchError.message === 'Failed to fetch') { this.props.toasts.addDanger( fetchError.message || - i18n.translate('xpack.reporting.listing.table.requestFailedErrorMessage', { + i18n.translate('xpack.reporting.exports.table.requestFailedErrorMessage', { defaultMessage: 'Request failed', }) ); @@ -216,9 +218,10 @@ export class ReportListingTable extends Component { type: '5%', title: '30%', status: '20%', - createdAt: '25%', - content: '10%', - actions: '10%', + createdAt: '21%', + content: '7%', + exportType: '12%', + actions: '5%', }; public render() { @@ -227,7 +230,7 @@ export class ReportListingTable extends Component { { field: 'type', width: tableColumnWidths.type, - name: i18n.translate('xpack.reporting.listing.tableColumns.typeTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.typeTitle', { defaultMessage: 'Type', }), render: (_type: string, job) => { @@ -251,8 +254,8 @@ export class ReportListingTable extends Component { }, { field: 'title', - name: i18n.translate('xpack.reporting.listing.tableColumns.reportTitle', { - defaultMessage: 'Title', + name: i18n.translate('xpack.reporting.exports.tableColumns.reportTitle', { + defaultMessage: 'Name', }), width: tableColumnWidths.title, render: (objectTitle: string, job) => { @@ -263,7 +266,7 @@ export class ReportListingTable extends Component { onClick={() => this.setState({ selectedJob: job })} > {objectTitle || - i18n.translate('xpack.reporting.listing.table.noTitleLabel', { + i18n.translate('xpack.reporting.exports.table.noTitleLabel', { defaultMessage: 'Untitled', })} @@ -278,7 +281,7 @@ export class ReportListingTable extends Component { { field: 'status', width: tableColumnWidths.status, - name: i18n.translate('xpack.reporting.listing.tableColumns.statusTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.statusTitle', { defaultMessage: 'Status', }), render: (_status: string, job) => { @@ -300,7 +303,7 @@ export class ReportListingTable extends Component { { field: 'created_at', width: tableColumnWidths.createdAt, - name: i18n.translate('xpack.reporting.listing.tableColumns.createdAtTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.createdAtTitle', { defaultMessage: 'Created at', }), render: (_createdAt: string, job) => ( @@ -313,16 +316,31 @@ export class ReportListingTable extends Component { { field: 'content', width: tableColumnWidths.content, - name: i18n.translate('xpack.reporting.listing.tableColumns.content', { + name: i18n.translate('xpack.reporting.exports.tableColumns.content', { defaultMessage: 'Content', }), - render: (_status: string, job) => prettyPrintJobType(job.jobtype), + render: (_status: string, job) => ( +
{prettyPrintJobType(job.jobtype)}
+ ), + mobileOptions: { + show: false, + }, + }, + { + field: 'exportType', + width: tableColumnWidths.exportType, + name: i18n.translate('xpack.reporting.exports.tableColumns.exportType', { + defaultMessage: 'Export type', + }), + render: (_scheduledReportId: string, job) => ( +
{job.getExportType()}
+ ), mobileOptions: { show: false, }, }, { - name: i18n.translate('xpack.reporting.listing.tableColumns.actionsTitle', { + name: i18n.translate('xpack.reporting.exports.tableColumns.actionsTitle', { defaultMessage: 'Actions', }), width: tableColumnWidths.actions, @@ -332,10 +350,10 @@ export class ReportListingTable extends Component { 'data-test-subj': (job) => `reportDownloadLink-${job.id}`, type: 'icon', icon: 'download', - name: i18n.translate('xpack.reporting.listing.table.downloadReportButtonLabel', { + name: i18n.translate('xpack.reporting.exports.table.downloadReportButtonLabel', { defaultMessage: 'Download report', }), - description: i18n.translate('xpack.reporting.listing.table.downloadReportDescription', { + description: i18n.translate('xpack.reporting.exports.table.downloadReportDescription', { defaultMessage: 'Download this report in a new tab.', }), onClick: (job) => this.props.apiClient.downloadReport(job.id), @@ -343,13 +361,13 @@ export class ReportListingTable extends Component { }, { name: i18n.translate( - 'xpack.reporting.listing.table.viewReportingInfoActionButtonLabel', + 'xpack.reporting.exports.table.viewReportingInfoActionButtonLabel', { defaultMessage: 'View report info', } ), description: i18n.translate( - 'xpack.reporting.listing.table.viewReportingInfoActionButtonDescription', + 'xpack.reporting.exports.table.viewReportingInfoActionButtonDescription', { defaultMessage: 'View additional information about this report.', } @@ -359,12 +377,12 @@ export class ReportListingTable extends Component { onClick: (job) => this.setState({ selectedJob: job }), }, { - name: i18n.translate('xpack.reporting.listing.table.openInKibanaAppLabel', { + name: i18n.translate('xpack.reporting.exports.table.openInKibanaAppLabel', { defaultMessage: 'Open in Kibana', }), 'data-test-subj': 'reportOpenInKibanaApp', description: i18n.translate( - 'xpack.reporting.listing.table.openInKibanaAppDescription', + 'xpack.reporting.exports.table.openInKibanaAppDescription', { defaultMessage: 'Open the Kibana app where this report was generated.', } @@ -386,7 +404,7 @@ export class ReportListingTable extends Component { pageIndex: this.state.page, pageSize: 10, totalItemCount: this.state.total, - showPerPageOptions: false, + showPerPageOptions: true, }; const selection = { @@ -396,6 +414,7 @@ export class ReportListingTable extends Component { return ( + {this.state.selectedJobs.length > 0 && (
@@ -405,22 +424,14 @@ export class ReportListingTable extends Component {
)} { ); } } + +// eslint-disable-next-line import/no-default-export +export { ReportExportsTable as default }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx new file mode 100644 index 0000000000000..bcdf1920337bf --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedule_indicator.tsx @@ -0,0 +1,61 @@ +/* + * 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 React, { FC } from 'react'; +import { i18n } from '@kbn/i18n'; +import { Frequency } from '@kbn/rrule'; +import { EuiBadge, EuiFlexGroup, EuiFlexItem, EuiIconTip } from '@elastic/eui'; +import { ScheduledReport } from '@kbn/reporting-common/types'; + +interface ReportScheduleIndicatorProps { + schedule: ScheduledReport['schedule']; +} + +export const ReportScheduleIndicator: FC = ({ schedule }) => { + if (!schedule || !schedule.rrule) { + return null; + } + + let statusText: string; + + switch (schedule.rrule.freq) { + case Frequency.DAILY: + statusText = i18n.translate('xpack.reporting.schedules.scheduleIndicator.daily', { + defaultMessage: 'Daily', + }); + break; + case Frequency.WEEKLY: + statusText = i18n.translate('xpack.reporting.schedules.scheduleIndicator.weekly', { + defaultMessage: 'Weekly', + }); + break; + case Frequency.MONTHLY: + statusText = i18n.translate('xpack.reporting.schedules.scheduleIndicator.monthly', { + defaultMessage: 'Monthly', + }); + break; + default: + statusText = i18n.translate('xpack.reporting.schedules.scheduleIndicator.unknown', { + defaultMessage: 'Unknown', + }); + } + + return ( + + + + + + {statusText} + + + ); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx new file mode 100644 index 0000000000000..5cbbda7732e80 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.test.tsx @@ -0,0 +1,198 @@ +/* + * 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 { + applicationServiceMock, + coreMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { render, screen, waitFor } from '@testing-library/react'; +import { ReportingAPIClient } from '@kbn/reporting-public'; +import { Observable } from 'rxjs'; +import { ILicense } from '@kbn/licensing-plugin/public'; +import { SharePluginSetup } from '@kbn/share-plugin/public'; +import { mockConfig } from '../__test__/report_listing.test.helpers'; +import React from 'react'; +import { RecursivePartial, UseEuiTheme } from '@elastic/eui'; +import ReportSchedulesTable from './report_schedules_table'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { useGetScheduledList } from '../hooks/use_get_scheduled_list'; +import { mockScheduledReports } from '../../../common/test/fixtures'; +import { userEvent } from '@testing-library/user-event'; +import { useBulkDisable } from '../hooks/use_bulk_disable'; + +jest.mock('../hooks/use_get_scheduled_list', () => ({ + useGetScheduledList: jest.fn(), +})); +jest.mock('../hooks/use_bulk_disable'); + +const useBulkDisableMock = useBulkDisable as jest.Mock; + +const coreStart = coreMock.createStart(); +const http = httpServiceMock.createSetupContract(); +const uiSettingsClient = coreMock.createSetup().uiSettings; +const httpService = httpServiceMock.createSetupContract(); +const application = applicationServiceMock.createStartContract(); +const reportingAPIClient = new ReportingAPIClient(httpService, uiSettingsClient, 'x.x.x'); +const validCheck = { + check: () => ({ + state: 'VALID', + message: '', + }), +}; +const license$ = { + subscribe: (handler: unknown) => { + return (handler as Function)(validCheck); + }, +} as Observable; + +export const getMockTheme = (partialTheme: RecursivePartial): UseEuiTheme => + partialTheme as UseEuiTheme; + +const defaultProps = { + coreStart, + http, + application, + apiClient: reportingAPIClient, + config: mockConfig, + license$, + urlService: {} as unknown as SharePluginSetup['url'], + toasts: notificationServiceMock.createSetupContract().toasts, + capabilities: application.capabilities, + redirect: application.navigateToApp, + navigateToUrl: application.navigateToUrl, +}; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + cacheTime: 0, + }, + }, +}); + +describe('ReportSchedulesTable', () => { + const bulkDisableScheduledReportsMock = jest.fn(); + useBulkDisableMock.mockReturnValue({ + isLoading: false, + mutateAsync: bulkDisableScheduledReportsMock, + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders table correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 0, + size: 10, + total: 0, + data: [], + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findByTestId('reportSchedulesTable')).toBeInTheDocument(); + }); + + it('renders empty state correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 0, + size: 10, + total: 0, + data: [], + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findByText('No reports have been created')).toBeInTheDocument(); + }); + + it('renders data correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + expect(await screen.findByText(mockScheduledReports[0].title)).toBeInTheDocument(); + expect(await screen.findAllByText('Active')).toHaveLength(2); + expect(await screen.findAllByText('Disabled')).toHaveLength(1); + }); + + it('disable schedule report correctly', async () => { + (useGetScheduledList as jest.Mock).mockReturnValueOnce({ + data: { + page: 3, + size: 10, + total: 3, + data: mockScheduledReports, + }, + isLoading: false, + }); + + render( + + + + + + ); + + expect(await screen.findAllByTestId('scheduledReportRow')).toHaveLength(3); + + userEvent.click((await screen.findAllByTestId('euiCollapsedItemActionsButton'))[0]); + + const firstReportDisable = await screen.findByTestId( + `reportDisableSchedule-${mockScheduledReports[0].id}` + ); + + expect(firstReportDisable).toBeInTheDocument(); + + userEvent.click(firstReportDisable); + + await waitFor(() => { + expect(bulkDisableScheduledReportsMock).toHaveBeenCalledWith({ + ids: [mockScheduledReports[0].id], + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx new file mode 100644 index 0000000000000..526fe1ba7a610 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/report_schedules_table.tsx @@ -0,0 +1,268 @@ +/* + * 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 { Fragment, default as React, useCallback, useState } from 'react'; +import { + EuiAvatar, + EuiBasicTable, + EuiBasicTableColumn, + EuiFlexGroup, + EuiFlexItem, + EuiHealth, + EuiIconTip, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +import moment from 'moment'; +import { stringify } from 'query-string'; +import { REPORTING_REDIRECT_APP, buildKibanaPath } from '@kbn/reporting-common'; +import { type ListScheduledReportApiJSON } from '../../../server/types'; +import { ListingPropsInternal } from '..'; +import { guessAppIconTypeFromObjectType, getDisplayNameFromObjectType } from '../utils'; +import { useGetScheduledList } from '../hooks/use_get_scheduled_list'; +import { prettyPrintJobType } from '../../../common/job_utils'; +import { ReportScheduleIndicator } from './report_schedule_indicator'; +import { useBulkDisable } from '../hooks/use_bulk_disable'; +import { NO_CREATED_REPORTS_DESCRIPTION } from '../../translations'; + +interface QueryParams { + index: number; + size: number; +} + +export const ReportSchedulesTable = (props: ListingPropsInternal) => { + const { http, toasts } = props; + + const [queryParams, setQueryParams] = useState({ + index: 1, + size: 10, + }); + const { data: scheduledList, isLoading } = useGetScheduledList({ + http, + ...queryParams, + }); + + const { mutateAsync: bulkDisableScheduledReports } = useBulkDisable({ + http, + toasts, + }); + + const tableColumns: Array> = [ + { + field: 'object_type', + name: i18n.translate('xpack.reporting.schedules.tableColumns.typeTitle', { + defaultMessage: 'Type', + }), + width: '5%', + render: (_objectType: string) => ( + + ), + }, + { + field: 'title', + name: i18n.translate('xpack.reporting.schedules.tableColumns.reportTitle', { + defaultMessage: 'Title', + }), + width: '20%', + render: (_title: string, item: ListScheduledReportApiJSON) => ( + {}}> + {_title} + + ), + mobileOptions: { + header: false, + width: '100%', + }, + }, + { + field: 'status', + name: i18n.translate('xpack.reporting.schedules.tableColumns.statusTitle', { + defaultMessage: 'Status', + }), + width: '10%', + render: (_status: string, item: ListScheduledReportApiJSON) => { + return ( + + {item.enabled + ? i18n.translate('xpack.reporting.schedules.status.active', { + defaultMessage: 'Active', + }) + : i18n.translate('xpack.reporting.schedules.status.disabled', { + defaultMessage: 'Disabled', + })} + + ); + }, + }, + { + field: 'schedule', + name: i18n.translate('xpack.reporting.schedules.tableColumns.scheduleTitle', { + defaultMessage: 'Schedule', + }), + width: '15%', + render: (_schedule: ListScheduledReportApiJSON['schedule']) => ( + + ), + }, + { + field: 'next_run', + name: i18n.translate('xpack.reporting.schedules.tableColumns.nextScheduleTitle', { + defaultMessage: 'Next schedule', + }), + width: '20%', + render: (_nextRun: string) => { + return moment(_nextRun).format('YYYY-MM-DD @ hh:mm A'); + }, + }, + { + field: 'jobtype', + width: '10%', + name: i18n.translate('xpack.reporting.schedules.tableColumns.fileType', { + defaultMessage: 'File Type', + }), + render: (_jobtype: string) => prettyPrintJobType(_jobtype), + mobileOptions: { + show: false, + }, + }, + { + field: 'created_by', + name: i18n.translate('xpack.reporting.schedules.tableColumns.createdByTitle', { + defaultMessage: 'Created by', + }), + width: '15%', + render: (_createdBy: string) => { + return ( + + + + + + + {_createdBy} + + + + ); + }, + }, + { + field: 'actions', + name: i18n.translate('xpack.reporting.schedules.tableColumns.actionsTitle', { + defaultMessage: 'Actions', + }), + width: '5%', + actions: [ + { + name: i18n.translate('xpack.reporting.schedules.table.viewConfig.title', { + defaultMessage: 'View schedule config', + }), + description: i18n.translate('xpack.reporting.schedules.table.viewConfig.description', { + defaultMessage: 'View schedule configuration details', + }), + 'data-test-subj': (item) => `reportViewConfig-${item.id}`, + type: 'icon', + icon: 'calendar', + onClick: () => {}, + }, + { + name: i18n.translate('xpack.reporting.schedules.table.openDashboard.title', { + defaultMessage: 'Open Dashboard', + }), + description: i18n.translate('xpack.reporting.schedules.table.openDashboard.description', { + defaultMessage: 'Open associated dashboard', + }), + 'data-test-subj': (item) => `reportOpenDashboard-${item.id}`, + type: 'icon', + icon: 'dashboardApp', + // available: (job) => job.canLinkToKibanaApp, + onClick: (item) => { + const searchParams = stringify({ id: item.id }); + + const path = buildKibanaPath({ + basePath: http.basePath.serverBasePath, + // spaceId: job.spaceId, + appPath: REPORTING_REDIRECT_APP, + }); + + const href = `${path}?${searchParams}`; + window.open(href, '_blank'); + window.focus(); + }, + }, + { + name: i18n.translate('xpack.reporting.schedules.table.disableSchedule.title', { + defaultMessage: 'Disable schedule', + }), + description: i18n.translate( + 'xpack.reporting.schedules.table.disableSchedule.description', + { + defaultMessage: 'Disable report schedule', + } + ), + 'data-test-subj': (item) => `reportDisableSchedule-${item.id}`, + enabled: (item) => item.enabled, + type: 'icon', + icon: 'cross', + onClick: (item) => { + bulkDisableScheduledReports({ ids: [item.id] }); + }, + }, + ], + }, + ]; + + const tableOnChangeCallback = useCallback( + ({ page }: { page: QueryParams }) => { + setQueryParams((prev) => ({ + ...prev, + index: page.index + 1, + size: page.size, + })); + }, + [setQueryParams] + ); + + return ( + + + ({ 'data-test-subj': 'scheduledReportRow' })} + /> + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ReportSchedulesTable as default }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx new file mode 100644 index 0000000000000..dcbb0013810f7 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.test.tsx @@ -0,0 +1,271 @@ +/* + * 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 * as React from 'react'; +import { render, screen } from '@testing-library/react'; +import { RouteComponentProps } from 'react-router-dom'; +import { Router } from '@kbn/shared-ux-router'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import { createMemoryHistory, createLocation } from 'history'; + +import ReportingTabs, { MatchParams, ReportingTabsProps } from './reporting_tabs'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + applicationServiceMock, + coreMock, + httpServiceMock, + notificationServiceMock, +} from '@kbn/core/public/mocks'; +import { InternalApiClientProvider, ReportingAPIClient } from '@kbn/reporting-public'; +import { Observable } from 'rxjs'; +import { ILicense } from '@kbn/licensing-plugin/public'; +import { LocatorPublic, SharePluginSetup } from '@kbn/share-plugin/public'; +import { SerializableRecord } from '@kbn/utility-types'; +import { ReportDiagnostic } from './report_diagnostic'; +import { mockConfig } from '../__test__/report_listing.test.helpers'; +import { dataPluginMock } from '@kbn/data-plugin/public/mocks'; +import { sharePluginMock } from '@kbn/share-plugin/public/mocks'; +import { EuiThemeProvider } from '@elastic/eui'; +import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import { IlmPolicyStatusContextProvider } from '../../lib/ilm_policy_status_context'; +import { dataService } from '@kbn/controls-plugin/public/services/kibana_services'; +import { shareService } from '@kbn/dashboard-plugin/public/services/kibana_services'; +import { IlmPolicyMigrationStatus } from '@kbn/reporting-common/types'; +import { HttpSetupMock } from '@kbn/core-http-browser-mocks'; +import { act } from 'react-dom/test-utils'; + +jest.mock('./report_exports_table', () => { + return () =>
{'Render Report Exports Table'}
; +}); + +jest.mock('./report_schedules_table', () => { + return () =>
{'Render Report Schedules Table'}
; +}); + +const queryClient = new QueryClient(); + +describe('Reporting tabs', () => { + const ilmLocator: LocatorPublic = { + getUrl: jest.fn(), + } as unknown as LocatorPublic; + const http = httpServiceMock.createSetupContract(); + const uiSettingsClient = coreMock.createSetup().uiSettings; + const httpService = httpServiceMock.createSetupContract(); + const application = applicationServiceMock.createStartContract(); + const reportingAPIClient = new ReportingAPIClient(httpService, uiSettingsClient, 'x.x.x'); + const validCheck = { + check: () => ({ + state: 'VALID', + message: '', + }), + }; + const license$ = { + subscribe: (handler: unknown) => { + return (handler as Function)(validCheck); + }, + } as Observable; + + const reportDiagnostic = () => ( + + ); + + const routeProps: RouteComponentProps = { + history: createMemoryHistory({ + initialEntries: ['/exports'], + }), + location: createLocation('/exports'), + match: { + isExact: true, + path: `/exports`, + url: '', + params: { + section: 'exports', + }, + }, + }; + + const props = { + ...routeProps, + coreStart: coreMock.createStart(), + http, + application, + apiClient: reportingAPIClient, + config: mockConfig, + license$, + urlService: { + locators: { + get: () => ilmLocator, + }, + } as unknown as SharePluginSetup['url'], + toasts: notificationServiceMock.createSetupContract().toasts, + ilmLocator, + uiSettings: uiSettingsClient, + reportDiagnostic, + dataService: dataPluginMock.createStartContract(), + shareService: sharePluginMock.createStartContract(), + }; + + const renderComponent = ( + renderProps: Partial & ReportingTabsProps, + newHttpService?: HttpSetupMock + ) => { + const updatedReportingAPIClient = newHttpService + ? new ReportingAPIClient(newHttpService, uiSettingsClient, 'x.x.x') + : reportingAPIClient; + return ( + + + + + + + + + + + + + + + + ); + }; + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('renders exports components', async () => { + await act(async () => render(renderComponent(props))); + + expect(await screen.findByTestId('reportingTabs-exports')).toBeInTheDocument(); + expect(await screen.findByTestId('reportingTabs-schedules')).toBeInTheDocument(); + }); + + it('shows the correct number of tabs', async () => { + const updatedProps: RouteComponentProps = { + history: createMemoryHistory(), + location: createLocation('/'), + match: { + isExact: true, + path: `/schedules`, + url: '', + params: { + section: 'schedules', + }, + }, + }; + + await act(async () => { + render(renderComponent({ ...props, ...updatedProps })); + }); + + expect(await screen.findAllByRole('tab')).toHaveLength(2); + }); + + describe('ILM policy', () => { + it('shows ILM policy link correctly when config is stateful', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + + application.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: true } }, + }; + + const updatedShareService = { + ...sharePluginMock.createStartContract(), + url: { + ...sharePluginMock.createStartContract().url, + locators: { + ...sharePluginMock.createStartContract().url.locators, + id: 'ILM_LOCATOR_ID', + get: () => ilmLocator, + }, + }, + }; + + await act(async () => { + // @ts-expect-error we don't need to provide all props for the test + render(renderComponent({ ...props, shareService: updatedShareService })); + }); + + expect(await screen.findByTestId('ilmPolicyLink')).toBeInTheDocument(); + }); + + it('hides ILM policy link correctly for non stateful config', async () => { + const status: IlmPolicyMigrationStatus = 'ok'; + httpService.get.mockResolvedValue({ status }); + + application.capabilities = { + catalogue: {}, + navLinks: {}, + management: { data: { index_lifecycle_management: true } }, + }; + + const updatedShareService = { + ...sharePluginMock.createStartContract(), + url: { + ...sharePluginMock.createStartContract().url, + locators: { + ...sharePluginMock.createStartContract().url.locators, + id: 'ILM_LOCATOR_ID', + get: () => ilmLocator, + }, + }, + }; + const newConfig = { ...mockConfig, statefulSettings: { enabled: false } }; + + await act(async () => { + // @ts-expect-error we don't need to provide all props for the test + render(renderComponent({ ...props, shareService: updatedShareService, config: newConfig })); + }); + + expect(screen.queryByTestId('ilmPolicyLink')).not.toBeInTheDocument(); + }); + }); + + describe('Screenshotting Diagnostic', () => { + it('shows screenshotting diagnostic link if config is stateful', async () => { + await act(async () => { + render(renderComponent(props)); + }); + + expect(await screen.findByTestId('screenshotDiagnosticLink')).toBeInTheDocument(); + }); + it('does not show when image reporting not set in config', async () => { + const mockNoImageConfig = { + ...mockConfig, + export_types: { + csv: { enabled: true }, + pdf: { enabled: false }, + png: { enabled: false }, + }, + }; + + await act(async () => { + render( + renderComponent({ + ...props, + config: mockNoImageConfig, + }) + ); + }); + + expect(screen.queryByTestId('screenshotDiagnosticLink')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx new file mode 100644 index 0000000000000..b18ed60cc8013 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/reporting_tabs.tsx @@ -0,0 +1,202 @@ +/* + * 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 React, { useCallback } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiLoadingSpinner, EuiPageTemplate } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import { Route, Routes } from '@kbn/shared-ux-router'; +import { RouteComponentProps } from 'react-router-dom'; +import { CoreStart, ScopedHistory } from '@kbn/core/public'; +import { LicensingPluginStart } from '@kbn/licensing-plugin/public'; +import { DataPublicPluginStart } from '@kbn/data-plugin/public'; +import { + ClientConfigType, + ReportingAPIClient, + useInternalApiClient, + useKibana, +} from '@kbn/reporting-public'; +import { SharePluginStart } from '@kbn/share-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { suspendedComponentWithProps } from './suspended_component_with_props'; +import { REPORTING_EXPORTS_PATH, REPORTING_SCHEDULES_PATH, Section } from '../../constants'; +import ReportExportsTable from './report_exports_table'; +import { IlmPolicyLink } from './ilm_policy_link'; +import { ReportDiagnostic } from './report_diagnostic'; +import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; +import { MigrateIlmPolicyCallOut } from './migrate_ilm_policy_callout'; +import ReportSchedulesTable from './report_schedules_table'; + +export interface MatchParams { + section: Section; +} + +export interface ReportingTabsProps { + coreStart: CoreStart; + license$: LicensingPluginStart['license$']; + dataService: DataPublicPluginStart; + shareService: SharePluginStart; + config: ClientConfigType; + apiClient: ReportingAPIClient; +} + +export const ReportingTabs: React.FunctionComponent< + Partial & ReportingTabsProps +> = (props) => { + const { coreStart, license$, shareService, config, ...rest } = props; + const { notifications } = coreStart; + const { section } = rest.match?.params as MatchParams; + const history = rest.history as ScopedHistory; + const { apiClient } = useInternalApiClient(); + const { + services: { + application: { capabilities, navigateToApp, navigateToUrl }, + http, + }, + } = useKibana(); + + const ilmLocator = shareService.url.locators.get('ILM_LOCATOR_ID'); + const ilmPolicyContextValue = useIlmPolicyStatus(); + const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; + const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); + + const tabs = [ + { + id: 'exports', + name: i18n.translate('xpack.reporting.tabs.exports', { + defaultMessage: 'Exports', + }), + }, + { + id: 'schedules', + name: i18n.translate('xpack.reporting.tabs.schedules', { + defaultMessage: 'Schedules', + }), + }, + ]; + + const renderExportsList = useCallback(() => { + return suspendedComponentWithProps( + ReportExportsTable, + 'xl' + )({ + apiClient, + toasts: notifications.toasts, + license$, + config, + capabilities, + redirect: navigateToApp, + navigateToUrl, + urlService: shareService.url, + http, + }); + }, [ + apiClient, + notifications.toasts, + license$, + config, + capabilities, + navigateToApp, + navigateToUrl, + shareService.url, + http, + ]); + + const renderSchedulesList = useCallback(() => { + return ( + + {suspendedComponentWithProps( + ReportSchedulesTable, + 'xl' + )({ + apiClient, + toasts: notifications.toasts, + license$, + config, + capabilities, + redirect: navigateToApp, + navigateToUrl, + urlService: shareService.url, + http, + })} + + ); + }, [ + apiClient, + notifications.toasts, + license$, + config, + capabilities, + navigateToApp, + navigateToUrl, + shareService.url, + http, + ]); + + const onSectionChange = (newSection: Section) => { + history.push(`/${newSection}`); + }; + + return ( + <> + , + + + , + + {capabilities?.management?.data?.index_lifecycle_management && ( + + {ilmPolicyContextValue?.isLoading ? ( + + ) : ( + showIlmPolicyLink && ( + + ) + )} + + )} + , + ] + : [] + } + data-test-subj="reportingPageHeader" + pageTitle={ + + } + description={ + + } + tabs={tabs.map(({ id, name }) => ({ + label: name, + onClick: () => onSectionChange(id as Section), + isSelected: id === section, + key: id, + 'data-test-subj': `reportingTabs-${id}`, + }))} + /> + + + + + + + ); +}; + +// eslint-disable-next-line import/no-default-export +export { ReportingTabs as default }; diff --git a/x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx b/x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx new file mode 100644 index 0000000000000..994aa399b177d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/components/suspended_component_with_props.tsx @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { Suspense } from 'react'; +import { EuiLoadingSpinner } from '@elastic/eui'; + +export function suspendedComponentWithProps( + ComponentToSuspend: React.ComponentType, + size?: 's' | 'm' | 'l' | 'xl' | 'xxl' +) { + return (props: T) => ( + }> + {/* @ts-expect-error upgrade typescript v4.9.5*/} + + + ); +} diff --git a/x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx b/x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx deleted file mode 100644 index a2b09ccd3c177..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/default/report_listing_default.tsx +++ /dev/null @@ -1,50 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; - -import { EuiPageHeader, EuiSpacer } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { ListingPropsInternal } from '..'; -import { ReportListingTable } from '../report_listing_table'; - -/** - * Used in non-stateful (Serverless) - * Does not render controls for features only applicable in Stateful - */ -export const ReportListingDefault: FC = (props) => { - const { apiClient, capabilities, config, navigateToUrl, toasts, urlService, ...listingProps } = - props; - return ( - <> - - } - description={ - - } - /> - - - - ); -}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx new file mode 100644 index 0000000000000..7cfd82a51fa17 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.test.tsx @@ -0,0 +1,76 @@ +/* + * 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 React from 'react'; +import { httpServiceMock, notificationServiceMock } from '@kbn/core/public/mocks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { useBulkDisable } from './use_bulk_disable'; +import { bulkDisableScheduledReports } from '../apis/bulk_disable_scheduled_reports'; + +jest.mock('../apis/bulk_disable_scheduled_reports', () => ({ + bulkDisableScheduledReports: jest.fn(), +})); + +describe('useBulkDisable', () => { + const http = httpServiceMock.createStartContract(); + const toasts = notificationServiceMock.createStartContract().toasts; + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls bulkDisableScheduledReports with correct arguments', async () => { + (bulkDisableScheduledReports as jest.Mock).mockResolvedValueOnce({ + scheduled_report_ids: ['random_schedule_report_1'], + errors: [], + total: 1, + }); + + const { result } = renderHook(() => useBulkDisable({ http, toasts }), { + wrapper, + }); + + result.current.mutate({ ids: ['random_schedule_report_1'] }); + + await waitFor(() => { + expect(bulkDisableScheduledReports).toBeCalledWith({ + http, + ids: ['random_schedule_report_1'], + }); + expect(result.current.data).toEqual({ + scheduled_report_ids: ['random_schedule_report_1'], + errors: [], + total: 1, + }); + expect(toasts.addSuccess).toHaveBeenCalled(); + }); + }); + + it('throws error', async () => { + (bulkDisableScheduledReports as jest.Mock).mockRejectedValueOnce({}); + + const { result } = renderHook(() => useBulkDisable({ http, toasts }), { + wrapper, + }); + + result.current.mutate({ ids: [] }); + + await waitFor(() => { + expect(bulkDisableScheduledReports).toBeCalledWith({ + http, + ids: [], + }); + expect(toasts.addError).toHaveBeenCalled(); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx new file mode 100644 index 0000000000000..b0ab70d05093d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_bulk_disable.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { HttpSetup, IHttpFetchError, ResponseErrorBody, ToastsStart } from '@kbn/core/public'; +import { i18n } from '@kbn/i18n'; +import { bulkDisableScheduledReports } from '../apis/bulk_disable_scheduled_reports'; +import { mutationKeys, queryKeys } from '../query_keys'; + +export type ServerError = IHttpFetchError; + +const getKey = mutationKeys.bulkDisableScheduledReports; + +export const useBulkDisable = (props: { http: HttpSetup; toasts: ToastsStart }) => { + const { http, toasts } = props; + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: getKey(), + mutationFn: ({ ids }: { ids: string[] }) => + bulkDisableScheduledReports({ + http, + ids, + }), + onError: (error: ServerError) => { + toasts.addError(error, { + title: i18n.translate('xpack.reporting.schedules.reports.disableError', { + defaultMessage: 'Error disabling scheduled report', + }), + }); + }, + onSuccess: () => { + toasts.addSuccess( + i18n.translate('xpack.reporting.schedules.reports.disabled', { + defaultMessage: 'Scheduled report disabled', + }) + ); + queryClient.invalidateQueries({ + queryKey: queryKeys.getScheduledList({}), + refetchType: 'active', + }); + }, + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx new file mode 100644 index 0000000000000..8a7fff87387e7 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.test.tsx @@ -0,0 +1,48 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { httpServiceMock } from '@kbn/core/public/mocks'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { renderHook, waitFor } from '@testing-library/react'; +import { getScheduledReportsList } from '../apis/get_scheduled_reports_list'; +import { useGetScheduledList } from './use_get_scheduled_list'; + +jest.mock('../apis/get_scheduled_reports_list', () => ({ + getScheduledReportsList: jest.fn(), +})); + +describe('useGetScheduledList', () => { + const http = httpServiceMock.createStartContract(); + const queryClient = new QueryClient(); + + const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('calls getScheduledList with correct arguments', async () => { + (getScheduledReportsList as jest.Mock).mockResolvedValueOnce({ data: [] }); + + const { result } = renderHook(() => useGetScheduledList({ http, index: 1, size: 10 }), { + wrapper, + }); + + await waitFor(() => { + expect(result.current.data).toEqual({ data: [] }); + }); + + expect(getScheduledReportsList).toBeCalledWith({ + http, + index: 1, + size: 10, + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx new file mode 100644 index 0000000000000..34cde02bcb0c8 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/hooks/use_get_scheduled_list.tsx @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useQuery } from '@tanstack/react-query'; +import { HttpSetup } from '@kbn/core/public'; +import { getScheduledReportsList } from '../apis/get_scheduled_reports_list'; +import { queryKeys } from '../query_keys'; + +export const getKey = queryKeys.getScheduledList; + +interface GetScheduledListQueryProps { + http: HttpSetup; + index?: number; + size?: number; +} + +export const useGetScheduledList = (props: GetScheduledListQueryProps) => { + const { index = 1, size = 10 } = props; + return useQuery({ + queryKey: getKey({ index, size }), + queryFn: () => getScheduledReportsList(props), + keepPreviousData: true, + }); +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/index.ts b/x-pack/platform/plugins/private/reporting/public/management/index.ts index 09c4517e304e0..9bc608a4a8fb6 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/index.ts +++ b/x-pack/platform/plugins/private/reporting/public/management/index.ts @@ -5,7 +5,7 @@ * 2.0. */ -import type { ApplicationStart, ToastsStart } from '@kbn/core/public'; +import type { ApplicationStart, HttpSetup, ToastsStart } from '@kbn/core/public'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/public'; import type { ClientConfigType, ReportingAPIClient } from '@kbn/reporting-public'; import type { SharePluginStart } from '@kbn/share-plugin/public'; @@ -21,6 +21,7 @@ export interface ListingProps { export type ListingPropsInternal = ListingProps & { capabilities: ApplicationStart['capabilities']; + http: HttpSetup; }; -export { ReportListing } from './report_listing'; +export { ReportingTabs } from './components/reporting_tabs'; diff --git a/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx b/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx index 4352557e57617..a989113ffe3e3 100644 --- a/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx +++ b/x-pack/platform/plugins/private/reporting/public/management/mount_management_section.tsx @@ -5,8 +5,8 @@ * 2.0. */ -import * as React from 'react'; -import { render, unmountComponentAtNode } from 'react-dom'; +import React, { Suspense, lazy } from 'react'; +import ReactDOM from 'react-dom'; import type { CoreStart } from '@kbn/core/public'; import type { DataPublicPluginStart } from '@kbn/data-plugin/public'; @@ -21,9 +21,17 @@ import { ReportingAPIClient, KibanaContext, } from '@kbn/reporting-public'; -import { ReportListing } from '.'; +import { Route, Router, Routes } from '@kbn/shared-ux-router'; +import { EuiLoadingSpinner } from '@elastic/eui'; +import { Redirect } from 'react-router-dom'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { Section } from '../constants'; import { PolicyStatusContextProvider } from '../lib/default_status_context'; +const ReportingTabs = lazy(() => import('./components/reporting_tabs')); + +const queryClient = new QueryClient(); + export async function mountManagementSection( coreStart: CoreStart, license$: LicensingPluginStart['license$'], @@ -41,29 +49,49 @@ export async function mountManagementSection( data: dataService, share: shareService, }; + const sections: Section[] = ['exports', 'schedules']; + const { element, history } = params; + + const sectionsRegex = sections.join('|'); - render( + ReactDOM.render( - + + + + { + return ( + }> + + + ); + }} + /> + + + + , - params.element + element ); return () => { - unmountComponentAtNode(params.element); + ReactDOM.unmountComponentAtNode(element); }; } diff --git a/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts new file mode 100644 index 0000000000000..d813c5d02b6b3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/management/query_keys.ts @@ -0,0 +1,15 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +const root = 'reporting'; +export const queryKeys = { + getScheduledList: (params: unknown) => [root, 'scheduledList', params] as const, +}; + +export const mutationKeys = { + bulkDisableScheduledReports: () => [root, 'bulkDisableScheduledReports'] as const, +}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts b/x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts deleted file mode 100644 index 8f0dd9bf17a7f..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/report_listing.test.ts +++ /dev/null @@ -1,289 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { act } from 'react-dom/test-utils'; -import type { Observable } from 'rxjs'; - -import type { ILicense } from '@kbn/licensing-plugin/public'; -import { IlmPolicyMigrationStatus } from '@kbn/reporting-common/types'; - -import { ListingProps as Props } from '.'; -import { mockJobs } from '../../common/test'; -import { TestBed, TestDependencies, setup } from './__test__'; -import { mockConfig } from './__test__/report_listing.test.helpers'; -import { Job } from '@kbn/reporting-public'; - -describe('ReportListing', () => { - let testBed: TestBed; - let applicationService: TestDependencies['application']; - - const runSetup = async (props?: Partial) => { - await act(async () => { - testBed = await setup(props); - }); - testBed.component.update(); - }; - - beforeEach(async () => { - await runSetup(); - // Collect all of the injected services so we can mutate for the tests - applicationService = testBed.testDependencies.application; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('renders a listing with some items', () => { - const { find } = testBed; - expect(find('reportJobRow').length).toBe(mockJobs.length); - }); - - it('subscribes to license changes, and unsubscribes on dismount', async () => { - const unsubscribeMock = jest.fn(); - const subMock = { - subscribe: jest.fn().mockReturnValue({ - unsubscribe: unsubscribeMock, - }), - } as unknown as Observable; - - await runSetup({ license$: subMock }); - - expect(subMock.subscribe).toHaveBeenCalled(); - expect(unsubscribeMock).not.toHaveBeenCalled(); - testBed.component.unmount(); - expect(unsubscribeMock).toHaveBeenCalled(); - }); - - it('navigates to a Kibana App in a new tab and is spaces aware', () => { - const { find } = testBed; - - jest.spyOn(window, 'open').mockImplementation(jest.fn()); - jest.spyOn(window, 'focus').mockImplementation(jest.fn()); - - find('euiCollapsedItemActionsButton').first().simulate('click'); - find('reportOpenInKibanaApp').first().simulate('click'); - - expect(window.open).toHaveBeenCalledWith( - '/s/my-space/app/reportingRedirect?jobId=k90e51pk1ieucbae0c3t8wo2', - '_blank' - ); - }); - - describe('flyout', () => { - let reportingAPIClient: TestDependencies['reportingAPIClient']; - let jobUnderTest: Job; - - beforeEach(async () => { - await runSetup(); - reportingAPIClient = testBed.testDependencies.reportingAPIClient; - jest.spyOn(reportingAPIClient, 'getInfo').mockResolvedValue(jobUnderTest); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('shows the enabled "open in Kibana" button in the actions menu for v2 jobs', async () => { - const [jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findOpenInAppButton().props().disabled).toBe(false); - }); - - it('shows the disabled "open in Kibana" button in the actions menu for pre-v2 jobs', async () => { - const [, jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findOpenInAppButton().props().disabled).toBe(true); - }); - - it('shows the disabled "Download" button in the actions menu for a job that is not done', async () => { - const [jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findDownloadButton().props().disabled).toBe(true); - }); - - it('shows the enabled "Download" button in the actions menu for a job is done', async () => { - const [, , jobJson] = mockJobs; - jobUnderTest = new Job(jobJson); - const { actions } = testBed; - - await actions.flyout.open(jobUnderTest.id); - actions.flyout.openActionsMenu(); - expect(actions.flyout.findDownloadButton().props().disabled).toBe(false); - }); - }); - - describe('ILM policy', () => { - let httpService: TestDependencies['http']; - let urlService: TestDependencies['urlService']; - let toasts: TestDependencies['toasts']; - let reportingAPIClient: TestDependencies['reportingAPIClient']; - - /** - * Simulate a fresh page load, useful for network requests and other effects - * that happen only at first load. - */ - const remountComponent = async () => { - const { component } = testBed; - act(() => { - component.unmount(); - }); - await act(async () => { - component.mount(); - }); - // Flush promises - await new Promise((r) => setImmediate(r)); - component.update(); - }; - - beforeEach(async () => { - await runSetup(); - // Collect all of the injected services so we can mutate for the tests - applicationService = testBed.testDependencies.application; - applicationService.capabilities = { - catalogue: {}, - navLinks: {}, - management: { data: { index_lifecycle_management: true } }, - }; - httpService = testBed.testDependencies.http; - urlService = testBed.testDependencies.urlService; - toasts = testBed.testDependencies.toasts; - reportingAPIClient = testBed.testDependencies.reportingAPIClient; - }); - - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('shows the migrate banner when migration status is not "OK"', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmMigrationBanner()).toBe(true); - }); - - it('does not show the migrate banner when migration status is "OK"', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'ok'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmMigrationBanner()).toBe(false); - }); - - it('hides the ILM policy link if there is no ILM policy', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'policy-not-found'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(false); - }); - - it('hides the ILM policy link if there is no ILM policy locator', async () => { - const { actions } = testBed; - jest.spyOn(urlService.locators, 'get').mockReturnValue(undefined); - const status: IlmPolicyMigrationStatus = 'ok'; // should never happen, but need to test that when the locator is missing we don't render the link - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(false); - }); - - it('always shows the ILM policy link if there is an ILM policy', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'ok'; - httpService.get.mockResolvedValue({ status }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(true); - - const status2: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - httpService.get.mockResolvedValue({ status: status2 }); - await remountComponent(); - expect(actions.hasIlmPolicyLink()).toBe(true); - }); - - it('hides the banner after migrating indices', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - const status2: IlmPolicyMigrationStatus = 'ok'; - httpService.get.mockResolvedValueOnce({ status }); - httpService.get.mockResolvedValueOnce({ status: status2 }); - await remountComponent(); - - expect(actions.hasIlmMigrationBanner()).toBe(true); - await actions.migrateIndices(); - expect(actions.hasIlmMigrationBanner()).toBe(false); - expect(actions.hasIlmPolicyLink()).toBe(true); - expect(toasts.addSuccess).toHaveBeenCalledTimes(1); - }); - - it('informs users when migrations failed', async () => { - const { actions } = testBed; - const status: IlmPolicyMigrationStatus = 'indices-not-managed-by-policy'; - httpService.get.mockResolvedValueOnce({ status }); - (reportingAPIClient.migrateReportingIndicesIlmPolicy as jest.Mock).mockRejectedValueOnce( - new Error('oops!') - ); - await remountComponent(); - - expect(actions.hasIlmMigrationBanner()).toBe(true); - await actions.migrateIndices(); - expect(toasts.addError).toHaveBeenCalledTimes(1); - expect(actions.hasIlmMigrationBanner()).toBe(true); - expect(actions.hasIlmPolicyLink()).toBe(true); - }); - - it('only shows the link to the ILM policy if UI capabilities allow it', async () => { - applicationService.capabilities = { - catalogue: {}, - navLinks: {}, - management: { data: { index_lifecycle_management: false } }, - }; - await remountComponent(); - - expect(testBed.actions.hasIlmPolicyLink()).toBe(false); - - applicationService.capabilities = { - catalogue: {}, - navLinks: {}, - management: { data: { index_lifecycle_management: true } }, - }; - - await remountComponent(); - - expect(testBed.actions.hasIlmPolicyLink()).toBe(true); - }); - }); - describe('Screenshotting Diagnostic', () => { - it('shows screenshotting diagnostic link if config enables image reports', () => { - expect(testBed.actions.hasScreenshotDiagnosticLink()).toBe(true); - }); - it('does not show when image reporting not set in config', async () => { - const mockNoImageConfig = { - ...mockConfig, - export_types: { - csv: { enabled: true }, - pdf: { enabled: false }, - png: { enabled: false }, - }, - }; - await runSetup({ config: mockNoImageConfig }); - expect(testBed.actions.hasScreenshotDiagnosticLink()).toBe(false); - }); - }); -}); diff --git a/x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx b/x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx deleted file mode 100644 index a658d2e190051..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/report_listing.tsx +++ /dev/null @@ -1,26 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useInternalApiClient, useKibana } from '@kbn/reporting-public'; -import { ReportListingStateful } from './stateful/report_listing_stateful'; -import { ReportListingDefault } from './default/report_listing_default'; -import { ListingProps } from '.'; - -export const ReportListing = (props: ListingProps) => { - const { apiClient } = useInternalApiClient(); - const { - services: { - application: { capabilities }, - }, - } = useKibana(); - return props.config.statefulSettings.enabled ? ( - - ) : ( - - ); -}; diff --git a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx b/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx deleted file mode 100644 index 3e7a3c8cb10fd..0000000000000 --- a/x-pack/platform/plugins/private/reporting/public/management/stateful/report_listing_stateful.tsx +++ /dev/null @@ -1,87 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { FC } from 'react'; - -import { - EuiFlexGroup, - EuiFlexItem, - EuiLoadingSpinner, - EuiPageHeader, - EuiSpacer, -} from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n-react'; - -import { ListingPropsInternal } from '..'; -import { useIlmPolicyStatus } from '../../lib/ilm_policy_status_context'; -import { IlmPolicyLink, MigrateIlmPolicyCallOut, ReportDiagnostic } from '../components'; -import { ReportListingTable } from '../report_listing_table'; - -/** - * Used in Stateful deployments only - * Renders controls for ILM and Screenshotting Diagnostics which are only applicable in Stateful - */ -export const ReportListingStateful: FC = (props) => { - const { apiClient, capabilities, config, navigateToUrl, toasts, urlService, ...listingProps } = - props; - const ilmLocator = urlService.locators.get('ILM_LOCATOR_ID'); - const ilmPolicyContextValue = useIlmPolicyStatus(); - const hasIlmPolicy = ilmPolicyContextValue?.status !== 'policy-not-found'; - const showIlmPolicyLink = Boolean(ilmLocator && hasIlmPolicy); - return ( - <> - - } - description={ - - } - /> - - - - - - - - - - {capabilities?.management?.data?.index_lifecycle_management && ( - - {ilmPolicyContextValue?.isLoading ? ( - - ) : ( - showIlmPolicyLink && ( - - ) - )} - - )} - - - - - - ); -}; diff --git a/x-pack/platform/plugins/private/reporting/public/plugin.ts b/x-pack/platform/plugins/private/reporting/public/plugin.ts index 4ba306e084302..a410e2b41a120 100644 --- a/x-pack/platform/plugins/private/reporting/public/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/public/plugin.ts @@ -33,6 +33,8 @@ import { InjectedIntl } from '@kbn/i18n-react'; import type { ReportingSetup, ReportingStart } from '.'; import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler'; import { StartServices } from './types'; +import { APP_DESC, APP_TITLE } from './translations'; +import { APP_PATH } from './constants'; export interface ReportingPublicPluginSetupDependencies { home: HomePublicPluginSetup; @@ -118,6 +120,7 @@ export class ReportingPublicPlugin notifications: start.notifications, rendering: start.rendering, uiSettings: start.uiSettings, + chrome: start.chrome, }, ...rest, ]; @@ -129,14 +132,10 @@ export class ReportingPublicPlugin homeSetup.featureCatalogue.register({ id: 'reporting', - title: i18n.translate('xpack.reporting.registerFeature.reportingTitle', { - defaultMessage: 'Reporting', - }), - description: i18n.translate('xpack.reporting.registerFeature.reportingDescription', { - defaultMessage: 'Manage your reports generated from Discover, Visualize, and Dashboard.', - }), + title: APP_TITLE, + description: APP_DESC, icon: 'reportingApp', - path: '/app/management/insightsAndAlerting/reporting', + path: APP_PATH, showOnHomePage: false, category: 'admin', }); diff --git a/x-pack/platform/plugins/private/reporting/public/translations.ts b/x-pack/platform/plugins/private/reporting/public/translations.ts new file mode 100644 index 0000000000000..b5fd9977f8a5c --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/public/translations.ts @@ -0,0 +1,30 @@ +/* + * 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 { i18n } from '@kbn/i18n'; + +export const APP_TITLE = i18n.translate('xpack.reporting.registerFeature.reportingTitle', { + defaultMessage: 'Reporting', +}); + +export const APP_DESC = i18n.translate('xpack.reporting.registerFeature.reportingDescription', { + defaultMessage: 'Manage your reports generated from Discover, Visualize, and Dashboard.', +}); + +export const LOADING_REPORTS_DESCRIPTION = i18n.translate( + 'xpack.reporting.table.loadingReportsDescription', + { + defaultMessage: 'Loading reports', + } +); + +export const NO_CREATED_REPORTS_DESCRIPTION = i18n.translate( + 'xpack.reporting.table.noCreatedReportsDescription', + { + defaultMessage: 'No reports have been created', + } +); diff --git a/x-pack/platform/plugins/private/reporting/server/core.ts b/x-pack/platform/plugins/private/reporting/server/core.ts index 35d36557f4bf7..88afab7eef1bf 100644 --- a/x-pack/platform/plugins/private/reporting/server/core.ts +++ b/x-pack/platform/plugins/private/reporting/server/core.ts @@ -23,6 +23,8 @@ import type { StatusServiceSetup, UiSettingsServiceStart, } from '@kbn/core/server'; +import { SECURITY_EXTENSION_ID } from '@kbn/core-saved-objects-server'; +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { PluginStart as DataPluginStart } from '@kbn/data-plugin/server'; import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; @@ -35,7 +37,7 @@ import { PngExportType } from '@kbn/reporting-export-types-png'; import type { ReportingConfigType } from '@kbn/reporting-server'; import { ExportType } from '@kbn/reporting-server'; import { ScreenshottingStart } from '@kbn/screenshotting-plugin/server'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import type { @@ -52,11 +54,19 @@ import type { ReportingSetup } from '.'; import { createConfig } from './config'; import { reportingEventLoggerFactory } from './lib/event_logger/logger'; import type { IReport, ReportingStore } from './lib/store'; -import { ExecuteReportTask, ReportTaskParams } from './lib/tasks'; +import { + RunSingleReportTask, + ReportTaskParams, + RunScheduledReportTask, + ScheduledReportTaskParamsWithoutSpaceId, +} from './lib/tasks'; import type { ReportingPluginRouter } from './types'; import { EventTracker } from './usage'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects'; +import { API_PRIVILEGES } from './features'; export interface ReportingInternalSetup { + actions: ActionsPluginSetupContract; basePath: Pick; docLinks: DocLinksServiceSetup; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; @@ -83,6 +93,7 @@ export interface ReportingInternalStart { logger: Logger; notifications: NotificationsPluginStart; screenshotting?: ScreenshottingStart; + security?: SecurityPluginStart; securityService: SecurityServiceStart; taskManager: TaskManagerStartContract; } @@ -96,7 +107,8 @@ export class ReportingCore { private pluginStartDeps?: ReportingInternalStart; private readonly pluginSetup$ = new Rx.ReplaySubject(); // observe async background setupDeps each are done private readonly pluginStart$ = new Rx.ReplaySubject(); // observe async background startDeps - private executeTask: ExecuteReportTask; + private runSingleReportTask: RunSingleReportTask; + private runScheduledReportTask: RunScheduledReportTask; private config: ReportingConfigType; private executing: Set; private exportTypesRegistry = new ExportTypesRegistry(); @@ -117,7 +129,16 @@ export class ReportingCore { this.getExportTypes().forEach((et) => { this.exportTypesRegistry.register(et); }); - this.executeTask = new ExecuteReportTask(this, config, this.logger); + this.runSingleReportTask = new RunSingleReportTask({ + reporting: this, + config, + logger: this.logger, + }); + this.runScheduledReportTask = new RunScheduledReportTask({ + reporting: this, + config, + logger: this.logger, + }); this.getContract = () => ({ registerExportTypes: (id) => id, @@ -142,9 +163,10 @@ export class ReportingCore { et.setup(setupDeps); }); - const { executeTask } = this; + const { runSingleReportTask, runScheduledReportTask } = this; setupDeps.taskManager.registerTaskDefinitions({ - [executeTask.TYPE]: executeTask.getTaskDefinition(), + [runSingleReportTask.TYPE]: runSingleReportTask.getTaskDefinition(), + [runScheduledReportTask.TYPE]: runScheduledReportTask.getTaskDefinition(), }); } @@ -160,9 +182,12 @@ export class ReportingCore { }); const { taskManager } = startDeps; - const { executeTask } = this; + const { runSingleReportTask, runScheduledReportTask } = this; // enable this instance to generate reports - await Promise.all([executeTask.init(taskManager)]); + await Promise.all([ + runSingleReportTask.init(taskManager), + runScheduledReportTask.init(taskManager), + ]); } public pluginStop() { @@ -278,6 +303,18 @@ export class ReportingCore { }; } + public async canManageReportingForSpace(req: KibanaRequest): Promise { + const { security } = await this.getPluginStartDeps(); + const spaceId = this.getSpaceId(req); + const result = await security?.authz + .checkPrivilegesWithRequest(req) + .atSpace(spaceId ?? DEFAULT_SPACE_ID, { + kibana: [security?.authz.actions.api.get(API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING)], + }); + + return result?.hasAllRequested ?? false; + } + /* * Gives synchronous access to the config */ @@ -322,7 +359,14 @@ export class ReportingCore { } public async scheduleTask(request: KibanaRequest, report: ReportTaskParams) { - return await this.executeTask.scheduleTask(request, report); + return await this.runSingleReportTask.scheduleTask(request, report); + } + + public async scheduleRecurringTask( + request: KibanaRequest, + report: ScheduledReportTaskParamsWithoutSpaceId + ) { + return await this.runScheduledReportTask.scheduleTask(request, report); } public async getStore() { @@ -354,6 +398,13 @@ export class ReportingCore { ); } + public validateNotificationEmails(emails: string[]): string | undefined { + const pluginSetupDeps = this.getPluginSetupDeps(); + return pluginSetupDeps.actions + .getActionsConfigurationUtilities() + .validateEmailAddresses(emails); + } + /* * Gives synchronous access to the setupDeps */ @@ -374,6 +425,24 @@ export class ReportingCore { return dataViews; } + public async getScopedSoClient(request: KibanaRequest) { + const { savedObjects } = await this.getPluginStartDeps(); + return savedObjects.getScopedClient(request, { + excludedExtensions: [SECURITY_EXTENSION_ID], + includedHiddenTypes: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], + }); + } + + public async getInternalSoClient() { + const { savedObjects } = await this.getPluginStartDeps(); + return savedObjects.createInternalRepository([SCHEDULED_REPORT_SAVED_OBJECT_TYPE]); + } + + public async getTaskManager() { + const { taskManager } = await this.getPluginStartDeps(); + return taskManager; + } + public async getDataService() { const startDeps = await this.getPluginStartDeps(); return startDeps.data; diff --git a/x-pack/platform/plugins/private/reporting/server/features.ts b/x-pack/platform/plugins/private/reporting/server/features.ts index a7971494b6c1a..7a8674701a2df 100644 --- a/x-pack/platform/plugins/private/reporting/server/features.ts +++ b/x-pack/platform/plugins/private/reporting/server/features.ts @@ -9,6 +9,11 @@ import { DEFAULT_APP_CATEGORIES } from '@kbn/core/server'; import { i18n } from '@kbn/i18n'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import { KibanaFeatureScope } from '@kbn/features-plugin/common'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from './saved_objects'; + +export const API_PRIVILEGES = { + MANAGE_SCHEDULED_REPORTING: 'manage_scheduled_reports', +}; interface FeatureRegistrationOpts { features: FeaturesPluginSetup; @@ -37,4 +42,29 @@ export function registerFeatures({ isServerless, features }: FeatureRegistration } features.enableReportingUiCapabilities(); + + features.registerKibanaFeature({ + id: 'manageReporting', + name: i18n.translate('xpack.reporting.features.manageScheduledReportsFeatureName', { + defaultMessage: 'Manage Scheduled Reports', + }), + description: i18n.translate( + 'xpack.reporting.features.manageScheduledReportsFeatureDescription', + { + defaultMessage: 'View and manage scheduled reports for all users in this space.', + } + ), + category: DEFAULT_APP_CATEGORIES.management, + scope: [KibanaFeatureScope.Spaces, KibanaFeatureScope.Security], + app: [], + privileges: { + all: { + api: [API_PRIVILEGES.MANAGE_SCHEDULED_REPORTING], + savedObject: { all: [SCHEDULED_REPORT_SAVED_OBJECT_TYPE], read: [] }, + ui: ['show'], + }, + // No read-only mode currently supported + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, + }, + }); } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts index 168f958600815..148ad6238ce84 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/index.ts @@ -7,6 +7,7 @@ export { Report } from './report'; export { SavedReport } from './saved_report'; +export { ScheduledReport } from './scheduled_report'; export { ReportingStore } from './store'; export { IlmPolicyManager } from './ilm_policy_manager'; diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts index 613485587324c..7d9db3b75b014 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/report.test.ts @@ -63,6 +63,61 @@ describe('Class Report', () => { expect(report._id).toBeDefined(); }); + it('constructs Report instance when scheduled_task_id is defined', () => { + const report = new Report({ + _index: '.reporting-test-index-12345', + jobtype: 'test-report', + created_by: 'created_by_test_string', + max_attempts: 50, + payload: { + headers: 'payload_test_field', + objectType: 'testOt', + title: 'cool report', + version: '7.14.0', + browserTimezone: 'UTC', + }, + meta: { objectType: 'test' }, + timeout: 30000, + scheduled_report_id: 'foobar', + }); + + expect(report.toReportSource()).toMatchObject({ + attempts: 0, + completed_at: undefined, + created_by: 'created_by_test_string', + jobtype: 'test-report', + max_attempts: 50, + meta: { objectType: 'test' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + started_at: undefined, + status: 'pending', + timeout: 30000, + scheduled_report_id: 'foobar', + }); + expect(report.toReportTaskJSON()).toMatchObject({ + attempts: 0, + created_by: 'created_by_test_string', + index: '.reporting-test-index-12345', + jobtype: 'test-report', + meta: { objectType: 'test' }, + payload: { headers: 'payload_test_field', objectType: 'testOt' }, + }); + expect(report.toApiJSON()).toMatchObject({ + attempts: 0, + created_by: 'created_by_test_string', + index: '.reporting-test-index-12345', + jobtype: 'test-report', + max_attempts: 50, + payload: { objectType: 'testOt' }, + meta: { objectType: 'test' }, + status: 'pending', + timeout: 30000, + scheduled_report_id: 'foobar', + }); + + expect(report._id).toBeDefined(); + }); + it('updateWithEsDoc method syncs fields to sync ES metadata', () => { const report = new Report({ _index: '.reporting-test-index-12345', diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts index 3f83ac577fb62..9edb44170851b 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/report.ts @@ -41,6 +41,7 @@ export class Report implements Partial { public readonly status: ReportSource['status']; public readonly attempts: ReportSource['attempts']; + public readonly scheduled_report_id: ReportSource['scheduled_report_id']; // fields with undefined values exist in report jobs that have not been claimed public readonly kibana_name: ReportSource['kibana_name']; @@ -99,6 +100,7 @@ export class Report implements Partial { this.status = opts.status || JOB_STATUS.PENDING; this.output = opts.output || null; this.error = opts.error; + this.scheduled_report_id = opts.scheduled_report_id; this.queue_time_ms = fields?.queue_time_ms; this.execution_time_ms = fields?.execution_time_ms; @@ -142,6 +144,7 @@ export class Report implements Partial { space_id: this.space_id, output: this.output || null, metrics: this.metrics, + scheduled_report_id: this.scheduled_report_id, }; } @@ -191,6 +194,7 @@ export class Report implements Partial { payload: omit(this.payload, 'headers'), output: omit(this.output, 'content'), metrics: this.metrics, + scheduled_report_id: this.scheduled_report_id, }; } } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.test.ts new file mode 100644 index 0000000000000..c47c3f349c3c3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.test.ts @@ -0,0 +1,178 @@ +/* + * 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 { Frequency } from '@kbn/task-manager-plugin/server'; +import { ScheduledReport } from '.'; + +const payload = { + headers: '', + title: 'Test Report', + browserTimezone: '', + objectType: 'test', + version: '8.0.0', +}; + +test('ScheduledReport should return correctly formatted outputs', () => { + const scheduledReport = new ScheduledReport({ + runAt: new Date('2023-10-01T00:00:00Z'), + kibanaId: 'instance-uuid', + kibanaName: 'kibana', + queueTimeout: 120000, + reportSO: { + id: 'report-so-id-111', + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: JSON.stringify(payload), + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', + }, + }); + expect(scheduledReport.toReportTaskJSON()).toEqual({ + attempts: 1, + created_at: '2023-10-01T00:00:00.000Z', + created_by: 'test-user', + id: expect.any(String), + index: '.kibana-reporting', + jobtype: 'test1', + meta: { + objectType: 'test', + }, + payload: { + browserTimezone: '', + forceNow: '2023-10-01T00:00:00.000Z', + headers: '', + objectType: 'test', + title: 'Test Report [2023-10-01T00:00:00.000Z]', + version: '8.0.0', + }, + }); + + expect(scheduledReport.toReportSource()).toEqual({ + attempts: 1, + max_attempts: 1, + created_at: '2023-10-01T00:00:00.000Z', + created_by: 'test-user', + jobtype: 'test1', + meta: { + objectType: 'test', + }, + migration_version: '7.14.0', + kibana_id: 'instance-uuid', + kibana_name: 'kibana', + output: null, + payload: { + browserTimezone: '', + forceNow: '2023-10-01T00:00:00.000Z', + headers: '', + objectType: 'test', + title: 'Test Report [2023-10-01T00:00:00.000Z]', + version: '8.0.0', + }, + scheduled_report_id: 'report-so-id-111', + status: 'processing', + started_at: expect.any(String), + process_expiration: expect.any(String), + timeout: 120000, + }); + + expect(scheduledReport.toApiJSON()).toEqual({ + id: expect.any(String), + index: '.kibana-reporting', + kibana_id: 'instance-uuid', + kibana_name: 'kibana', + jobtype: 'test1', + created_at: '2023-10-01T00:00:00.000Z', + created_by: 'test-user', + meta: { + objectType: 'test', + }, + timeout: 120000, + max_attempts: 1, + status: 'processing', + attempts: 1, + started_at: expect.any(String), + migration_version: '7.14.0', + output: {}, + queue_time_ms: expect.any(Number), + payload: { + browserTimezone: '', + forceNow: '2023-10-01T00:00:00.000Z', + objectType: 'test', + title: 'Test Report [2023-10-01T00:00:00.000Z]', + version: '8.0.0', + }, + scheduled_report_id: 'report-so-id-111', + }); +}); + +test('ScheduledReport should throw an error if report payload is malformed', () => { + const createInstance = () => { + return new ScheduledReport({ + runAt: new Date('2023-10-01T00:00:00Z'), + kibanaId: 'instance-uuid', + kibanaName: 'kibana', + queueTimeout: 120000, + reportSO: { + id: 'report-so-id-111', + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: 'abc', + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', + }, + }); + }; + expect(createInstance).toThrowErrorMatchingInlineSnapshot( + `"Unable to parse payload from scheduled report saved object: SyntaxError: Unexpected token 'a', \\"abc\\" is not valid JSON"` + ); +}); + +test('ScheduledReport should throw an error if report saved object is missing ID', () => { + const createInstance = () => { + return new ScheduledReport({ + runAt: new Date('2023-10-01T00:00:00Z'), + kibanaId: 'instance-uuid', + kibanaName: 'kibana', + queueTimeout: 120000, + // @ts-expect-error - missing id + reportSO: { + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: JSON.stringify(payload), + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', + }, + }); + }; + expect(createInstance).toThrowErrorMatchingInlineSnapshot( + `"Invalid scheduled report saved object - no id"` + ); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.ts new file mode 100644 index 0000000000000..1e13139ee8d5e --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/scheduled_report.ts @@ -0,0 +1,69 @@ +/* + * 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 moment from 'moment'; +import { JOB_STATUS } from '@kbn/reporting-common'; + +import { SavedObject } from '@kbn/core/server'; +import { BasePayload } from '@kbn/reporting-common/types'; +import { Report } from './report'; +import { ScheduledReportType } from '../../types'; + +interface ConstructorOpts { + runAt: Date; + kibanaId: string; + kibanaName: string; + queueTimeout: number; + reportSO: SavedObject; +} + +export class ScheduledReport extends Report { + /* + * Create a report from a scheduled report saved object + */ + constructor(opts: ConstructorOpts) { + const { kibanaId, kibanaName, runAt, reportSO, queueTimeout } = opts; + const now = moment.utc(); + const startTime = now.toISOString(); + const expirationTime = now.add(queueTimeout).toISOString(); + + let payload: BasePayload; + try { + payload = JSON.parse(reportSO.attributes.payload); + } catch (e) { + throw new Error(`Unable to parse payload from scheduled report saved object: ${e}`); + } + + payload.forceNow = runAt.toISOString(); + payload.title = `${reportSO.attributes.title} [${runAt.toISOString()}]`; + + if (!reportSO.id) { + throw new Error(`Invalid scheduled report saved object - no id`); + } + + super( + { + migration_version: reportSO.attributes.migrationVersion, + jobtype: reportSO.attributes.jobType, + created_at: runAt.toISOString(), + created_by: reportSO.attributes.createdBy as string | false, + payload, + meta: reportSO.attributes.meta, + status: JOB_STATUS.PROCESSING, + attempts: 1, + process_expiration: expirationTime, + kibana_id: kibanaId, + kibana_name: kibanaName, + max_attempts: 1, + started_at: startTime, + timeout: queueTimeout, + scheduled_report_id: reportSO.id, + }, + { queue_time_ms: [now.diff(moment.utc(runAt), 'milliseconds')] } + ); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts index 1f321a66846cf..046d825d57108 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/store.test.ts @@ -60,6 +60,53 @@ describe('ReportingStore', () => { }); }); + it('uses report status if set', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const mockReport = new Report({ + _index: '.reporting-mock', + attempts: 0, + created_by: 'username1', + jobtype: 'unknowntype', + status: 'processing', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + attempts: 0, + completed_at: undefined, + created_by: 'username1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + status: 'processing', + }); + }); + + it('defaults to pending status if not set', async () => { + const store = new ReportingStore(mockCore, mockLogger); + const mockReport = new Report({ + _index: '.reporting-mock', + attempts: 0, + created_by: 'username1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + } as any); + await expect(store.addReport(mockReport)).resolves.toMatchObject({ + _primary_term: undefined, + _seq_no: undefined, + attempts: 0, + completed_at: undefined, + created_by: 'username1', + jobtype: 'unknowntype', + payload: {}, + meta: {}, + status: 'pending', + }); + }); + it('throws if options has invalid indexInterval', async () => { const reportingConfig = { index: '.reporting-test', @@ -181,6 +228,7 @@ describe('ReportingStore', () => { }, "process_expiration": undefined, "queue_time_ms": undefined, + "scheduled_report_id": undefined, "space_id": undefined, "started_at": undefined, "status": "pending", diff --git a/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts b/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts index 671a9dbc924b1..417c9599a7744 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/store/store.ts @@ -145,8 +145,8 @@ export class ReportingStore { ...report.toReportSource(), ...sourceDoc({ process_expiration: new Date(0).toISOString(), - attempts: 0, - status: JOB_STATUS.PENDING, + attempts: report.attempts || 0, + status: report.status || JOB_STATUS.PENDING, }), }, }; diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts index 841d499da1059..e3da53487caa6 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/index.ts @@ -5,14 +5,16 @@ * 2.0. */ -import { TaskRunCreatorFunction } from '@kbn/task-manager-plugin/server'; +import { RruleSchedule, TaskRegisterDefinition } from '@kbn/task-manager-plugin/server'; import { BasePayload, ReportSource } from '@kbn/reporting-common/types'; export const REPORTING_EXECUTE_TYPE = 'report:execute'; +export const SCHEDULED_REPORTING_EXECUTE_TYPE = 'report:execute-scheduled'; export const TIME_BETWEEN_ATTEMPTS = 10 * 1000; // 10 seconds -export { ExecuteReportTask } from './execute_report'; +export { RunSingleReportTask } from './run_single_report'; +export { RunScheduledReportTask } from './run_scheduled_report'; export interface ReportTaskParams { id: string; @@ -25,18 +27,21 @@ export interface ReportTaskParams { meta: ReportSource['meta']; } +export interface ScheduledReportTaskParams { + id: string; + jobtype: ReportSource['jobtype']; + spaceId: string; + schedule: RruleSchedule; +} + +export type ScheduledReportTaskParamsWithoutSpaceId = Omit; + export enum ReportingTaskStatus { UNINITIALIZED = 'uninitialized', INITIALIZED = 'initialized', } export interface ReportingTask { - getTaskDefinition: () => { - type: string; - title: string; - createTaskRunner: TaskRunCreatorFunction; - maxAttempts: number; - timeout: string; - }; + getTaskDefinition: () => TaskRegisterDefinition; getStatus: () => ReportingTaskStatus; } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_report.ts similarity index 60% rename from x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.ts rename to x-pack/platform/plugins/private/reporting/server/lib/tasks/run_report.ts index 8b99f2e9514f4..5ef691a139804 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_report.ts @@ -15,10 +15,9 @@ import type { KibanaRequest, Logger } from '@kbn/core/server'; import { CancellationToken, KibanaShuttingDownError, - QueueTimeoutError, + MissingAuthenticationError, ReportingError, durationToNumber, - numberToDuration, } from '@kbn/reporting-common'; import type { ExecutionError, @@ -28,34 +27,26 @@ import type { TaskRunResult, } from '@kbn/reporting-common/types'; import { decryptJobHeaders, type ReportingConfigType } from '@kbn/reporting-server'; -import type { - RunContext, - TaskManagerStartContract, - TaskRunCreatorFunction, +import { + throwRetryableError, + type ConcreteTaskInstance, + type RunContext, + type TaskManagerStartContract, + type TaskRegisterDefinition, + type TaskRunCreatorFunction, } from '@kbn/task-manager-plugin/server'; -import { throwRetryableError } from '@kbn/task-manager-plugin/server'; import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import { kibanaRequestFactory } from '@kbn/core-http-server-utils'; import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; -import { - REPORTING_EXECUTE_TYPE, - ReportTaskParams, - ReportingTask, - ReportingTaskStatus, - TIME_BETWEEN_ATTEMPTS, -} from '.'; -import { getContentStream, finishedWithNoPendingCallbacks } from '../content_stream'; +import { mapToReportingError } from '../../../common/errors/map_to_reporting_error'; +import { ReportTaskParams, ReportingTask, ReportingTaskStatus, TIME_BETWEEN_ATTEMPTS } from '.'; import type { ReportingCore } from '../..'; -import { - isExecutionError, - mapToReportingError, -} from '../../../common/errors/map_to_reporting_error'; import { EventTracker } from '../../usage'; -import type { ReportingStore } from '../store'; import { Report, SavedReport } from '../store'; -import type { ReportFailedFields, ReportProcessingFields } from '../store/store'; +import type { ReportFailedFields } from '../store/store'; import { errorLogger } from './error_logger'; +import { finishedWithNoPendingCallbacks, getContentStream } from '../content_stream'; type CompletedReportOutput = Omit; @@ -72,12 +63,6 @@ interface GetHeadersOpts { requestFromTask?: KibanaRequest; spaceId: string | undefined; } -interface ReportingExecuteTaskInstance { - state: object; - taskType: string; - params: ReportTaskParams; - runAt?: Date; -} function isOutput(output: CompletedReportOutput | Error): output is CompletedReportOutput { return (output as CompletedReportOutput).size != null; @@ -95,63 +80,84 @@ function parseError(error: unknown): ExecutionError | unknown { return error; } -export class ExecuteReportTask implements ReportingTask { - public TYPE = REPORTING_EXECUTE_TYPE; - - private logger: Logger; - private taskManagerStart?: TaskManagerStartContract; - private kibanaId?: string; - private kibanaName?: string; - private exportTypesRegistry: ExportTypesRegistry; - private store?: ReportingStore; - private eventTracker?: EventTracker; - - constructor( - private reporting: ReportingCore, - private config: ReportingConfigType, - logger: Logger - ) { - this.logger = logger.get('runTask'); - this.exportTypesRegistry = this.reporting.getExportTypesRegistry(); +export interface ConstructorOpts { + config: ReportingConfigType; + logger: Logger; + reporting: ReportingCore; +} + +export interface PrepareJobResults { + isLastAttempt: boolean; + jobId: string; + report?: SavedReport; + task?: ReportTaskParams; +} + +type ReportTaskParamsType = Record; + +export abstract class RunReportTask + implements ReportingTask +{ + protected readonly logger: Logger; + protected readonly queueTimeout: number; + + protected taskManagerStart?: TaskManagerStartContract; + protected kibanaId?: string; + protected kibanaName?: string; + protected exportTypesRegistry: ExportTypesRegistry; + protected eventTracker?: EventTracker; + + constructor(protected readonly opts: ConstructorOpts) { + this.logger = opts.logger.get('runTask'); + this.exportTypesRegistry = opts.reporting.getExportTypesRegistry(); + this.queueTimeout = durationToNumber(opts.config.queue.timeout); } - /* - * To be called from plugin start - */ + // Abstract methods + public abstract get TYPE(): string; + + public abstract getTaskDefinition(): TaskRegisterDefinition; + + public abstract scheduleTask( + request: KibanaRequest, + params: TaskParams + ): Promise; + + protected abstract prepareJob(taskInstance: ConcreteTaskInstance): Promise; + + protected abstract getMaxAttempts(): number | undefined; + + // Public methods public async init(taskManager: TaskManagerStartContract) { this.taskManagerStart = taskManager; - const { reporting } = this; - const { uuid, name } = reporting.getServerInfo(); + const { uuid, name } = this.opts.reporting.getServerInfo(); this.kibanaId = uuid; this.kibanaName = name; } - /* - * Async get the ReportingStore: it is only available after PluginStart - */ - private async getStore(): Promise { - if (this.store) { - return this.store; + public getStatus() { + if (this.taskManagerStart) { + return ReportingTaskStatus.INITIALIZED; } - const { store } = await this.reporting.getPluginStartDeps(); - this.store = store; - return store; + + return ReportingTaskStatus.UNINITIALIZED; } - private getTaskManagerStart() { + // Protected methods + protected getTaskManagerStart() { if (!this.taskManagerStart) { throw new Error('Reporting task runner has not been initialized!'); } return this.taskManagerStart; } - private getEventTracker(report: Report) { + protected getEventTracker(report: Report) { if (this.eventTracker) { return this.eventTracker; } - const eventTracker = this.reporting.getEventTracker( + const eventTracker = this.opts.reporting.getEventTracker( report._id, report.jobtype, report.payload.objectType @@ -160,91 +166,12 @@ export class ExecuteReportTask implements ReportingTask { return this.eventTracker; } - private getJobContentEncoding(jobType: string) { + protected getJobContentEncoding(jobType: string) { const exportType = this.exportTypesRegistry.getByJobType(jobType); return exportType.jobContentEncoding; } - private async _claimJob(task: ReportTaskParams): Promise { - if (this.kibanaId == null) { - throw new Error(`Kibana instance ID is undefined!`); - } - if (this.kibanaName == null) { - throw new Error(`Kibana instance name is undefined!`); - } - - const store = await this.getStore(); - const report = await store.findReportFromTask(task); // receives seq_no and primary_term - const logger = this.logger.get(report._id); - - if (report.status === 'completed') { - throw new Error(`Can not claim the report job: it is already completed!`); - } - - const m = moment(); - - // check if job has exceeded the configured maxAttempts - const maxAttempts = this.getMaxAttempts(); - if (report.attempts >= maxAttempts) { - let err: ReportingError; - if (report.error && isExecutionError(report.error)) { - // We have an error stored from a previous attempts, so we'll use that - // error to fail the job and return it to the user. - const { error } = report; - err = mapToReportingError(error); - err.stack = error.stack; - } else { - if (report.error && report.error instanceof Error) { - errorLogger(logger, 'Error executing report', report.error); - } - err = new QueueTimeoutError( - `Max attempts reached (${maxAttempts}). Queue timeout reached.` - ); - } - await this._failJob(report, err); - throw err; - } - - const queueTimeout = durationToNumber(this.config.queue.timeout); - const startTime = m.toISOString(); - const expirationTime = m.add(queueTimeout).toISOString(); - - const doc: ReportProcessingFields = { - kibana_id: this.kibanaId, - kibana_name: this.kibanaName, - attempts: report.attempts + 1, - max_attempts: maxAttempts, - started_at: startTime, - timeout: queueTimeout, - process_expiration: expirationTime, - }; - - const claimedReport = new SavedReport({ - ...report, - ...doc, - }); - - logger.info( - `Claiming ${claimedReport.jobtype} ${report._id} ` + - `[_index: ${report._index}] ` + - `[_seq_no: ${report._seq_no}] ` + - `[_primary_term: ${report._primary_term}] ` + - `[attempts: ${report.attempts}] ` + - `[process_expiration: ${expirationTime}]` - ); - - // event tracking of claimed job - const eventTracker = this.getEventTracker(report); - const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf(); - eventTracker?.claimJob({ timeSinceCreation }); - - const resp = await store.setReportClaimed(claimedReport, doc); - claimedReport._seq_no = resp._seq_no!; - claimedReport._primary_term = resp._primary_term!; - return claimedReport; - } - - private async _failJob( + protected async failJob( report: SavedReport, error?: ReportingError ): Promise> { @@ -255,13 +182,13 @@ export class ExecuteReportTask implements ReportingTask { let docOutput; if (error) { errorLogger(logger, message, error); - docOutput = this._formatOutput(error); + docOutput = this.formatOutput(error); } else { errorLogger(logger, message); } // update the report in the store - const store = await this.getStore(); + const store = await this.opts.reporting.getStore(); const completedTime = moment(); const doc: ReportFailedFields = { completed_at: completedTime.toISOString(), @@ -280,7 +207,7 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportFailed(report, doc); } - private async _saveExecutionError( + protected async saveExecutionError( report: SavedReport, failedToExecuteErr: any ): Promise> { @@ -291,7 +218,7 @@ export class ExecuteReportTask implements ReportingTask { errorLogger(logger, message, failedToExecuteErr); // update the report in the store - const store = await this.getStore(); + const store = await this.opts.reporting.getStore(); const doc: ReportFailedFields = { output: null, error: errorParsed, @@ -300,7 +227,7 @@ export class ExecuteReportTask implements ReportingTask { return await store.setReportError(report, doc); } - private _formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput { + protected formatOutput(output: CompletedReportOutput | ReportingError): ReportOutput { const docOutput = {} as ReportOutput; const unknownMime = null; @@ -324,7 +251,7 @@ export class ExecuteReportTask implements ReportingTask { return docOutput; } - private async _getRequestToUse({ + protected async getRequestToUse({ requestFromTask, spaceId, encryptedHeaders, @@ -339,17 +266,17 @@ export class ExecuteReportTask implements ReportingTask { } let decryptedHeaders; - if (this.config.encryptionKey && encryptedHeaders) { + if (this.opts.config.encryptionKey && encryptedHeaders) { // get decrypted headers decryptedHeaders = await decryptJobHeaders( - this.config.encryptionKey, + this.opts.config.encryptionKey, encryptedHeaders, this.logger ); } if (!decryptedHeaders && !apiKeyAuthHeaders) { - throw new Error('No headers found to execute report'); + throw new MissingAuthenticationError(); } let headersToUse: Headers = {}; @@ -367,10 +294,10 @@ export class ExecuteReportTask implements ReportingTask { headersToUse = decryptedHeaders || {}; } - return this._getFakeRequest(headersToUse, spaceId, this.logger); + return this.getFakeRequest(headersToUse, spaceId, this.logger); } - private _getFakeRequest( + protected getFakeRequest( headers: Headers, spaceId: string | undefined, logger = this.logger @@ -381,7 +308,7 @@ export class ExecuteReportTask implements ReportingTask { }; const fakeRequest = kibanaRequestFactory(rawRequest); - const setupDeps = this.reporting.getPluginSetupDeps(); + const setupDeps = this.opts.reporting.getPluginSetupDeps(); const spacesService = setupDeps.spaces?.spacesService; if (spacesService) { if (spaceId && spaceId !== DEFAULT_SPACE_ID) { @@ -392,7 +319,7 @@ export class ExecuteReportTask implements ReportingTask { return fakeRequest; } - private async _performJob({ + protected async performJob({ task, fakeRequest, taskInstanceFields, @@ -405,8 +332,7 @@ export class ExecuteReportTask implements ReportingTask { } // run the report // if workerFn doesn't finish before timeout, call the cancellationToken and throw an error - const queueTimeout = durationToNumber(this.config.queue.timeout); - const request = await this._getRequestToUse({ + const request = await this.getRequestToUse({ requestFromTask: fakeRequest, spaceId: task.payload.spaceId, encryptedHeaders: task.payload.headers, @@ -422,11 +348,11 @@ export class ExecuteReportTask implements ReportingTask { cancellationToken, stream, }) - ).pipe(timeout(queueTimeout)) // throw an error if a value is not emitted before timeout + ).pipe(timeout(this.queueTimeout)) // throw an error if a value is not emitted before timeout ); } - private async _completeJob( + protected async completeJob( report: SavedReport, output: CompletedReportOutput ): Promise { @@ -436,8 +362,8 @@ export class ExecuteReportTask implements ReportingTask { logger.debug(`Saving ${report.jobtype} to ${docId}.`); const completedTime = moment(); - const docOutput = this._formatOutput(output); - const store = await this.getStore(); + const docOutput = this.formatOutput(output); + const store = await this.opts.reporting.getStore(); const doc = { completed_at: completedTime.toISOString(), metrics: output.metrics, @@ -477,25 +403,20 @@ export class ExecuteReportTask implements ReportingTask { } // Generic is used to let TS infer the return type at call site. - private async throwIfKibanaShutsDown(): Promise { - await Rx.firstValueFrom(this.reporting.getKibanaShutdown$()); + protected async throwIfKibanaShutsDown(): Promise { + await Rx.firstValueFrom(this.opts.reporting.getKibanaShutdown$()); throw new KibanaShuttingDownError(); } /* * Provides a TaskRunner for Task Manager */ - private getTaskRunner(): TaskRunCreatorFunction { + protected getTaskRunner(): TaskRunCreatorFunction { // Keep a separate local stack for each task run return ({ taskInstance, fakeRequest }: RunContext) => { let jobId: string; const cancellationToken = new CancellationToken(); - const { - attempts: taskAttempts, - params: reportTaskParams, - retryAt: taskRetryAt, - startedAt: taskStartedAt, - } = taskInstance; + const { retryAt: taskRetryAt, startedAt: taskStartedAt } = taskInstance; return { /* @@ -506,31 +427,29 @@ export class ExecuteReportTask implements ReportingTask { * If any error happens, additional retry attempts may be picked up by a separate instance */ run: async () => { - let report: SavedReport | undefined; - const isLastAttempt = taskAttempts >= this.getMaxAttempts(); - - // find the job in the store and set status to processing - const task = reportTaskParams as ReportTaskParams; - jobId = task?.id; - - try { - if (!jobId) { - throw new Error('Invalid report data provided in scheduled task!'); - } - if (!isLastAttempt) { - this.reporting.trackReport(jobId); - } + if (this.kibanaId == null) { + throw new Error(`Kibana instance ID is undefined!`); + } + if (this.kibanaName == null) { + throw new Error(`Kibana instance name is undefined!`); + } - // Update job status to claimed - report = await this._claimJob(task); - } catch (failedToClaim) { - // error claiming report - log the error - // could be version conflict, or too many attempts or no longer connected to ES - errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim); + let report: SavedReport | undefined; + const { + isLastAttempt, + jobId: jId, + report: preparedReport, + task, + } = await this.prepareJob(taskInstance); + jobId = jId; + report = preparedReport; + + if (!isLastAttempt) { + this.opts.reporting.trackReport(jobId); } - if (!report) { - this.reporting.untrackReport(jobId); + if (!report || !task) { + this.opts.reporting.untrackReport(jobId); if (isLastAttempt) { errorLogger(this.logger, `Job ${jobId} failed too many times. Exiting...`); @@ -545,22 +464,27 @@ export class ExecuteReportTask implements ReportingTask { } const { jobtype: jobType, attempts } = report; - const maxAttempts = this.getMaxAttempts(); const logger = this.logger.get(jobId); - logger.debug( - `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` - ); - logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); + const maxAttempts = this.getMaxAttempts(); + if (maxAttempts) { + logger.debug( + `Starting ${jobType} report ${jobId}: attempt ${attempts} of ${maxAttempts}.` + ); + } else { + logger.debug(`Starting ${jobType} report ${jobId}.`); + } - const eventLog = this.reporting.getEventLogger( + logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`); + + const eventLog = this.opts.reporting.getEventLogger( new Report({ ...task, _id: task.id, _index: task.index }) ); try { const jobContentEncoding = this.getJobContentEncoding(jobType); const stream = await getContentStream( - this.reporting, + this.opts.reporting, { id: report._id, index: report._index, @@ -574,7 +498,7 @@ export class ExecuteReportTask implements ReportingTask { eventLog.logExecutionStart(); const output = await Promise.race([ - this._performJob({ + this.performJob({ task, fakeRequest, taskInstanceFields: { retryAt: taskRetryAt, startedAt: taskStartedAt }, @@ -601,7 +525,7 @@ export class ExecuteReportTask implements ReportingTask { if (output) { logger.debug(`Job output size: ${stream.bytesWritten} bytes.`); // Update the job status to "completed" - report = await this._completeJob(report, { + report = await this.completeJob(report, { ...output, size: stream.bytesWritten, }); @@ -612,11 +536,9 @@ export class ExecuteReportTask implements ReportingTask { } catch (failedToExecuteErr) { eventLog.logError(failedToExecuteErr); - await this._saveExecutionError(report, failedToExecuteErr).catch( - (failedToSaveError) => { - errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError); - } - ); + await this.saveExecutionError(report, failedToExecuteErr).catch((failedToSaveError) => { + errorLogger(logger, `Error in saving execution error ${jobId}`, failedToSaveError); + }); cancellationToken.cancel(); @@ -624,8 +546,8 @@ export class ExecuteReportTask implements ReportingTask { throwRetryableError(error, new Date(Date.now() + TIME_BETWEEN_ATTEMPTS)); } finally { - this.reporting.untrackReport(jobId); - logger.debug(`Reports running: ${this.reporting.countConcurrentReports()}.`); + this.opts.reporting.untrackReport(jobId); + logger.debug(`Reports running: ${this.opts.reporting.countConcurrentReports()}.`); } }, @@ -642,47 +564,4 @@ export class ExecuteReportTask implements ReportingTask { }; }; } - - private getMaxAttempts() { - return this.config.capture.maxAttempts ?? 1; - } - - public getTaskDefinition() { - // round up from ms to the nearest second - const queueTimeout = Math.ceil(numberToDuration(this.config.queue.timeout).asSeconds()) + 's'; - const maxConcurrency = this.config.queue.pollEnabled ? 1 : 0; - const maxAttempts = this.getMaxAttempts(); - - return { - type: REPORTING_EXECUTE_TYPE, - title: 'Reporting: execute job', - createTaskRunner: this.getTaskRunner(), - maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart - timeout: queueTimeout, - maxConcurrency, - }; - } - - public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) { - const reportingHealth = await this.reporting.getHealthInfo(); - const shouldScheduleWithApiKey = - reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure; - const taskInstance: ReportingExecuteTaskInstance = { - taskType: REPORTING_EXECUTE_TYPE, - state: {}, - params, - }; - - return shouldScheduleWithApiKey - ? await this.getTaskManagerStart().schedule(taskInstance, { request }) - : await this.getTaskManagerStart().schedule(taskInstance); - } - - public getStatus() { - if (this.taskManagerStart) { - return ReportingTaskStatus.INITIALIZED; - } - - return ReportingTaskStatus.UNINITIALIZED; - } } diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.test.ts new file mode 100644 index 0000000000000..2522e3644ad82 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.test.ts @@ -0,0 +1,443 @@ +/* + * 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 { Transform } from 'stream'; +import type { estypes } from '@elastic/elasticsearch'; +import { loggingSystemMock } from '@kbn/core/server/mocks'; +import { JOB_STATUS, KibanaShuttingDownError } from '@kbn/reporting-common'; +import { ReportDocument } from '@kbn/reporting-common/types'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { type ExportType, type ReportingConfigType } from '@kbn/reporting-server'; +import type { RunContext } from '@kbn/task-manager-plugin/server'; +import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; + +import { RunScheduledReportTask, SCHEDULED_REPORTING_EXECUTE_TYPE } from '.'; +import { ReportingCore } from '../..'; +import { createMockReportingCore } from '../../test_helpers'; +import { + FakeRawRequest, + KibanaRequest, + SavedObject, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { Frequency } from '@kbn/rrule'; +import { ReportingStore, SavedReport } from '../store'; +import { ScheduledReportType } from '../../types'; + +interface StreamMock { + getSeqNo: () => number; + getPrimaryTerm: () => number; + write: (data: string) => void; + fail: () => void; + end: () => void; + transform: Transform; +} + +function createStreamMock(): StreamMock { + const transform: Transform = new Transform({}); + + return { + getSeqNo: () => 10, + getPrimaryTerm: () => 20, + write: (data: string) => { + transform.push(`${data}\n`); + }, + fail: () => { + transform.emit('error', new Error('Stream failed')); + transform.end(); + }, + transform, + end: () => { + transform.end(); + }, + }; +} + +const mockStream = createStreamMock(); +jest.mock('../content_stream', () => ({ + getContentStream: () => mockStream, + finishedWithNoPendingCallbacks: () => Promise.resolve(), +})); + +const logger = loggingSystemMock.createLogger(); +const fakeRawRequest: FakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +}; + +const payload = { + headers: '', + title: 'Test Report', + browserTimezone: '', + objectType: 'test', + version: '8.0.0', +}; + +const reportSO: SavedObject = { + id: 'report-so-id', + attributes: { + createdAt: new Date().toISOString(), + createdBy: 'test-user', + enabled: true, + jobType: 'test1', + meta: { objectType: 'test' }, + migrationVersion: '8.0.0', + payload: JSON.stringify(payload), + schedule: { rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' } }, + title: 'Test Report', + }, + references: [], + type: 'scheduled-report', +}; + +describe('Run Scheduled Report Task', () => { + let mockReporting: ReportingCore; + let configType: ReportingConfigType; + let soClient: SavedObjectsClientContract; + let reportStore: ReportingStore; + + const runTaskFn = jest.fn().mockResolvedValue({ content_type: 'application/pdf' }); + beforeAll(async () => { + configType = createMockConfigSchema(); + mockReporting = await createMockReportingCore(configType); + + soClient = await mockReporting.getInternalSoClient(); + soClient.get = jest.fn().mockImplementation(async () => { + return reportSO; + }); + + mockReporting.getExportTypesRegistry().register({ + id: 'test1', + name: 'Test1', + setup: jest.fn(), + start: jest.fn(), + createJob: () => new Promise(() => {}), + runTask: runTaskFn, + jobContentEncoding: 'base64', + jobType: 'test1', + validLicenses: [], + } as unknown as ExportType); + }); + + beforeEach(async () => { + reportStore = await mockReporting.getStore(); + reportStore.addReport = jest.fn().mockImplementation(async () => { + return new SavedReport({ + _id: '290357209345723095', + _index: '.reporting-fantastic', + _seq_no: 23, + _primary_term: 354000, + jobtype: 'test1', + migration_version: '8.0.0', + payload, + created_at: new Date().toISOString(), + created_by: 'test-user', + meta: { objectType: 'test' }, + scheduled_report_id: 'report-so-id', + status: JOB_STATUS.PROCESSING, + }); + }); + reportStore.setReportError = jest.fn(() => + Promise.resolve({ + _id: 'test', + jobtype: 'noop', + status: 'processing', + } as unknown as estypes.UpdateUpdateWriteResponseBase) + ); + }); + + it('Instance setup', () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + expect(task.getStatus()).toBe('uninitialized'); + expect(task.getTaskDefinition()).toMatchInlineSnapshot(` + Object { + "createTaskRunner": [Function], + "maxConcurrency": 1, + "timeout": "120s", + "title": "Reporting: execute scheduled job", + "type": "report:execute-scheduled", + } + `); + }); + + it('Instance start', () => { + const mockTaskManager = taskManagerMock.createStart(); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + expect(task.init(mockTaskManager)); + expect(task.getStatus()).toBe('initialized'); + }); + + it('create task runner', async () => { + logger.info = jest.fn(); + logger.error = jest.fn(); + + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'random-task-id', + params: { id: 'cool-reporting-id', jobtype: 'test1' }, + }, + } as unknown as RunContext); + expect(taskRunner).toHaveProperty('run'); + expect(taskRunner).toHaveProperty('cancel'); + }); + + it('Max Concurrency is 0 if pollEnabled is false', () => { + const queueConfig = { + queue: { pollEnabled: false, timeout: 55000 }, + } as unknown as ReportingConfigType['queue']; + + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: { ...configType, ...queueConfig }, + logger, + }); + expect(task.getStatus()).toBe('uninitialized'); + expect(task.getTaskDefinition()).toMatchInlineSnapshot(` + Object { + "createTaskRunner": [Function], + "maxConcurrency": 0, + "timeout": "55s", + "title": "Reporting: execute scheduled job", + "type": "report:execute-scheduled", + } + `); + }); + + it('schedules task with request', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager); + + await task.scheduleTask(fakeRawRequest as unknown as KibanaRequest, { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + } as never, + }); + + expect(mockTaskManager.schedule).toHaveBeenCalledWith( + { + id: 'report-so-id', + taskType: SCHEDULED_REPORTING_EXECUTE_TYPE, + state: {}, + params: { + id: 'report-so-id', + spaceId: 'default', + jobtype: 'test1', + }, + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + { request: fakeRawRequest } + ); + }); + + it('uses authorization headers from task manager fake request', async () => { + const runAt = new Date('2023-10-01T00:00:00Z'); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + + jest + // @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance + .spyOn(task, 'completeJob') + .mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never); + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'report-so-id', + runAt, + params: { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + }, + fakeRequest: fakeRawRequest, + } as unknown as RunContext); + + await taskRunner.run(); + + expect(soClient.get).toHaveBeenCalledWith('scheduled_report', 'report-so-id', { + namespace: 'default', + }); + expect(reportStore.addReport).toHaveBeenCalledWith( + expect.objectContaining({ + _id: expect.any(String), + _index: '.kibana-reporting', + jobtype: 'test1', + created_at: expect.any(String), + created_by: 'test-user', + payload: { + headers: '', + title: expect.any(String), + browserTimezone: '', + objectType: 'test', + version: '8.0.0', + forceNow: expect.any(String), + }, + meta: { objectType: 'test' }, + status: 'processing', + attempts: 1, + scheduled_report_id: 'report-so-id', + kibana_name: 'kibana', + kibana_id: 'instance-uuid', + started_at: expect.any(String), + timeout: 120000, + max_attempts: 1, + process_expiration: expect.any(String), + migration_version: '7.14.0', + }) + ); + expect(runTaskFn.mock.calls[0][0].request.headers).toEqual({ + authorization: 'ApiKey skdjtq4u543yt3rhewrh', + }); + }); + + it('throws if no fake request from task', async () => { + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'report-so-id', + runAt: new Date('2023-10-01T00:00:00Z'), + params: { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + }, + fakeRequest: undefined, + } as unknown as RunContext); + + await expect(taskRunner.run()).rejects.toThrowErrorMatchingInlineSnapshot( + `"ReportingError(code: missing_authentication_header_error)"` + ); + + expect(soClient.get).toHaveBeenCalled(); + expect(reportStore.addReport).toHaveBeenCalled(); + + expect(reportStore.setReportError).toHaveBeenLastCalledWith( + expect.objectContaining({ + _id: '290357209345723095', + }), + expect.objectContaining({ + error: expect.objectContaining({ + message: `ReportingError(code: missing_authentication_header_error)`, + }), + }) + ); + }); + + it('throws during reporting if Kibana starts shutting down', async () => { + mockReporting.getExportTypesRegistry().register({ + id: 'noop', + name: 'Noop', + setup: jest.fn(), + start: jest.fn(), + createJob: () => new Promise(() => {}), + runTask: () => new Promise(() => {}), + jobContentExtension: 'pdf', + jobType: 'noop', + validLicenses: [], + } as unknown as ExportType); + const task = new RunScheduledReportTask({ + reporting: mockReporting, + config: configType, + logger, + }); + + jest + // @ts-expect-error TS compilation fails: this overrides a private method of the RunScheduledReportTask instance + .spyOn(task, 'prepareJob') + .mockResolvedValueOnce({ + isLastAttempt: false, + jobId: '290357209345723095', + report: { _id: '290357209345723095', jobtype: 'noop' }, + task: { + id: '290357209345723095', + index: '.reporting-fantastic', + jobtype: 'noop', + payload, + }, + } as never); + + const mockTaskManager = taskManagerMock.createStart(); + await task.init(mockTaskManager); + + const taskDef = task.getTaskDefinition(); + const taskRunner = taskDef.createTaskRunner({ + taskInstance: { + id: 'report-so-id', + params: { + id: 'report-so-id', + jobtype: 'test1', + schedule: { + rrule: { freq: Frequency.DAILY, interval: 2, tzid: 'UTC' }, + }, + }, + }, + fakeRequest: fakeRawRequest, + } as unknown as RunContext); + + const taskPromise = taskRunner.run(); + setImmediate(() => { + mockReporting.pluginStop(); + }); + await taskPromise.catch(() => {}); + + expect(reportStore.setReportError).toHaveBeenLastCalledWith( + expect.objectContaining({ + _id: '290357209345723095', + }), + expect.objectContaining({ + error: expect.objectContaining({ + message: `ReportingError(code: ${new KibanaShuttingDownError().code})`, + }), + }) + ); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.ts new file mode 100644 index 0000000000000..67f5a9dc1bf61 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_scheduled_report.ts @@ -0,0 +1,118 @@ +/* + * 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 type { KibanaRequest } from '@kbn/core/server'; +import { numberToDuration } from '@kbn/reporting-common'; +import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server'; + +import { DEFAULT_SPACE_ID } from '@kbn/spaces-utils'; +import { + SCHEDULED_REPORTING_EXECUTE_TYPE, + ScheduledReportTaskParams, + ScheduledReportTaskParamsWithoutSpaceId, +} from '.'; +import type { SavedReport } from '../store'; +import { errorLogger } from './error_logger'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../saved_objects'; +import { PrepareJobResults, RunReportTask } from './run_report'; +import { ScheduledReport } from '../store/scheduled_report'; +import { ScheduledReportType } from '../../types'; + +type ScheduledReportTaskInstance = Omit & { + params: Omit; +}; +export class RunScheduledReportTask extends RunReportTask { + public get TYPE() { + return SCHEDULED_REPORTING_EXECUTE_TYPE; + } + + protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise { + const { runAt, params: scheduledReportTaskParams } = taskInstance; + + let report: SavedReport | undefined; + let jobId: string; + const task = scheduledReportTaskParams as ScheduledReportTaskParams; + const reportSoId = task.id; + const reportSpaceId = task.spaceId || DEFAULT_SPACE_ID; + + try { + if (!reportSoId) { + throw new Error( + `Invalid scheduled report saved object data provided in scheduled task! - No saved object with id "${reportSoId}"` + ); + } + + const internalSoClient = await this.opts.reporting.getInternalSoClient(); + const reportSO = await internalSoClient.get( + SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + reportSoId, + { namespace: reportSpaceId } + ); + + const store = await this.opts.reporting.getStore(); + + // Add the report to ReportingStore to show as processing + report = await store.addReport( + new ScheduledReport({ + runAt, + kibanaId: this.kibanaId!, + kibanaName: this.kibanaName!, + queueTimeout: this.queueTimeout, + reportSO, + }) + ); + + jobId = report._id; + if (!jobId) { + throw new Error(`Unable to store report document in ReportingStore`); + } + } catch (failedToClaim) { + // error claiming report - log the error + errorLogger(this.logger, `Error in running scheduled report ${reportSoId}`, failedToClaim); + } + + return { isLastAttempt: false, jobId: jobId!, report, task: report?.toReportTaskJSON() }; + } + + protected getMaxAttempts() { + return undefined; + } + + public getTaskDefinition() { + // round up from ms to the nearest second + const queueTimeout = + Math.ceil(numberToDuration(this.opts.config.queue.timeout).asSeconds()) + 's'; + const maxConcurrency = this.opts.config.queue.pollEnabled ? 1 : 0; + + return { + type: SCHEDULED_REPORTING_EXECUTE_TYPE, + title: 'Reporting: execute scheduled job', + createTaskRunner: this.getTaskRunner(), + timeout: queueTimeout, + maxConcurrency, + }; + } + + public async scheduleTask( + request: KibanaRequest, + params: ScheduledReportTaskParamsWithoutSpaceId + ) { + const spaceId = this.opts.reporting.getSpaceId(request, this.logger); + const taskInstance: ScheduledReportTaskInstance = { + id: params.id, + taskType: SCHEDULED_REPORTING_EXECUTE_TYPE, + state: {}, + params: { + id: params.id, + spaceId: spaceId || DEFAULT_SPACE_ID, + jobtype: params.jobtype, + }, + schedule: params.schedule, + }; + return await this.getTaskManagerStart().schedule(taskInstance, { request }); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.test.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.test.ts similarity index 88% rename from x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.test.ts rename to x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.test.ts index a8b5eb35f6ad0..58c28dffffe27 100644 --- a/x-pack/platform/plugins/private/reporting/server/lib/tasks/execute_report.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.test.ts @@ -16,7 +16,7 @@ import { cryptoFactory, type ExportType, type ReportingConfigType } from '@kbn/r import type { RunContext } from '@kbn/task-manager-plugin/server'; import { taskManagerMock } from '@kbn/task-manager-plugin/server/mocks'; -import { ExecuteReportTask, REPORTING_EXECUTE_TYPE } from '.'; +import { RunSingleReportTask, REPORTING_EXECUTE_TYPE } from '.'; import { ReportingCore } from '../..'; import { createMockReportingCore } from '../../test_helpers'; import { FakeRawRequest, KibanaRequest } from '@kbn/core/server'; @@ -104,7 +104,7 @@ const fakeRawRequest: FakeRawRequest = { path: '/', }; -describe('Execute Report Task', () => { +describe('Run Single Report Task', () => { let mockReporting: ReportingCore; let configType: ReportingConfigType; beforeAll(async () => { @@ -113,7 +113,7 @@ describe('Execute Report Task', () => { }); it('Instance setup', () => { - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); expect(task.getStatus()).toBe('uninitialized'); expect(task.getTaskDefinition()).toMatchInlineSnapshot(` Object { @@ -129,7 +129,7 @@ describe('Execute Report Task', () => { it('Instance start', () => { const mockTaskManager = taskManagerMock.createStart(); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); expect(task.init(mockTaskManager)); expect(task.getStatus()).toBe('initialized'); }); @@ -138,7 +138,7 @@ describe('Execute Report Task', () => { logger.info = jest.fn(); logger.error = jest.fn(); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const taskDef = task.getTaskDefinition(); const taskRunner = taskDef.createTaskRunner({ taskInstance: { @@ -155,7 +155,11 @@ describe('Execute Report Task', () => { queue: { pollEnabled: false, timeout: 55000 }, } as unknown as ReportingConfigType['queue']; - const task = new ExecuteReportTask(mockReporting, { ...configType, ...queueConfig }, logger); + const task = new RunSingleReportTask({ + reporting: mockReporting, + config: { ...configType, ...queueConfig }, + logger, + }); expect(task.getStatus()).toBe('uninitialized'); expect(task.getTaskDefinition()).toMatchInlineSnapshot(` Object { @@ -175,7 +179,7 @@ describe('Execute Report Task', () => { hasPermanentEncryptionKey: true, areNotificationsEnabled: true, }); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -208,7 +212,7 @@ describe('Execute Report Task', () => { hasPermanentEncryptionKey: true, areNotificationsEnabled: false, }); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -238,7 +242,7 @@ describe('Execute Report Task', () => { hasPermanentEncryptionKey: false, areNotificationsEnabled: true, }); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -275,14 +279,14 @@ describe('Execute Report Task', () => { jobType: 'test1', validLicenses: [], } as unknown as ExportType); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_completeJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'completeJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test1', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -320,14 +324,14 @@ describe('Execute Report Task', () => { jobType: 'test2', validLicenses: [], } as unknown as ExportType); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_completeJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'completeJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test2', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -367,14 +371,14 @@ describe('Execute Report Task', () => { jobType: 'test3', validLicenses: [], } as unknown as ExportType); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_completeJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'completeJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'test3', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); @@ -421,10 +425,10 @@ describe('Execute Report Task', () => { status: 'processing', } as unknown as estypes.UpdateUpdateWriteResponseBase) ); - const task = new ExecuteReportTask(mockReporting, configType, logger); + const task = new RunSingleReportTask({ reporting: mockReporting, config: configType, logger }); jest - // @ts-expect-error TS compilation fails: this overrides a private method of the ExecuteReportTask instance - .spyOn(task, '_claimJob') + // @ts-expect-error TS compilation fails: this overrides a private method of the RunSingleReportTask instance + .spyOn(task, 'claimJob') .mockResolvedValueOnce({ _id: 'test', jobtype: 'noop', status: 'pending' } as never); const mockTaskManager = taskManagerMock.createStart(); await task.init(mockTaskManager); diff --git a/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.ts b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.ts new file mode 100644 index 0000000000000..d4734801945b8 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/lib/tasks/run_single_report.ts @@ -0,0 +1,160 @@ +/* + * 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 moment from 'moment'; +import type { KibanaRequest } from '@kbn/core/server'; +import { QueueTimeoutError, ReportingError, numberToDuration } from '@kbn/reporting-common'; +import type { ConcreteTaskInstance, TaskInstance } from '@kbn/task-manager-plugin/server'; + +import { REPORTING_EXECUTE_TYPE, ReportTaskParams } from '.'; +import { + isExecutionError, + mapToReportingError, +} from '../../../common/errors/map_to_reporting_error'; +import { SavedReport } from '../store'; +import type { ReportProcessingFields } from '../store/store'; +import { errorLogger } from './error_logger'; +import { PrepareJobResults, RunReportTask } from './run_report'; + +type SingleReportTaskInstance = Omit & { + params: ReportTaskParams; +}; +export class RunSingleReportTask extends RunReportTask { + public get TYPE() { + return REPORTING_EXECUTE_TYPE; + } + + private async claimJob(task: ReportTaskParams): Promise { + const store = await this.opts.reporting.getStore(); + const report = await store.findReportFromTask(task); // receives seq_no and primary_term + const logger = this.logger.get(report._id); + + if (report.status === 'completed') { + throw new Error(`Can not claim the report job: it is already completed!`); + } + + const m = moment(); + + // check if job has exceeded the configured maxAttempts + const maxAttempts = this.getMaxAttempts(); + if (report.attempts >= maxAttempts) { + let err: ReportingError; + if (report.error && isExecutionError(report.error)) { + // We have an error stored from a previous attempts, so we'll use that + // error to fail the job and return it to the user. + const { error } = report; + err = mapToReportingError(error); + err.stack = error.stack; + } else { + if (report.error && report.error instanceof Error) { + errorLogger(logger, 'Error executing report', report.error); + } + err = new QueueTimeoutError( + `Max attempts reached (${maxAttempts}). Queue timeout reached.` + ); + } + await this.failJob(report, err); + throw err; + } + + const startTime = m.toISOString(); + const expirationTime = m.add(this.queueTimeout).toISOString(); + + const doc: ReportProcessingFields = { + kibana_id: this.kibanaId, + kibana_name: this.kibanaName, + attempts: report.attempts + 1, + max_attempts: maxAttempts, + started_at: startTime, + timeout: this.queueTimeout, + process_expiration: expirationTime, + }; + + const claimedReport = new SavedReport({ ...report, ...doc }); + + logger.info( + `Claiming ${claimedReport.jobtype} ${report._id} ` + + `[_index: ${report._index}] ` + + `[_seq_no: ${report._seq_no}] ` + + `[_primary_term: ${report._primary_term}] ` + + `[attempts: ${report.attempts}] ` + + `[process_expiration: ${expirationTime}]` + ); + + // event tracking of claimed job + const eventTracker = this.getEventTracker(report); + const timeSinceCreation = Date.now() - new Date(report.created_at).valueOf(); + eventTracker?.claimJob({ timeSinceCreation }); + + const resp = await store.setReportClaimed(claimedReport, doc); + claimedReport._seq_no = resp._seq_no!; + claimedReport._primary_term = resp._primary_term!; + return claimedReport; + } + + protected async prepareJob(taskInstance: ConcreteTaskInstance): Promise { + const { attempts: taskAttempts, params: reportTaskParams } = taskInstance; + + let report: SavedReport | undefined; + const isLastAttempt = taskAttempts >= this.getMaxAttempts(); + + // find the job in the store and set status to processing + const task = reportTaskParams as ReportTaskParams; + const jobId = task?.id; + + try { + if (!jobId) { + throw new Error('Invalid report data provided in scheduled task!'); + } + + // Update job status to claimed + report = await this.claimJob(task); + } catch (failedToClaim) { + // error claiming report - log the error + // could be version conflict, or too many attempts or no longer connected to ES + errorLogger(this.logger, `Error in claiming ${jobId}`, failedToClaim); + } + + return { isLastAttempt, jobId, report, task }; + } + + protected getMaxAttempts() { + return this.opts.config.capture.maxAttempts ?? 1; + } + + public getTaskDefinition() { + // round up from ms to the nearest second + const queueTimeout = + Math.ceil(numberToDuration(this.opts.config.queue.timeout).asSeconds()) + 's'; + const maxConcurrency = this.opts.config.queue.pollEnabled ? 1 : 0; + const maxAttempts = this.getMaxAttempts(); + + return { + type: REPORTING_EXECUTE_TYPE, + title: 'Reporting: execute job', + createTaskRunner: this.getTaskRunner(), + maxAttempts: maxAttempts + 1, // Add 1 so we get an extra attempt in case of failure during a Kibana restart + timeout: queueTimeout, + maxConcurrency, + }; + } + + public async scheduleTask(request: KibanaRequest, params: ReportTaskParams) { + const reportingHealth = await this.opts.reporting.getHealthInfo(); + const shouldScheduleWithApiKey = + reportingHealth.hasPermanentEncryptionKey && reportingHealth.isSufficientlySecure; + const taskInstance: SingleReportTaskInstance = { + taskType: REPORTING_EXECUTE_TYPE, + state: {}, + params, + }; + + return shouldScheduleWithApiKey + ? await this.getTaskManagerStart().schedule(taskInstance, { request }) + : await this.getTaskManagerStart().schedule(taskInstance); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/plugin.test.ts b/x-pack/platform/plugins/private/reporting/server/plugin.test.ts index 2322794836989..59b15be74e934 100644 --- a/x-pack/platform/plugins/private/reporting/server/plugin.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/plugin.test.ts @@ -76,6 +76,21 @@ describe('Reporting Plugin', () => { ); }); + it('registers a saved object for scheduled reports', async () => { + plugin.setup(coreSetup, pluginSetup); + expect(coreSetup.savedObjects.registerType).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'scheduled_report', + namespaceType: 'multiple', + hidden: true, + indexPattern: '.kibana_alerting_cases', + management: { + importableAndExportable: false, + }, + }) + ); + }); + it('logs start issues', async () => { // wait for the setup phase background work plugin.setup(coreSetup, pluginSetup); @@ -168,21 +183,37 @@ describe('Reporting Plugin', () => { }); describe('features registration', () => { - it('does not register Kibana reporting feature in traditional build flavour', async () => { + it('registers Kibana manage scheduled reporting feature in traditional build flavour', async () => { plugin.setup(coreSetup, pluginSetup); - expect(featuresSetup.registerKibanaFeature).not.toHaveBeenCalled(); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1); + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({ + id: 'manageReporting', + name: 'Manage Scheduled Reports', + description: 'View and manage scheduled reports for all users in this space.', + category: DEFAULT_APP_CATEGORIES.management, + scope: ['spaces', 'security'], + app: [], + privileges: { + all: { + api: ['manage_scheduled_reports'], + savedObject: { all: ['scheduled_report'], read: [] }, + ui: ['show'], + }, + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, + }, + }); expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1); }); - it('registers Kibana reporting feature in serverless build flavour', async () => { + it('registers additional Kibana reporting feature in serverless build flavour', async () => { const serverlessInitContext = coreMock.createPluginInitializerContext(configSchema); // Force type-cast to convert `ReadOnly` to mutable `PackageInfo`. (serverlessInitContext.env.packageInfo as PackageInfo).buildFlavor = 'serverless'; plugin = new ReportingPlugin(serverlessInitContext); plugin.setup(coreSetup, pluginSetup); - expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(1); - expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledWith({ + expect(featuresSetup.registerKibanaFeature).toHaveBeenCalledTimes(2); + expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(1, { id: 'reporting', name: 'Reporting', category: DEFAULT_APP_CATEGORIES.management, @@ -193,6 +224,22 @@ describe('Reporting Plugin', () => { read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, }, }); + expect(featuresSetup.registerKibanaFeature).toHaveBeenNthCalledWith(2, { + id: 'manageReporting', + name: 'Manage Scheduled Reports', + description: 'View and manage scheduled reports for all users in this space.', + category: DEFAULT_APP_CATEGORIES.management, + scope: ['spaces', 'security'], + app: [], + privileges: { + all: { + api: ['manage_scheduled_reports'], + savedObject: { all: ['scheduled_report'], read: [] }, + ui: ['show'], + }, + read: { disabled: true, savedObject: { all: [], read: [] }, ui: [] }, + }, + }); expect(featuresSetup.enableReportingUiCapabilities).toHaveBeenCalledTimes(1); }); }); diff --git a/x-pack/platform/plugins/private/reporting/server/plugin.ts b/x-pack/platform/plugins/private/reporting/server/plugin.ts index 12bfb3decb805..42d0889b08477 100644 --- a/x-pack/platform/plugins/private/reporting/server/plugin.ts +++ b/x-pack/platform/plugins/private/reporting/server/plugin.ts @@ -27,6 +27,7 @@ import type { import { ReportingRequestHandlerContext } from './types'; import { registerReportingEventTypes, registerReportingUsageCollector } from './usage'; import { registerFeatures } from './features'; +import { setupSavedObjects } from './saved_objects'; /* * @internal @@ -75,6 +76,9 @@ export class ReportingPlugin registerReportingUsageCollector(reportingCore, plugins.usageCollection); registerReportingEventTypes(core); + // Saved objects + setupSavedObjects(core.savedObjects); + // Routes registerRoutes(reportingCore, this.logger); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts deleted file mode 100644 index 7e0348d7b93cc..0000000000000 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.ts +++ /dev/null @@ -1,264 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import Boom from '@hapi/boom'; -import moment from 'moment'; - -import { schema, TypeOf } from '@kbn/config-schema'; -import type { KibanaRequest, KibanaResponseFactory, Logger } from '@kbn/core/server'; -import { i18n } from '@kbn/i18n'; -import { PUBLIC_ROUTES } from '@kbn/reporting-common'; -import type { BaseParams } from '@kbn/reporting-common/types'; -import { cryptoFactory } from '@kbn/reporting-server'; -import rison from '@kbn/rison'; - -import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; -import { type Counters, getCounters } from '..'; -import type { ReportingCore } from '../../..'; -import { checkParamsVersion } from '../../../lib'; -import { Report } from '../../../lib/store'; -import type { - ReportingJobResponse, - ReportingRequestHandlerContext, - ReportingUser, -} from '../../../types'; - -export const handleUnavailable = (res: KibanaResponseFactory) => { - return res.custom({ statusCode: 503, body: 'Not Available' }); -}; - -const validation = { - params: schema.object({ exportType: schema.string({ minLength: 2 }) }), - body: schema.nullable(schema.object({ jobParams: schema.maybe(schema.string()) })), - query: schema.nullable(schema.object({ jobParams: schema.string({ defaultValue: '' }) })), -}; - -/** - * Handles the common parts of requests to generate a report - * Serves report job handling in the context of the request to generate the report - */ -export class RequestHandler { - constructor( - private reporting: ReportingCore, - private user: ReportingUser, - private context: ReportingRequestHandlerContext, - private path: string, - private req: KibanaRequest< - TypeOf<(typeof validation)['params']>, - TypeOf<(typeof validation)['query']>, - TypeOf<(typeof validation)['body']> - >, - private res: KibanaResponseFactory, - private logger: Logger - ) {} - - private async encryptHeaders() { - const { encryptionKey } = this.reporting.getConfig(); - const crypto = cryptoFactory(encryptionKey); - return await crypto.encrypt(this.req.headers); - } - - public async enqueueJob(exportTypeId: string, jobParams: BaseParams) { - const { reporting, logger, context, req, user } = this; - - const exportType = reporting.getExportTypesRegistry().getById(exportTypeId); - - if (exportType == null) { - throw new Error(`Export type ${exportTypeId} does not exist in the registry!`); - } - - const store = await reporting.getStore(); - - if (!exportType.createJob) { - throw new Error(`Export type ${exportTypeId} is not a valid instance!`); - } - - // 1. Ensure the incoming params have a version field (should be set by the UI) - jobParams.version = checkParamsVersion(jobParams, logger); - - // 2. Encrypt request headers to store for the running report job to authenticate itself with Kibana - const headers = await this.encryptHeaders(); - - // 3. Create a payload object by calling exportType.createJob(), and adding some automatic parameters - const job = await exportType.createJob(jobParams, context, req); - - const spaceId = reporting.getSpaceId(req, logger); - - const payload = { - ...job, - headers, - title: job.title, - objectType: jobParams.objectType, - browserTimezone: jobParams.browserTimezone, - version: jobParams.version, - spaceId, - }; - - // 4. Add the report to ReportingStore to show as pending - const report = await store.addReport( - new Report({ - jobtype: exportType.jobType, - created_by: user ? user.username : false, - payload, - migration_version: jobParams.version, - space_id: spaceId || DEFAULT_SPACE_ID, - meta: { - // telemetry fields - objectType: jobParams.objectType, - layout: jobParams.layout?.id, - isDeprecated: job.isDeprecated, - }, - }) - ); - logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`); - - // 5. Schedule the report with Task Manager - const task = await reporting.scheduleTask(req, report.toReportTaskJSON()); - logger.info( - `Scheduled ${exportType.name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` - ); - - // 6. Log the action with event log - reporting.getEventLogger(report, task).logScheduleTask(); - return report; - } - - public getJobParams(): BaseParams { - let jobParamsRison: null | string = null; - const req = this.req; - const res = this.res; - - if (req.body) { - const { jobParams: jobParamsPayload } = req.body; - jobParamsRison = jobParamsPayload ? jobParamsPayload : null; - } else if (req.query?.jobParams) { - const { jobParams: queryJobParams } = req.query; - if (queryJobParams) { - jobParamsRison = queryJobParams; - } else { - jobParamsRison = null; - } - } - - if (!jobParamsRison) { - throw res.customError({ - statusCode: 400, - body: 'A jobParams RISON string is required in the querystring or POST body', - }); - } - - let jobParams; - - try { - jobParams = rison.decode(jobParamsRison) as BaseParams | null; - if (!jobParams) { - throw res.customError({ - statusCode: 400, - body: 'Missing jobParams!', - }); - } - } catch (err) { - throw res.customError({ - statusCode: 400, - body: `invalid rison: ${jobParamsRison}`, - }); - } - - return jobParams; - } - - public static getValidation() { - return validation; - } - - public async handleGenerateRequest(exportTypeId: string, jobParams: BaseParams) { - const req = this.req; - const reporting = this.reporting; - - const counters = getCounters( - req.route.method, - this.path.replace(/{exportType}/, exportTypeId), - reporting.getUsageCounter() - ); - - // ensure the async dependencies are loaded - if (!this.context.reporting) { - return handleUnavailable(this.res); - } - - const licenseInfo = await this.reporting.getLicenseInfo(); - const licenseResults = licenseInfo[exportTypeId]; - - if (!licenseResults) { - return this.res.badRequest({ body: `Invalid export-type of ${exportTypeId}` }); - } - - if (!licenseResults.enableLinks) { - return this.res.forbidden({ body: licenseResults.message }); - } - - if (jobParams.browserTimezone && !moment.tz.zone(jobParams.browserTimezone)) { - return this.res.badRequest({ - body: `Invalid timezone "${jobParams.browserTimezone ?? ''}".`, - }); - } - - let report: Report | undefined; - try { - report = await this.enqueueJob(exportTypeId, jobParams); - const { basePath } = this.reporting.getServerInfo(); - const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX; - - // return task manager's task information and the download URL - counters.usageCounter(); - const eventTracker = reporting.getEventTracker( - report._id, - exportTypeId, - jobParams.objectType - ); - eventTracker?.createReport({ - isDeprecated: Boolean(report.payload.isDeprecated), - isPublicApi: this.path.match(/internal/) === null, - }); - - return this.res.ok({ - headers: { 'content-type': 'application/json' }, - body: { - path: `${publicDownloadPath}/${report._id}`, - job: report.toApiJSON(), - }, - }); - } catch (err) { - return this.handleError(err, counters, report?.jobtype); - } - } - - private handleError(err: Error | Boom.Boom, counters: Counters, jobtype?: string) { - this.logger.error(err); - - if (err instanceof Boom.Boom) { - const statusCode = err.output.statusCode; - counters?.errorCounter(jobtype, statusCode); - - return this.res.customError({ - statusCode, - body: err.output.payload.message, - }); - } - - counters?.errorCounter(jobtype, 500); - - return this.res.customError({ - statusCode: 500, - body: - err?.message || - i18n.translate('xpack.reporting.errorHandler.unknownError', { - defaultMessage: 'Unknown error', - }), - }); - } -} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts index dab96944ea6e8..3f2aa4fd5e3b2 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/jobs/get_job_routes.ts @@ -12,7 +12,7 @@ import { getCounters } from '..'; import { ReportingCore } from '../../..'; import { getContentStream } from '../../../lib'; import { ReportingRequestHandlerContext, ReportingUser } from '../../../types'; -import { handleUnavailable } from '../generate'; +import { handleUnavailable } from '../request_handler'; import { jobManagementPreRouting } from './job_management_pre_routing'; import { jobsQueryFactory } from './jobs_query'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.test.ts similarity index 82% rename from x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.test.ts rename to x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.test.ts index 12216b999045f..724e69448646c 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/request_handler.test.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.test.ts @@ -22,7 +22,7 @@ import { ReportingRequestHandlerContext, ReportingSetup, } from '../../../types'; -import { RequestHandler } from './request_handler'; +import { GenerateRequestHandler } from './generate_request_handler'; jest.mock('@kbn/reporting-server/crypto', () => ({ cryptoFactory: () => ({ @@ -68,7 +68,7 @@ describe('Handle request to generate', () => { let mockContext: ReturnType; let mockRequest: ReturnType; let mockResponseFactory: ReturnType; - let requestHandler: RequestHandler; + let requestHandler: GenerateRequestHandler; beforeEach(async () => { reportingCore = await createMockReportingCore(createMockConfigSchema({})); @@ -91,20 +91,23 @@ describe('Handle request to generate', () => { mockContext = getMockContext(); mockContext.reporting = Promise.resolve({} as ReportingSetup); - requestHandler = new RequestHandler( - reportingCore, - { username: 'testymcgee' }, - mockContext, - '/api/reporting/test/generate/pdf', - mockRequest, - mockResponseFactory, - mockLogger - ); + requestHandler = new GenerateRequestHandler({ + reporting: reportingCore, + user: { username: 'testymcgee' }, + context: mockContext, + path: '/api/reporting/test/generate/pdf', + req: mockRequest, + res: mockResponseFactory, + logger: mockLogger, + }); }); describe('Enqueue Job', () => { test('creates a report object to queue', async () => { - const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams); + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + }); const { _id, created_at: _created_at, payload, ...snapObj } = report; expect(snapObj).toMatchInlineSnapshot(` @@ -131,6 +134,7 @@ describe('Handle request to generate', () => { "output": null, "process_expiration": undefined, "queue_time_ms": undefined, + "scheduled_report_id": undefined, "space_id": "default", "started_at": undefined, "status": "pending", @@ -158,7 +162,10 @@ describe('Handle request to generate', () => { test('provides a default kibana version field for older POST URLs', async () => { // how do we handle the printable_pdf endpoint that isn't migrating to the class instance of export types? (mockJobParams as unknown as { version?: string }).version = undefined; - const report = await requestHandler.enqueueJob('printablePdfV2', mockJobParams); + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + }); const { _id, created_at: _created_at, ...snapObj } = report; expect(snapObj.payload.version).toBe('7.14.0'); @@ -207,10 +214,14 @@ describe('Handle request to generate', () => { }); }); - describe('handleGenerateRequest', () => { + describe('handleRequest', () => { test('disallows invalid export type', async () => { - expect(await requestHandler.handleGenerateRequest('neanderthals', mockJobParams)) - .toMatchInlineSnapshot(` + expect( + await requestHandler.handleRequest({ + exportTypeId: 'neanderthals', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` Object { "body": "Invalid export-type of neanderthals", } @@ -225,8 +236,12 @@ describe('Handle request to generate', () => { }, })); - expect(await requestHandler.handleGenerateRequest('csv_searchsource', mockJobParams)) - .toMatchInlineSnapshot(` + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` Object { "body": "seeing this means the license isn't supported", } @@ -234,30 +249,26 @@ describe('Handle request to generate', () => { }); test('disallows invalid browser timezone', async () => { - (reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({ - csv_searchsource: { - enableLinks: false, - message: `seeing this means the license isn't supported`, - }, - })); - expect( - await requestHandler.handleGenerateRequest('csv_searchsource', { - ...mockJobParams, - browserTimezone: 'America/Amsterdam', + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: { + ...mockJobParams, + browserTimezone: 'America/Amsterdam', + }, }) ).toMatchInlineSnapshot(` Object { - "body": "seeing this means the license isn't supported", + "body": "Invalid timezone \\"America/Amsterdam\\".", } `); }); test('generates the download path', async () => { - const { body } = (await requestHandler.handleGenerateRequest( - 'csv_searchsource', - mockJobParams - )) as unknown as { body: ReportingJobResponse }; + const { body } = (await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + })) as unknown as { body: ReportingJobResponse }; expect(body.path).toMatch('/mock-server-basepath/api/reporting/jobs/download/mock-report-id'); }); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.ts new file mode 100644 index 0000000000000..9a835a668b993 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/generate_request_handler.ts @@ -0,0 +1,134 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { PUBLIC_ROUTES } from '@kbn/reporting-common'; +import { DEFAULT_SPACE_ID } from '@kbn/spaces-plugin/common'; +import { getCounters } from '..'; +import { Report, SavedReport } from '../../../lib/store'; +import type { ReportingJobResponse } from '../../../types'; +import { RequestHandler, RequestParams } from './request_handler'; + +const validation = { + params: schema.object({ exportType: schema.string({ minLength: 2 }) }), + body: schema.nullable(schema.object({ jobParams: schema.maybe(schema.string()) })), + query: schema.nullable(schema.object({ jobParams: schema.string({ defaultValue: '' }) })), +}; + +/** + * Handles the common parts of requests to generate a report + * Serves report job handling in the context of the request to generate the report + */ +export class GenerateRequestHandler extends RequestHandler< + (typeof validation)['params'], + (typeof validation)['query'], + (typeof validation)['body'], + SavedReport +> { + public static getValidation() { + return validation; + } + + public async enqueueJob(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, logger, req, user } = this.opts; + + const store = await reporting.getStore(); + const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams); + + const spaceId = reporting.getSpaceId(req, logger); + + // Encrypt request headers to store for the running report job to authenticate itself with Kibana + const headers = await this.encryptHeaders(); + + const payload = { + ...job, + headers, + title: job.title, + objectType: jobParams.objectType, + browserTimezone: jobParams.browserTimezone, + version, + spaceId, + }; + + // Add the report to ReportingStore to show as pending + const report = await store.addReport( + new Report({ + jobtype: jobType, + created_by: user ? user.username : false, + payload, + migration_version: version, + space_id: spaceId || DEFAULT_SPACE_ID, + meta: { + // telemetry fields + objectType: jobParams.objectType, + layout: jobParams.layout?.id, + isDeprecated: job.isDeprecated, + }, + }) + ); + logger.debug(`Successfully stored pending job: ${report._index}/${report._id}`); + + // Schedule the report with Task Manager + const task = await reporting.scheduleTask(req, report.toReportTaskJSON()); + logger.info( + `Scheduled ${name} reporting task. Task ID: task:${task.id}. Report ID: ${report._id}` + ); + + // Log the action with event log + reporting.getEventLogger(report, task).logScheduleTask(); + return report; + } + + public async handleRequest(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, req, res, path } = this.opts; + + const counters = getCounters( + req.route.method, + path.replace(/{exportType}/, exportTypeId), + reporting.getUsageCounter() + ); + + const checkErrorResponse = await this.checkLicenseAndTimezone( + exportTypeId, + jobParams.browserTimezone + ); + if (checkErrorResponse) { + return checkErrorResponse; + } + + let report: Report | undefined; + try { + report = await this.enqueueJob(params); + const { basePath } = reporting.getServerInfo(); + const publicDownloadPath = basePath + PUBLIC_ROUTES.JOBS.DOWNLOAD_PREFIX; + + // return task manager's task information and the download URL + counters.usageCounter(); + const eventTracker = reporting.getEventTracker( + report._id, + exportTypeId, + jobParams.objectType + ); + eventTracker?.createReport({ + isDeprecated: Boolean(report.payload.isDeprecated), + isPublicApi: path.match(/internal/) === null, + }); + + return res.ok({ + headers: { 'content-type': 'application/json' }, + body: { + path: `${publicDownloadPath}/${report._id}`, + job: report.toApiJSON(), + }, + }); + } catch (err) { + return this.handleError(err, counters, report?.jobtype); + } + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/index.ts new file mode 100644 index 0000000000000..185b6ec86c37a --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/index.ts @@ -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 + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { handleUnavailable } from './request_handler'; +export { GenerateRequestHandler } from './generate_request_handler'; +export { ScheduleRequestHandler } from './schedule_request_handler'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/index.ts new file mode 100644 index 0000000000000..256f5046051f1 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { transformRawScheduledReportToReport } from './transform_raw_scheduled_report'; +export { transformRawScheduledReportToTaskParams } from './transform_raw_scheduled_report_to_task'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report.ts new file mode 100644 index 0000000000000..3006ff48bfad3 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report.ts @@ -0,0 +1,26 @@ +/* + * 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 { SavedObject } from '@kbn/core/server'; +import { ScheduledReportApiJSON, ScheduledReportType } from '../../../../types'; + +export function transformRawScheduledReportToReport( + rawScheduledReport: SavedObject +): ScheduledReportApiJSON { + const parsedPayload = JSON.parse(rawScheduledReport.attributes.payload); + return { + id: rawScheduledReport.id, + jobtype: rawScheduledReport.attributes.jobType, + created_at: rawScheduledReport.attributes.createdAt, + created_by: rawScheduledReport.attributes.createdBy as string | false, + payload: parsedPayload, + meta: rawScheduledReport.attributes.meta, + migration_version: rawScheduledReport.attributes.migrationVersion, + schedule: rawScheduledReport.attributes.schedule, + notification: rawScheduledReport.attributes.notification, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report_to_task.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report_to_task.ts new file mode 100644 index 0000000000000..c221ae4d92493 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/lib/transform_raw_scheduled_report_to_task.ts @@ -0,0 +1,20 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { SavedObject } from '@kbn/core/server'; +import { ScheduledReportType } from '../../../../types'; +import { ScheduledReportTaskParamsWithoutSpaceId } from '../../../../lib/tasks'; + +export function transformRawScheduledReportToTaskParams( + rawScheduledReport: SavedObject +): ScheduledReportTaskParamsWithoutSpaceId { + return { + id: rawScheduledReport.id, + jobtype: rawScheduledReport.attributes.jobType, + schedule: rawScheduledReport.attributes.schedule, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts new file mode 100644 index 0000000000000..55bffafc82552 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/request_handler.ts @@ -0,0 +1,207 @@ +/* + * 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 Boom from '@hapi/boom'; +import moment from 'moment'; +import { schema, TypeOf } from '@kbn/config-schema'; +import type { + IKibanaResponse, + KibanaRequest, + KibanaResponseFactory, + Logger, +} from '@kbn/core/server'; +import { i18n } from '@kbn/i18n'; +import type { BaseParams } from '@kbn/reporting-common/types'; +import { cryptoFactory } from '@kbn/reporting-server'; +import rison from '@kbn/rison'; + +import { RruleSchedule } from '@kbn/task-manager-plugin/server'; +import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest'; +import { checkParamsVersion } from '../../../lib'; +import { type Counters } from '..'; +import type { ReportingCore } from '../../..'; +import type { ReportingRequestHandlerContext, ReportingUser } from '../../../types'; + +export const handleUnavailable = (res: KibanaResponseFactory) => { + return res.custom({ statusCode: 503, body: 'Not Available' }); +}; + +const ParamsValidation = schema.recordOf(schema.string(), schema.string()); +const QueryValidation = schema.nullable( + schema.recordOf(schema.string(), schema.maybe(schema.string())) +); +const BodyValidation = schema.nullable( + schema.recordOf(schema.string(), schema.maybe(schema.any())) +); + +interface ConstructorOpts< + Params extends typeof ParamsValidation, + Query extends typeof QueryValidation, + Body extends typeof BodyValidation +> { + reporting: ReportingCore; + user: ReportingUser; + context: ReportingRequestHandlerContext; + path: string; + req: KibanaRequest, TypeOf, TypeOf>; + res: KibanaResponseFactory; + logger: Logger; +} + +export interface RequestParams { + exportTypeId: string; + jobParams: BaseParams; + schedule?: RruleSchedule; + notification?: RawNotification; +} + +/** + * Handles the common parts of requests to generate or schedule a report + * Serves report job handling in the context of the request to generate the report + */ +export abstract class RequestHandler< + Params extends typeof ParamsValidation, + Query extends typeof QueryValidation, + Body extends typeof BodyValidation, + Output extends Record +> { + constructor(protected readonly opts: ConstructorOpts) {} + + public static getValidation() { + throw new Error('getValidation() must be implemented in a subclass'); + } + + public abstract enqueueJob(params: RequestParams): Promise; + + public abstract handleRequest(params: RequestParams): Promise; + + public getJobParams(): BaseParams { + let jobParamsRison: null | string = null; + const req = this.opts.req; + const res = this.opts.res; + + if (req.body) { + const { jobParams: jobParamsPayload } = req.body; + jobParamsRison = jobParamsPayload ? jobParamsPayload : null; + } else if (req.query?.jobParams) { + const { jobParams: queryJobParams } = req.query; + if (queryJobParams) { + jobParamsRison = queryJobParams; + } else { + jobParamsRison = null; + } + } + + if (!jobParamsRison) { + throw res.customError({ + statusCode: 400, + body: 'A jobParams RISON string is required in the querystring or POST body', + }); + } + + let jobParams; + + try { + jobParams = rison.decode(jobParamsRison) as BaseParams | null; + if (!jobParams) { + throw res.customError({ + statusCode: 400, + body: 'Missing jobParams!', + }); + } + } catch (err) { + throw res.customError({ + statusCode: 400, + body: `invalid rison: ${jobParamsRison}`, + }); + } + + return jobParams; + } + + protected async createJob(exportTypeId: string, jobParams: BaseParams) { + const exportType = this.opts.reporting.getExportTypesRegistry().getById(exportTypeId); + + if (exportType == null) { + throw new Error(`Export type ${exportTypeId} does not exist in the registry!`); + } + + if (!exportType.createJob) { + throw new Error(`Export type ${exportTypeId} is not a valid instance!`); + } + + // 1. Ensure the incoming params have a version field (should be set by the UI) + const version = checkParamsVersion(jobParams, this.opts.logger); + + // 2. Create a payload object by calling exportType.createJob(), and adding some automatic parameters + const job = await exportType.createJob(jobParams, this.opts.context, this.opts.req); + + return { job, version, jobType: exportType.jobType, name: exportType.name }; + } + + protected async checkLicenseAndTimezone( + exportTypeId: string, + browserTimezone: string + ): Promise { + const { reporting, context, res } = this.opts; + + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + + const licenseInfo = await reporting.getLicenseInfo(); + const licenseResults = licenseInfo[exportTypeId]; + + if (!licenseResults) { + return res.badRequest({ body: `Invalid export-type of ${exportTypeId}` }); + } + + if (!licenseResults.enableLinks) { + return res.forbidden({ body: licenseResults.message }); + } + + if (browserTimezone && !moment.tz.zone(browserTimezone)) { + return res.badRequest({ + body: `Invalid timezone "${browserTimezone ?? ''}".`, + }); + } + + return null; + } + + protected async encryptHeaders() { + const { encryptionKey } = this.opts.reporting.getConfig(); + const crypto = cryptoFactory(encryptionKey); + return await crypto.encrypt(this.opts.req.headers); + } + + protected handleError(err: Error | Boom.Boom, counters?: Counters, jobtype?: string) { + this.opts.logger.error(err); + + if (err instanceof Boom.Boom) { + const statusCode = err.output.statusCode; + counters?.errorCounter(jobtype, statusCode); + + return this.opts.res.customError({ + statusCode, + body: err.output.payload.message, + }); + } + + counters?.errorCounter(jobtype, 500); + + return this.opts.res.customError({ + statusCode: 500, + body: + err?.message || + i18n.translate('xpack.reporting.errorHandler.unknownError', { + defaultMessage: 'Unknown error', + }), + }); + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts new file mode 100644 index 0000000000000..1f3f4f8b36c32 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.test.ts @@ -0,0 +1,675 @@ +/* + * 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. + */ + +jest.mock('uuid', () => ({ v4: () => 'mock-report-id' })); + +import rison from '@kbn/rison'; + +import { + FakeRawRequest, + KibanaRequest, + KibanaResponseFactory, + SavedObjectsClientContract, +} from '@kbn/core/server'; +import { coreMock, httpServerMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { JobParamsPDFV2 } from '@kbn/reporting-export-types-pdf-common'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { ReportingCore } from '../../..'; +import { createMockReportingCore } from '../../../test_helpers'; +import { ReportingRequestHandlerContext, ReportingSetup } from '../../../types'; +import { ScheduleRequestHandler } from './schedule_request_handler'; +import { TaskStatus } from '@kbn/task-manager-plugin/server'; + +const getMockContext = () => + ({ + core: coreMock.createRequestHandlerContext(), + } as unknown as ReportingRequestHandlerContext); + +const getMockRequest = () => + ({ + url: { port: '5601', search: '', pathname: '/foo' }, + route: { path: '/foo', options: {} }, + } as KibanaRequest); + +const getMockResponseFactory = () => + ({ + ...httpServerMock.createResponseFactory(), + forbidden: (obj: unknown) => obj, + unauthorized: (obj: unknown) => obj, + customError: (err: unknown) => err, + } as unknown as KibanaResponseFactory); + +const mockLogger = loggingSystemMock.createLogger(); +const mockJobParams: JobParamsPDFV2 = { + browserTimezone: 'UTC', + objectType: 'cool_object_type', + title: 'cool_title', + version: 'unknown', + layout: { id: 'preserve_layout' }, + locatorParams: [], +}; + +const fakeRawRequest: FakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +}; + +describe('Handle request to schedule', () => { + let reportingCore: ReportingCore; + let mockContext: ReturnType; + let mockRequest: ReturnType; + let mockResponseFactory: ReturnType; + let requestHandler: ScheduleRequestHandler; + let soClient: SavedObjectsClientContract; + + beforeEach(async () => { + reportingCore = await createMockReportingCore(createMockConfigSchema({})); + + mockRequest = getMockRequest(); + + mockResponseFactory = getMockResponseFactory(); + (mockResponseFactory.ok as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.forbidden as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.badRequest as jest.Mock) = jest.fn((args: unknown) => args); + + mockContext = getMockContext(); + mockContext.reporting = Promise.resolve({} as ReportingSetup); + + soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest); + soClient.create = jest.fn().mockImplementation(async (_, opts) => { + return { + id: 'foo', + attributes: opts, + type: 'scheduled-report', + }; + }); + + jest.spyOn(reportingCore, 'scheduleRecurringTask').mockResolvedValue({ + id: 'task-id', + scheduledAt: new Date(), + attempts: 0, + status: TaskStatus.Idle, + runAt: new Date(), + startedAt: new Date(), + retryAt: new Date(), + state: {}, + ownerId: 'reporting', + taskType: 'reporting:printable_pdf_v2', + params: {}, + }); + + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, + }); + + requestHandler = new ScheduleRequestHandler({ + reporting: reportingCore, + user: { username: 'testymcgee' }, + context: mockContext, + path: '/api/reporting/test/generate/pdf', + // @ts-ignore + req: mockRequest, + res: mockResponseFactory, + logger: mockLogger, + }); + }); + + describe('enqueueJob', () => { + test('creates a scheduled report saved object and schedules task', async () => { + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + }); + + const { id, created_at: _created_at, payload, ...snapObj } = report; + expect(snapObj).toMatchInlineSnapshot(` + Object { + "created_by": "testymcgee", + "jobtype": "printable_pdf_v2", + "meta": Object { + "isDeprecated": false, + "layout": "preserve_layout", + "objectType": "cool_object_type", + }, + "migration_version": "unknown", + "notification": undefined, + "schedule": Object { + "rrule": Object { + "freq": 1, + "interval": 2, + "tzid": "UTC", + }, + }, + } + `); + expect(payload).toMatchInlineSnapshot(` + Object { + "browserTimezone": "UTC", + "isDeprecated": false, + "layout": Object { + "id": "preserve_layout", + }, + "locatorParams": Array [], + "objectType": "cool_object_type", + "title": "cool_title", + "version": "unknown", + } + `); + + expect(soClient.create).toHaveBeenCalledWith('scheduled_report', { + jobType: 'printable_pdf_v2', + createdAt: expect.any(String), + createdBy: 'testymcgee', + title: 'cool_title', + enabled: true, + payload: JSON.stringify(payload), + schedule: { + rrule: { + freq: 1, + interval: 2, + tzid: 'UTC', + }, + }, + migrationVersion: 'unknown', + meta: { + objectType: 'cool_object_type', + layout: 'preserve_layout', + isDeprecated: false, + }, + }); + + expect(reportingCore.scheduleRecurringTask).toHaveBeenCalledWith(mockRequest, { + id: 'foo', + jobtype: 'printable_pdf_v2', + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + }); + }); + + test('creates a scheduled report saved object with notification', async () => { + const report = await requestHandler.enqueueJob({ + exportTypeId: 'printablePdfV2', + jobParams: mockJobParams, + schedule: { rrule: { freq: 1, interval: 2, tzid: 'UTC' } }, + notification: { email: { to: ['a@b.com'] } }, + }); + + const { id, created_at: _created_at, payload, ...snapObj } = report; + expect(snapObj).toMatchInlineSnapshot(` + Object { + "created_by": "testymcgee", + "jobtype": "printable_pdf_v2", + "meta": Object { + "isDeprecated": false, + "layout": "preserve_layout", + "objectType": "cool_object_type", + }, + "migration_version": "unknown", + "notification": Object { + "email": Object { + "to": Array [ + "a@b.com", + ], + }, + }, + "schedule": Object { + "rrule": Object { + "freq": 1, + "interval": 2, + "tzid": "UTC", + }, + }, + } + `); + expect(payload).toMatchInlineSnapshot(` + Object { + "browserTimezone": "UTC", + "isDeprecated": false, + "layout": Object { + "id": "preserve_layout", + }, + "locatorParams": Array [], + "objectType": "cool_object_type", + "title": "cool_title", + "version": "unknown", + } + `); + + expect(soClient.create).toHaveBeenCalledWith('scheduled_report', { + jobType: 'printable_pdf_v2', + createdAt: expect.any(String), + createdBy: 'testymcgee', + title: 'cool_title', + enabled: true, + payload: JSON.stringify(payload), + schedule: { + rrule: { + freq: 1, + interval: 2, + tzid: 'UTC', + }, + }, + migrationVersion: 'unknown', + meta: { + objectType: 'cool_object_type', + layout: 'preserve_layout', + isDeprecated: false, + }, + notification: { email: { to: ['a@b.com'] } }, + }); + }); + }); + + describe('getJobParams', () => { + test('parse jobParams from body', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { jobParams: rison.encode(mockJobParams) }; + expect(requestHandler.getJobParams()).toEqual(mockJobParams); + }); + + test('handles missing job params', () => { + let error: { statusCode: number; body: string } | undefined; + try { + requestHandler.getJobParams(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + }); + + test('handles null job params', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { jobParams: rison.encode(null) }; + requestHandler.getJobParams(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + }); + + test('handles invalid rison', () => { + let error: { statusCode: number; body: string } | undefined; + // @ts-ignore body is a read-only property + mockRequest.body = { jobParams: mockJobParams }; + try { + requestHandler.getJobParams(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + }); + }); + + describe('getSchedule', () => { + test('parse schedule from body', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + }; + expect(requestHandler.getSchedule()).toEqual({ rrule: { freq: 1, interval: 2 } }); + }); + + test('handles missing schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles null schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: null, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles empty schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: {}, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles null rrule schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: null }, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + + test('handles empty rrule schedule', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: {} }, + }; + requestHandler.getSchedule(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('A schedule is required to create a scheduled report.'); + }); + }); + + describe('getNotification', () => { + test('parse notification from body', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { to: ['a@b.com'] } }, + }; + expect(requestHandler.getNotification()).toEqual({ email: { to: ['a@b.com'] } }); + }); + + test('parse notification from body when no to defined', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { bcc: ['a@b.com'] } }, + }; + expect(requestHandler.getNotification()).toEqual({ email: { bcc: ['a@b.com'] } }); + }); + + test('returns undefined if notification object is empty', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: {}, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification object is null', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: null, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification.email object is empty', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: {} }, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification.email arrays are all empty', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { to: [], cc: [], bcc: [] } }, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('returns undefined if notification.email object is null', () => { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: null }, + }; + expect(requestHandler.getNotification()).toBeUndefined(); + }); + + test('handles invalid email address', () => { + jest + .spyOn(reportingCore, 'validateNotificationEmails') + .mockReturnValueOnce('not valid emails: foo'); + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { email: { to: ['foo'] } }, + }; + requestHandler.getNotification(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe('Invalid email address(es): not valid emails: foo'); + }); + + test('handles too many recipients', () => { + let error: { statusCode: number; body: string } | undefined; + try { + // @ts-ignore body is a read-only property + mockRequest.body = { + jobParams: rison.encode(mockJobParams), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { + email: { + to: [ + '1@elastic.co', + '2@elastic.co', + '3@elastic.co', + '4@elastic.co', + '5@elastic.co', + '6@elastic.co', + '7@elastic.co', + ], + cc: [ + '8@elastic.co', + '9@elastic.co', + '10@elastic.co', + '11@elastic.co', + '12@elastic.co', + '13@elastic.co', + '14@elastic.co', + '15@elastic.co', + '16@elastic.co', + '17@elastic.co', + ], + bcc: [ + '18@elastic.co', + '19@elastic.co', + '20@elastic.co', + '21@elastic.co', + '22@elastic.co', + '23@elastic.co', + '24@elastic.co', + '25@elastic.co', + '26@elastic.co', + '27@elastic.co', + '28@elastic.co', + '29@elastic.co', + '30@elastic.co', + '31@elastic.co', + ], + }, + }, + }; + requestHandler.getNotification(); + } catch (err) { + error = err; + } + + expect(error?.statusCode).toBe(400); + expect(error?.body).toBe( + 'Maximum number of recipients exceeded: cannot specify more than 30 recipients.' + ); + }); + }); + + describe('handleRequest', () => { + test('disallows invalid export type', async () => { + expect( + await requestHandler.handleRequest({ + exportTypeId: 'neanderthals', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Invalid export-type of neanderthals", + } + `); + }); + + test('disallows unsupporting license', async () => { + (reportingCore.getLicenseInfo as jest.Mock) = jest.fn(() => ({ + csv_searchsource: { + enableLinks: false, + message: `seeing this means the license isn't supported`, + }, + })); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "seeing this means the license isn't supported", + } + `); + }); + + test('disallows invalid browser timezone', async () => { + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: { + ...mockJobParams, + browserTimezone: 'America/Amsterdam', + }, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Invalid timezone \\"America/Amsterdam\\".", + } + `); + }); + + test('disallows scheduling when user is "false"', async () => { + requestHandler = new ScheduleRequestHandler({ + reporting: reportingCore, + user: false, + context: mockContext, + path: '/api/reporting/test/generate/pdf', + // @ts-ignore + req: mockRequest, + res: mockResponseFactory, + logger: mockLogger, + }); + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "User must be authenticated to schedule a report", + } + `); + }); + + test('disallows scheduling when reportingHealth.hasPermanentEncryptionKey = false', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: false, + areNotificationsEnabled: true, + }); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Permanent encryption key must be set for scheduled reporting", + } + `); + }); + + test('disallows scheduling when reportingHealth.isSufficientlySecure=false', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, + }); + + expect( + await requestHandler.handleRequest({ + exportTypeId: 'csv_searchsource', + jobParams: mockJobParams, + }) + ).toMatchInlineSnapshot(` + Object { + "body": "Security and API keys must be enabled for scheduled reporting", + } + `); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts new file mode 100644 index 0000000000000..b7b0077279d97 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/request_handler/schedule_request_handler.ts @@ -0,0 +1,212 @@ +/* + * 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 moment from 'moment'; + +import { schema } from '@kbn/config-schema'; +import { isEmpty, omit } from 'lodash'; +import { RruleSchedule, scheduleRruleSchema } from '@kbn/task-manager-plugin/server'; +import { RawNotification } from '../../../saved_objects/scheduled_report/schemas/latest'; +import { rawNotificationSchema } from '../../../saved_objects/scheduled_report/schemas/v1'; +import { + ScheduledReportApiJSON, + ScheduledReportType, + ScheduledReportingJobResponse, +} from '../../../types'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects'; +import { RequestHandler, RequestParams } from './request_handler'; +import { + transformRawScheduledReportToReport, + transformRawScheduledReportToTaskParams, +} from './lib'; + +// Using the limit specified in the cloud email service limits +// https://www.elastic.co/docs/explore-analyze/alerts-cases/watcher/enable-watcher#cloud-email-service-limits +const MAX_ALLOWED_EMAILS = 30; + +const validation = { + params: schema.object({ exportType: schema.string({ minLength: 2 }) }), + body: schema.object({ + schedule: scheduleRruleSchema, + notification: schema.maybe(rawNotificationSchema), + jobParams: schema.string(), + }), + query: schema.nullable(schema.object({})), +}; + +/** + * Handles the common parts of requests to generate a report + * Serves report job handling in the context of the request to generate the report + */ +export class ScheduleRequestHandler extends RequestHandler< + (typeof validation)['params'], + (typeof validation)['query'], + (typeof validation)['body'], + ScheduledReportApiJSON +> { + public static getValidation() { + return validation; + } + + public getSchedule(): RruleSchedule { + let rruleDef: null | RruleSchedule['rrule'] = null; + const req = this.opts.req; + const res = this.opts.res; + + const { schedule } = req.body; + const { rrule } = schedule ?? {}; + rruleDef = rrule; + + if (isEmpty(rruleDef)) { + throw res.customError({ + statusCode: 400, + body: 'A schedule is required to create a scheduled report.', + }); + } + + return schedule; + } + + public getNotification(): RawNotification | undefined { + const { reporting, req, res } = this.opts; + + const { notification } = req.body; + if (isEmpty(notification) || isEmpty(notification.email)) { + return undefined; + } + + const allEmails = new Set([ + ...(notification.email.to || []), + ...(notification.email.bcc || []), + ...(notification.email.cc || []), + ]); + + if (allEmails.size === 0) { + return undefined; + } + + if (allEmails.size > MAX_ALLOWED_EMAILS) { + throw res.customError({ + statusCode: 400, + body: `Maximum number of recipients exceeded: cannot specify more than ${MAX_ALLOWED_EMAILS} recipients.`, + }); + } + + const invalidEmails = reporting.validateNotificationEmails([...allEmails]); + if (invalidEmails) { + throw res.customError({ + statusCode: 400, + body: `Invalid email address(es): ${invalidEmails}`, + }); + } + + return notification; + } + + public async enqueueJob(params: RequestParams) { + const { exportTypeId, jobParams, schedule, notification } = params; + const { reporting, logger, req, user } = this.opts; + + const soClient = await reporting.getScopedSoClient(req); + const { version, job, jobType, name } = await this.createJob(exportTypeId, jobParams); + + const payload = { + ...job, + title: job.title, + objectType: jobParams.objectType, + browserTimezone: jobParams.browserTimezone, + version, + spaceId: reporting.getSpaceId(req, logger), + }; + + // TODO - extract saved object references before persisting + + const attributes = { + createdAt: moment.utc().toISOString(), + // we've already checked that user exists in handleRequest + // this fallback is just to satisfy the type + createdBy: user ? user.username : 'unknown', + enabled: true, + jobType, + meta: { + // telemetry fields + isDeprecated: job.isDeprecated, + layout: jobParams.layout?.id, + objectType: jobParams.objectType, + }, + migrationVersion: version, + ...(notification ? { notification } : {}), + title: job.title, + payload: JSON.stringify(omit(payload, 'forceNow')), + schedule: schedule!, + }; + + // Create a scheduled report saved object + const report = await soClient.create( + SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + attributes + ); + logger.debug(`Successfully created scheduled report: ${report.id}`); + + // Schedule the report with Task Manager + const task = await reporting.scheduleRecurringTask( + req, + transformRawScheduledReportToTaskParams(report) + ); + logger.info( + `Scheduled "${name}" reporting task. Task ID: task:${task.id}. Report ID: ${report.id}` + ); + + return transformRawScheduledReportToReport(report); + } + + public async handleRequest(params: RequestParams) { + const { exportTypeId, jobParams } = params; + const { reporting, res } = this.opts; + + const checkErrorResponse = await this.checkLicenseAndTimezone( + exportTypeId, + jobParams.browserTimezone + ); + if (checkErrorResponse) { + return checkErrorResponse; + } + + // check that security requirements are met + const reportingHealth = await reporting.getHealthInfo(); + if (!reportingHealth.hasPermanentEncryptionKey) { + return res.forbidden({ + body: `Permanent encryption key must be set for scheduled reporting`, + }); + } + if (!reportingHealth.isSufficientlySecure) { + return res.forbidden({ + body: `Security and API keys must be enabled for scheduled reporting`, + }); + } + + // check that username exists + if (!this.opts.user || !this.opts.user.username) { + return res.forbidden({ + body: `User must be authenticated to schedule a report`, + }); + } + + let report: ScheduledReportApiJSON | undefined; + try { + report = await this.enqueueJob(params); + return res.ok({ + headers: { 'content-type': 'application/json' }, + body: { + job: report, + }, + }); + } catch (err) { + return this.handleError(err, undefined, report?.jobtype); + } + } +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/index.ts similarity index 78% rename from x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts rename to x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/index.ts index a16ddf1204b8f..61acdbbbb77ac 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/common/generate/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { handleUnavailable, RequestHandler } from './request_handler'; +export { scheduledQueryFactory } from './scheduled_query'; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts new file mode 100644 index 0000000000000..59381b6539bd8 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.test.ts @@ -0,0 +1,1045 @@ +/* + * 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 { + ElasticsearchClient, + KibanaRequest, + KibanaResponseFactory, + SavedObject, + SavedObjectsClientContract, + SavedObjectsFindResponse, +} from '@kbn/core/server'; +import { + elasticsearchServiceMock, + httpServerMock, + loggingSystemMock, +} from '@kbn/core/server/mocks'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { createMockReportingCore } from '../../../test_helpers'; +import { + transformResponse, + scheduledQueryFactory, + CreatedAtSearchResponse, +} from './scheduled_query'; +import { ReportingCore } from '../../..'; +import { ScheduledReportType } from '../../../types'; +import { TaskManagerStartContract } from '@kbn/task-manager-plugin/server'; + +const fakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +} as unknown as KibanaRequest; + +const getMockResponseFactory = () => + ({ + ...httpServerMock.createResponseFactory(), + forbidden: (obj: unknown) => obj, + unauthorized: (obj: unknown) => obj, + customError: (err: unknown) => err, + } as unknown as KibanaResponseFactory); + +const savedObjects: Array> = [ + { + type: 'scheduled_report', + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + namespaces: ['default'], + attributes: { + createdAt: '2025-05-06T21:10:17.137Z', + createdBy: 'elastic', + enabled: true, + jobType: 'printable_pdf_v2', + meta: { + isDeprecated: false, + layout: 'preserve_layout', + objectType: 'dashboard', + }, + migrationVersion: '9.1.0', + title: '[Logs] Web Traffic', + payload: + '{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}', + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + }, + references: [], + managed: false, + updated_at: '2025-05-06T21:10:17.137Z', + created_at: '2025-05-06T21:10:17.137Z', + version: 'WzEsMV0=', + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '10.1.0', + }, + { + type: 'scheduled_report', + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + namespaces: ['default'], + attributes: { + createdAt: '2025-05-06T21:12:06.584Z', + createdBy: 'not-elastic', + enabled: true, + jobType: 'PNGV2', + meta: { + isDeprecated: false, + layout: 'preserve_layout', + objectType: 'dashboard', + }, + migrationVersion: '9.1.0', + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: '[Logs] Web Traffic', + payload: + '{"browserTimezone":"America/New_York","layout":{"dimensions":{"height":2220,"width":1364},"id":"preserve_layout"},"objectType":"dashboard","title":"[Logs] Web Traffic","version":"9.1.0","locatorParams":[{"id":"DASHBOARD_APP_LOCATOR","params":{"dashboardId":"edf84fe0-e1a0-11e7-b6d5-4dc382ef7f5b","preserveSavedFilters":true,"timeRange":{"from":"now-7d/d","to":"now"},"useHash":false,"viewMode":"view"}}],"isDeprecated":false}', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + references: [], + managed: false, + updated_at: '2025-05-06T21:12:06.584Z', + created_at: '2025-05-06T21:12:06.584Z', + version: 'WzIsMV0=', + coreMigrationVersion: '8.8.0', + typeMigrationVersion: '10.1.0', + }, +]; +const soResponse: SavedObjectsFindResponse = { + page: 1, + per_page: 10, + total: 2, + saved_objects: savedObjects.map((so) => ({ ...so, score: 0 })), +}; + +const lastRunResponse: CreatedAtSearchResponse = { + took: 1, + timed_out: false, + _shards: { total: 1, successful: 1, skipped: 0, failed: 0 }, + hits: { + total: { value: 2, relation: 'eq' }, + max_score: null, + hits: [ + { + _index: '.ds-.kibana-reporting-2025.05.06-000001', + _id: '7c14d3e0-5d3f-4374-87f8-1758d2aaa10b', + _score: null, + _source: { + created_at: '2025-05-06T21:12:07.198Z', + }, + fields: { + scheduled_report_id: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + }, + sort: [1746565930198], + }, + { + _index: '.ds-.kibana-reporting-2025.05.06-000001', + _id: '895f9620-cf3c-4e9e-9bf2-3750360ebd81', + _score: null, + _source: { + created_at: '2025-05-06T12:00:00.500Z', + }, + fields: { + scheduled_report_id: ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca'], + }, + sort: [1746565930198], + }, + ], + }, +}; + +const mockLogger = loggingSystemMock.createLogger(); + +describe('scheduledQueryFactory', () => { + let client: ReturnType; + let core: ReportingCore; + let soClient: SavedObjectsClientContract; + let taskManager: TaskManagerStartContract; + let scheduledQuery: ReturnType; + let mockResponseFactory: ReturnType; + + beforeEach(async () => { + const schema = createMockConfigSchema(); + core = await createMockReportingCore(schema); + + soClient = await core.getScopedSoClient(fakeRawRequest); + soClient.find = jest.fn().mockImplementation(async () => { + return soResponse; + }); + soClient.bulkGet = jest.fn().mockImplementation(async () => ({ saved_objects: savedObjects })); + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: savedObjects.map((so) => ({ + id: so.id, + type: so.type, + attributes: { enabled: false }, + })), + })); + client = (await core.getEsClient()).asInternalUser as typeof client; + client.search.mockResponse( + lastRunResponse as unknown as Awaited> + ); + taskManager = await core.getTaskManager(); + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: savedObjects.map((so) => ({ id: so.id })), + errors: [], + })); + scheduledQuery = scheduledQueryFactory(core); + jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValue(true); + + mockResponseFactory = getMockResponseFactory(); + (mockResponseFactory.ok as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.forbidden as jest.Mock) = jest.fn((args: unknown) => args); + (mockResponseFactory.badRequest as jest.Mock) = jest.fn((args: unknown) => args); + }); + + describe('list', () => { + it('should pass parameters in the request body', async () => { + const result = await scheduledQuery.list( + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'scheduled_report', + page: 1, + perPage: 10, + }); + expect(client.search).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledWith({ + _source: ['created_at'], + collapse: { field: 'scheduled_report_id' }, + index: '.reporting-*,.kibana-reporting*', + query: { + bool: { + filter: [ + { + terms: { + scheduled_report_id: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + }, + }, + ], + }, + }, + size: 10, + sort: [{ created_at: { order: 'desc' } }], + }); + + expect(result).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + object_type: 'dashboard', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + object_type: 'dashboard', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: '[Logs] Web Traffic', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); + + it('should filter by username when user does not have manage reporting permissions', async () => { + jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValueOnce(false); + await scheduledQuery.list( + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'scheduled_report', + page: 1, + perPage: 10, + filter: 'scheduled_report.attributes.createdBy: "somebody"', + }); + expect(client.search).toHaveBeenCalledTimes(1); + expect(client.search).toHaveBeenCalledWith({ + _source: ['created_at'], + collapse: { field: 'scheduled_report_id' }, + index: '.reporting-*,.kibana-reporting*', + query: { + bool: { + filter: [ + { + terms: { + scheduled_report_id: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + }, + }, + ], + }, + }, + size: 10, + sort: [{ created_at: { order: 'desc' } }], + }); + }); + + it('should return an empty array when there are no hits', async () => { + soClient.find = jest.fn().mockImplementationOnce(async () => ({ + page: 1, + per_page: 10, + total: 0, + saved_objects: [], + })); + const result = await scheduledQuery.list( + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + expect(soClient.find).toHaveBeenCalledTimes(1); + expect(soClient.find).toHaveBeenCalledWith({ + type: 'scheduled_report', + page: 1, + perPage: 10, + }); + expect(client.search).not.toHaveBeenCalled(); + expect(result).toEqual({ page: 1, per_page: 10, total: 0, data: [] }); + }); + + it('should reject if the soClient.find throws an error', async () => { + soClient.find = jest.fn().mockImplementationOnce(async () => { + throw new Error('Some error'); + }); + + await expect( + scheduledQuery.list(fakeRawRequest, mockResponseFactory, { username: 'somebody' }, 1, 10) + ).rejects.toMatchInlineSnapshot(` + Object { + "body": "Error listing scheduled reports: Some error", + "statusCode": 500, + } + `); + }); + + it('should gracefully handle esClient.search errors', async () => { + client.search.mockImplementationOnce(async () => { + throw new Error('Some other error'); + }); + + const result = await scheduledQuery.list( + fakeRawRequest, + mockResponseFactory, + { username: 'somebody' }, + 1, + 10 + ); + + expect(result).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + object_type: 'dashboard', + next_run: expect.any(String), + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + object_type: 'dashboard', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: '[Logs] Web Traffic', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); + }); + + describe('bulkDisable', () => { + it('should pass parameters in the request body', async () => { + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'somebody' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(result).toEqual({ + scheduled_report_ids: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + errors: [], + total: 2, + }); + }); + + it('should not disable scheduled report when user does not have permissions', async () => { + jest.spyOn(core, 'canManageReportingForSpace').mockResolvedValueOnce(false); + soClient.bulkUpdate = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ], + })); + taskManager.bulkDisable = jest.fn().mockImplementationOnce(async () => ({ + tasks: [{ id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca' }], + errors: [], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + ]); + + expect(result).toEqual({ + scheduled_report_ids: ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca'], + errors: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + message: `Not found.`, + status: 404, + }, + ], + total: 2, + }); + expect(mockLogger.warn).toHaveBeenCalledWith( + `User "elastic" attempted to disable scheduled report "2da1cb75-04c7-4202-a9f0-f8bcce63b0f4" created by "not-elastic" without sufficient privileges.` + ); + }); + + it('should handle errors in bulk get', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: savedObjects[0].id, + type: savedObjects[0].type, + error: { + error: 'Not Found', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + statusCode: 404, + }, + }, + savedObjects[1], + ], + })); + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ], + })); + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: [{ id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4' }], + errors: [], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(result).toEqual({ + scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + status: 404, + }, + ], + total: 2, + }); + }); + + it('should short-circuit if no saved objects to update', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: savedObjects[0].id, + type: savedObjects[0].type, + error: { + error: 'Not found', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + statusCode: 404, + }, + }, + { + id: savedObjects[1].id, + type: savedObjects[1].type, + error: { error: 'Bad Request', message: 'Some unspecified error', statusCode: 404 }, + }, + ], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).not.toHaveBeenCalled(); + expect(taskManager.bulkDisable).not.toHaveBeenCalled(); + expect(result).toEqual({ + scheduled_report_ids: [], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: + 'Saved object [scheduled-report/aa8b6fb3-cf61-4903-bce3-eec9ddc823ca] not found', + status: 404, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + message: 'Some unspecified error', + status: 404, + }, + ], + total: 2, + }); + }); + + it('should not update saved object if already disabled', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => ({ + saved_objects: [ + { + id: savedObjects[0].id, + type: savedObjects[0].type, + attributes: { ...savedObjects[0].attributes, enabled: false }, + }, + savedObjects[1], + ], + })); + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'somebody' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(mockLogger.debug).toHaveBeenCalledWith( + `Scheduled report aa8b6fb3-cf61-4903-bce3-eec9ddc823ca is already disabled` + ); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + // TM still called with both in case the task was not disabled + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + ]); + + expect(result).toEqual({ + scheduled_report_ids: [ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ], + errors: [], + total: 2, + }); + }); + + it('should handle errors in bulk update', async () => { + soClient.bulkUpdate = jest.fn().mockImplementation(async () => ({ + saved_objects: [ + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + error: { error: 'Conflict', message: 'Error updating saved object', statusCode: 409 }, + }, + ], + })); + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: [{ id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4' }], + errors: [], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(result).toEqual({ + scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: 'Error updating saved object', + status: 409, + }, + ], + total: 2, + }); + }); + + it('should handle errors in bulk disable', async () => { + taskManager.bulkDisable = jest.fn().mockImplementation(async () => ({ + tasks: [{ id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4' }], + errors: [ + { + type: 'task', + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + error: { + statusCode: 400, + error: 'Fail', + message: 'Error disabling task', + }, + }, + ], + })); + const result = await scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'elastic' } + ); + + expect(soClient.bulkGet).toHaveBeenCalledTimes(1); + expect(soClient.bulkGet).toHaveBeenCalledWith([ + { id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', type: 'scheduled_report' }, + { id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', type: 'scheduled_report' }, + ]); + expect(soClient.bulkUpdate).toHaveBeenCalledTimes(1); + expect(soClient.bulkUpdate).toHaveBeenCalledWith([ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + type: 'scheduled_report', + attributes: { enabled: false }, + }, + ]); + expect(taskManager.bulkDisable).toHaveBeenCalledTimes(1); + expect(taskManager.bulkDisable).toHaveBeenCalledWith([ + 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + ]); + + expect(result).toEqual({ + scheduled_report_ids: ['2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + errors: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + message: + 'Scheduled report disabled but task disabling failed due to: Error disabling task', + status: 400, + }, + ], + total: 2, + }); + }); + + it('should reject if the soClient throws an error', async () => { + soClient.bulkGet = jest.fn().mockImplementationOnce(async () => { + throw new Error('Some error'); + }); + + await expect( + scheduledQuery.bulkDisable( + mockLogger, + fakeRawRequest, + mockResponseFactory, + ['aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4'], + { username: 'somebody' } + ) + ).rejects.toMatchInlineSnapshot(` + Object { + "body": "Error disabling scheduled reports: Some error", + "statusCode": 500, + } + `); + }); + }); +}); + +describe('transformResponse', () => { + it('should correctly transform the responses', () => { + expect(transformResponse(soResponse, lastRunResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + object_type: 'dashboard', + last_run: '2025-05-06T12:00:00.500Z', + next_run: expect.any(String), + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + object_type: 'dashboard', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: '[Logs] Web Traffic', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); + + it('handles missing last run response', () => { + const thisLastRunResponse: CreatedAtSearchResponse = { + ...lastRunResponse, + hits: { + total: { value: 1, relation: 'eq' }, + hits: [lastRunResponse.hits.hits[0]], + }, + }; + + expect(transformResponse(soResponse, thisLastRunResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + object_type: 'dashboard', + last_run: undefined, + next_run: expect.any(String), + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + object_type: 'dashboard', + last_run: '2025-05-06T21:12:07.198Z', + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: '[Logs] Web Traffic', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); + + it('handles undefined last run response', () => { + expect(transformResponse(soResponse)).toEqual({ + page: 1, + per_page: 10, + total: 2, + data: [ + { + id: 'aa8b6fb3-cf61-4903-bce3-eec9ddc823ca', + created_at: '2025-05-06T21:10:17.137Z', + created_by: 'elastic', + enabled: true, + jobtype: 'printable_pdf_v2', + object_type: 'dashboard', + last_run: undefined, + next_run: expect.any(String), + schedule: { + rrule: { + freq: 3, + interval: 3, + byhour: [12], + byminute: [0], + tzid: 'UTC', + }, + }, + title: '[Logs] Web Traffic', + }, + { + id: '2da1cb75-04c7-4202-a9f0-f8bcce63b0f4', + created_at: '2025-05-06T21:12:06.584Z', + created_by: 'not-elastic', + enabled: true, + jobtype: 'PNGV2', + object_type: 'dashboard', + last_run: undefined, + next_run: expect.any(String), + notification: { + email: { + to: ['user@elastic.co'], + }, + }, + title: '[Logs] Web Traffic', + schedule: { + rrule: { + freq: 1, + interval: 3, + tzid: 'UTC', + }, + }, + }, + ], + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts new file mode 100644 index 0000000000000..4664236630979 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/common/scheduled/scheduled_query.ts @@ -0,0 +1,290 @@ +/* + * 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 type { + KibanaRequest, + KibanaResponseFactory, + SavedObject, + SavedObjectsFindResponse, +} from '@kbn/core/server'; +import type { Logger } from '@kbn/core/server'; +import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY } from '@kbn/reporting-server'; +import { SearchResponse } from '@elastic/elasticsearch/lib/api/types'; +import { RRule } from '@kbn/rrule'; +import type { ReportingCore } from '../../..'; +import type { + ListScheduledReportApiJSON, + ReportingUser, + ScheduledReportType, +} from '../../../types'; +import { SCHEDULED_REPORT_SAVED_OBJECT_TYPE } from '../../../saved_objects'; + +export const MAX_SCHEDULED_REPORT_LIST_SIZE = 100; +export const DEFAULT_SCHEDULED_REPORT_LIST_SIZE = 10; + +const SCHEDULED_REPORT_ID_FIELD = 'scheduled_report_id'; +const CREATED_AT_FIELD = 'created_at'; +const getUsername = (user: ReportingUser) => (user ? user.username : false); + +interface ApiResponse { + page: number; + per_page: number; + total: number; + data: ListScheduledReportApiJSON[]; +} + +const getEmptyApiResponse = (page: number, perPage: number) => ({ + page, + per_page: perPage, + total: 0, + data: [], +}); + +interface BulkOperationError { + message: string; + status?: number; + id: string; +} + +interface BulkDisableResult { + scheduled_report_ids: string[]; + errors: BulkOperationError[]; + total: number; +} + +export type CreatedAtSearchResponse = SearchResponse<{ created_at: string }>; + +export function transformResponse( + result: SavedObjectsFindResponse, + lastResponse?: CreatedAtSearchResponse +): ApiResponse { + return { + page: result.page, + per_page: result.per_page, + total: result.total, + data: result.saved_objects.map((so) => { + const id = so.id; + const lastRunForId = (lastResponse?.hits.hits ?? []).find( + (hit) => hit.fields?.[SCHEDULED_REPORT_ID_FIELD]?.[0] === id + ); + + const schedule = so.attributes.schedule; + const _rrule = new RRule({ + ...schedule.rrule, + dtstart: new Date(), + }); + + return { + id, + created_at: so.attributes.createdAt, + created_by: so.attributes.createdBy, + enabled: so.attributes.enabled, + jobtype: so.attributes.jobType, + object_type: so.attributes.meta.objectType, + last_run: lastRunForId?._source?.[CREATED_AT_FIELD], + next_run: _rrule.after(new Date())?.toISOString(), + notification: so.attributes.notification, + schedule: so.attributes.schedule, + title: so.attributes.title, + }; + }), + }; +} + +export interface ScheduledQueryFactory { + list( + req: KibanaRequest, + res: KibanaResponseFactory, + user: ReportingUser, + page: number, + size: number + ): Promise; + bulkDisable( + logger: Logger, + req: KibanaRequest, + res: KibanaResponseFactory, + ids: string[], + user: ReportingUser + ): Promise; +} + +export function scheduledQueryFactory(reportingCore: ReportingCore): ScheduledQueryFactory { + return { + async list(req, res, user, page = 1, size = DEFAULT_SCHEDULED_REPORT_LIST_SIZE) { + try { + const esClient = await reportingCore.getEsClient(); + const savedObjectsClient = await reportingCore.getScopedSoClient(req); + const username = getUsername(user); + + // if user has Manage Reporting privileges, we can list + // scheduled reports for all users in this space, otherwise + // we will filter only to the scheduled reports created by the user + const canManageReporting = await reportingCore.canManageReportingForSpace(req); + + const response = await savedObjectsClient.find({ + type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + page, + perPage: size, + ...(!canManageReporting + ? { filter: `scheduled_report.attributes.createdBy: "${username}"` } + : {}), + }); + + if (!response) { + return getEmptyApiResponse(page, size); + } + + const scheduledReportSoIds = response?.saved_objects.map((so) => so.id); + + if (!scheduledReportSoIds || scheduledReportSoIds.length === 0) { + return getEmptyApiResponse(page, size); + } + + let lastRunResponse; + try { + lastRunResponse = (await esClient.asInternalUser.search({ + index: REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY, + size, + _source: [CREATED_AT_FIELD], + sort: [{ [CREATED_AT_FIELD]: { order: 'desc' } }], + query: { + bool: { + filter: [ + { + terms: { + [SCHEDULED_REPORT_ID_FIELD]: scheduledReportSoIds, + }, + }, + ], + }, + }, + collapse: { field: SCHEDULED_REPORT_ID_FIELD }, + })) as CreatedAtSearchResponse; + } catch (error) { + // if no scheduled reports have run yet, we will get an error from the collapse query + // ignore these and return an empty last run + } + + return transformResponse(response, lastRunResponse); + } catch (error) { + throw res.customError({ + statusCode: 500, + body: `Error listing scheduled reports: ${error.message}`, + }); + } + }, + + async bulkDisable(logger, req, res, ids, user) { + try { + const savedObjectsClient = await reportingCore.getScopedSoClient(req); + const taskManager = await reportingCore.getTaskManager(); + + const bulkErrors: BulkOperationError[] = []; + const disabledScheduledReportIds: Set = new Set(); + let taskIdsToDisable: string[] = []; + + const username = getUsername(user); + + // if user has Manage Reporting privileges, they can disable + // scheduled reports for all users in this space + const canManageReporting = await reportingCore.canManageReportingForSpace(req); + + const bulkGetResult = await savedObjectsClient.bulkGet( + ids.map((id) => ({ id, type: SCHEDULED_REPORT_SAVED_OBJECT_TYPE })) + ); + + const scheduledReportSavedObjectsToUpdate: Array> = []; + for (const so of bulkGetResult.saved_objects) { + if (so.error) { + bulkErrors.push({ + message: so.error.message, + status: so.error.statusCode, + id: so.id, + }); + } else { + // check if user is allowed to update this scheduled report + if (so.attributes.createdBy !== username && !canManageReporting) { + bulkErrors.push({ + message: `Not found.`, + status: 404, + id: so.id, + }); + logger.warn( + `User "${username}" attempted to disable scheduled report "${so.id}" created by "${so.attributes.createdBy}" without sufficient privileges.` + ); + } else if (so.attributes.enabled === false) { + logger.debug(`Scheduled report ${so.id} is already disabled`); + disabledScheduledReportIds.add(so.id); + } else { + scheduledReportSavedObjectsToUpdate.push(so); + } + } + } + + // nothing to update, return early + if (scheduledReportSavedObjectsToUpdate.length > 0) { + const bulkUpdateResult = await savedObjectsClient.bulkUpdate( + scheduledReportSavedObjectsToUpdate.map((so) => ({ + id: so.id, + type: so.type, + attributes: { + enabled: false, + }, + })) + ); + + for (const so of bulkUpdateResult.saved_objects) { + if (so.error) { + bulkErrors.push({ + message: so.error.message, + status: so.error.statusCode, + id: so.id, + }); + } else { + taskIdsToDisable.push(so.id); + } + } + } else { + return { + scheduled_report_ids: [...disabledScheduledReportIds], + errors: bulkErrors, + total: disabledScheduledReportIds.size + bulkErrors.length, + }; + } + + // it's possible that the scheduled report saved object was disabled but + // task disabling failed so add the list of already disabled IDs + // task manager filters out disabled tasks so this will not cause extra load + taskIdsToDisable = taskIdsToDisable.concat([...disabledScheduledReportIds]); + + const resultFromDisablingTasks = await taskManager.bulkDisable(taskIdsToDisable); + for (const error of resultFromDisablingTasks.errors) { + bulkErrors.push({ + message: `Scheduled report disabled but task disabling failed due to: ${error.error.message}`, + status: error.error.statusCode, + id: error.id, + }); + } + + for (const result of resultFromDisablingTasks.tasks) { + disabledScheduledReportIds.add(result.id); + } + + return { + scheduled_report_ids: [...disabledScheduledReportIds], + errors: bulkErrors, + total: disabledScheduledReportIds.size + bulkErrors.length, + }; + } catch (error) { + throw res.customError({ + statusCode: 500, + body: `Error disabling scheduled reports: ${error.message}`, + }); + } + }, + }; +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/index.ts b/x-pack/platform/plugins/private/reporting/server/routes/index.ts index f9fbd12802e26..f8436002f08fd 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/index.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/index.ts @@ -11,7 +11,9 @@ import { registerDeprecationsRoutes } from './internal/deprecations/deprecations import { registerDiagnosticRoutes } from './internal/diagnostic'; import { registerHealthRoute } from './internal/health'; import { registerGenerationRoutesInternal } from './internal/generate/generate_from_jobparams'; +import { registerScheduleRoutesInternal } from './internal/schedule/schedule_from_jobparams'; import { registerJobInfoRoutesInternal } from './internal/management/jobs'; +import { registerScheduledRoutesInternal } from './internal/management/scheduled'; import { registerGenerationRoutesPublic } from './public/generate_from_jobparams'; import { registerJobInfoRoutesPublic } from './public/jobs'; @@ -20,7 +22,9 @@ export function registerRoutes(reporting: ReportingCore, logger: Logger) { registerHealthRoute(reporting, logger); registerDiagnosticRoutes(reporting, logger); registerGenerationRoutesInternal(reporting, logger); + registerScheduleRoutesInternal(reporting, logger); registerJobInfoRoutesInternal(reporting); + registerScheduledRoutesInternal(reporting, logger); registerGenerationRoutesPublic(reporting, logger); registerJobInfoRoutesPublic(reporting); } diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts index bd26c88bf6a0a..2d762ebf1cdb7 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/generate/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server'; import { INTERNAL_ROUTES } from '@kbn/reporting-common'; import type { ReportingCore } from '../../..'; import { authorizedUserPreRouting } from '../../common'; -import { RequestHandler } from '../../common/generate'; +import { GenerateRequestHandler } from '../../common/request_handler'; const { GENERATE_PREFIX } = INTERNAL_ROUTES; @@ -30,7 +30,7 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge requiredPrivileges: kibanaAccessControlTags, }, }, - validate: RequestHandler.getValidation(), + validate: GenerateRequestHandler.getValidation(), options: { tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`), access: 'internal', @@ -38,17 +38,20 @@ export function registerGenerationRoutesInternal(reporting: ReportingCore, logge }, authorizedUserPreRouting(reporting, async (user, context, req, res) => { try { - const requestHandler = new RequestHandler( + const requestHandler = new GenerateRequestHandler({ reporting, user, context, path, req, res, - logger - ); + logger, + }); const jobParams = requestHandler.getJobParams(); - return await requestHandler.handleGenerateRequest(req.params.exportType, jobParams); + return await requestHandler.handleRequest({ + exportTypeId: req.params.exportType, + jobParams, + }); } catch (err) { if (err instanceof KibanaResponse) { return err; diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts index 1f631f0f01582..a19e690a21e67 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/jobs.ts @@ -10,7 +10,7 @@ import { INTERNAL_ROUTES } from '@kbn/reporting-common'; import { ROUTE_TAG_CAN_REDIRECT } from '@kbn/security-plugin/server'; import { ReportingCore } from '../../..'; import { authorizedUserPreRouting, getCounters } from '../../common'; -import { handleUnavailable } from '../../common/generate'; +import { handleUnavailable } from '../../common/request_handler'; import { commonJobsRouteHandlerFactory, jobManagementPreRouting, diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/management/scheduled.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/scheduled.ts new file mode 100644 index 0000000000000..c7aef0608299d --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/management/scheduled.ts @@ -0,0 +1,142 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import type { Logger } from '@kbn/core/server'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { KibanaResponse } from '@kbn/core-http-router-server-internal'; +import { ReportingCore } from '../../..'; +import { authorizedUserPreRouting, getCounters } from '../../common'; +import { handleUnavailable } from '../../common/request_handler'; +import { scheduledQueryFactory } from '../../common/scheduled'; +import { + DEFAULT_SCHEDULED_REPORT_LIST_SIZE, + MAX_SCHEDULED_REPORT_LIST_SIZE, +} from '../../common/scheduled/scheduled_query'; + +const { SCHEDULED } = INTERNAL_ROUTES; + +export function registerScheduledRoutesInternal(reporting: ReportingCore, logger: Logger) { + const setupDeps = reporting.getPluginSetupDeps(); + const { router } = setupDeps; + const scheduledQuery = scheduledQueryFactory(reporting); + + const registerInternalGetList = () => { + // list scheduled jobs in the queue, paginated + const path = SCHEDULED.LIST; + router.get( + { + path, + security: { + authz: { + enabled: false, + reason: + 'This route is opted out from authorization because reporting uses its own authorization model.', + }, + }, + validate: { + query: schema.object({ + page: schema.string({ defaultValue: '1' }), + size: schema.string({ + defaultValue: `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`, + validate: (value: string) => { + try { + const size = parseInt(value, 10); + if (size < 1 || size > MAX_SCHEDULED_REPORT_LIST_SIZE) { + return `size must be between 1 and ${MAX_SCHEDULED_REPORT_LIST_SIZE}: size: ${value}`; + } + } catch (e) { + return `size must be an integer: size: ${value}`; + } + }, + }), + }), + }, + options: { access: 'internal' }, + }, + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + try { + const counters = getCounters(req.route.method, path, reporting.getUsageCounter()); + + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + + const { + page: queryPage = '1', + size: querySize = `${DEFAULT_SCHEDULED_REPORT_LIST_SIZE}`, + } = req.query; + const page = parseInt(queryPage, 10) || 1; + const size = Math.min( + MAX_SCHEDULED_REPORT_LIST_SIZE, + parseInt(querySize, 10) || DEFAULT_SCHEDULED_REPORT_LIST_SIZE + ); + const results = await scheduledQuery.list(req, res, user, page, size); + + counters.usageCounter(); + + return res.ok({ body: results, headers: { 'content-type': 'application/json' } }); + } catch (err) { + if (err instanceof KibanaResponse) { + return err; + } + throw err; + } + }) + ); + }; + + const registerInternalPatchBulkDisable = () => { + // allow scheduled reports to be disabled + const path = SCHEDULED.BULK_DISABLE; + + router.patch( + { + path, + security: { + authz: { + enabled: false, + reason: 'This route is opted out from authorization', + }, + }, + validate: { + body: schema.object({ + ids: schema.arrayOf(schema.string(), { minSize: 1, maxSize: 1000 }), + }), + }, + options: { access: 'internal' }, + }, + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + try { + const counters = getCounters(req.route.method, path, reporting.getUsageCounter()); + + // ensure the async dependencies are loaded + if (!context.reporting) { + return handleUnavailable(res); + } + + const { ids } = req.body; + + const results = await scheduledQuery.bulkDisable(logger, req, res, ids, user); + + counters.usageCounter(); + + return res.ok({ body: results, headers: { 'content-type': 'application/json' } }); + } catch (err) { + if (err instanceof KibanaResponse) { + return err; + } + throw err; + } + }) + ); + }; + + registerInternalGetList(); + registerInternalPatchBulkDisable(); +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts new file mode 100644 index 0000000000000..675953d2cbbf5 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/integration_tests/scheduling_from_jobparams.test.ts @@ -0,0 +1,356 @@ +/* + * 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 rison from '@kbn/rison'; +import { BehaviorSubject } from 'rxjs'; +import supertest from 'supertest'; + +import { setupServer } from '@kbn/core-test-helpers-test-utils'; +import { coreMock, loggingSystemMock } from '@kbn/core/server/mocks'; +import { licensingMock } from '@kbn/licensing-plugin/server/mocks'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import { PdfExportType } from '@kbn/reporting-export-types-pdf'; +import { createMockConfigSchema } from '@kbn/reporting-mocks-server'; +import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; + +import { ReportingCore } from '../../../..'; +import { reportingMock } from '../../../../mocks'; +import { + createMockPluginSetup, + createMockPluginStart, + createMockReportingCore, +} from '../../../../test_helpers'; +import { ReportingRequestHandlerContext } from '../../../../types'; +import { registerScheduleRoutesInternal } from '../schedule_from_jobparams'; +import { FakeRawRequest, KibanaRequest, SavedObjectsClientContract } from '@kbn/core/server'; + +type SetupServerReturn = Awaited>; + +const fakeRawRequest: FakeRawRequest = { + headers: { + authorization: `ApiKey skdjtq4u543yt3rhewrh`, + }, + path: '/', +}; + +describe(`POST ${INTERNAL_ROUTES.SCHEDULE_PREFIX}`, () => { + const reportingSymbol = Symbol('reporting'); + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let mockExportTypesRegistry: ExportTypesRegistry; + let reportingCore: ReportingCore; + let soClient: SavedObjectsClientContract; + + const mockConfigSchema = createMockConfigSchema({ + queue: { indexInterval: 'year', timeout: 10000, pollEnabled: true }, + }); + + const mockLogger = loggingSystemMock.createLogger(); + const mockCoreSetup = coreMock.createSetup(); + + const mockPdfExportType = new PdfExportType( + mockCoreSetup, + mockConfigSchema, + mockLogger, + coreMock.createPluginInitializerContext(mockConfigSchema) + ); + + beforeEach(async () => { + ({ server, httpSetup } = await setupServer(reportingSymbol)); + httpSetup.registerRouteHandlerContext( + reportingSymbol, + 'reporting', + () => reportingMock.createStart() + ); + + const mockSetupDeps = createMockPluginSetup({ + security: { license: { isEnabled: () => true, getFeature: () => true } }, + router: httpSetup.createRouter(''), + }); + + const mockStartDeps = await createMockPluginStart( + { + licensing: { + ...licensingMock.createStart(), + license$: new BehaviorSubject({ + isActive: true, + isAvailable: true, + type: 'gold', + getFeature: () => true, + }), + }, + securityService: { + authc: { + apiKeys: { areAPIKeysEnabled: () => true }, + getCurrentUser: () => ({ id: '123', roles: ['superuser'], username: 'Tom Riddle' }), + }, + }, + }, + mockConfigSchema + ); + + reportingCore = await createMockReportingCore(mockConfigSchema, mockSetupDeps, mockStartDeps); + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValue({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: true, + }); + + mockExportTypesRegistry = new ExportTypesRegistry(); + mockExportTypesRegistry.register(mockPdfExportType); + + soClient = await reportingCore.getScopedSoClient(fakeRawRequest as unknown as KibanaRequest); + soClient.create = jest.fn().mockImplementation(async (_, opts) => { + return { + id: 'foo', + attributes: opts, + type: 'scheduled-report', + }; + }); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('returns 400 if there are no job params', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + '"[request body]: expected a plain object value, but found [null] instead."' + ) + ); + }); + + it('returns 400 if job params body is invalid', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ jobParams: `foo:`, schedule: { rrule: { freq: 1, interval: 2 } } }) + .expect(400) + .then(({ body }) => expect(body.message).toMatchInlineSnapshot('"invalid rison: foo:"')); + }); + + it('returns 400 export type is invalid', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/TonyHawksProSkater2`) + .send({ + schedule: { rrule: { freq: 1, interval: 2 } }, + jobParams: rison.encode({ title: `abc` }), + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot('"Invalid export-type of TonyHawksProSkater2"') + ); + }); + + it('returns 400 on invalid browser timezone', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(`"Invalid timezone \\"America/Amsterdam\\"."`) + ); + }); + + it('returns 400 on invalid rrule', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 6, interval: 2 } }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot(` + "[request body.schedule.rrule]: types that failed validation: + - [request body.schedule.rrule.0.freq]: expected value to equal [1] + - [request body.schedule.rrule.1.freq]: expected value to equal [2] + - [request body.schedule.rrule.2.freq]: expected value to equal [3]" + `) + ); + }); + + it('returns 400 on invalid notification list', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { + email: { + to: 'single@email.com', + }, + }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"[request body.notification.email.to]: could not parse array value from json input"` + ) + ); + }); + + it('returns 400 on empty notification list', async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ browserTimezone: 'America/Amsterdam', title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + notification: { + email: { + to: [], + }, + }, + }) + .expect(400) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"[request body.notification.email]: At least one email address is required"` + ) + ); + }); + + it('returns 403 on when no permanent encryption key', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: true, + hasPermanentEncryptionKey: false, + areNotificationsEnabled: false, + }); + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(403) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"Permanent encryption key must be set for scheduled reporting"` + ) + ); + }); + + it('returns 403 on when not sufficiently secure', async () => { + jest.spyOn(reportingCore, 'getHealthInfo').mockResolvedValueOnce({ + isSufficientlySecure: false, + hasPermanentEncryptionKey: true, + areNotificationsEnabled: false, + }); + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(403) + .then(({ body }) => + expect(body.message).toMatchInlineSnapshot( + `"Security and API keys must be enabled for scheduled reporting"` + ) + ); + }); + + it('returns 500 if job handler throws an error', async () => { + soClient.create = jest.fn().mockRejectedValue('silly'); + + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ title: `abc` }), + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(500); + }); + + it(`returns 200 if job handler doesn't error`, async () => { + registerScheduleRoutesInternal(reportingCore, mockLogger); + + await server.start(); + + await supertest(httpSetup.server.listener) + .post(`${INTERNAL_ROUTES.SCHEDULE_PREFIX}/printablePdfV2`) + .send({ + jobParams: rison.encode({ + title: `abc`, + layout: { id: 'test' }, + objectType: 'canvas workpad', + }), + notification: { + email: { + bcc: ['single@email.com'], + }, + }, + schedule: { rrule: { freq: 1, interval: 2 } }, + }) + .expect(200) + .then(({ body }) => { + expect(body).toMatchObject({ + job: { + created_by: 'Tom Riddle', + id: 'foo', + jobtype: 'printable_pdf_v2', + payload: { + isDeprecated: false, + layout: { + id: 'test', + }, + objectType: 'canvas workpad', + title: 'abc', + version: '7.14.0', + }, + schedule: { rrule: { freq: 1, interval: 2 } }, + }, + }); + }); + }); +}); diff --git a/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/schedule_from_jobparams.ts b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/schedule_from_jobparams.ts new file mode 100644 index 0000000000000..0c501c4321898 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/routes/internal/schedule/schedule_from_jobparams.ts @@ -0,0 +1,71 @@ +/* + * 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 { KibanaResponse } from '@kbn/core-http-router-server-internal'; +import type { Logger } from '@kbn/core/server'; +import { INTERNAL_ROUTES } from '@kbn/reporting-common'; +import type { ReportingCore } from '../../..'; +import { authorizedUserPreRouting } from '../../common'; +import { ScheduleRequestHandler } from '../../common/request_handler'; + +const { SCHEDULE_PREFIX } = INTERNAL_ROUTES; + +export function registerScheduleRoutesInternal(reporting: ReportingCore, logger: Logger) { + const setupDeps = reporting.getPluginSetupDeps(); + const { router } = setupDeps; + + const kibanaAccessControlTags = ['generateReport']; + + const registerInternalPostScheduleEndpoint = () => { + const path = `${SCHEDULE_PREFIX}/{exportType}`; + router.post( + { + path, + security: { + authz: { + requiredPrivileges: kibanaAccessControlTags, + }, + }, + validate: ScheduleRequestHandler.getValidation(), + options: { + tags: kibanaAccessControlTags.map((accessControlTag) => `access:${accessControlTag}`), + access: 'internal', + }, + }, + authorizedUserPreRouting(reporting, async (user, context, req, res) => { + try { + const requestHandler = new ScheduleRequestHandler({ + reporting, + user, + context, + path, + req, + res, + logger, + }); + const jobParams = requestHandler.getJobParams(); + const schedule = requestHandler.getSchedule(); + const notification = requestHandler.getNotification(); + + return await requestHandler.handleRequest({ + exportTypeId: req.params.exportType, + jobParams, + schedule, + notification, + }); + } catch (err) { + if (err instanceof KibanaResponse) { + return err; + } + throw err; + } + }) + ); + }; + + registerInternalPostScheduleEndpoint(); +} diff --git a/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts b/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts index f547faa9cab52..34507cf79d56c 100644 --- a/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts +++ b/x-pack/platform/plugins/private/reporting/server/routes/public/generate_from_jobparams.ts @@ -10,7 +10,7 @@ import type { Logger } from '@kbn/core/server'; import { PUBLIC_ROUTES } from '@kbn/reporting-common'; import type { ReportingCore } from '../..'; import { authorizedUserPreRouting } from '../common'; -import { RequestHandler } from '../common/generate'; +import { GenerateRequestHandler } from '../common/request_handler'; export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: Logger) { const setupDeps = reporting.getPluginSetupDeps(); @@ -28,7 +28,7 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: requiredPrivileges: kibanaAccessControlTags, }, }, - validate: RequestHandler.getValidation(), + validate: GenerateRequestHandler.getValidation(), options: { tags: kibanaAccessControlTags.map((controlAccessTag) => `access:${controlAccessTag}`), access: 'public', @@ -36,19 +36,19 @@ export function registerGenerationRoutesPublic(reporting: ReportingCore, logger: }, authorizedUserPreRouting(reporting, async (user, context, req, res) => { try { - const requestHandler = new RequestHandler( + const requestHandler = new GenerateRequestHandler({ reporting, user, context, path, req, res, - logger - ); - return await requestHandler.handleGenerateRequest( - req.params.exportType, - requestHandler.getJobParams() - ); + logger, + }); + return await requestHandler.handleRequest({ + exportTypeId: req.params.exportType, + jobParams: requestHandler.getJobParams(), + }); } catch (err) { if (err instanceof KibanaResponse) { return err; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/index.ts new file mode 100644 index 0000000000000..df34d38fc50d8 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/index.ts @@ -0,0 +1,24 @@ +/* + * 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 { SavedObjectsServiceSetup } from '@kbn/core/server'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { scheduledReportMappings, scheduledReportModelVersions } from './scheduled_report'; + +export const SCHEDULED_REPORT_SAVED_OBJECT_TYPE = 'scheduled_report'; + +export function setupSavedObjects(savedObjects: SavedObjectsServiceSetup) { + savedObjects.registerType({ + name: SCHEDULED_REPORT_SAVED_OBJECT_TYPE, + indexPattern: ALERTING_CASES_SAVED_OBJECT_INDEX, + hidden: true, + namespaceType: 'multiple', + mappings: scheduledReportMappings, + management: { importableAndExportable: false }, + modelVersions: scheduledReportModelVersions, + }); +} diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/index.ts new file mode 100644 index 0000000000000..285297b977eef --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { scheduledReportMappings } from './mappings'; +export { scheduledReportModelVersions } from './model_versions'; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/mappings.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/mappings.ts new file mode 100644 index 0000000000000..26520db4d22ab --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/mappings.ts @@ -0,0 +1,17 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsTypeMappingDefinition } from '@kbn/core/server'; + +export const scheduledReportMappings: SavedObjectsTypeMappingDefinition = { + dynamic: false, + properties: { + createdBy: { + type: 'keyword', + }, + }, +}; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/model_versions.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/model_versions.ts new file mode 100644 index 0000000000000..4123d20974d6c --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/model_versions.ts @@ -0,0 +1,19 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { SavedObjectsModelVersionMap } from '@kbn/core-saved-objects-server'; +import { rawScheduledReportSchemaV1 } from './schemas'; + +export const scheduledReportModelVersions: SavedObjectsModelVersionMap = { + '1': { + changes: [], + schemas: { + forwardCompatibility: rawScheduledReportSchemaV1.extends({}, { unknowns: 'ignore' }), + create: rawScheduledReportSchemaV1, + }, + }, +}; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts new file mode 100644 index 0000000000000..6df4417bb6cef --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/index.ts @@ -0,0 +1,8 @@ +/* + * 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. + */ + +export { rawScheduledReportSchema as rawScheduledReportSchemaV1 } from './v1'; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/latest.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/latest.ts new file mode 100644 index 0000000000000..6f684f9d7cbd7 --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/latest.ts @@ -0,0 +1,12 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { TypeOf } from '@kbn/config-schema'; +import type { rawNotificationSchema, rawScheduledReportSchema } from './v1'; + +export type RawNotification = TypeOf; +export type RawScheduledReport = TypeOf; diff --git a/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/v1.ts b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/v1.ts new file mode 100644 index 0000000000000..4a5dcced733ba --- /dev/null +++ b/x-pack/platform/plugins/private/reporting/server/saved_objects/scheduled_report/schemas/v1.ts @@ -0,0 +1,57 @@ +/* + * 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 { schema } from '@kbn/config-schema'; +import { scheduleRruleSchema } from '@kbn/task-manager-plugin/server'; + +const rawLayoutIdSchema = schema.oneOf([ + schema.literal('preserve_layout'), + schema.literal('print'), + schema.literal('canvas'), +]); + +export const rawNotificationSchema = schema.object({ + email: schema.maybe( + schema.object( + { + to: schema.maybe(schema.arrayOf(schema.string())), + bcc: schema.maybe(schema.arrayOf(schema.string())), + cc: schema.maybe(schema.arrayOf(schema.string())), + }, + { + validate: (value) => { + const allEmails = new Set([ + ...(value.to || []), + ...(value.bcc || []), + ...(value.cc || []), + ]); + + if (allEmails.size === 0) { + return 'At least one email address is required'; + } + }, + } + ) + ), +}); + +export const rawScheduledReportSchema = schema.object({ + createdAt: schema.string(), + createdBy: schema.string(), + enabled: schema.boolean(), + jobType: schema.string(), + meta: schema.object({ + isDeprecated: schema.maybe(schema.boolean()), + layout: schema.maybe(rawLayoutIdSchema), + objectType: schema.string(), + }), + migrationVersion: schema.maybe(schema.string()), + notification: schema.maybe(rawNotificationSchema), + payload: schema.string(), + schedule: scheduleRruleSchema, + title: schema.string(), +}); diff --git a/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts index 36b4420f1670d..2a6d49b61e7f6 100644 --- a/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts +++ b/x-pack/platform/plugins/private/reporting/server/test_helpers/create_mock_reportingplugin.ts @@ -15,8 +15,10 @@ import { docLinksServiceMock, elasticsearchServiceMock, loggingSystemMock, + savedObjectsClientMock, statusServiceMock, } from '@kbn/core/server/mocks'; +import { actionsMock } from '@kbn/actions-plugin/server/mocks'; import { dataPluginMock } from '@kbn/data-plugin/server/mocks'; import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks'; import { encryptedSavedObjectsMock } from '@kbn/encrypted-saved-objects-plugin/server/mocks'; @@ -40,10 +42,22 @@ export const createMockPluginSetup = ( setupMock: Partial> ): ReportingInternalSetup => { return { - encryptedSavedObjects: encryptedSavedObjectsMock.createSetup(), + actions: { + ...actionsMock.createSetup(), + getActionsConfigurationUtilities: jest.fn().mockReturnValue({ + validateEmailAddresses: jest.fn(), + }), + }, + encryptedSavedObjects: encryptedSavedObjectsMock.createSetup({ canEncrypt: true }), features: featuresPluginMock.createSetup(), basePath: { set: jest.fn() }, - router: { get: jest.fn(), post: jest.fn(), put: jest.fn(), delete: jest.fn() }, + router: { + get: jest.fn(), + patch: jest.fn(), + post: jest.fn(), + put: jest.fn(), + delete: jest.fn(), + }, security: securityMock.createSetup(), taskManager: taskManagerMock.createSetup(), logger: loggingSystemMock.createLogger(), @@ -56,6 +70,7 @@ export const createMockPluginSetup = ( const coreSetupMock = coreMock.createSetup(); const coreStartMock = coreMock.createStart(); const logger = loggingSystemMock.createLogger(); +const savedObjectsClient = savedObjectsClientMock.create(); const createMockReportingStore = async (config: ReportingConfigType) => { const mockConfigSchema = createMockConfigSchema(config); @@ -71,7 +86,10 @@ export const createMockPluginStart = async ( return { analytics: coreSetupMock.analytics, esClient: elasticsearchServiceMock.createClusterClient(), - savedObjects: { getScopedClient: jest.fn() }, + savedObjects: { + getScopedClient: jest.fn().mockReturnValue(savedObjectsClient), + createInternalRepository: jest.fn().mockReturnValue(savedObjectsClient), + }, uiSettings: { asScopedToClient: () => ({ get: jest.fn() }) }, discover: discoverPluginMock.createStartContract(), data: dataPluginMock.createStartContract(), diff --git a/x-pack/platform/plugins/private/reporting/server/types.ts b/x-pack/platform/plugins/private/reporting/server/types.ts index e4644380227b4..6a819d22cb159 100644 --- a/x-pack/platform/plugins/private/reporting/server/types.ts +++ b/x-pack/platform/plugins/private/reporting/server/types.ts @@ -12,7 +12,7 @@ import type { DiscoverServerPluginStart } from '@kbn/discover-plugin/server'; import type { FeaturesPluginSetup } from '@kbn/features-plugin/server'; import type { FieldFormatsStart } from '@kbn/field-formats-plugin/server'; import type { LicensingPluginStart } from '@kbn/licensing-plugin/server'; -import type { UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types'; +import type { ReportSource, UrlOrUrlLocatorTuple } from '@kbn/reporting-common/types'; import type { ReportApiJSON } from '@kbn/reporting-common/types'; import type { ReportingConfigType } from '@kbn/reporting-server'; import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/server'; @@ -21,18 +21,24 @@ import type { PngScreenshotOptions as BasePngScreenshotOptions, ScreenshottingStart, } from '@kbn/screenshotting-plugin/server'; -import type { SecurityPluginSetup } from '@kbn/security-plugin/server'; +import type { SecurityPluginSetup, SecurityPluginStart } from '@kbn/security-plugin/server'; import type { SpacesPluginSetup } from '@kbn/spaces-plugin/server'; import type { + RruleSchedule, TaskManagerSetupContract, TaskManagerStartContract, } from '@kbn/task-manager-plugin/server'; import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/server'; +import type { PluginSetupContract as ActionsPluginSetupContract } from '@kbn/actions-plugin/server'; import type { NotificationsPluginStart } from '@kbn/notifications-plugin/server'; import { ExportTypesRegistry } from '@kbn/reporting-server/export_types_registry'; import type { AuthenticatedUser } from '@kbn/core-security-common'; import { EncryptedSavedObjectsPluginSetup } from '@kbn/encrypted-saved-objects-plugin/server'; +import { + RawNotification, + RawScheduledReport, +} from './saved_objects/scheduled_report/schemas/latest'; /** * Plugin Setup Contract @@ -50,6 +56,7 @@ export type ReportingUser = { username: AuthenticatedUser['username'] } | false; export type ScrollConfig = ReportingConfigType['csv']['scroll']; export interface ReportingSetupDeps { + actions: ActionsPluginSetupContract; encryptedSavedObjects: EncryptedSavedObjectsPluginSetup; features: FeaturesPluginSetup; screenshotMode: ScreenshotModePluginSetup; @@ -66,6 +73,7 @@ export interface ReportingStartDeps { licensing: LicensingPluginStart; notifications: NotificationsPluginStart; taskManager: TaskManagerStartContract; + security?: SecurityPluginStart; screenshotting?: ScreenshottingStart; } @@ -92,6 +100,43 @@ export interface ReportingJobResponse { job: ReportApiJSON; } +export type ScheduledReportApiJSON = Omit< + ReportSource, + 'attempts' | 'migration_version' | 'output' | 'payload' | 'status' +> & { + id: string; + migration_version?: string; + notification?: RawNotification; + payload: Omit; + schedule: RruleSchedule; +}; + +export interface ScheduledReportingJobResponse { + /** + * Details of a new report job that was requested + * @public + */ + job: ScheduledReportApiJSON; +} + +export type ScheduledReportType = Omit & { + schedule: RruleSchedule; +}; + +export interface ListScheduledReportApiJSON { + id: string; + created_at: RawScheduledReport['createdAt']; + created_by: RawScheduledReport['createdBy']; + enabled: RawScheduledReport['enabled']; + jobtype: RawScheduledReport['jobType']; + object_type: RawScheduledReport['meta']['objectType']; + last_run: string | undefined; + next_run: string | undefined; + notification: RawScheduledReport['notification']; + schedule: RruleSchedule; + title: RawScheduledReport['title']; +} + export interface PdfScreenshotOptions extends Omit { urls: UrlOrUrlLocatorTuple[]; } diff --git a/x-pack/platform/plugins/private/reporting/tsconfig.json b/x-pack/platform/plugins/private/reporting/tsconfig.json index c7cfd10ec35e3..f8bf4489655cd 100644 --- a/x-pack/platform/plugins/private/reporting/tsconfig.json +++ b/x-pack/platform/plugins/private/reporting/tsconfig.json @@ -5,6 +5,7 @@ }, "include": ["common/**/*", "public/**/*", "server/**/*", "../../../../../typings/**/*"], "kbn_references": [ + "@kbn/actions-plugin", "@kbn/core", "@kbn/data-plugin", "@kbn/discover-plugin", @@ -52,7 +53,14 @@ "@kbn/react-kibana-mount", "@kbn/core-security-common", "@kbn/core-http-server-utils", + "@kbn/core-saved-objects-server", + "@kbn/rrule", "@kbn/notifications-plugin", + "@kbn/spaces-utils", + "@kbn/shared-ux-router", + "@kbn/controls-plugin", + "@kbn/dashboard-plugin", + "@kbn/core-http-browser-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/platform/plugins/private/translations/translations/fr-FR.json b/x-pack/platform/plugins/private/translations/translations/fr-FR.json index 57381932a5a65..1f5887c30762e 100644 --- a/x-pack/platform/plugins/private/translations/translations/fr-FR.json +++ b/x-pack/platform/plugins/private/translations/translations/fr-FR.json @@ -33397,41 +33397,20 @@ "xpack.reporting.listing.infoPanel.tzInfo": "Fuseau horaire", "xpack.reporting.listing.infoPanel.unknownLabel": "inconnue", "xpack.reporting.listing.reports.ilmPolicyLinkText": "Modifier la politique ILM de reporting", - "xpack.reporting.listing.reports.subtitle": "Obtenir les rapports générés dans les applications Kibana.", "xpack.reporting.listing.reports.subtitleStateful": "Obtenir les rapports générés dans les applications Kibana.", "xpack.reporting.listing.reports.titleStateful": "Rapports", - "xpack.reporting.listing.reportstitle": "Rapports", - "xpack.reporting.listing.table.captionDescription": "Rapports générés dans les applications Kibana", "xpack.reporting.listing.table.deleteCancelButton": "Annuler", - "xpack.reporting.listing.table.deleteConfim": "Le rapport {reportTitle} a été supprimé", "xpack.reporting.listing.table.deleteConfirmButton": "Supprimer", "xpack.reporting.listing.table.deleteConfirmMessage": "Vous ne pouvez pas récupérer les rapports supprimés.", "xpack.reporting.listing.table.deleteConfirmTitle": "Supprimer le rapport \"{name}\" ?", - "xpack.reporting.listing.table.deleteFailedErrorMessage": "Le rapport n'a pas été supprimé : {error}", "xpack.reporting.listing.table.deleteNumConfirmTitle": "Supprimer les {num} rapports sélectionnés ?", "xpack.reporting.listing.table.deleteReportButton": "Supprimer {num, plural, one {le rapport} other {les rapports} }", - "xpack.reporting.listing.table.downloadReportButtonLabel": "Télécharger le rapport", - "xpack.reporting.listing.table.downloadReportDescription": "Téléchargez ce rapport dans un nouvel onglet.", - "xpack.reporting.listing.table.loadingReportsDescription": "Chargement des rapports", - "xpack.reporting.listing.table.noCreatedReportsDescription": "Aucun rapport n'a été créé", - "xpack.reporting.listing.table.noTitleLabel": "Sans titre", - "xpack.reporting.listing.table.openInKibanaAppDescription": "Ouvrez l’application Kibana directement à l’emplacement de génération de ce rapport.", - "xpack.reporting.listing.table.openInKibanaAppLabel": "Ouvrir dans Kibana", "xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "Consultez les informations de rapport et le message d'erreur.", "xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "Consultez les informations de rapport et les avertissements.", "xpack.reporting.listing.table.reportInfoButtonTooltip": "Consultez les informations de rapport.", "xpack.reporting.listing.table.reportInfoUnableToFetch": "Impossible de récupérer les informations de rapport.", - "xpack.reporting.listing.table.requestFailedErrorMessage": "Demande refusée", "xpack.reporting.listing.table.showReportInfoAriaLabel": "Afficher les informations de rapport", "xpack.reporting.listing.table.untitledReport": "Rapport sans titre", - "xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "Accédez à des informations supplémentaires sur ce rapport.", - "xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "Afficher les informations du rapport", - "xpack.reporting.listing.tableColumns.actionsTitle": "Actions", - "xpack.reporting.listing.tableColumns.content": "Contenu", - "xpack.reporting.listing.tableColumns.createdAtTitle": "Créé à", - "xpack.reporting.listing.tableColumns.reportTitle": "Titre", - "xpack.reporting.listing.tableColumns.statusTitle": "Statut", - "xpack.reporting.listing.tableColumns.typeTitle": "Type", "xpack.reporting.management.reportingTitle": "Reporting", "xpack.reporting.pdfFooterImageDescription": "Image personnalisée à utiliser dans le pied de page du PDF", "xpack.reporting.pdfFooterImageLabel": "Image de pied de page du PDF", diff --git a/x-pack/platform/plugins/private/translations/translations/ja-JP.json b/x-pack/platform/plugins/private/translations/translations/ja-JP.json index d439382066200..7276955f9188a 100644 --- a/x-pack/platform/plugins/private/translations/translations/ja-JP.json +++ b/x-pack/platform/plugins/private/translations/translations/ja-JP.json @@ -33374,41 +33374,20 @@ "xpack.reporting.listing.infoPanel.tzInfo": "タイムゾーン", "xpack.reporting.listing.infoPanel.unknownLabel": "不明", "xpack.reporting.listing.reports.ilmPolicyLinkText": "レポートILMポリシーを編集", - "xpack.reporting.listing.reports.subtitle": "Kibanaアプリケーションで生成されたレポートを取得します。", "xpack.reporting.listing.reports.subtitleStateful": "Kibanaアプリケーションで生成されたレポートを取得します。", "xpack.reporting.listing.reports.titleStateful": "レポート", - "xpack.reporting.listing.reportstitle": "レポート", - "xpack.reporting.listing.table.captionDescription": "Kibanaアプリケーションでレポートが生成されました", "xpack.reporting.listing.table.deleteCancelButton": "キャンセル", - "xpack.reporting.listing.table.deleteConfim": "{reportTitle} レポートを削除しました", "xpack.reporting.listing.table.deleteConfirmButton": "削除", "xpack.reporting.listing.table.deleteConfirmMessage": "削除されたレポートは復元できません。", "xpack.reporting.listing.table.deleteConfirmTitle": "「{name}」レポートを削除しますか?", - "xpack.reporting.listing.table.deleteFailedErrorMessage": "レポートは削除されませんでした:{error}", "xpack.reporting.listing.table.deleteNumConfirmTitle": "{num}件の選択したレポートを削除しますか?", "xpack.reporting.listing.table.deleteReportButton": "{num, plural, other {件のレポート} }を削除", - "xpack.reporting.listing.table.downloadReportButtonLabel": "レポートをダウンロード", - "xpack.reporting.listing.table.downloadReportDescription": "このレポートを新しいタブでダウンロードします。", - "xpack.reporting.listing.table.loadingReportsDescription": "レポートを読み込み中です", - "xpack.reporting.listing.table.noCreatedReportsDescription": "レポートが作成されていません", - "xpack.reporting.listing.table.noTitleLabel": "無題", - "xpack.reporting.listing.table.openInKibanaAppDescription": "このレポートが生成されたKibanaアプリを開きます。", - "xpack.reporting.listing.table.openInKibanaAppLabel": "Kibanaで開く", "xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "レポート情報とエラーメッセージを参照してください。", "xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "レポート情報と警告を参照してください。", "xpack.reporting.listing.table.reportInfoButtonTooltip": "レポート情報を参照してください。", "xpack.reporting.listing.table.reportInfoUnableToFetch": "レポート情報を取得できません。", - "xpack.reporting.listing.table.requestFailedErrorMessage": "リクエストに失敗しました", "xpack.reporting.listing.table.showReportInfoAriaLabel": "レポート情報を表示", "xpack.reporting.listing.table.untitledReport": "無題のレポート", - "xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "このレポートの詳細を表示してください。", - "xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "レポート情報を表示", - "xpack.reporting.listing.tableColumns.actionsTitle": "アクション", - "xpack.reporting.listing.tableColumns.content": "コンテンツ", - "xpack.reporting.listing.tableColumns.createdAtTitle": "作成日時:", - "xpack.reporting.listing.tableColumns.reportTitle": "タイトル", - "xpack.reporting.listing.tableColumns.statusTitle": "ステータス", - "xpack.reporting.listing.tableColumns.typeTitle": "型", "xpack.reporting.management.reportingTitle": "レポート", "xpack.reporting.pdfFooterImageDescription": "PDFのフッターに使用するカスタム画像です", "xpack.reporting.pdfFooterImageLabel": "PDFフッター画像", diff --git a/x-pack/platform/plugins/private/translations/translations/zh-CN.json b/x-pack/platform/plugins/private/translations/translations/zh-CN.json index b7b3b28febad2..7f1f0581429d4 100644 --- a/x-pack/platform/plugins/private/translations/translations/zh-CN.json +++ b/x-pack/platform/plugins/private/translations/translations/zh-CN.json @@ -33431,41 +33431,20 @@ "xpack.reporting.listing.infoPanel.tzInfo": "时区", "xpack.reporting.listing.infoPanel.unknownLabel": "未知", "xpack.reporting.listing.reports.ilmPolicyLinkText": "编辑报告 ILM 策略", - "xpack.reporting.listing.reports.subtitle": "获取在 Kibana 应用程序中生成的报告。", "xpack.reporting.listing.reports.subtitleStateful": "获取在 Kibana 应用程序中生成的报告。", "xpack.reporting.listing.reports.titleStateful": "报告", - "xpack.reporting.listing.reportstitle": "报告", - "xpack.reporting.listing.table.captionDescription": "在 Kibana 应用程序中生成的报告", "xpack.reporting.listing.table.deleteCancelButton": "取消", - "xpack.reporting.listing.table.deleteConfim": "报告 {reportTitle} 已删除", "xpack.reporting.listing.table.deleteConfirmButton": "删除", "xpack.reporting.listing.table.deleteConfirmMessage": "您无法恢复删除的报告。", "xpack.reporting.listing.table.deleteConfirmTitle": "删除“{name}”报告?", - "xpack.reporting.listing.table.deleteFailedErrorMessage": "报告未删除:{error}", "xpack.reporting.listing.table.deleteNumConfirmTitle": "删除 {num} 个选定报告?", "xpack.reporting.listing.table.deleteReportButton": "删除{num, plural, other {报告} }", - "xpack.reporting.listing.table.downloadReportButtonLabel": "下载报告", - "xpack.reporting.listing.table.downloadReportDescription": "在新选项卡中下载此报告。", - "xpack.reporting.listing.table.loadingReportsDescription": "正在载入报告", - "xpack.reporting.listing.table.noCreatedReportsDescription": "未创建任何报告", - "xpack.reporting.listing.table.noTitleLabel": "未命名", - "xpack.reporting.listing.table.openInKibanaAppDescription": "打开生成此报告的 Kibana 应用。", - "xpack.reporting.listing.table.openInKibanaAppLabel": "在 Kibana 中打开", "xpack.reporting.listing.table.reportInfoAndErrorButtonTooltip": "查看报告信息和错误消息。", "xpack.reporting.listing.table.reportInfoAndWarningsButtonTooltip": "查看报告信息和警告。", "xpack.reporting.listing.table.reportInfoButtonTooltip": "查看报告信息。", "xpack.reporting.listing.table.reportInfoUnableToFetch": "无法提取报告信息。", - "xpack.reporting.listing.table.requestFailedErrorMessage": "请求失败", "xpack.reporting.listing.table.showReportInfoAriaLabel": "显示报告信息", "xpack.reporting.listing.table.untitledReport": "未命名报告", - "xpack.reporting.listing.table.viewReportingInfoActionButtonDescription": "查看有关此报告的其他信息。", - "xpack.reporting.listing.table.viewReportingInfoActionButtonLabel": "查看报告信息", - "xpack.reporting.listing.tableColumns.actionsTitle": "操作", - "xpack.reporting.listing.tableColumns.content": "内容", - "xpack.reporting.listing.tableColumns.createdAtTitle": "创建于", - "xpack.reporting.listing.tableColumns.reportTitle": "标题", - "xpack.reporting.listing.tableColumns.statusTitle": "状态", - "xpack.reporting.listing.tableColumns.typeTitle": "类型", "xpack.reporting.management.reportingTitle": "Reporting", "xpack.reporting.pdfFooterImageDescription": "要在 PDF 的页脚中使用的定制图像", "xpack.reporting.pdfFooterImageLabel": "PDF 页脚图像", diff --git a/x-pack/platform/plugins/shared/cases/public/components/cases_context/query_client.ts b/x-pack/platform/plugins/shared/cases/public/components/cases_context/query_client.ts index 3f27e7b550adf..be57279819e9d 100644 --- a/x-pack/platform/plugins/shared/cases/public/components/cases_context/query_client.ts +++ b/x-pack/platform/plugins/shared/cases/public/components/cases_context/query_client.ts @@ -7,4 +7,13 @@ import { QueryClient } from '@tanstack/react-query'; -export const casesQueryClient = new QueryClient(); +export const casesQueryClient = new QueryClient({ + defaultOptions: { + queries: { + networkMode: 'always', + }, + mutations: { + networkMode: 'always', + }, + }, +}); diff --git a/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap b/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap index b8b5910d83921..01a0046f28e8a 100644 --- a/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap +++ b/x-pack/platform/plugins/shared/features/server/__snapshots__/oss_features.test.ts.snap @@ -479,7 +479,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -570,7 +572,9 @@ Array [ }, "name": "Generate CSV reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -646,7 +650,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -706,7 +712,9 @@ Array [ "minimumLicense": "gold", "name": "Generate PDF or PNG reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -822,7 +830,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -850,7 +860,9 @@ Array [ }, ], "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -942,7 +954,9 @@ Array [ "minimumLicense": "gold", "name": "Generate PDF or PNG reports", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ @@ -962,7 +976,9 @@ Array [ }, "name": "Generate CSV reports from Discover session panels", "savedObject": Object { - "all": Array [], + "all": Array [ + "scheduled_report", + ], "read": Array [], }, "ui": Array [ diff --git a/x-pack/platform/plugins/shared/features/server/oss_features.ts b/x-pack/platform/plugins/shared/features/server/oss_features.ts index 39e016301b0ef..7f3f815453cfb 100644 --- a/x-pack/platform/plugins/shared/features/server/oss_features.ts +++ b/x-pack/platform/plugins/shared/features/server/oss_features.ts @@ -802,7 +802,7 @@ const reportingFeatures: { defaultMessage: 'Generate CSV reports', }), includeIn: 'all', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['generateReport'], ui: ['generateCsv'], @@ -830,7 +830,7 @@ const reportingFeatures: { ), includeIn: 'all', minimumLicense: 'gold', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['generateReport'], ui: ['generateScreenshot'], @@ -844,7 +844,7 @@ const reportingFeatures: { defaultMessage: 'Generate CSV reports from Discover session panels', }), includeIn: 'all', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['downloadCsv'], ui: ['downloadCsv'], @@ -872,7 +872,7 @@ const reportingFeatures: { ), includeIn: 'all', minimumLicense: 'gold', - savedObject: { all: [], read: [] }, + savedObject: { all: ['scheduled_report'], read: [] }, management: { insightsAndAlerting: ['reporting'] }, api: ['generateReport'], ui: ['generateScreenshot'], diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/app.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/app.tsx index 9517529540cd0..6aa4c6f42cee0 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/fleet/app.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/fleet/app.tsx @@ -65,7 +65,16 @@ import { DebugPage } from './sections/debug'; const FEEDBACK_URL = 'https://ela.st/fleet-feedback'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + networkMode: 'always', + }, + mutations: { + networkMode: 'always', + }, + }, +}); export const WithPermissionsAndSetup = memo<{ children?: React.ReactNode }>(({ children }) => { useBreadcrumbs('base'); diff --git a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/app.tsx b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/app.tsx index e1259f12943bc..ebfbaa9f1dda9 100644 --- a/x-pack/platform/plugins/shared/fleet/public/applications/integrations/app.tsx +++ b/x-pack/platform/plugins/shared/fleet/public/applications/integrations/app.tsx @@ -47,7 +47,16 @@ import { IntegrationsHeader } from './components/header'; import { AgentEnrollmentFlyout } from './components'; import { ReadOnlyContextProvider } from './hooks/use_read_only_context'; -const queryClient = new QueryClient(); +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + networkMode: 'always', + }, + mutations: { + networkMode: 'always', + }, + }, +}); const EmptyContext = () => <>; diff --git a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx index 17fc0208b0bf7..7d2466731e104 100644 --- a/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx +++ b/x-pack/platform/plugins/shared/index_management/__jest__/client_integration/index_template_wizard/template_create.test.tsx @@ -235,11 +235,10 @@ describe('', () => { showFilters(); - expect(find('filterList.filterItem').map((wrapper) => wrapper.text())).toEqual([ - 'Index settings', - 'Mappings', - 'Aliases', - ]); + const filtersList = find('filterList.filterItem').map((wrapper) => wrapper.text()); + expect(filtersList[0]).toContain('Index settings'); + expect(filtersList[1]).toContain('Mappings'); + expect(filtersList[2]).toContain('Aliases'); await selectFilter('settings'); expect(getComponentTemplatesInList()).toEqual(['test_component_template_2']); // only this one has settings diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx index bb3bc25254d0e..ba072f684c81a 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/component_templates/component_template_selector/components/filter_list_button.tsx @@ -7,13 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterButton, - EuiPopover, - EuiFilterSelectItem, - EuiFilterGroup, - useEuiTheme, -} from '@elastic/eui'; +import { EuiFilterButton, EuiPopover, EuiFilterGroup, EuiSelectable } from '@elastic/eui'; interface Filter { name: string; @@ -30,7 +24,6 @@ export interface Filters { } export function FilterListButton({ onChange, filters }: Props) { - const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const activeFilters = Object.values(filters).filter((v) => (v as Filter).checked === 'on'); @@ -43,6 +36,13 @@ export function FilterListButton({ onChange, filters }: Props) { setIsPopoverOpen(false); }; + const selectableOptions = Object.entries(filters).map(([filter, item]) => ({ + key: filter, + label: (item as Filter).name, + checked: (item as Filter).checked, + 'data-test-subj': 'filterItem', + })); + const toggleFilter = (filter: string) => { const previousValue = filters[filter].checked; const nextValue = previousValue === 'on' ? 'off' : 'on'; @@ -83,21 +83,23 @@ export function FilterListButton({ onChange, filters }: Props) { panelPaddingSize="none" data-test-subj="filterList" > - {/* EUI NOTE: Please use EuiSelectable (which already has height/scrolling built in) - instead of EuiFilterSelectItem (which is pending deprecation). - @see https://elastic.github.io/eui/#/forms/filter-group#multi-select */} -
- {Object.entries(filters).map(([filter, item], index) => ( - toggleFilter(filter)} - data-test-subj="filterItem" - > - {(item as Filter).name} - - ))} -
+ { + if (changedOption) { + toggleFilter(changedOption.key); + } + }} + > + {(list) => list} + ); diff --git a/x-pack/platform/plugins/shared/index_management/public/application/components/shared/fields/unit_field.tsx b/x-pack/platform/plugins/shared/index_management/public/application/components/shared/fields/unit_field.tsx index 8a01118a5f095..056db92359fd8 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/components/shared/fields/unit_field.tsx +++ b/x-pack/platform/plugins/shared/index_management/public/application/components/shared/fields/unit_field.tsx @@ -6,7 +6,7 @@ */ import React, { FunctionComponent, useState } from 'react'; -import { EuiFilterSelectItem, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; +import { EuiSelectable, EuiPopover, EuiButtonEmpty } from '@elastic/eui'; import { UseField } from '../../../../shared_imports'; interface Props { @@ -52,16 +52,28 @@ export const UnitField: FunctionComponent = ({ path, disabled, options, e closePopover={() => setOpen(false)} {...euiFieldProps} > - {options.map((item) => ( - onSelect(item.value)} - data-test-subj={`filter-option-${item.value}`} - > - {item.text} - - ))} + ({ + key: item.value, + label: item.text, + checked: field.value === item.value ? 'on' : undefined, + 'data-test-subj': `filter-option-${item.value}`, + }))} + onChange={(newOptions, event, changedOption) => { + if (changedOption) { + onSelect(changedOption.key); + } + }} + > + {(list) => list} + ); }} diff --git a/x-pack/platform/plugins/shared/index_management/public/application/sections/home/components/filter_list_button.tsx b/x-pack/platform/plugins/shared/index_management/public/application/sections/home/components/filter_list_button.tsx index fd302285cfb85..afcabdfa1f904 100644 --- a/x-pack/platform/plugins/shared/index_management/public/application/sections/home/components/filter_list_button.tsx +++ b/x-pack/platform/plugins/shared/index_management/public/application/sections/home/components/filter_list_button.tsx @@ -7,13 +7,7 @@ import React, { useState } from 'react'; import { FormattedMessage } from '@kbn/i18n-react'; -import { - EuiFilterButton, - EuiFilterGroup, - EuiPopover, - EuiFilterSelectItem, - useEuiTheme, -} from '@elastic/eui'; +import { EuiFilterButton, EuiFilterGroup, EuiPopover, EuiSelectable } from '@elastic/eui'; interface Filter { name: string; @@ -30,7 +24,6 @@ export type Filters = { }; export function FilterListButton({ onChange, filters }: Props) { - const { euiTheme } = useEuiTheme(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const activeFilters = Object.values(filters).filter((v) => (v as Filter).checked === 'on'); @@ -43,6 +36,13 @@ export function FilterListButton({ onChange, filters }: Props< setIsPopoverOpen(false); }; + const selectableOptions = Object.entries(filters).map(([filter, item]) => ({ + key: filter as T, + label: (item as Filter).name, + checked: (item as Filter).checked, + 'data-test-subj': 'filterItem', + })); + const toggleFilter = (filter: T) => { const previousValue = filters[filter].checked; onChange({ @@ -81,21 +81,23 @@ export function FilterListButton({ onChange, filters }: Props< panelPaddingSize="none" data-test-subj="filterList" > - {/* EUI NOTE: Please use EuiSelectable (which already has height/scrolling built in) - instead of EuiFilterSelectItem (which is pending deprecation). - @see https://elastic.github.io/eui/#/forms/filter-group#multi-select */} -
- {Object.entries(filters).map(([filter, item], index) => ( - toggleFilter(filter as T)} - data-test-subj="filterItem" - > - {(item as Filter).name} - - ))} -
+ { + if (changedOption) { + toggleFilter(changedOption.key); + } + }} + > + {(list) => list} + ); diff --git a/x-pack/platform/plugins/shared/lens/public/plugin.ts b/x-pack/platform/plugins/shared/lens/public/plugin.ts index 4e77308b25be0..4310ff7e9296d 100644 --- a/x-pack/platform/plugins/shared/lens/public/plugin.ts +++ b/x-pack/platform/plugins/shared/lens/public/plugin.ts @@ -28,6 +28,7 @@ import { ACTION_CONVERT_DASHBOARD_PANEL_TO_LENS, ACTION_CONVERT_TO_LENS, DASHBOARD_VISUALIZATION_PANEL_TRIGGER, + ACTION_CONVERT_AGG_BASED_TO_LENS, VisualizationsSetup, VisualizationsStart, } from '@kbn/visualizations-plugin/public'; @@ -672,11 +673,11 @@ export class LensPlugin { startDependencies.uiActions.addTriggerActionAsync( AGG_BASED_VISUALIZATION_TRIGGER, - ACTION_CONVERT_DASHBOARD_PANEL_TO_LENS, + ACTION_CONVERT_AGG_BASED_TO_LENS, async () => { const { convertToLensActionFactory } = await import('./async_services'); const action = convertToLensActionFactory( - ACTION_CONVERT_DASHBOARD_PANEL_TO_LENS, + ACTION_CONVERT_AGG_BASED_TO_LENS, i18n.translate('xpack.lens.visualizeAggBasedLegend', { defaultMessage: 'Visualize agg based chart', }), diff --git a/x-pack/platform/plugins/shared/task_manager/server/constants.ts b/x-pack/platform/plugins/shared/task_manager/server/constants.ts index 86238e44ae642..3fa3ac2489b74 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/constants.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/constants.ts @@ -16,4 +16,5 @@ export const CONCURRENCY_ALLOW_LIST_BY_TASK_TYPE: string[] = [ // task types requiring a concurrency 'report:execute', + 'report:execute-scheduled', ]; diff --git a/x-pack/platform/plugins/shared/task_manager/server/index.ts b/x-pack/platform/plugins/shared/task_manager/server/index.ts index 6678d4c08ca18..a20f3bf83b41f 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/index.ts @@ -24,7 +24,9 @@ export type { } from './task'; export { Frequency, Weekday } from '@kbn/rrule'; +export { scheduleRruleSchema } from './saved_objects'; +export type { RruleSchedule } from './task'; export { TaskStatus, TaskPriority, TaskCost } from './task'; export type { TaskRegisterDefinition, TaskDefinitionRegistry } from './task_type_dictionary'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts b/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts index d3e562c67b723..e030aa9c61e8a 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/lib/get_first_run_at.test.ts @@ -159,7 +159,7 @@ describe('getFirstRunAt', () => { freq: 2, // Weekly interval: 1, tzid: 'UTC', - byweekday: [1], // Monday + byweekday: ['1'], // Monday }, }, }; @@ -182,7 +182,7 @@ describe('getFirstRunAt', () => { freq: 2, // Weekly interval: 1, tzid: 'UTC', - byweekday: [1], // Monday + byweekday: ['MO'], // Monday byhour: [12], byminute: [15], }, @@ -257,7 +257,7 @@ describe('getFirstRunAt', () => { freq: 1, // Monthly interval: 1, tzid: 'UTC', - byweekday: [3], // Wednesday + byweekday: ['3'], // Wednesday byhour: [12], byminute: [17], }, diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts index 9994426fb0831..75b200acc38b1 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/index.ts @@ -14,6 +14,8 @@ import { getOldestIdleActionTask } from '../queries/oldest_idle_action_task'; import { TASK_MANAGER_INDEX } from '../constants'; import { backgroundTaskNodeModelVersions, taskModelVersions } from './model_versions'; +export { scheduleRruleSchema } from './schemas/task'; + export const TASK_SO_NAME = 'task'; export const BACKGROUND_TASK_NODE_SO_NAME = 'background-task-node'; diff --git a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts index 365cc506cb123..874f374355bbb 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/saved_objects/schemas/task.ts @@ -55,6 +55,14 @@ export const taskSchemaV3 = taskSchemaV2.extends({ priority: schema.maybe(schema.number()), }); +export const scheduleIntervalSchema = schema.object({ + interval: schema.string({ validate: validateDuration }), +}); + +export const scheduleRruleSchema = schema.object({ + rrule: rruleSchedule, +}); + export const taskSchemaV4 = taskSchemaV3.extends({ apiKey: schema.maybe(schema.string()), userScope: schema.maybe( @@ -67,14 +75,5 @@ export const taskSchemaV4 = taskSchemaV3.extends({ }); export const taskSchemaV5 = taskSchemaV4.extends({ - schedule: schema.maybe( - schema.oneOf([ - schema.object({ - interval: schema.string({ validate: validateDuration }), - }), - schema.object({ - rrule: rruleSchedule, - }), - ]) - ), + schedule: schema.maybe(schema.oneOf([scheduleIntervalSchema, scheduleRruleSchema])), }); diff --git a/x-pack/platform/plugins/shared/task_manager/server/task.ts b/x-pack/platform/plugins/shared/task_manager/server/task.ts index 2e22f5be2b4a5..b9a59e3452267 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task.ts @@ -11,7 +11,7 @@ import type { ObjectType, TypeOf } from '@kbn/config-schema'; import { schema } from '@kbn/config-schema'; import { isNumber } from 'lodash'; import type { KibanaRequest } from '@kbn/core/server'; -import type { Frequency, Weekday } from '@kbn/rrule'; +import type { Frequency } from '@kbn/rrule'; import { isErr, tryAsResult } from './lib/result_type'; import type { Interval } from './lib/intervals'; import { isInterval, parseIntervalAsMillisecond } from './lib/intervals'; @@ -259,8 +259,9 @@ export interface IntervalSchedule { rrule?: never; } +export type Rrule = RruleMonthly | RruleWeekly | RruleDaily; export interface RruleSchedule { - rrule: RruleMonthly | RruleWeekly | RruleDaily; + rrule: Rrule; interval?: never; } @@ -269,17 +270,16 @@ interface RruleCommon { interval: number; tzid: string; } - interface RruleMonthly extends RruleCommon { freq: Frequency.MONTHLY; bymonthday?: number[]; byhour?: number[]; byminute?: number[]; - byweekday?: Weekday[]; + byweekday?: string[]; } interface RruleWeekly extends RruleCommon { freq: Frequency.WEEKLY; - byweekday?: Weekday[]; + byweekday?: string[]; byhour?: number[]; byminute?: number[]; bymonthday?: never; @@ -288,7 +288,7 @@ interface RruleDaily extends RruleCommon { freq: Frequency.DAILY; byhour?: number[]; byminute?: number[]; - byweekday?: Weekday[]; + byweekday?: string[]; bymonthday?: never; } diff --git a/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts b/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts index cf21ad4c70ff7..84415cc81a0ce 100644 --- a/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts +++ b/x-pack/platform/plugins/shared/task_manager/server/task_type_dictionary.ts @@ -35,6 +35,9 @@ export const REMOVED_TYPES: string[] = [ export const SHARED_CONCURRENCY_TASKS: string[][] = [ // for testing ['sampleTaskSharedConcurrencyType1', 'sampleTaskSharedConcurrencyType2'], + + // reporting + ['report:execute', 'report:execute-scheduled'], ]; /** diff --git a/x-pack/platform/test/api_integration/apis/features/features/features.ts b/x-pack/platform/test/api_integration/apis/features/features/features.ts index cef238e8dc28e..7386d58ed939d 100644 --- a/x-pack/platform/test/api_integration/apis/features/features/features.ts +++ b/x-pack/platform/test/api_integration/apis/features/features/features.ts @@ -127,6 +127,7 @@ export default function ({ getService }: FtrProviderContext) { 'inventory', 'logs', 'maintenanceWindow', + 'manageReporting', 'maps_v2', 'osquery', 'rulesSettings', @@ -210,6 +211,7 @@ export default function ({ getService }: FtrProviderContext) { 'fleet', 'fleetv2', 'entityManager', + 'manageReporting', ]; const features = body.filter( diff --git a/x-pack/platform/test/api_integration/apis/security/privileges.ts b/x-pack/platform/test/api_integration/apis/security/privileges.ts index 9af6fd1b3df8a..e82bfd81a8a11 100644 --- a/x-pack/platform/test/api_integration/apis/security/privileges.ts +++ b/x-pack/platform/test/api_integration/apis/security/privileges.ts @@ -208,6 +208,7 @@ export default function ({ getService }: FtrProviderContext) { infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'], apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'], discover: [ 'all', diff --git a/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts b/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts index 1a4e99fc1a73c..1f285475c70ad 100644 --- a/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts +++ b/x-pack/platform/test/api_integration_basic/apis/security/privileges.ts @@ -81,6 +81,7 @@ export default function ({ getService }: FtrProviderContext) { aiAssistantManagementSelection: ['all', 'read', 'minimal_all', 'minimal_read'], inventory: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'], entityManager: ['all', 'read', 'minimal_all', 'minimal_read'], }, global: ['all', 'read'], @@ -312,6 +313,7 @@ export default function ({ getService }: FtrProviderContext) { infrastructure: ['all', 'read', 'minimal_all', 'minimal_read'], logs: ['all', 'read', 'minimal_all', 'minimal_read'], dataQuality: ['all', 'read', 'minimal_all', 'minimal_read'], + manageReporting: ['all', 'read', 'minimal_all', 'minimal_read'], apm: ['all', 'read', 'minimal_all', 'minimal_read', 'settings_save'], discover: [ 'all', diff --git a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts index 29d1231e54811..a9ceec4809a92 100644 --- a/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts +++ b/x-pack/platform/test/plugin_api_integration/test_suites/task_manager/check_registered_task_types.ts @@ -170,6 +170,7 @@ export default function ({ getService }: FtrProviderContext) { 'osquery:telemetry-packs', 'osquery:telemetry-saved-queries', 'report:execute', + 'report:execute-scheduled', 'risk_engine:risk_scoring', 'search:agentless-connectors-manager', 'security-solution-ea-asset-criticality-ecs-migration', diff --git a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/documentation.ts b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/documentation.ts index cf407a9c9832c..ecde1b2a07d5e 100644 --- a/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/documentation.ts +++ b/x-pack/solutions/observability/plugins/observability_ai_assistant_app/server/functions/documentation.ts @@ -43,6 +43,7 @@ export async function registerDocumentationFunction({ properties: { query: { description: `The query to use to retrieve documentation + Always write the query in English, as the documentation is available only in English. Examples: - "How to enable TLS for Elasticsearch?" - "What is Kibana Lens?"`, diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts index 267b97fda8872..7e38aa6036f53 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.gen.ts @@ -113,6 +113,14 @@ export const BulkActionBase = z.object({ * Array of rule IDs. Array of rule IDs to which a bulk action will be applied. Only valid when query property is undefined. */ ids: z.array(z.string()).min(1).optional(), + /** + * Gaps range start, valid only when query is provided + */ + gaps_range_start: z.string().optional(), + /** + * Gaps range end, valid only when query is provided + */ + gaps_range_end: z.string().optional(), }); export type BulkDeleteRules = z.infer; diff --git a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml index 2525b13d754af..eb760bdc38b11 100644 --- a/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/common/api/detection_engine/rule_management/bulk_actions/bulk_actions_route.schema.yaml @@ -1146,6 +1146,12 @@ components: minItems: 1 items: type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string BulkDeleteRules: allOf: diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml index aafb2965b981f..b2111722f1034 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/ess/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -4696,6 +4696,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4716,6 +4722,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4749,6 +4761,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4849,6 +4867,12 @@ components: $ref: '#/components/schemas/BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4874,6 +4898,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4896,6 +4926,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4916,6 +4952,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be diff --git a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml index 8b6133337a97c..f3937a3bdb954 100644 --- a/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml +++ b/x-pack/solutions/security/plugins/security_solution/docs/openapi/serverless/security_solution_detections_api_2023_10_31.bundled.schema.yaml @@ -4026,6 +4026,12 @@ components: enum: - delete type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4046,6 +4052,12 @@ components: enum: - disable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4079,6 +4091,12 @@ components: required: - include_exceptions - include_expired_exceptions + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4179,6 +4197,12 @@ components: $ref: '#/components/schemas/BulkActionEditPayload' minItems: 1 type: array + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4204,6 +4228,12 @@ components: enum: - enable type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4226,6 +4256,12 @@ components: enum: - export type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be @@ -4246,6 +4282,12 @@ components: enum: - run type: string + gaps_range_end: + description: Gaps range end, valid only when query is provided + type: string + gaps_range_start: + description: Gaps range start, valid only when query is provided + type: string ids: description: >- Array of rule IDs. Array of rule IDs to which a bulk action will be diff --git a/x-pack/solutions/security/plugins/security_solution/public/common/containers/query_client/query_client_provider.tsx b/x-pack/solutions/security/plugins/security_solution/public/common/containers/query_client/query_client_provider.tsx index 016d0d494fae0..7f296bdce04a7 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/common/containers/query_client/query_client_provider.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/common/containers/query_client/query_client_provider.tsx @@ -25,7 +25,11 @@ export class SecuritySolutionQueryClient extends QueryClient { refetchOnWindowFocus: false, refetchOnMount: true, keepPreviousData: true, - ...(options?.defaultOptions?.queries ?? {}), + ...(options?.defaultOptions?.queries ?? { networkMode: 'always' }), + }, + mutations: { + networkMode: 'always', + ...(options?.defaultOptions?.mutations ?? { networkMode: 'always' }), }, }, }; diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts index d3de6cf7aaa0f..37ea2769a9c1f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.test.ts @@ -809,6 +809,33 @@ describe('Detections Rules API', () => { ); }); + test('passes gap range with query', async () => { + const gapRange = { start: '2025-01-01T00:00:00.000Z', end: '2025-01-02T00:00:00.000Z' }; + await performBulkAction({ + bulkAction: { + type: BulkActionTypeEnum.enable, + query: '', + gapRange, + }, + }); + + expect(fetchMock).toHaveBeenCalledWith( + '/api/detection_engine/rules/_bulk_action', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ + action: 'enable', + query: '', + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }), + query: { + dry_run: false, + }, + }) + ); + }); + test('executes dry run', async () => { await performBulkAction({ bulkAction: { type: BulkActionTypeEnum.disable, query: 'some query' }, diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts index 1af72cf2227a6..64e96789c656a 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management/api/api.ts @@ -341,7 +341,10 @@ export interface BulkActionErrorResponse { attributes?: BulkActionAttributes; } -export type QueryOrIds = { query: string; ids?: undefined } | { query?: undefined; ids: string[] }; +export type QueryOrIds = + | { query: string; ids?: undefined; gapRange?: { start: string; end: string } } + | { query?: undefined; ids: string[] }; + type PlainBulkAction = { type: Exclude< BulkActionType, @@ -398,6 +401,8 @@ export async function performBulkAction({ duplicate: bulkAction.type === BulkActionTypeEnum.duplicate ? bulkAction.duplicatePayload : undefined, run: bulkAction.type === BulkActionTypeEnum.run ? bulkAction.runPayload : undefined, + gaps_range_start: 'gapRange' in bulkAction ? bulkAction.gapRange?.start : undefined, + gaps_range_end: 'gapRange' in bulkAction ? bulkAction.gapRange?.end : undefined, }; return KibanaServices.get().http.fetch(DETECTION_ENGINE_RULES_BULK_ACTION, { diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx index 53424e500a709..2d89937064f9f 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/use_bulk_actions.tsx @@ -10,7 +10,7 @@ import type { EuiContextMenuPanelDescriptor } from '@elastic/eui'; import { EuiButton, EuiFlexGroup, EuiFlexItem, EuiTextColor } from '@elastic/eui'; import type { Toast } from '@kbn/core/public'; import { toMountPoint } from '@kbn/react-kibana-mount'; -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { ML_RULES_UNAVAILABLE } from './translations'; import { MAX_MANUAL_RULE_RUN_BULK_SIZE } from '../../../../../../common/constants'; import type { TimeRange } from '../../../../rule_gaps/types'; @@ -18,6 +18,8 @@ import { useKibana } from '../../../../../common/lib/kibana'; import { useUserPrivileges } from '../../../../../common/components/user_privileges'; import { convertRulesFilterToKQL } from '../../../../../../common/detection_engine/rule_management/rule_filtering'; import { DuplicateOptions } from '../../../../../../common/detection_engine/rule_management/constants'; +import { getGapRange } from '../../../../rule_gaps/api/hooks/utils'; +import { defaultRangeValue } from '../../../../rule_gaps/constants'; import type { BulkActionEditPayload, BulkActionEditType, @@ -91,6 +93,16 @@ export const useBulkActions = ({ state: { isAllSelected, rules, loadingRuleIds, selectedRuleIds }, actions: { clearRulesSelection, setIsPreflightInProgress }, } = rulesTableContext; + const globalQuery = useMemo(() => { + const gapRange = filterOptions?.showRulesWithGaps + ? getGapRange(filterOptions.gapSearchRange ?? defaultRangeValue) + : undefined; + + return { + query: kql, + ...(gapRange && { gapRange }), + }; + }, [kql, filterOptions]); const getBulkItemsPopoverContent = useCallback( (closePopover: () => void): EuiContextMenuPanelDescriptor[] => { @@ -122,7 +134,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.enable, - ...(isAllSelected ? { query: kql } : { ids: ruleIds }), + ...(isAllSelected ? globalQuery : { ids: ruleIds }), }); }; @@ -134,7 +146,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.disable, - ...(isAllSelected ? { query: kql } : { ids: enabledIds }), + ...(isAllSelected ? globalQuery : { ids: enabledIds }), }); }; @@ -158,7 +170,7 @@ export const useBulkActions = ({ DuplicateOptions.withExceptionsExcludeExpiredExceptions ), }, - ...(isAllSelected ? { query: kql } : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), }); clearRulesSelection(); }; @@ -175,7 +187,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.delete, - ...(isAllSelected ? { query: kql } : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), }); }; @@ -183,9 +195,7 @@ export const useBulkActions = ({ closePopover(); startTransaction({ name: BULK_RULE_ACTIONS.EXPORT }); - const response = await bulkExport( - isAllSelected ? { query: kql } : { ids: selectedRuleIds } - ); + const response = await bulkExport(isAllSelected ? globalQuery : { ids: selectedRuleIds }); // if response null, likely network error happened and export rules haven't been received if (!response) { @@ -215,9 +225,7 @@ export const useBulkActions = ({ const dryRunResult = await executeBulkActionsDryRun({ type: BulkActionTypeEnum.run, - ...(isAllSelected - ? { query: convertRulesFilterToKQL(filterOptions) } - : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), runPayload: { start_date: new Date(Date.now() - 1000).toISOString(), end_date: new Date().toISOString(), @@ -252,7 +260,7 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.run, - ...(isAllSelected ? { query: kql } : { ids: enabledIds }), + ...(isAllSelected ? globalQuery : { ids: enabledIds }), runPayload: { start_date: modalManualRuleRunConfirmationResult.startDate.toISOString(), end_date: modalManualRuleRunConfirmationResult.endDate.toISOString(), @@ -278,9 +286,7 @@ export const useBulkActions = ({ const dryRunResult = await executeBulkActionsDryRun({ type: BulkActionTypeEnum.edit, - ...(isAllSelected - ? { query: convertRulesFilterToKQL(filterOptions) } - : { ids: selectedRuleIds }), + ...(isAllSelected ? globalQuery : { ids: selectedRuleIds }), editPayload: computeDryRunEditPayload(bulkEditActionType), }); @@ -343,7 +349,9 @@ export const useBulkActions = ({ await executeBulkAction({ type: BulkActionTypeEnum.edit, ...prepareSearchParams({ - ...(isAllSelected ? { filterOptions } : { selectedRuleIds }), + ...(isAllSelected + ? { filterOptions, gapRange: globalQuery.gapRange } + : { selectedRuleIds }), dryRunResult, }), editPayload: [editPayload], @@ -584,7 +592,6 @@ export const useBulkActions = ({ startTransaction, hasMlPermissions, executeBulkAction, - kql, toasts, showBulkDuplicateConfirmation, showManualRuleRunConfirmation, @@ -600,6 +607,7 @@ export const useBulkActions = ({ completeBulkEditForm, startServices, canCreateTimelines, + globalQuery, ] ); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts index 778d0a3f3225f..ac074cd162893 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.test.ts @@ -118,4 +118,54 @@ describe('prepareSearchParams', () => { expect(result).toEqual({ query: expect.any(String) }); } ); + + test('should not include gapRange in the output when provided with ids', () => { + const selectedRuleIds = ['rule:1', 'rule:2']; + const dryRunResult: DryRunResult = { + ruleErrors: [], + }; + const gapRange = { start: '2025-01-01T00:00:00.000Z', end: '2025-01-02T00:00:00.000Z' }; + const result = prepareSearchParams({ + selectedRuleIds, + dryRunResult, + gapRange, + }); + + expect(result).toEqual({ ids: ['rule:1', 'rule:2'] }); + }); + + test('should include gapRange in the query when provided with query', () => { + const filterOptions: FilterOptions = { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, + }; + const dryRunResult: DryRunResult = { + ruleErrors: [], + }; + const gapRange = { start: '2025-01-01T00:00:00.000Z', end: '2025-01-02T00:00:00.000Z' }; + const result = prepareSearchParams({ + filterOptions, + dryRunResult, + gapRange, + }); + + expect(result).toEqual({ query: expect.any(String), gapRange }); + }); + + test('should return only query when neither selectedRuleIds nor gapRange are provided', () => { + const dryRunResult: DryRunResult = { ruleErrors: [] }; + + const filterOptions: FilterOptions = { + filter: '', + tags: [], + showCustomRules: false, + showElasticRules: false, + }; + + const result = prepareSearchParams({ dryRunResult, filterOptions }); + + expect(result).toEqual({ query: expect.any(String) }); + }); }); diff --git a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts index 646b289dbf336..658d020261962 100644 --- a/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts +++ b/x-pack/solutions/security/plugins/security_solution/public/detection_engine/rule_management_ui/components/rules_table/bulk_actions/utils/prepare_search_params.ts @@ -14,7 +14,11 @@ import { BulkActionsDryRunErrCodeEnum } from '../../../../../../../common/api/de type PrepareSearchFilterProps = | { selectedRuleIds: string[]; dryRunResult?: DryRunResult } - | { filterOptions: FilterOptions; dryRunResult?: DryRunResult }; + | { + filterOptions: FilterOptions; + gapRange?: { start: string; end: string }; + dryRunResult?: DryRunResult; + }; /** * helper methods to prepare search params for bulk actions based on results of previous dry run @@ -61,5 +65,6 @@ export const prepareSearchParams = ({ return { query: convertRulesFilterToKQL(modifiedFilterOptions), + ...(props.gapRange ? { gapRange: props.gapRange } : {}), }; }; diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts index d74bcc5e7f450..c20ee9cc2132d 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/fetch_rules_by_query_or_ids.ts @@ -7,6 +7,7 @@ import type { RulesClient } from '@kbn/alerting-plugin/server'; import { BadRequestError } from '@kbn/securitysolution-es-utils'; +import { gapStatus } from '@kbn/alerting-plugin/common'; import { MAX_RULES_TO_UPDATE_IN_PARALLEL } from '../../../../../../../common/constants'; import type { PromisePoolOutcome } from '../../../../../../utils/promise_pool'; import { initPromisePool } from '../../../../../../utils/promise_pool'; @@ -20,12 +21,14 @@ export const fetchRulesByQueryOrIds = async ({ rulesClient, abortSignal, maxRules, + gapRange, }: { query: string | undefined; ids: string[] | undefined; rulesClient: RulesClient; abortSignal: AbortSignal; maxRules: number; + gapRange?: { start: string; end: string }; }): Promise> => { if (ids) { return initPromisePool({ @@ -42,6 +45,23 @@ export const fetchRulesByQueryOrIds = async ({ }); } + let ruleIdsWithGaps: string[] | undefined; + // If there is a gap range, we need to find the rules that have gaps in that range + if (gapRange) { + const ruleIdsWithGapsResponse = await rulesClient.getRuleIdsWithGaps({ + start: gapRange.start, + end: gapRange.end, + statuses: [gapStatus.UNFILLED, gapStatus.PARTIALLY_FILLED], + }); + ruleIdsWithGaps = ruleIdsWithGapsResponse.ruleIds; + if (ruleIdsWithGaps.length === 0) { + return { + results: [], + errors: [], + }; + } + } + const { data, total } = await findRules({ rulesClient, perPage: maxRules, @@ -50,6 +70,7 @@ export const fetchRulesByQueryOrIds = async ({ sortField: undefined, sortOrder: undefined, fields: undefined, + ruleIds: ruleIdsWithGaps, }); if (total > maxRules) { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts index 476c3b47514ca..2795956fc791b 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.test.ts @@ -630,6 +630,93 @@ describe('Perform bulk action route', () => { ) ); }); + + it('rejects payload if both ids and gap range are defined', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: undefined, + ids: ['id'], + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Cannot use both ids and gaps_range_start/gaps_range_end in request payload.' + ); + }); + + it('rejects payload if only gaps_range_start is defined without gaps_range_end', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_start: '2025-01-01T00:00:00.000Z', + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Both gaps_range_start and gaps_range_end must be provided together.' + ); + }); + + it('rejects payload if only gaps_range_end is defined without gaps_range_start', async () => { + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_end: '2025-01-02T00:00:00.000Z', + }, + }); + + const response = await server.inject(request, requestContextMock.convertContext(context)); + + expect(response.status).toEqual(400); + expect(response.body.message).toEqual( + 'Both gaps_range_start and gaps_range_end must be provided together.' + ); + }); + }); + + describe('gap range functionality', () => { + it('passes gap range to rules find when provided with query', async () => { + const gapStartDate = '2025-01-01T00:00:00.000Z'; + const gapEndDate = '2025-01-02T00:00:00.000Z'; + + const request = requestMock.create({ + method: 'patch', + path: DETECTION_ENGINE_RULES_BULK_ACTION, + body: { + ...getBulkDisableRuleActionSchemaMock(), + query: '', + gaps_range_start: gapStartDate, + gaps_range_end: gapEndDate, + }, + }); + + await server.inject(request, requestContextMock.convertContext(context)); + + expect(clients.rulesClient.getRuleIdsWithGaps).toHaveBeenCalledWith( + expect.objectContaining({ + start: gapStartDate, + end: gapEndDate, + statuses: ['unfilled', 'partially_filled'], + }) + ); + }); }); it('should process large number of rules, larger than configured concurrency', async () => { diff --git a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts index 54940c6ab7ed9..6a685c0f7874a 100644 --- a/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts +++ b/x-pack/solutions/security/plugins/security_solution/server/lib/detection_engine/rule_management/api/rules/bulk_actions/route.ts @@ -50,6 +50,50 @@ const MAX_RULES_TO_PROCESS_TOTAL = 10000; const MAX_RULES_TO_BULK_EDIT = 2000; const MAX_ROUTE_CONCURRENCY = 5; +interface ValidationError { + body: string; + statusCode: number; +} + +const validateBulkAction = ( + body: PerformRulesBulkActionRequestBody +): ValidationError | undefined => { + if (body?.ids && body.ids.length > RULES_TABLE_MAX_PAGE_SIZE) { + return { + body: `More than ${RULES_TABLE_MAX_PAGE_SIZE} ids sent for bulk edit action.`, + statusCode: 400, + }; + } + + if (body?.ids && body.query !== undefined) { + return { + body: `Both query and ids are sent. Define either ids or query in request payload.`, + statusCode: 400, + }; + } + + // Validate that ids and gap range params are not used together + if (body?.ids && (body.gaps_range_start || body.gaps_range_end)) { + return { + body: `Cannot use both ids and gaps_range_start/gaps_range_end in request payload.`, + statusCode: 400, + }; + } + + // Validate that both gap range params are provided if any is used + if ( + (body.gaps_range_start && !body.gaps_range_end) || + (!body.gaps_range_start && body.gaps_range_end) + ) { + return { + body: `Both gaps_range_start and gaps_range_end must be provided together.`, + statusCode: 400, + }; + } + + return undefined; +}; + export const performBulkActionRoute = ( router: SecuritySolutionPluginRouter, ml: SetupPlugins['ml'] @@ -89,18 +133,9 @@ export const performBulkActionRoute = ( const { body } = request; const siemResponse = buildSiemResponse(response); - if (body?.ids && body.ids.length > RULES_TABLE_MAX_PAGE_SIZE) { - return siemResponse.error({ - body: `More than ${RULES_TABLE_MAX_PAGE_SIZE} ids sent for bulk edit action.`, - statusCode: 400, - }); - } - - if (body?.ids && body.query !== undefined) { - return siemResponse.error({ - body: `Both query and ids are sent. Define either ids or query in request payload.`, - statusCode: 400, - }); + const validationError = validateBulkAction(body); + if (validationError) { + return siemResponse.error(validationError); } const isDryRun = request.query.dry_run; @@ -148,6 +183,15 @@ export const performBulkActionRoute = ( }); const query = body.query !== '' ? body.query : undefined; + let gapRange; + + // If gap range params are present, set up the gap range parameter + if (body.gaps_range_start && body.gaps_range_end) { + gapRange = { + start: body.gaps_range_start, + end: body.gaps_range_end, + }; + } const fetchRulesOutcome = await fetchRulesByQueryOrIds({ rulesClient, @@ -158,6 +202,7 @@ export const performBulkActionRoute = ( body.action === BulkActionTypeEnum.edit ? MAX_RULES_TO_BULK_EDIT : MAX_RULES_TO_PROCESS_TOTAL, + gapRange, }); const rules = fetchRulesOutcome.results.map(({ result }) => result); diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/disable_scheduled_reports.ts b/x-pack/test/reporting_api_integration/reporting_and_security/disable_scheduled_reports.ts new file mode 100644 index 0000000000000..73b6794e312d6 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/disable_scheduled_reports.ts @@ -0,0 +1,167 @@ +/* + * 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 expect from '@kbn/expect'; +import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Disable Scheduled Reports', () => { + const scheduledReportIds: string[] = []; + + before(async () => { + await reportingAPI.initEcommerce(); + }); + + after(async () => { + await reportingAPI.teardownEcommerce(); + await reportingAPI.deleteAllReports(); + await reportingAPI.deleteScheduledReportSOs(scheduledReportIds); + await reportingAPI.deleteTasks(scheduledReportIds); + }); + + it('should allow reporting user to disable their own scheduled report', async () => { + const report = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + + expect(report.status).to.eql(200); + const reportId = report.body.job.id; + + scheduledReportIds.push(reportId); + + // report created by reporting user, reporting user should be able to disable + const res = await reportingAPI.disableScheduledReports( + [reportId], + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + expect(res).to.eql({ scheduled_report_ids: [reportId], errors: [], total: 1 }); + + const soResult = await reportingAPI.getScheduledReportSO(reportId); + expect(soResult.body._source.scheduled_report.enabled).to.eql(false); + const taskResult = await reportingAPI.getTask(reportId); + expect(taskResult.body._source?.task.enabled).to.eql(false); + }); + + it('should not allow user to disable other users reports when no ManageReporting feature privilege', async () => { + const report = await reportingAPI.schedulePdf( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + + expect(report.status).to.eql(200); + const reportId = report.body.job.id; + + scheduledReportIds.push(reportId); + + // report created by manage reporting user, reporting user should not be able to disable + const res = await reportingAPI.disableScheduledReports( + [reportId], + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + expect(res).to.eql({ + scheduled_report_ids: [], + errors: [ + { + message: `Not found.`, + status: 404, + id: reportId, + }, + ], + total: 1, + }); + + const soResult = await reportingAPI.getScheduledReportSO(reportId); + expect(soResult.body._source.scheduled_report.enabled).to.eql(true); + const taskResult = await reportingAPI.getTask(reportId); + expect(taskResult.body._source?.task.enabled).to.eql(true); + }); + + it('should allow user to disable other users reports when they have ManageReporting feature privilege', async () => { + const report1 = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: true }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as unknown as SerializedSearchSourceFields, + columns: [], + version: '7.13.0', + }, + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + const report2 = await reportingAPI.schedulePdf( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + + expect(report1.status).to.eql(200); + expect(report2.status).to.eql(200); + + const report1Id = report1.body.job.id; + const report2Id = report2.body.job.id; + + scheduledReportIds.push(report1Id); + scheduledReportIds.push(report2Id); + + // manage reporting user should be able to disable their own report and reporting user report + const res = await reportingAPI.disableScheduledReports( + [report1Id, report2Id], + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD + ); + + expect(res).to.eql({ scheduled_report_ids: [report1Id, report2Id], errors: [], total: 2 }); + + const soResult1 = await reportingAPI.getScheduledReportSO(report1Id); + expect(soResult1.body._source.scheduled_report.enabled).to.eql(false); + const soResult2 = await reportingAPI.getScheduledReportSO(report2Id); + expect(soResult2.body._source.scheduled_report.enabled).to.eql(false); + const taskResult1 = await reportingAPI.getTask(report1Id); + expect(taskResult1.body._source?.task.enabled).to.eql(false); + const taskResult2 = await reportingAPI.getTask(report2Id); + expect(taskResult2.body._source?.task.enabled).to.eql(false); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts index fedad1bf589fd..e3d8a55ac96dc 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/index.ts @@ -17,6 +17,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await reportingAPI.createTestReportingUserRole(); await reportingAPI.createDataAnalyst(); await reportingAPI.createTestReportingUser(); + await reportingAPI.createManageReportingUserRole(); + await reportingAPI.createManageReportingUser(); }); loadTestFile(require.resolve('./bwc_existing_indexes')); @@ -25,6 +27,8 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { loadTestFile(require.resolve('./ilm_migration_apis')); loadTestFile(require.resolve('./security_roles_privileges')); loadTestFile(require.resolve('./spaces')); + loadTestFile(require.resolve('./list_scheduled_reports')); + loadTestFile(require.resolve('./disable_scheduled_reports')); loadTestFile(require.resolve('./list_jobs')); // CSV-specific diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/list_scheduled_reports.ts b/x-pack/test/reporting_api_integration/reporting_and_security/list_scheduled_reports.ts new file mode 100644 index 0000000000000..c065bafef6222 --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_and_security/list_scheduled_reports.ts @@ -0,0 +1,121 @@ +/* + * 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 expect from '@kbn/expect'; +import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('List Scheduled Reports', () => { + let report1Id: string; + let report2Id: string; + let report3Id: string; + const scheduledReportIds: string[] = []; + + before(async () => { + await reportingAPI.initEcommerce(); + + const report1 = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + + const report2 = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: true }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as unknown as SerializedSearchSourceFields, + columns: [], + version: '7.13.0', + }, + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + const report3 = await reportingAPI.schedulePdf( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + + expect(report1.status).to.eql(200); + expect(report2.status).to.eql(200); + expect(report3.status).to.eql(200); + + report1Id = report1.body.job.id; + report2Id = report2.body.job.id; + report3Id = report3.body.job.id; + + scheduledReportIds.push(report1Id); + scheduledReportIds.push(report2Id); + scheduledReportIds.push(report3Id); + }); + + after(async () => { + await reportingAPI.teardownEcommerce(); + await reportingAPI.deleteAllReports(); + await reportingAPI.deleteScheduledReportSOs(scheduledReportIds); + await reportingAPI.deleteTasks(scheduledReportIds); + }); + + it('should only return reports scheduled by the user when user does not have ManageReporting feature privilege', async () => { + const res = await reportingAPI.listScheduledReports( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + + expect(res.total).to.eql(2); + + for (const report of res.data) { + expect(report.created_by).to.eql(reportingAPI.REPORTING_USER_USERNAME); + expect([report1Id, report2Id]).to.contain(report.id); + expect(report.next_run).not.to.be(undefined); + } + }); + + it('should return reports scheduled by all users when user has ManageReporting feature privilege', async () => { + const res = await reportingAPI.listScheduledReports( + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_PASSWORD + ); + + expect(res.total).to.eql(3); + + for (const report of res.data) { + expect([ + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.MANAGE_REPORTING_USER_USERNAME, + ]).to.contain(report.created_by); + expect([report1Id, report2Id, report3Id]).to.contain(report.id); + expect(report.next_run).not.to.be(undefined); + } + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts index 0b5237a9051d6..f565f97188d17 100644 --- a/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts +++ b/x-pack/test/reporting_api_integration/reporting_and_security/security_roles_privileges.ts @@ -7,6 +7,8 @@ import expect from '@kbn/expect'; import { SerializedSearchSourceFields } from '@kbn/data-plugin/common'; +import { SearchHit } from '@elastic/elasticsearch/lib/api/types'; +import { SerializedConcreteTaskInstance } from '@kbn/task-manager-plugin/server/task'; import { FtrProviderContext } from '../ftr_provider_context'; // eslint-disable-next-line import/no-default-export @@ -14,13 +16,33 @@ export default function ({ getService }: FtrProviderContext) { const reportingAPI = getService('reportingAPI'); const supertest = getService('supertest'); + function testExpectedTask( + id: string, + jobtype: string, + task: SearchHit<{ task: SerializedConcreteTaskInstance }> + ) { + expect(task._source?.task.taskType).to.eql('report:execute-scheduled'); + + const params = JSON.parse(task._source?.task.params ?? ''); + expect(params.id).to.eql(id); + expect(params.jobtype).to.eql(jobtype); + + expect(task._source?.task.apiKey).not.to.be(undefined); + expect(task._source?.task.schedule?.rrule).not.to.be(undefined); + + expect(task._source?.task.schedule?.interval).to.be(undefined); + } describe('Security Roles and Privileges for Applications', () => { + const scheduledReportIds: string[] = []; + const scheduledReportTaskIds: string[] = []; before(async () => { await reportingAPI.initEcommerce(); }); after(async () => { await reportingAPI.teardownEcommerce(); await reportingAPI.deleteAllReports(); + await reportingAPI.deleteScheduledReportSOs(scheduledReportIds); + await reportingAPI.deleteTasks(scheduledReportTaskIds); }); describe('Dashboard: Generate PDF report', () => { @@ -162,6 +184,185 @@ export default function ({ getService }: FtrProviderContext) { }); }); + describe('Dashboard: Schedule PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + + describe('Visualize: Schedule PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'visualization', + version: '7.14.0', + } + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + + describe('Canvas: Schedule PDF report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF disallowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'canvas', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'canvas', + version: '7.14.0', + } + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('test PDF allowed'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'printable_pdf_v2', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + + describe('Discover: Schedule CSV report', () => { + it('does not allow user that does not have the role-based privilege', async () => { + const res = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + searchSource: {} as SerializedSearchSourceFields, + objectType: 'search', + title: 'test disallowed', + version: '7.14.0', + }, + reportingAPI.DATA_ANALYST_USERNAME, + reportingAPI.DATA_ANALYST_PASSWORD + ); + expect(res.status).to.eql(403); + }); + + it('does allow user with the role-based privilege', async () => { + const res = await reportingAPI.scheduleCsv( + { + browserTimezone: 'UTC', + title: 'allowed search', + objectType: 'search', + searchSource: { + version: true, + fields: [{ field: '*', include_unmapped: true }], + index: '5193f870-d861-11e9-a311-0fa548c5f953', + } as unknown as SerializedSearchSourceFields, + columns: [], + version: '7.13.0', + }, + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD + ); + expect(res.status).to.eql(200); + + const soResult = await reportingAPI.getScheduledReportSO(res.body.job.id); + expect(soResult.status).to.eql(200); + expect(soResult.body._source.scheduled_report.title).to.eql('allowed search'); + scheduledReportIds.push(res.body.job.id); + + const taskResult = await reportingAPI.getTask(res.body.job.id); + expect(taskResult.status).to.eql(200); + testExpectedTask(res.body.job.id, 'csv_searchsource', taskResult.body); + scheduledReportTaskIds.push(res.body.job.id); + }); + }); + // This tests the same API as x-pack/test/api_integration/apis/security/privileges.ts, but it uses the non-deprecated config it('should register reporting privileges with the security privileges API', async () => { await supertest diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts index cef965443962f..4f4d4b116a5c4 100644 --- a/x-pack/test/reporting_api_integration/reporting_without_security/index.ts +++ b/x-pack/test/reporting_api_integration/reporting_without_security/index.ts @@ -17,5 +17,6 @@ export default function ({ loadTestFile, getService }: FtrProviderContext) { }); loadTestFile(require.resolve('./csv/job_apis_csv')); + loadTestFile(require.resolve('./schedule')); }); } diff --git a/x-pack/test/reporting_api_integration/reporting_without_security/schedule.ts b/x-pack/test/reporting_api_integration/reporting_without_security/schedule.ts new file mode 100644 index 0000000000000..9bd29ef4b3fdd --- /dev/null +++ b/x-pack/test/reporting_api_integration/reporting_without_security/schedule.ts @@ -0,0 +1,47 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { FtrProviderContext } from '../ftr_provider_context'; + +// eslint-disable-next-line import/no-default-export +export default function ({ getService }: FtrProviderContext) { + const reportingAPI = getService('reportingAPI'); + + describe('Scheduled Reports', () => { + before(async () => { + await reportingAPI.initLogs(); + }); + + after(async () => { + await reportingAPI.teardownLogs(); + }); + + afterEach(async () => { + await reportingAPI.deleteAllReports(); + }); + + it('should return error when scheduling reports', async () => { + const res = await reportingAPI.schedulePdf( + reportingAPI.REPORTING_USER_USERNAME, + reportingAPI.REPORTING_USER_PASSWORD, + { + browserTimezone: 'UTC', + title: 'test PDF allowed', + layout: { id: 'preserve_layout' }, + locatorParams: [{ id: 'canvas', version: '7.14.0', params: {} }], + objectType: 'dashboard', + version: '7.14.0', + } + ); + expect(res.status).to.eql(403); + expect(res.body.message).to.eql( + `Security and API keys must be enabled for scheduled reporting` + ); + }); + }); +} diff --git a/x-pack/test/reporting_api_integration/services/scenarios.ts b/x-pack/test/reporting_api_integration/services/scenarios.ts index 559a2fd5ba434..13ab153fe5c37 100644 --- a/x-pack/test/reporting_api_integration/services/scenarios.ts +++ b/x-pack/test/reporting_api_integration/services/scenarios.ts @@ -15,6 +15,8 @@ import { REPORTING_DATA_STREAM_WILDCARD_WITH_LEGACY, } from '@kbn/reporting-server'; import rison from '@kbn/rison'; +import { ALERTING_CASES_SAVED_OBJECT_INDEX } from '@kbn/core-saved-objects-server'; +import { RruleSchedule } from '@kbn/task-manager-plugin/server'; import { FtrProviderContext } from '../ftr_provider_context'; function removeWhitespace(str: string) { @@ -39,6 +41,9 @@ export function createScenarios({ getService }: Pick { // Check task manager health for analyzing test failures. See https://github.com/elastic/kibana/issues/114946 @@ -126,6 +131,36 @@ export function createScenarios({ getService }: Pick { + await security.role.create(MANAGE_REPORTING_ROLE, { + metadata: {}, + elasticsearch: { + cluster: [], + indices: [ + { + names: ['ecommerce'], + privileges: ['read', 'view_index_metadata'], + allow_restricted_indices: false, + }, + ], + run_as: [], + }, + kibana: [ + { + base: [], + feature: { + manageReporting: ['all'], + dashboard: ['minimal_read', 'download_csv_report', 'generate_report'], + discover: ['minimal_read', 'generate_report'], + canvas: ['minimal_read', 'generate_report'], + visualize: ['minimal_read', 'generate_report'], + }, + spaces: ['*'], + }, + ], + }); + }; + const createDataAnalyst = async () => { await security.user.create('data_analyst', { password: 'data_analyst-password', @@ -134,6 +169,14 @@ export function createScenarios({ getService }: Pick { + await security.user.create(MANAGE_REPORTING_USER_USERNAME, { + password: MANAGE_REPORTING_USER_PASSWORD, + roles: [MANAGE_REPORTING_ROLE], + full_name: 'Manage Reporting User', + }); + }; + const createTestReportingUser = async () => { await security.user.create(REPORTING_USER_USERNAME, { password: REPORTING_USER_PASSWORD, @@ -156,6 +199,19 @@ export function createScenarios({ getService }: Pick { + const jobParams = rison.encode(job); + return await supertestWithoutAuth + .post(`/internal/reporting/schedule/printablePdfV2`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams, schedule }); + }; const generatePng = async ( username: string, password: string, @@ -170,6 +226,19 @@ export function createScenarios({ getService }: Pick { + const jobParams = rison.encode(job); + return await supertestWithoutAuth + .post(`/internal/reporting/schedule/pngV2`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams, schedule }); + }; const generateCsv = async ( job: JobParamsCSV, username = 'elastic', @@ -184,6 +253,47 @@ export function createScenarios({ getService }: Pick { + const jobParams = rison.encode(job); + + return await supertestWithoutAuth + .post(`/internal/reporting/schedule/csv_searchsource`) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ jobParams, schedule }); + }; + + const listScheduledReports = async ( + username = 'elastic', + password = process.env.TEST_KIBANA_PASS || 'changeme' + ) => { + const res = await supertestWithoutAuth + .get(INTERNAL_ROUTES.SCHEDULED.LIST) + .auth(username, password) + .set('kbn-xsrf', 'xxx'); + + log.info(`listScheduledReports: ${JSON.stringify(res)}`); + return res.body; + }; + + const disableScheduledReports = async ( + ids: string[], + username = 'elastic', + password = process.env.TEST_KIBANA_PASS || 'changeme' + ) => { + const { body } = await supertestWithoutAuth + .patch(INTERNAL_ROUTES.SCHEDULED.BULK_DISABLE) + .auth(username, password) + .set('kbn-xsrf', 'xxx') + .send({ ids }) + .expect(200); + return body; + }; const postJob = async ( apiPath: string, @@ -221,7 +331,6 @@ export function createScenarios({ getService }: Pick { + return await esSupertest.get( + `/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}` + ); + }; + + const deleteScheduledReportSOs = async (ids: string[]) => { + return await Promise.all( + ids.map((id) => + esSupertest.delete(`/${ALERTING_CASES_SAVED_OBJECT_INDEX}/_doc/scheduled_report:${id}`) + ) + ); + }; + + const getTask = async (taskId: string) => { + return await esSupertest.get(`/.kibana_task_manager/_doc/task:${taskId}`); + }; + + const deleteTasks = async (ids: string[]) => { + return await Promise.all( + ids.map((id) => esSupertest.delete(`/.kibana_task_manager/_doc/task:${id}`)) + ); + }; + return { logTaskManagerHealth, initEcommerce, @@ -301,13 +434,21 @@ export function createScenarios({ getService }: Pick { }); }); + describe('gaps_range filtering', () => { + it('should not affect rules without gaps when using gaps_range filters', async () => { + // Create two rules without gaps + await createRule(supertest, log, { + ...getSimpleRule('rule-without-gaps-1'), + }); + await createRule(supertest, log, { + ...getSimpleRule('rule-without-gaps-2'), + }); + + // Execute bulk action with gaps range filter + const { body } = await postBulkAction().send({ + query: '', + action: BulkActionTypeEnum.duplicate, + gaps_range_start: '2025-01-01T00:00:00.000Z', + gaps_range_end: '2025-01-02T00:00:00.000Z', + duplicate: { include_exceptions: false, include_expired_exceptions: false }, + }); + + // Verify the summary shows no rules were processed + expect(body.attributes.summary).toEqual({ + failed: 0, + skipped: 0, + succeeded: 0, + total: 0, + }); + }); + }); + it('should limit concurrent requests to 5', async () => { const ruleId = 'ruleId'; const timelineId = '91832785-286d-4ebe-b884-1a208d111a70'; diff --git a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts index d3b1726de3afa..dffd646b96e0e 100644 --- a/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts +++ b/x-pack/test/spaces_api_integration/spaces_only/telemetry/telemetry.ts @@ -69,6 +69,7 @@ export default function ({ getService }: FtrProviderContext) { generalCases: 0, generalCasesV2: 0, generalCasesV3: 0, + manageReporting: 0, maps: 2, maps_v2: 2, canvas: 2, diff --git a/x-pack/test_serverless/api_integration/test_suites/chat/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/chat/platform_security/authorization.ts index 882e772f93e27..1906b5ab2bed4 100644 --- a/x-pack/test_serverless/api_integration/test_suites/chat/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/chat/platform_security/authorization.ts @@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard/downloadCsv", "ui:dashboard_v2/downloadCsv", ], @@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard/generateScreenshot", "ui:dashboard_v2/generateScreenshot", ], @@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:url/delete", "saved_object:url/bulk_delete", "saved_object:url/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:visualize/show", "ui:visualize/delete", "ui:visualize/save", @@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", ], "generate_report": Array [ "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/generateScreenshot", ], "minimal_all": Array [ @@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:url/delete", "saved_object:url/bulk_delete", "saved_object:url/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:visualize_v2/show", "ui:visualize_v2/delete", "ui:visualize_v2/save", @@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover/generateCsv", "ui:discover_v2/generateCsv", ], @@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover_v2/generateCsv", ], "minimal_all": Array [ @@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", @@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", diff --git a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts index 7f53719b63f4b..0be6cf7758ff7 100644 --- a/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/observability/platform_security/authorization.ts @@ -4601,6 +4601,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -4728,6 +4740,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard/downloadCsv", "ui:dashboard_v2/downloadCsv", ], @@ -4735,6 +4759,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard/generateScreenshot", "ui:dashboard_v2/generateScreenshot", ], @@ -4899,6 +4935,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:url/delete", "saved_object:url/bulk_delete", "saved_object:url/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:visualize/show", "ui:visualize/delete", "ui:visualize/save", @@ -5246,6 +5294,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -5354,12 +5414,36 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", ], "generate_report": Array [ "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/generateScreenshot", ], "minimal_all": Array [ @@ -5503,6 +5587,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:url/delete", "saved_object:url/bulk_delete", "saved_object:url/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:visualize_v2/show", "ui:visualize_v2/delete", "ui:visualize_v2/save", @@ -5828,6 +5924,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -7067,6 +7175,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover/generateCsv", "ui:discover_v2/generateCsv", ], @@ -9533,6 +9653,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -10764,6 +10896,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover_v2/generateCsv", ], "minimal_all": Array [ @@ -19916,6 +20060,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", @@ -19961,6 +20117,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", diff --git a/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts index 882e772f93e27..1906b5ab2bed4 100644 --- a/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/search/platform_security/authorization.ts @@ -120,6 +120,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -247,6 +259,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard/downloadCsv", "ui:dashboard_v2/downloadCsv", ], @@ -254,6 +278,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard/generateScreenshot", "ui:dashboard_v2/generateScreenshot", ], @@ -418,6 +454,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:url/delete", "saved_object:url/bulk_delete", "saved_object:url/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:visualize/show", "ui:visualize/delete", "ui:visualize/save", @@ -765,6 +813,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -873,12 +933,36 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", ], "generate_report": Array [ "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/generateScreenshot", ], "minimal_all": Array [ @@ -1022,6 +1106,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:url/delete", "saved_object:url/bulk_delete", "saved_object:url/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:visualize_v2/show", "ui:visualize_v2/delete", "ui:visualize_v2/save", @@ -1347,6 +1443,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -1390,6 +1498,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover/generateCsv", "ui:discover_v2/generateCsv", ], @@ -1698,6 +1818,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "saved_object:index-pattern/bulk_get", "saved_object:index-pattern/get", "saved_object:index-pattern/find", @@ -1733,6 +1865,18 @@ export default function ({ getService }: FtrProviderContext) { "login:", "api:generateReport", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover_v2/generateCsv", ], "minimal_all": Array [ @@ -1983,6 +2127,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", @@ -2028,6 +2184,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", diff --git a/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts b/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts index 69fd0d9d54fb5..2c38cd02f9d8e 100644 --- a/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts +++ b/x-pack/test_serverless/api_integration/test_suites/security/platform_security/authorization.ts @@ -89,6 +89,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", @@ -134,6 +146,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:cloud/close_point_in_time", "api:downloadCsv", "ui:management/insightsAndAlerting/reporting", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:dashboard_v2/downloadCsv", "api:generateReport", "ui:discover_v2/generateCsv", @@ -858,6 +882,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover_v2/show", "ui:discover_v2/save", "ui:discover_v2/createShortUrl", @@ -1724,6 +1760,18 @@ export default function ({ getService }: FtrProviderContext) { "saved_object:search-session/delete", "saved_object:search-session/bulk_delete", "saved_object:search-session/share_to_space", + "saved_object:scheduled_report/bulk_get", + "saved_object:scheduled_report/get", + "saved_object:scheduled_report/find", + "saved_object:scheduled_report/open_point_in_time", + "saved_object:scheduled_report/close_point_in_time", + "saved_object:scheduled_report/create", + "saved_object:scheduled_report/bulk_create", + "saved_object:scheduled_report/update", + "saved_object:scheduled_report/bulk_update", + "saved_object:scheduled_report/delete", + "saved_object:scheduled_report/bulk_delete", + "saved_object:scheduled_report/share_to_space", "ui:discover_v2/show", "ui:discover_v2/save", "ui:discover_v2/createShortUrl",