Reporting/fix csv download with scroll duration auto (#178047)

A bugfix/refactoring part of
https://github.com/elastic/kibana/issues/164104

## Summary

This PR prevents an exception that happens with CSV download when the
`xpack.reporting.csv.scroll.duration: auto` configuration is set.

Steps to reproduce:
1. Use `xpack.reporting.csv.scroll.duration: auto` in kibana.yml
2. Attempt CSV download from a dashboard search panel.

**Before**


![csv-download-bug-before](074fc9f9-05c0-4fd4-b29a-a84a043d0874)

**After**


![csv-download-bug-after](ce7159b3-c863-4da4-b9a5-c76b3d096769)

## Other changes
* Move `get_panel_action` to the `kbn-reporting/public` package.
* Update unit test

### Checklist

Delete any items that are not applicable to this PR.

- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Tim Sullivan 2024-03-06 12:34:23 -07:00 committed by GitHub
parent b051cc163d
commit 152423691a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 200 additions and 60 deletions

View file

@ -0,0 +1,130 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CsvGenerator } from '@kbn/generate-csv';
jest.mock('@kbn/generate-csv', () => {
return {
CsvGenerator: jest.fn().mockImplementation(() => {
return { generateData: jest.fn() };
}),
};
});
import { httpServerMock } from '@kbn/core-http-server-mocks';
import type { CoreStart, KibanaRequest } from '@kbn/core/server';
import { coreMock, elasticsearchServiceMock, loggingSystemMock } from '@kbn/core/server/mocks';
import { dataPluginMock } from '@kbn/data-plugin/server/mocks';
import { discoverPluginMock } from '@kbn/discover-plugin/server/mocks';
import { createFieldFormatsStartMock } from '@kbn/field-formats-plugin/server/mocks';
import { createMockConfigSchema } from '@kbn/reporting-mocks-server';
import { setFieldFormats } from '@kbn/reporting-server';
import type { Writable } from 'stream';
import { CsvSearchSourceImmediateExportType } from '.';
import { ReportingRequestHandlerContext } from './types';
const mockLogger = loggingSystemMock.createLogger();
const encryptionKey = 'tetkey';
let stream: jest.Mocked<Writable>;
let mockCsvSearchSourceImmediateExportType: CsvSearchSourceImmediateExportType;
let mockCoreStart: CoreStart;
let mockRequest: KibanaRequest<any, any, any, any>;
let mockRequestHandlerContext: ReportingRequestHandlerContext;
beforeEach(async () => {
// use fieldFormats plugin for csv formats
// normally, this is done in the Reporting plugin
setFieldFormats(createFieldFormatsStartMock());
stream = {} as typeof stream;
const configType = createMockConfigSchema({
encryptionKey,
csv: {
checkForFormulas: true,
escapeFormulaValues: true,
maxSizeBytes: 180000,
scroll: { size: 500, duration: 'auto' },
},
});
const mockCoreSetup = coreMock.createSetup();
mockCoreStart = coreMock.createStart();
const context = coreMock.createPluginInitializerContext(configType);
mockRequest = httpServerMock.createKibanaRequest();
mockCsvSearchSourceImmediateExportType = new CsvSearchSourceImmediateExportType(
mockCoreSetup,
configType,
mockLogger,
context
);
mockRequestHandlerContext = {
core: Promise.resolve(mockCoreStart),
} as unknown as ReportingRequestHandlerContext;
mockCsvSearchSourceImmediateExportType.setup({
basePath: { set: jest.fn() },
});
mockCsvSearchSourceImmediateExportType.start({
esClient: elasticsearchServiceMock.createClusterClient(),
savedObjects: mockCoreStart.savedObjects,
uiSettings: mockCoreStart.uiSettings,
discover: discoverPluginMock.createStartContract(),
data: dataPluginMock.createStartContract(),
});
jest.useFakeTimers();
jest.setSystemTime(1630526670000);
});
afterEach(() => {
jest.useRealTimers();
});
test('allows csv.scroll.duration to be "auto"', async () => {
const mockGenerateData = jest.fn().mockResolvedValue(() => ({ csv_contains_formulas: false }));
(CsvGenerator as jest.Mock).mockImplementationOnce(() => {
return { generateData: mockGenerateData };
});
await mockCsvSearchSourceImmediateExportType.runTask(
'cool-job-id',
{ browserTimezone: 'US/Alaska', searchSource: {}, title: 'Test Search' },
mockRequestHandlerContext,
stream,
mockRequest
);
expect(CsvGenerator).toBeCalledWith(
{
browserTimezone: 'US/Alaska',
objectType: 'immediate-search',
searchSource: {},
title: 'Test Search',
},
{
checkForFormulas: true,
escapeFormulaValues: true,
maxSizeBytes: 180000,
scroll: { duration: 'auto', size: 500 },
},
{
retryAt: new Date('2021-09-01T20:06:30.000Z'),
startedAt: new Date('2021-09-01T20:04:30.000Z'),
},
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything(),
expect.anything()
);
expect(mockGenerateData).toBeCalled();
});

