[Reporting/CSV] Make searches used for export inspectable (#171248)

## Summary

Resolves https://github.com/elastic/kibana/issues/162366

## Release note
Added a troubleshooting enhancement for Kibana CSV export to allow users
to inspect the queries used for collecting all of the data.

## Other changes:
* Expose the reporting `csv.scroll` settings to the browser.
* Lazy-load the report job info panel component.
* Fix a few mixups of "setup" and "start" contracts.

## Screenshots
<details>
<summary>Option in Stack Management for CSV report jobs</summary>

![image](a382bfee-ce1f-4229-bf89-bf8836328ad3)
</details>

<details>
<summary>Screencast</summary>


a2fba0f4-0ede-4d97-aad3-4b13351e24a3

</details>

## 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
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Sébastien Loix <sabee77@gmail.com>
Co-authored-by: Sébastien Loix <sebastien.loix@elastic.co>
This commit is contained in:
Tim Sullivan 2023-12-06 10:09:19 -07:00 committed by GitHub
parent b1c7372083
commit 7795901fe7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 565 additions and 78 deletions

View file

@ -51,3 +51,5 @@ export const CSV_SEARCHSOURCE_IMMEDIATE_TYPE = 'csv_searchsource_immediate';
// but the extension points are still needed for pre-existing scripted automation, until 8.0
export const CSV_REPORT_TYPE_DEPRECATED = 'CSV';
export const CSV_JOB_TYPE_DEPRECATED = 'csv';
export { getQueryFromCsvJob, type QueryInspection } from './lib/get_query_from_job';

View file

@ -0,0 +1,13 @@
/*
* 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.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/packages/kbn-reporting/export_types/csv_common'],
};

View file

@ -0,0 +1,79 @@
/*
* 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 type { EsQuerySortValue, SearchSourceFields } from '@kbn/data-plugin/common';
import { searchSourceCommonMock } from '@kbn/data-plugin/common/search/search_source/mocks';
import { createStubDataView } from '@kbn/data-views-plugin/common/stubs';
import { getQueryFromCsvJob } from './get_query_from_job';
describe('getQueryFromCsvJob', () => {
it('returns QueryInspection data', async () => {
const searchSourceStart = { ...searchSourceCommonMock };
const originalCreate = searchSourceStart.create;
searchSourceStart.create = jest.fn().mockImplementation(async () => {
const original = await originalCreate();
const originalGetField = original.getField;
const getField = (fieldName: keyof SearchSourceFields) => {
if (fieldName === 'index') {
return createStubDataView({
spec: {
id: 'test-*',
title: 'test-*',
},
});
}
return originalGetField(fieldName);
};
return {
...original,
getField,
};
});
const config = { scroll: { duration: '2m', size: 500 } };
const fromTime = '2019-06-20T00:00:00.000Z';
const toTime = '2019-06-24T00:00:00.000Z';
const serializedSearchSource = {
version: true,
query: { query: '', language: 'kuery' },
index: '5193f870-d861-11e9-a311-0fa548c5f953',
sort: [{ order_date: 'desc' }] as EsQuerySortValue[],
fields: ['*'],
filter: [],
parent: {
query: { language: 'kuery', query: '' },
filter: [],
parent: {
filter: [
{
meta: { index: '5193f870-d861-11e9-a311-0fa548c5f953', params: {} },
range: {
order_date: {
gte: fromTime,
lte: toTime,
format: 'strict_date_optional_time',
},
},
},
],
},
},
};
const searchSource = await searchSourceStart.create(serializedSearchSource);
const query = getQueryFromCsvJob(searchSource, config);
// NOTE the mocked search source service is not returning assertable info
expect(query).toMatchInlineSnapshot(`
Object {
"requestBody": undefined,
}
`);
});
});

View file

@ -0,0 +1,60 @@
/*
* 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 type { estypes } from '@elastic/elasticsearch';
import type { ISearchSource } from '@kbn/data-plugin/common';
import { durationToNumber } from '@kbn/reporting-common';
/**
* Type to wrap the untyped object returned when
* getting the query from SearchSource service
*/
export interface QueryInspection {
requestBody: estypes.SearchRequest;
}
/**
* @internal
*/
interface CsvConfigType {
scroll: {
size: number;
duration: string;
};
}
/**
* A utility to get the query from a CSV reporting job to inspect or analyze
* @public
*/
export const getQueryFromCsvJob = (
searchSource: ISearchSource,
{ scroll: config }: CsvConfigType,
pitId?: string
): QueryInspection => {
// Max number of documents in each returned page
searchSource.setField('size', durationToNumber(config.size));
// Max time to wait for result
searchSource.setField('timeout', config.duration);
// Request high accuracy for calculating total hits
searchSource.setField('trackTotalHits', true);
if (pitId) {
// Always use most recently provided PIT
searchSource.setField('pit', {
id: pitId,
keep_alive: config.duration,
});
}
return {
requestBody: searchSource.getSearchRequestBody(),
};
};

View file

@ -16,5 +16,6 @@
"kbn_references": [
"@kbn/data-plugin",
"@kbn/reporting-common",
"@kbn/data-views-plugin",
]
}

View file

@ -7,6 +7,7 @@
*/
export interface ClientConfigType {
csv: { scroll: { duration: string; size: number } };
poll: { jobsRefresh: { interval: number; intervalErrorMultiplier: number } };
roles: { enabled: boolean };
export_types: { pdf: { enabled: boolean }; png: { enabled: boolean }; csv: { enabled: boolean } };

View file

@ -32,6 +32,8 @@ type ReportPayload = ReportSource['payload'];
* It can be instantiated with ReportApiJSON: the response data format for the report job APIs
*/
export class Job {
public readonly payload: Omit<ReportPayload, 'headers'>;
public readonly id: JobId;
public readonly index: string;
@ -73,6 +75,8 @@ export class Job {
this.id = report.id;
this.index = report.index;
this.payload = report.payload;
this.jobtype = report.jobtype;
this.objectType = report.payload.objectType;
this.title = report.payload.title;
@ -105,6 +109,10 @@ export class Job {
this.execution_time_ms = report.execution_time_ms;
}
public isSearch() {
return this.objectType === 'search';
}
getStatusMessage() {
const status = this.status;
let smallMessage;

View file

@ -18,9 +18,12 @@ import {
notificationServiceMock,
coreMock,
} from '@kbn/core/public/mocks';
import type { LocatorPublic, SharePluginSetup } from '@kbn/share-plugin/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import type { LocatorPublic, SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { ILicense } from '@kbn/licensing-plugin/public';
import { dataPluginMock } from '@kbn/data-plugin/public/mocks';
import { sharePluginMock } from '@kbn/share-plugin/public/mocks';
import { mockJobs } from '../../../common/test';
@ -43,9 +46,17 @@ export interface TestDependencies {
ilmLocator: LocatorPublic<SerializableRecord>;
uiSettings: ReturnType<typeof coreMock.createSetup>['uiSettings'];
reportDiagnostic: typeof ReportDiagnostic;
data: DataPublicPluginStart;
share: SharePluginStart;
}
export const mockConfig = {
csv: {
scroll: {
duration: '10m',
size: 500,
},
},
poll: {
jobCompletionNotifier: {
interval: 5000,
@ -95,9 +106,11 @@ export const createTestBed = registerTestBed(
urlService,
toasts,
uiSettings,
data,
share,
...rest
}: Partial<Props> & TestDependencies) => (
<KibanaContextProvider services={{ http, application, uiSettings }}>
<KibanaContextProvider services={{ http, application, uiSettings, data, share }}>
<InternalApiClientProvider apiClient={reportingAPIClient}>
<IlmPolicyStatusContextProvider>
<ReportListing
@ -152,6 +165,8 @@ export const setup = async (props?: Partial<Props>) => {
},
} as unknown as SharePluginSetup['url'],
reportDiagnostic,
data: dataPluginMock.createStartContract(),
share: sharePluginMock.createStartContract(),
};
const testBed = createTestBed({ ...testDependencies, ...props });

View file

@ -0,0 +1,203 @@
/*
* 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 { compressToEncodedURIComponent } from 'lz-string';
import React, { useCallback } from 'react';
import { EuiContextMenuItem } from '@elastic/eui';
import type { ISearchStartSearchSource } from '@kbn/data-plugin/common';
import { i18n } from '@kbn/i18n';
import { useKibana } from '@kbn/kibana-react-plugin/public';
import {
getQueryFromCsvJob,
QueryInspection,
TaskPayloadCSV,
} from '@kbn/reporting-export-types-csv-common';
import type { ClientConfigType } from '@kbn/reporting-public';
import type { LocatorClient } from '@kbn/share-plugin/common/url_service';
import type { Job } from '../../lib/job';
import type { KibanaContext } from '../../types';
interface PropsUI {
job: Job;
csvConfig: ClientConfigType['csv'];
searchSourceStart: ISearchStartSearchSource;
locators: LocatorClient;
}
const InspectInConsoleButtonUi: React.FC<PropsUI> = (props) => {
const { csvConfig, job, searchSourceStart, locators } = props;
const { title: jobTitle } = job;
const serializedSearchSource = (job.payload as TaskPayloadCSV).searchSource;
const handleDevToolsLinkClick = useCallback(async () => {
const searchSource = await searchSourceStart.create(serializedSearchSource);
const index = searchSource.getField('index');
if (!index) {
throw new Error(`The search must have a reference to an index pattern!`);
}
const indexPatternTitle = index.getIndexPattern();
const examplePitId = i18n.translate(
'xpack.reporting.reportInfoFlyout.devToolsContent.examplePitId',
{
defaultMessage: `[ID returned from first request]`,
description: `This gets used in place of an ID string that is sent in a request body.`,
}
);
const queryInfo = getQueryFromCsvJob(searchSource, csvConfig, examplePitId);
const queryUri = compressToEncodedURIComponent(
getTextForConsole(jobTitle, indexPatternTitle, queryInfo, csvConfig)
);
const consoleLocator = locators.get('CONSOLE_APP_LOCATOR');
consoleLocator?.navigate({
loadFrom: `data:text/plain,${queryUri}`,
});
}, [searchSourceStart, serializedSearchSource, jobTitle, csvConfig, locators]);
return (
<EuiContextMenuItem
data-test-subj="reportInfoFlyoutOpenInConsoleButton"
key="download"
icon="wrench"
onClick={handleDevToolsLinkClick}
>
{i18n.translate('xpack.reporting.reportInfoFlyout.openInConsole', {
defaultMessage: 'Inspect query in Console',
description: 'An option in a menu of actions.',
})}
</EuiContextMenuItem>
);
};
interface Props {
job: Job;
config: ClientConfigType;
}
export const InspectInConsoleButton: React.FC<Props> = (props) => {
const { config, job } = props;
const { services } = useKibana<KibanaContext>();
const { application, data, share } = services;
const { capabilities } = application;
const { locators } = share.url;
// To show the Console button,
// check if job object type is search,
// and if both the Dev Tools UI and the Console UI are enabled.
const canShowDevTools = job.objectType === 'search' && capabilities.dev_tools.show;
if (!canShowDevTools) {
return null;
}
return (
<InspectInConsoleButtonUi
searchSourceStart={data.search.searchSource}
locators={locators}
job={job}
csvConfig={config.csv}
/>
);
};
const getTextForConsole = (
jobTitle: string,
indexPattern: string,
queryInfo: QueryInspection,
csvConfig: ClientConfigType['csv']
) => {
const { requestBody } = queryInfo;
const queryAsString = JSON.stringify(requestBody, null, ' ');
const pitRequest =
`POST /${indexPattern}/_pit?keep_alive=${csvConfig.scroll.duration}` +
`&ignore_unavailable=true`;
const queryRequest = `POST /_search`;
const closePitRequest = `DELETE /_pit\n${JSON.stringify({
id: `[ID returned from latest request]`,
})}`;
const introText = i18n.translate(
// intro to the content
'xpack.reporting.reportInfoFlyout.devToolsContent.introText',
{
description: `Script used in the Console app`,
defaultMessage: `
# Report title: {jobTitle}
# These are the queries used when exporting data for
# the CSV report.
#
# For reference about the Elasticsearch Point-In-Time
# API, see
# https://www.elastic.co/guide/en/elasticsearch/reference/current/point-in-time-api.html
# The first query opens a Point-In-Time (PIT) context
# and receive back the ID reference. The "keep_alive"
# value is taken from the
# "xpack.reporting.csv.scroll.duration" setting.
#
# The response will include an "id" value, which is
# needed for the second query.
{pitRequest}`,
values: {
jobTitle,
pitRequest,
},
}
);
const queryText = i18n.translate(
// query with the request path and body
'xpack.reporting.reportInfoFlyout.devToolsContent.queryText',
{
description: `Script used in the Console app`,
defaultMessage: `
# The second query executes a search using the PIT ID.
# The "keep_alive" and "size" values come from the
# "xpack.reporting.csv.scroll.duration" and
# "xpack.reporting.csv.scroll.size" settings in
# kibana.yml.
#
# The reponse will include new a PIT ID, which might
# not be the same as the ID returned from the first
# query.
{queryRequest}
{queryAsString}`,
values: { queryRequest, queryAsString },
}
);
const pagingText = i18n.translate(
// info about querying further pages, and link to documentation
'xpack.reporting.reportInfoFlyout.devToolsContent.pagingText',
{
description: `Script used in the Console app`,
defaultMessage: `# The first request retrieves the first page of search
# results. If you want to retrieve more hits, use PIT
# with search_after. Always use the PIT ID from the
# latest search response. See
# https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html`,
}
);
const closingText = i18n.translate(
// reminder to close the point-in-time context
'xpack.reporting.reportInfoFlyout.devToolsContent.closingText',
{
description: `Script used in the Console app`,
defaultMessage: `
# Finally, release the resources held in Elasticsearch
# memory by clearing the PIT.
{closePitRequest}
`,
values: { closePitRequest },
}
);
return (introText + queryText + pagingText + closingText).trim();
};

View file

@ -5,48 +5,50 @@
* 2.0.
*/
import React, { FunctionComponent, useState, useEffect } from 'react';
import React, { FunctionComponent, useEffect, useState } from 'react';
import useMountedState from 'react-use/lib/useMountedState';
import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiFlyoutFooter,
EuiPortal,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiButton,
EuiTitle,
EuiLoadingSpinner,
EuiPopover,
EuiButtonEmpty,
EuiContextMenuItem,
EuiContextMenuPanel,
EuiFlexGroup,
EuiFlexItem,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutFooter,
EuiFlyoutHeader,
EuiLoadingSpinner,
EuiPopover,
EuiPortal,
EuiTitle,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { ClientConfigType } from '@kbn/reporting-public';
import { Job } from '../../lib/job';
import type { Job } from '../../lib/job';
import { useInternalApiClient } from '../../lib/reporting_api_client';
import { InspectInConsoleButton } from './inspect_in_console_button';
import { ReportInfoFlyoutContent } from './report_info_flyout_content';
interface Props {
config: ClientConfigType;
onClose: () => void;
job: Job;
}
export const ReportInfoFlyout: FunctionComponent<Props> = ({ onClose, job }) => {
export const ReportInfoFlyout: FunctionComponent<Props> = ({ config, onClose, job }) => {
const isMounted = useMountedState();
const { apiClient } = useInternalApiClient();
const [isLoading, setIsLoading] = useState<boolean>(true);
const [loadingError, setLoadingError] = useState<undefined | Error>();
const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState<boolean>(false);
const [info, setInfo] = useState<undefined | Job>();
const isMounted = useMountedState();
const { apiClient } = useInternalApiClient();
const closePopover = () => setIsActionsPopoverOpen(false);
useEffect(() => {
(async function loadInfo() {
async function loadInfo() {
if (isLoading) {
try {
const infoResponse = await apiClient.getInfo(job.id);
@ -63,7 +65,8 @@ export const ReportInfoFlyout: FunctionComponent<Props> = ({ onClose, job }) =>
}
}
}
})();
}
loadInfo();
}, [isLoading, apiClient, job.id, isMounted]);
const actionsButton = (
@ -92,6 +95,7 @@ export const ReportInfoFlyout: FunctionComponent<Props> = ({ onClose, job }) =>
defaultMessage: 'Download',
})}
</EuiContextMenuItem>,
<InspectInConsoleButton job={job} config={config} />,
<EuiContextMenuItem
data-test-subj="reportInfoFlyoutOpenInKibanaButton"
disabled={!job.canLinkToKibanaApp}
@ -106,7 +110,9 @@ export const ReportInfoFlyout: FunctionComponent<Props> = ({ onClose, job }) =>
defaultMessage: 'Open in Kibana',
})}
</EuiContextMenuItem>,
];
].filter(Boolean);
const closePopover = () => setIsActionsPopoverOpen(false);
return (
<EuiPortal>

View file

@ -5,11 +5,11 @@
* 2.0.
*/
import type { ApplicationStart, ToastsSetup } from '@kbn/core/public';
import type { ApplicationStart, ToastsStart } from '@kbn/core/public';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { ClientConfigType } from '@kbn/reporting-public';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import type { ReportingAPIClient } from '../lib/reporting_api_client';
import type { SharePluginSetup } from '../shared_imports';
export interface ListingProps {
apiClient: ReportingAPIClient;
@ -17,8 +17,8 @@ export interface ListingProps {
config: ClientConfigType;
redirect: ApplicationStart['navigateToApp'];
navigateToUrl: ApplicationStart['navigateToUrl'];
toasts: ToastsSetup;
urlService: SharePluginSetup['url'];
toasts: ToastsStart;
urlService: SharePluginStart['url'];
}
export type ListingPropsInternal = ListingProps & {

View file

@ -7,49 +7,53 @@
import * as React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import { Observable } from 'rxjs';
import { CoreSetup, CoreStart } from '@kbn/core/public';
import type { CoreStart } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { I18nProvider } from '@kbn/i18n-react';
import { ILicense } from '@kbn/licensing-plugin/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import type { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import type { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { KibanaThemeProvider } from '@kbn/react-kibana-context-theme';
import type { ClientConfigType } from '@kbn/reporting-public';
import type { SharePluginStart } from '@kbn/share-plugin/public';
import { ReportListing } from '.';
import { InternalApiClientProvider, ReportingAPIClient } from '../lib/reporting_api_client';
import type { ManagementAppMountParams, SharePluginSetup } from '../shared_imports';
import { KibanaContextProvider } from '../shared_imports';
import { PolicyStatusContextProvider } from '../lib/default_status_context';
import { InternalApiClientProvider, type ReportingAPIClient } from '../lib/reporting_api_client';
import type { KibanaContext } from '../types';
export async function mountManagementSection(
coreSetup: CoreSetup,
coreStart: CoreStart,
license$: Observable<ILicense>,
license$: LicensingPluginStart['license$'],
dataService: DataPublicPluginStart,
shareService: SharePluginStart,
config: ClientConfigType,
apiClient: ReportingAPIClient,
urlService: SharePluginSetup['url'],
params: ManagementAppMountParams
) {
const services: KibanaContext = {
http: coreStart.http,
application: coreStart.application,
uiSettings: coreStart.uiSettings,
docLinks: coreStart.docLinks,
data: dataService,
share: shareService,
};
render(
<KibanaThemeProvider theme={{ theme$: params.theme$ }}>
<I18nProvider>
<KibanaContextProvider
services={{
http: coreSetup.http,
application: coreStart.application,
uiSettings: coreStart.uiSettings,
docLinks: coreStart.docLinks,
}}
>
<KibanaContextProvider services={services}>
<InternalApiClientProvider apiClient={apiClient}>
<PolicyStatusContextProvider config={config}>
<ReportListing
apiClient={apiClient}
toasts={coreSetup.notifications.toasts}
toasts={coreStart.notifications.toasts}
license$={license$}
config={config}
redirect={coreStart.application.navigateToApp}
navigateToUrl={coreStart.application.navigateToUrl}
urlService={urlService}
urlService={shareService.url}
/>
</PolicyStatusContextProvider>
</InternalApiClientProvider>

View file

@ -430,6 +430,7 @@ export class ReportListingTable extends Component<ListingPropsInternal, State> {
/>
{!!this.state.selectedJob && (
<ReportInfoFlyout
config={this.props.config}
onClose={() => this.setState({ selectedJob: undefined })}
job={this.state.selectedJob}
/>

View file

@ -9,6 +9,7 @@ import * as Rx from 'rxjs';
import { catchError, filter, map, mergeMap, takeUntil } from 'rxjs/operators';
import {
AppNavLinkStatus,
CoreSetup,
CoreStart,
HttpSetup,
@ -22,13 +23,15 @@ import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { CONTEXT_MENU_TRIGGER } from '@kbn/embeddable-plugin/public';
import type { HomePublicPluginSetup, HomePublicPluginStart } from '@kbn/home-plugin/public';
import { i18n } from '@kbn/i18n';
import { LicensingPluginStart } from '@kbn/licensing-plugin/public';
import { ManagementSetup, ManagementStart } from '@kbn/management-plugin/public';
import { ClientConfigType } from '@kbn/reporting-public';
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 { JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY, durationToNumber } from '@kbn/reporting-common';
import type { JobId } from '@kbn/reporting-common/types';
import type { ClientConfigType } from '@kbn/reporting-public';
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
import type { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import type { ReportingSetup, ReportingStart } from '.';
import { ReportingAPIClient } from './lib/reporting_api_client';
@ -38,14 +41,8 @@ import { ReportingCsvPanelAction } from './panel_actions/get_csv_panel_action';
import { reportingCsvShareProvider } from './share_context_menu/register_csv_reporting';
import { reportingScreenshotShareProvider } from './share_context_menu/register_pdf_png_reporting';
import { getSharedComponents } from './shared';
import type {
SharePluginSetup,
SharePluginStart,
UiActionsSetup,
UiActionsStart,
} from './shared_imports';
import { AppNavLinkStatus } from './shared_imports';
import type { JobSummarySet } from './types';
function getStored(): JobId[] {
const sessionValue = sessionStorage.getItem(JOB_COMPLETION_NOTIFICATIONS_SESSION_KEY);
return sessionValue ? JSON.parse(sessionValue) : [];
@ -146,14 +143,20 @@ export class ReportingPublicPlugin
setupDeps: ReportingPublicPluginSetupDependencies
) {
const { getStartServices, uiSettings } = core;
const { home, management, screenshotMode, share, uiActions } = setupDeps;
const {
home: homeSetup,
management: managementSetup,
screenshotMode: screenshotModeSetup,
share: shareSetup,
uiActions: uiActionsSetup,
} = setupDeps;
const startServices$ = Rx.from(getStartServices());
const usesUiCapabilities = !this.config.roles.enabled;
const apiClient = this.getApiClient(core.http, core.uiSettings);
home.featureCatalogue.register({
homeSetup.featureCatalogue.register({
id: 'reporting',
title: i18n.translate('xpack.reporting.registerFeature.reportingTitle', {
defaultMessage: 'Reporting',
@ -167,28 +170,28 @@ export class ReportingPublicPlugin
category: 'admin',
});
management.sections.section.insightsAndAlerting.registerApp({
managementSetup.sections.section.insightsAndAlerting.registerApp({
id: 'reporting',
title: this.title,
order: 3,
mount: async (params) => {
params.setBreadcrumbs([{ text: this.breadcrumbText }]);
const [[start, startDeps], { mountManagementSection }] = await Promise.all([
const [[coreStart, startDeps], { mountManagementSection }] = await Promise.all([
getStartServices(),
import('./management/mount_management_section'),
]);
const { docTitle } = start.chrome;
const { licensing, data, share } = startDeps;
const { docTitle } = coreStart.chrome;
docTitle.change(this.title);
const { license$ } = startDeps.licensing;
const umountAppCallback = await mountManagementSection(
core,
start,
license$,
coreStart,
licensing.license$,
data,
share,
this.config,
apiClient,
share.url,
params
);
@ -203,7 +206,12 @@ export class ReportingPublicPlugin
id: 'reportingRedirect',
mount: async (params) => {
const { mountRedirectApp } = await import('./redirect');
return mountRedirectApp({ ...params, apiClient, screenshotMode, share });
return mountRedirectApp({
...params,
apiClient,
screenshotMode: screenshotModeSetup,
share: shareSetup,
});
},
title: 'Reporting redirect app',
searchable: false,
@ -212,7 +220,7 @@ export class ReportingPublicPlugin
navLinkStatus: AppNavLinkStatus.hidden,
});
uiActions.addTriggerAction(
uiActionsSetup.addTriggerAction(
CONTEXT_MENU_TRIGGER,
new ReportingCsvPanelAction({ core, apiClient, startServices$, usesUiCapabilities })
);
@ -222,7 +230,7 @@ export class ReportingPublicPlugin
startServices$.subscribe(([{ application }, { licensing }]) => {
licensing.license$.subscribe((license) => {
share.register(
shareSetup.register(
reportingCsvShareProvider({
apiClient,
toasts,
@ -235,7 +243,7 @@ export class ReportingPublicPlugin
);
if (this.config.export_types.pdf.enabled || this.config.export_types.png.enabled) {
share.register(
shareSetup.register(
reportingScreenshotShareProvider({
apiClient,
toasts,

View file

@ -5,15 +5,15 @@
* 2.0.
*/
import { render, unmountComponentAtNode } from 'react-dom';
import React from 'react';
import { EuiErrorBoundary } from '@elastic/eui';
import React from 'react';
import { render, unmountComponentAtNode } from 'react-dom';
import type { AppMountParameters } from '@kbn/core/public';
import type { ScreenshotModePluginSetup } from '@kbn/screenshot-mode-plugin/public';
import type { SharePluginSetup } from '../shared_imports';
import type { ReportingAPIClient } from '../lib/reporting_api_client';
import type { SharePluginSetup } from '@kbn/share-plugin/public';
import type { ReportingAPIClient } from '../lib/reporting_api_client';
import { RedirectApp } from './redirect_app';
interface MountParams extends AppMountParameters {

View file

@ -5,6 +5,8 @@
* 2.0.
*/
// FIXME: Reporting code does not consistently import from here. It would be
// better to remove this file and do without indirect imports.
export type { SharePluginSetup, SharePluginStart, LocatorPublic } from '@kbn/share-plugin/public';
export { AppNavLinkStatus } from '@kbn/core/public';

View file

@ -6,10 +6,15 @@
*/
import type { CoreSetup, CoreStart } from '@kbn/core/public';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
import { JOB_STATUS } from '@kbn/reporting-common';
import type { JobId, ReportOutput, ReportSource, TaskRunResult } from '@kbn/reporting-common/types';
import type { SharePluginStart } from '@kbn/share-plugin/public';
/* Notifier Toasts */
/*
* Notifier Toasts
* @internal
*/
export interface JobSummary {
id: JobId;
status: JOB_STATUS;
@ -20,14 +25,23 @@ export interface JobSummary {
csvContainsFormulas: TaskRunResult['csv_contains_formulas'];
}
/*
* Notifier Toasts
* @internal
*/
export interface JobSummarySet {
completed: JobSummary[];
failed: JobSummary[];
}
/* Services received through useKibana context
* @internal
*/
export interface KibanaContext {
http: CoreSetup['http'];
application: CoreStart['application'];
uiSettings: CoreStart['uiSettings'];
docLinks: CoreStart['docLinks'];
data: DataPublicPluginStart;
share: SharePluginStart;
}

View file

@ -12,7 +12,13 @@ import { i18n } from '@kbn/i18n';
import { ConfigSchema, ReportingConfigType } from '@kbn/reporting-server';
export const config: PluginConfigDescriptor<ReportingConfigType> = {
exposeToBrowser: { poll: true, roles: true, export_types: true, statefulSettings: true },
exposeToBrowser: {
csv: { scroll: true },
poll: true,
roles: true,
export_types: true,
statefulSettings: true,
},
schema: ConfigSchema,
deprecations: ({ unused }) => [
unused('capture.browser.chromium.maxScreenshotDimension', { level: 'warning' }), // unused since 7.8

View file

@ -10,21 +10,30 @@ import { REPORT_TABLE_ID } from '@kbn/reporting-plugin/common/constants';
import { FtrProviderContext } from '../../ftr_provider_context';
export default ({ getPageObjects, getService }: FtrProviderContext) => {
const pageObjects = getPageObjects(['common', 'reporting']);
const pageObjects = getPageObjects(['common', 'reporting', 'settings', 'console']);
const log = getService('log');
const retry = getService('retry');
const security = getService('security');
const kibanaServer = getService('kibanaServer');
const testSubjects = getService('testSubjects');
const esArchiver = getService('esArchiver');
const find = getService('find');
describe('Listing of Reports', function () {
const kbnArchive =
'x-pack/test/functional/fixtures/kbn_archiver/reporting/view_in_console_index_pattern.json';
before(async () => {
await security.testUser.setRoles([
'kibana_admin', // to access stack management
'reporting_user', // NOTE: the built-in role granting full reporting access is deprecated. See xpack.reporting.roles.enabled
]);
await kibanaServer.savedObjects.cleanStandardList();
await kibanaServer.importExport.load(kbnArchive);
});
after(async () => {
await kibanaServer.importExport.unload(kbnArchive);
});
beforeEach(async () => {
@ -142,5 +151,31 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
]
`);
});
it('Exposes an action to see the ES query in console', async () => {
const csvReportTitle = 'Discover search [2021-07-19T11:44:48.670-07:00]';
await pageObjects.reporting.openReportFlyout(csvReportTitle);
// Open "Actions" menu inside the flyout
await testSubjects.click('reportInfoFlyoutActionsButton');
expect(await find.existsByCssSelector('.euiContextMenuPanel')).to.eql(true);
const contextMenu = await find.byClassName('euiContextMenuPanel');
const contextMenuText = await contextMenu.getVisibleText();
expect(contextMenuText).to.contain('Inspect query in Console');
await testSubjects.click('reportInfoFlyoutOpenInConsoleButton');
await pageObjects.common.waitUntilUrlIncludes('dev_tools#/console');
await retry.try(async () => {
const actualRequest = await pageObjects.console.getRequest();
expect(actualRequest.trim()).to.contain(
'# These are the queries used when exporting data for\n# the CSV report'
);
expect(actualRequest).to.contain('POST /_search');
});
});
});
};

View file

@ -0,0 +1,16 @@
{
"attributes": {
"fieldAttrs": "{\"referer\":{\"customLabel\":\"Referer custom\"}}",
"fields": "[]",
"timeFieldName": "@timestamp",
"title": "ff959d40-b880-11e8-a6d9-e546fe2bba5f"
},
"coreMigrationVersion": "8.4.0",
"id": "ff959d40-b880-11e8-a6d9-e546fe2bba5f",
"migrationVersion": {
"index-pattern": "8.0.0"
},
"references": [],
"type": "index-pattern",
"version": "WzQsMV0="
}

View file

@ -192,6 +192,19 @@ export class ReportingPageObject extends FtrService {
);
}
async openReportFlyout(reportTitle: string) {
const table = await this.testSubjects.find(REPORT_TABLE_ID);
const allRows = await table.findAllByTestSubject(REPORT_TABLE_ROW_ID);
for (const row of allRows) {
const titleColumn = await row.findByTestSubject('reportingListItemObjectTitle');
const title = await titleColumn.getVisibleText();
if (title === reportTitle) {
titleColumn.click();
return;
}
}
}
async writeSessionReport(name: string, reportExt: string, rawPdf: Buffer, folder: string) {
const sessionDirectory = path.resolve(folder, 'session');
await mkdirAsync(sessionDirectory, { recursive: true });