View file

@ -20,6 +20,7 @@ import {
LICENSE_TYPE_GOLD,
LICENSE_TYPE_PLATINUM,
LICENSE_TYPE_TRIAL,
durationToNumber,
} from '@kbn/reporting-common';
import type { TaskRunResult } from '@kbn/reporting-common/types';
import {
@ -108,7 +109,17 @@ export class CsvSearchSourceImmediateExportType extends ExportType<
};
const cancellationToken = new CancellationToken();
const csvConfig = this.config.csv;
const taskInstanceFields = { startedAt: null, retryAt: null };
const taskInstanceFields =
csvConfig.scroll.duration === 'auto'
? {
startedAt: new Date(),
retryAt: new Date(Date.now() + durationToNumber(this.config.queue.timeout)),
}
: {
startedAt: null,
retryAt: null,
};
const csv = new CsvGenerator(
job,
csvConfig,

View file

@ -27,5 +27,6 @@
"@kbn/reporting-mocks-server",
"@kbn/core-http-request-handler-context-server",
"@kbn/field-formats-plugin",
"@kbn/core-http-server-mocks",
]
}

View file

@ -9,4 +9,5 @@
export { getSharedComponents } from './shared';
export { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting';
export { reportingCsvShareProvider } from './share_context_menu/register_csv_reporting';
export { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action';
export type { ReportingPublicComponents } from './shared/get_shared_components';

View file

@ -1,24 +1,27 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import * as Rx from 'rxjs';
import { first } from 'rxjs/operators';
import { CoreStart } from '@kbn/core/public';
import type { SearchSource } from '@kbn/data-plugin/common';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { LicenseCheckState } from '@kbn/licensing-plugin/public';
import { coreMock } from '@kbn/core/public/mocks';
import type { SearchSource } from '@kbn/data-plugin/common';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import type { ReportingPublicPluginStartDependencies } from '../plugin';
import type { ActionContext } from './get_csv_panel_action';
import { ReportingCsvPanelAction } from './get_csv_panel_action';
import { dataViewMock } from '@kbn/discover-utils/src/__mocks__';
import { ReportingAPIClient } from '@kbn/reporting-public';
import { LicenseCheckState } from '@kbn/licensing-plugin/public';
import { licensingMock } from '@kbn/licensing-plugin/public/mocks';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { ReportingAPIClient } from '../..';
import {
ActionContext,
type PanelActionDependencies,
ReportingCsvPanelAction,
} from './get_csv_panel_action';
const core = coreMock.createSetup();
let apiClient: ReportingAPIClient;
@ -27,7 +30,7 @@ describe('GetCsvReportPanelAction', () => {
let context: ActionContext;
let mockLicenseState: LicenseCheckState;
let mockSearchSource: SearchSource;
let mockStartServicesPayload: [CoreStart, ReportingPublicPluginStartDependencies, unknown];
let mockStartServicesPayload: [CoreStart, PanelActionDependencies, unknown];
let mockStartServices$: Rx.Observable<typeof mockStartServicesPayload>;
const mockLicense$ = () => {
@ -65,7 +68,7 @@ describe('GetCsvReportPanelAction', () => {
{
data: dataPluginMock.createStartContract(),
licensing: { ...licensingMock.createStart(), license$: mockLicense$() },
} as unknown as ReportingPublicPluginStartDependencies,
} as unknown as PanelActionDependencies,
null,
];
mockStartServices$ = Rx.from(Promise.resolve(mockStartServicesPayload));
@ -106,7 +109,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
await panel.execute(context);
@ -142,7 +145,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
await panel.execute(context);
@ -164,7 +167,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
await panel.execute(context);
@ -179,7 +182,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
await panel.execute(context);
@ -196,7 +199,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
await panel.execute(context);
@ -213,7 +216,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
expect(await plugin.isCompatible(context)).toEqual(false);
});
@ -225,10 +228,10 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
expect(panel.getIconType()).toMatchInlineSnapshot(`"document"`);
expect(panel.getDisplayName()).toMatchInlineSnapshot(`"Download CSV"`);
expect(panel.getIconType()).toBe('document');
expect(panel.getDisplayName()).toBe('Download CSV');
});
describe('Application UI Capabilities', () => {
@ -241,7 +244,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
expect(await plugin.isCompatible(context)).toEqual(false);
});
@ -254,7 +257,7 @@ describe('GetCsvReportPanelAction', () => {
usesUiCapabilities: true,
});
await mockStartServices$.pipe(first()).toPromise();
await Rx.firstValueFrom(mockStartServices$);
expect(await plugin.isCompatible(context)).toEqual(true);
});

View file

@ -1,26 +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.
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { CoreSetup, CoreStart, NotificationsSetup } from '@kbn/core/public';
import { i18n } from '@kbn/i18n';
import { firstValueFrom, Observable } from 'rxjs';
import type { CoreSetup, NotificationsSetup } from '@kbn/core/public';
import { CoreStart } from '@kbn/core/public';
import { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { ISearchEmbeddable } from '@kbn/discover-plugin/public';
import { loadSharingDataHelpers, SEARCH_EMBEDDABLE_TYPE } from '@kbn/discover-plugin/public';
import type { IEmbeddable } from '@kbn/embeddable-plugin/public';
import { ViewMode } from '@kbn/embeddable-plugin/public';
import { i18n } from '@kbn/i18n';
import { CSV_REPORTING_ACTION } from '@kbn/reporting-export-types-csv-common';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import type { UiActionsActionDefinition as ActionDefinition } from '@kbn/ui-actions-plugin/public';
import { IncompatibleActionError } from '@kbn/ui-actions-plugin/public';
import { ReportingAPIClient, checkLicense } from '@kbn/reporting-public';
import type { ReportingPublicPluginStartDependencies } from '../plugin';
import { CSV_REPORTING_ACTION } from '@kbn/reporting-export-types-csv-common';
import { checkLicense } from '../../license_check';
import { ReportingAPIClient } from '../../reporting_api_client';
function isSavedSearchEmbeddable(
embeddable: IEmbeddable | ISearchEmbeddable
@ -32,10 +34,15 @@ export interface ActionContext {
embeddable: ISearchEmbeddable;
}
export interface PanelActionDependencies {
data: DataPublicPluginStart;
licensing: LicensingPluginStart;
}
interface Params {
apiClient: ReportingAPIClient;
core: CoreSetup;
startServices$: Observable<[CoreStart, ReportingPublicPluginStartDependencies, unknown]>;
startServices$: Observable<[CoreStart, PanelActionDependencies, unknown]>;
usesUiCapabilities: boolean;
}
@ -63,7 +70,7 @@ export class ReportingCsvPanelAction implements ActionDefinition<ActionContext>
}
public getDisplayName() {
return i18n.translate('xpack.reporting.dashboard.downloadCsvPanelTitle', {
return i18n.translate('reporting.share.panelAction.generateCsvPanelTitle', {
defaultMessage: 'Download CSV',
});
}
@ -131,10 +138,10 @@ export class ReportingCsvPanelAction implements ActionDefinition<ActionContext>
this.isDownloading = true;
this.notifications.toasts.addSuccess({
title: i18n.translate('xpack.reporting.dashboard.csvDownloadStartedTitle', {
title: i18n.translate('reporting.share.panelAction.csvDownloadStartedTitle', {
defaultMessage: `CSV Download Started`,
}),
text: i18n.translate('xpack.reporting.dashboard.csvDownloadStartedMessage', {
text: i18n.translate('reporting.share.panelAction.csvDownloadStartedMessage', {
defaultMessage: `Your CSV will download momentarily.`,
}),
'data-test-subj': 'csvDownloadStarted',
@ -173,10 +180,10 @@ export class ReportingCsvPanelAction implements ActionDefinition<ActionContext>
private onGenerationFail(_error: Error) {
this.isDownloading = false;
this.notifications.toasts.addDanger({
title: i18n.translate('xpack.reporting.dashboard.failedCsvDownloadTitle', {
title: i18n.translate('reporting.share.panelAction.failedCsvReportTitle', {
defaultMessage: `CSV download failed`,
}),
text: i18n.translate('xpack.reporting.dashboard.failedCsvDownloadMessage', {
text: i18n.translate('reporting.share.panelAction.failedCsvReportMessage', {
defaultMessage: `We couldn't generate your CSV at this time.`,
}),
'data-test-subj': 'downloadCsvFail',

View file

@ -30,5 +30,10 @@
"@kbn/screenshotting-plugin",
"@kbn/i18n-react",
"@kbn/test-jest-helpers",
"@kbn/discover-utils",
"@kbn/saved-search-plugin",
"@kbn/discover-plugin",
"@kbn/embeddable-plugin",
"@kbn/ui-actions-plugin",
]
}

View file

@ -31,7 +31,6 @@
"screenshotting"
],
"requiredBundles": [
"discover",
"embeddable",
"esUiShared",
"kibanaReact"

View file

@ -22,21 +22,21 @@ import { i18n } from '@kbn/i18n';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public';
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public';
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { durationToNumber } from '@kbn/reporting-common';
import type { ClientConfigType } from '@kbn/reporting-public';
import { ReportingAPIClient } from '@kbn/reporting-public';
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import {
ReportingCsvPanelAction,
getSharedComponents,
reportingCsvShareProvider,
reportingScreenshotShareProvider,
} from '@kbn/reporting-public/share';
import type { ReportingSetup, ReportingStart } from '.';
import { ReportingNotifierStreamHandler as StreamHandler } from './lib/stream_handler';
import { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action';
export interface ReportingPublicPluginSetupDependencies {
home: HomePublicPluginSetup;

View file

@ -36,7 +36,6 @@
"@kbn/core-test-helpers-test-utils",
"@kbn/safer-lodash-set",
"@kbn/reporting-common",
"@kbn/saved-search-plugin",
"@kbn/core-http-router-server-internal",
"@kbn/reporting-public",
"@kbn/reporting-server",
@ -50,7 +49,6 @@
"@kbn/reporting-mocks-server",
"@kbn/core-http-request-handler-context-server",
"@kbn/reporting-public",
"@kbn/discover-utils",
"@kbn/analytics-client",
],
"exclude": [

View file

@ -29748,11 +29748,6 @@
"xpack.reporting.statusIndicator.processingMaxAttemptsLabel": "En cours de traitement, tentative {attempt} sur {of}",
"xpack.reporting.userAccessError.message": "Demandez à votre administrateur un accès aux fonctionnalités de reporting. {grantUserAccessDocs}.",
"xpack.reporting.breadcrumb": "Reporting",
"xpack.reporting.dashboard.csvDownloadStartedMessage": "Votre CSV sera téléchargé dans un instant.",
"xpack.reporting.dashboard.csvDownloadStartedTitle": "Téléchargement du CSV démarré",
"xpack.reporting.dashboard.downloadCsvPanelTitle": "Télécharger CSV",
"xpack.reporting.dashboard.failedCsvDownloadMessage": "Nous n'avons pas pu générer votre CSV pour le moment.",
"xpack.reporting.dashboard.failedCsvDownloadTitle": "Le téléchargement du CSV a échoué",
"xpack.reporting.deprecations.migrateIndexIlmPolicyActionTitle": "Des index de reporting gérés par une politique ILM personnalisée ont été détectés.",
"xpack.reporting.deprecations.reportingRole.forbiddenErrorCorrectiveAction": "Assurez-vous de disposer du privilège de cluster \"manage_security\".",
"xpack.reporting.deprecations.reportingRole.forbiddenErrorMessage": "Vous navez pas les autorisations requises pour remédier à ce déclassement.",

View file

@ -29749,11 +29749,6 @@
"xpack.reporting.statusIndicator.processingMaxAttemptsLabel": "処理中。{attempt}/{of}回試行",
"xpack.reporting.userAccessError.message": "レポート機能にアクセスするには、管理者に問い合わせてください。{grantUserAccessDocs}。",
"xpack.reporting.breadcrumb": "レポート",
"xpack.reporting.dashboard.csvDownloadStartedMessage": "間もなく CSV がダウンロードされます。",
"xpack.reporting.dashboard.csvDownloadStartedTitle": "CSV のダウンロードが開始しました",
"xpack.reporting.dashboard.downloadCsvPanelTitle": "CSV をダウンロード",
"xpack.reporting.dashboard.failedCsvDownloadMessage": "現在 CSV を生成できません。",
"xpack.reporting.dashboard.failedCsvDownloadTitle": "CSV のダウンロードに失敗",
"xpack.reporting.deprecations.migrateIndexIlmPolicyActionTitle": "カスタムILMポリシーで管理されたレポートインデックスが見つかりました。",
"xpack.reporting.deprecations.reportingRole.forbiddenErrorCorrectiveAction": "「manage_security」クラスター権限が割り当てられていることを確認してください。",
"xpack.reporting.deprecations.reportingRole.forbiddenErrorMessage": "この廃止予定を修正する十分な権限がありません。",

View file

@ -29733,11 +29733,6 @@
"xpack.reporting.statusIndicator.processingMaxAttemptsLabel": "正在处理,尝试 {attempt} {of}",
"xpack.reporting.userAccessError.message": "请联系管理员以访问报告功能。{grantUserAccessDocs}.",
"xpack.reporting.breadcrumb": "Reporting",
"xpack.reporting.dashboard.csvDownloadStartedMessage": "您的 CSV 将很快下载。",
"xpack.reporting.dashboard.csvDownloadStartedTitle": "CSV 下载已开始",
"xpack.reporting.dashboard.downloadCsvPanelTitle": "下载 CSV",
"xpack.reporting.dashboard.failedCsvDownloadMessage": "我们此次无法生成 CSV。",
"xpack.reporting.dashboard.failedCsvDownloadTitle": "CSV 下载失败。",
"xpack.reporting.deprecations.migrateIndexIlmPolicyActionTitle": "找到由定制 ILM 策略管理的报告索引。",
"xpack.reporting.deprecations.reportingRole.forbiddenErrorCorrectiveAction": "请确保您分配有“manage_security”集群权限。",
"xpack.reporting.deprecations.reportingRole.forbiddenErrorMessage": "您没有足够的权限来修复此弃用。",