[search source] open incomplete response warning in inspector (#167205)

Closes https://github.com/elastic/kibana/issues/167098

PR updates "View details" button in incomplete response callouts to open
inspector to request id and cluster tab. PR them removes shards model as
its no longer used.

Clicking "View details"
<img width="400" alt="Screenshot 2023-09-28 at 9 19 49 AM"
src="1b91e70e-3dc6-4757-89eb-0c58482fff2c">

Opens
<img width="400" alt="Screenshot 2023-09-28 at 9 21 35 AM"
src="e26031e8-b2b7-45c9-9339-7206ce73e551">

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Nathan Reese 2023-10-02 11:16:03 -06:00 committed by GitHub
parent 398ae8d767
commit 5bbdec9416
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
47 changed files with 385 additions and 1388 deletions

View file

@ -37,4 +37,5 @@ export const searchResponseIncompleteWarningLocalCluster: SearchResponseWarning
],
},
},
openInInspector: () => {},
};

View file

@ -36,49 +36,11 @@ describe('getSearchResponseInterceptedWarnings', () => {
expect(warnings.length).toBe(1);
expect(warnings[0].originalWarning).toEqual(searchResponseIncompleteWarningLocalCluster);
expect(warnings[0].action).toMatchInlineSnapshot(`
<OpenIncompleteResultsModalButton
<ViewWarningButton
color="primary"
getRequestMeta={[Function]}
isButtonEmpty={true}
onClick={[Function]}
size="s"
theme={
Object {
"theme$": Observable {
"_subscribe": [Function],
},
}
}
warning={
Object {
"clusters": Object {
"(local)": Object {
"_shards": Object {
"failed": 1,
"skipped": 0,
"successful": 3,
"total": 4,
},
"failures": Array [
Object {
"index": "sample-01-rollup",
"node": "VFTFJxpHSdaoiGxJFLSExQ",
"reason": Object {
"reason": "Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]",
"type": "illegal_argument_exception",
},
"shard": 0,
},
],
"indices": "",
"status": "partial",
"timed_out": false,
"took": 25,
},
},
"message": "The data might be incomplete or wrong.",
"type": "incomplete",
}
}
/>
`);
});

View file

@ -7,12 +7,8 @@
*/
import React from 'react';
import {
type DataPublicPluginStart,
OpenIncompleteResultsModalButton,
} from '@kbn/data-plugin/public';
import { type DataPublicPluginStart, ViewWarningButton } from '@kbn/data-plugin/public';
import type { RequestAdapter } from '@kbn/inspector-plugin/common';
import type { CoreStart } from '@kbn/core-lifecycle-browser';
import type { SearchResponseInterceptedWarning } from '../types';
/**
@ -27,28 +23,20 @@ export const getSearchResponseInterceptedWarnings = ({
}: {
services: {
data: DataPublicPluginStart;
theme: CoreStart['theme'];
};
adapter: RequestAdapter;
}): SearchResponseInterceptedWarning[] => {
const interceptedWarnings: SearchResponseInterceptedWarning[] = [];
services.data.search.showWarnings(adapter, (warning, meta) => {
const { request, response } = meta;
services.data.search.showWarnings(adapter, (warning) => {
interceptedWarnings.push({
originalWarning: warning,
action:
warning.type === 'incomplete' ? (
<OpenIncompleteResultsModalButton
theme={services.theme}
warning={warning}
size="s"
getRequestMeta={() => ({
request,
response,
})}
<ViewWarningButton
color="primary"
size="s"
onClick={warning.openInInspector}
isButtonEmpty={true}
/>
) : undefined,

View file

@ -40,6 +40,7 @@ describe('hasUnsupportedDownsampledAggregationFailure', () => {
],
},
},
openInInspector: () => {},
})
).toBe(false);
});
@ -74,6 +75,7 @@ describe('hasUnsupportedDownsampledAggregationFailure', () => {
],
},
},
openInInspector: () => {},
})
).toBe(true);
});

View file

@ -10,7 +10,6 @@
"@kbn/i18n",
"@kbn/inspector-plugin",
"@kbn/core",
"@kbn/core-lifecycle-browser",
],
"exclude": ["target/**/*"]
}

View file

@ -1,271 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`IncompleteResultsModal should render shard failures 1`] = `
<Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle
size="xs"
>
<FormattedMessage
defaultMessage="Response contains incomplete results"
id="data.search.searchSource.fetch.incompleteResultsModal.headerTitle"
values={Object {}}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiTabbedContent
autoFocus="selected"
initialSelectedTab={
Object {
"content": <React.Fragment>
<ShardFailureTable
failures={
Array [
Object {
"index": "sample-01-rollup",
"node": "VFTFJxpHSdaoiGxJFLSExQ",
"reason": Object {
"reason": "Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]",
"type": "illegal_argument_exception",
},
"shard": 0,
},
]
}
/>
</React.Fragment>,
"data-test-subj": "showClusterDetailsButton",
"id": "table",
"name": "Cluster details",
}
}
tabs={
Array [
Object {
"content": <React.Fragment>
<ShardFailureTable
failures={
Array [
Object {
"index": "sample-01-rollup",
"node": "VFTFJxpHSdaoiGxJFLSExQ",
"reason": Object {
"reason": "Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]",
"type": "illegal_argument_exception",
},
"shard": 0,
},
]
}
/>
</React.Fragment>,
"data-test-subj": "showClusterDetailsButton",
"id": "table",
"name": "Cluster details",
},
Object {
"content": <EuiCodeBlock
data-test-subj="incompleteResultsModalRequestBlock"
isCopyable={true}
language="json"
>
{}
</EuiCodeBlock>,
"data-test-subj": "showRequestButton",
"id": "json-request",
"name": "Request",
},
Object {
"content": <EuiCodeBlock
data-test-subj="incompleteResultsModalResponseBlock"
isCopyable={true}
language="json"
>
{
"_shards": {
"total": 4,
"successful": 3,
"skipped": 0,
"failed": 1,
"failures": [
{
"shard": 0,
"index": "sample-01-rollup",
"node": "VFTFJxpHSdaoiGxJFLSExQ",
"reason": {
"type": "illegal_argument_exception",
"reason": "Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]"
}
}
]
}
}
</EuiCodeBlock>,
"data-test-subj": "showResponseButton",
"id": "json-response",
"name": "Response",
},
]
}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiCopy
afterMessage="Copied"
textToCopy="{
\\"_shards\\": {
\\"total\\": 4,
\\"successful\\": 3,
\\"skipped\\": 0,
\\"failed\\": 1,
\\"failures\\": [
{
\\"shard\\": 0,
\\"index\\": \\"sample-01-rollup\\",
\\"node\\": \\"VFTFJxpHSdaoiGxJFLSExQ\\",
\\"reason\\": {
\\"type\\": \\"illegal_argument_exception\\",
\\"reason\\": \\"Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]\\"
}
}
]
}
}"
>
<Component />
</EuiCopy>
<EuiButton
color="primary"
data-test-subj="closeIncompleteResultsModal"
fill={true}
onClick={[Function]}
size="m"
>
<FormattedMessage
defaultMessage="Close"
description="Closing the Modal"
id="data.search.searchSource.fetch.incompleteResultsModal.close"
values={Object {}}
/>
</EuiButton>
</EuiModalFooter>
</Fragment>
`;
exports[`IncompleteResultsModal should render time out 1`] = `
<Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle
size="xs"
>
<FormattedMessage
defaultMessage="Response contains incomplete results"
id="data.search.searchSource.fetch.incompleteResultsModal.headerTitle"
values={Object {}}
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiTabbedContent
autoFocus="selected"
initialSelectedTab={
Object {
"content": <React.Fragment>
<EuiCallOut
color="warning"
>
<p>
Request timed out
</p>
</EuiCallOut>
</React.Fragment>,
"data-test-subj": "showClusterDetailsButton",
"id": "table",
"name": "Cluster details",
}
}
tabs={
Array [
Object {
"content": <React.Fragment>
<EuiCallOut
color="warning"
>
<p>
Request timed out
</p>
</EuiCallOut>
</React.Fragment>,
"data-test-subj": "showClusterDetailsButton",
"id": "table",
"name": "Cluster details",
},
Object {
"content": <EuiCodeBlock
data-test-subj="incompleteResultsModalRequestBlock"
isCopyable={true}
language="json"
>
{}
</EuiCodeBlock>,
"data-test-subj": "showRequestButton",
"id": "json-request",
"name": "Request",
},
Object {
"content": <EuiCodeBlock
data-test-subj="incompleteResultsModalResponseBlock"
isCopyable={true}
language="json"
>
{
"timed_out": true,
"_shards": {
"total": 4,
"successful": 4,
"skipped": 0,
"failed": 0
}
}
</EuiCodeBlock>,
"data-test-subj": "showResponseButton",
"id": "json-response",
"name": "Response",
},
]
}
/>
</EuiModalBody>
<EuiModalFooter>
<EuiCopy
afterMessage="Copied"
textToCopy="{
\\"timed_out\\": true,
\\"_shards\\": {
\\"total\\": 4,
\\"successful\\": 4,
\\"skipped\\": 0,
\\"failed\\": 0
}
}"
>
<Component />
</EuiCopy>
<EuiButton
color="primary"
data-test-subj="closeIncompleteResultsModal"
fill={true}
onClick={[Function]}
size="m"
>
<FormattedMessage
defaultMessage="Close"
description="Closing the Modal"
id="data.search.searchSource.fetch.incompleteResultsModal.close"
values={Object {}}
/>
</EuiButton>
</EuiModalFooter>
</Fragment>
`;

View file

@ -1,15 +0,0 @@
// set width and height to fixed values to prevent resizing when you switch tabs
.incompleteResultsModal {
min-height: 75vh;
width: 768px;
// show buttons at the bottom of the modal
.kbnOverlayMountWrapper {
flex-grow: 1;
}
// smaller gap between the modal title and body
.euiModalHeader {
padding-bottom: 0;
}
}

View file

@ -1,72 +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 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 React from 'react';
import { shallow } from 'enzyme';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { SearchResponseIncompleteWarning } from '../search';
import { IncompleteResultsModal } from './incomplete_results_modal';
describe('IncompleteResultsModal', () => {
test('should render shard failures', () => {
const component = shallow(
<IncompleteResultsModal
warning={{} as unknown as SearchResponseIncompleteWarning}
request={{}}
response={
{
_shards: {
total: 4,
successful: 3,
skipped: 0,
failed: 1,
failures: [
{
shard: 0,
index: 'sample-01-rollup',
node: 'VFTFJxpHSdaoiGxJFLSExQ',
reason: {
type: 'illegal_argument_exception',
reason:
'Field [kubernetes.container.memory.available.bytes] of type [aggregate_metric_double] is not supported for aggregation [percentiles]',
},
},
],
},
} as unknown as estypes.SearchResponse<any>
}
onClose={jest.fn()}
/>
);
expect(component).toMatchSnapshot();
});
test('should render time out', () => {
const component = shallow(
<IncompleteResultsModal
warning={{} as unknown as SearchResponseIncompleteWarning}
request={{}}
response={
{
timed_out: true,
_shards: {
total: 4,
successful: 4,
skipped: 0,
failed: 0,
},
} as unknown as estypes.SearchResponse<any>
}
onClose={jest.fn()}
/>
);
expect(component).toMatchSnapshot();
});
});

View file

@ -1,148 +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 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 React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { i18n } from '@kbn/i18n';
import {
EuiCallOut,
EuiCodeBlock,
EuiTabbedContent,
EuiCopy,
EuiButton,
EuiModalBody,
EuiModalHeader,
EuiModalHeaderTitle,
EuiModalFooter,
EuiButtonEmpty,
} from '@elastic/eui';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { SearchRequest } from '..';
import type { SearchResponseIncompleteWarning } from '../search';
import { ShardFailureTable } from '../shard_failure_modal/shard_failure_table';
export interface Props {
onClose: () => void;
request: SearchRequest;
response: estypes.SearchResponse<any>;
warning: SearchResponseIncompleteWarning;
}
export function IncompleteResultsModal({ request, response, warning, onClose }: Props) {
const requestJSON = JSON.stringify(request, null, 2);
const responseJSON = JSON.stringify(response, null, 2);
const tabs = [
{
id: 'table',
name: i18n.translate(
'data.search.searchSource.fetch.incompleteResultsModal.tabHeaderClusterDetails',
{
defaultMessage: 'Cluster details',
description: 'Name of the tab displaying cluster details',
}
),
content: (
<>
{response.timed_out ? (
<EuiCallOut color="warning">
<p>
{i18n.translate(
'data.search.searchSource.fetch.incompleteResultsModal.requestTimedOutMessage',
{
defaultMessage: 'Request timed out',
}
)}
</p>
</EuiCallOut>
) : null}
{response._shards.failures?.length ? (
<ShardFailureTable failures={response._shards.failures ?? []} />
) : null}
</>
),
['data-test-subj']: 'showClusterDetailsButton',
},
{
id: 'json-request',
name: i18n.translate(
'data.search.searchSource.fetch.incompleteResultsModal.tabHeaderRequest',
{
defaultMessage: 'Request',
description: 'Name of the tab displaying the JSON request',
}
),
content: (
<EuiCodeBlock
language="json"
isCopyable
data-test-subj="incompleteResultsModalRequestBlock"
>
{requestJSON}
</EuiCodeBlock>
),
['data-test-subj']: 'showRequestButton',
},
{
id: 'json-response',
name: i18n.translate(
'data.search.searchSource.fetch.incompleteResultsModal.tabHeaderResponse',
{
defaultMessage: 'Response',
description: 'Name of the tab displaying the JSON response',
}
),
content: (
<EuiCodeBlock
language="json"
isCopyable
data-test-subj="incompleteResultsModalResponseBlock"
>
{responseJSON}
</EuiCodeBlock>
),
['data-test-subj']: 'showResponseButton',
},
];
return (
<React.Fragment>
<EuiModalHeader>
<EuiModalHeaderTitle size="xs">
<FormattedMessage
id="data.search.searchSource.fetch.incompleteResultsModal.headerTitle"
defaultMessage="Response contains incomplete results"
/>
</EuiModalHeaderTitle>
</EuiModalHeader>
<EuiModalBody>
<EuiTabbedContent tabs={tabs} initialSelectedTab={tabs[0]} autoFocus="selected" />
</EuiModalBody>
<EuiModalFooter>
<EuiCopy textToCopy={responseJSON}>
{(copy) => (
<EuiButtonEmpty onClick={copy}>
<FormattedMessage
id="data.search.searchSource.fetch.incompleteResultsModal.copyToClipboard"
defaultMessage="Copy response to clipboard"
/>
</EuiButtonEmpty>
)}
</EuiCopy>
<EuiButton onClick={() => onClose()} fill data-test-subj="closeIncompleteResultsModal">
<FormattedMessage
id="data.search.searchSource.fetch.incompleteResultsModal.close"
defaultMessage="Close"
description="Closing the Modal"
/>
</EuiButton>
</EuiModalFooter>
</React.Fragment>
);
}

View file

@ -1,78 +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 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 React, { useCallback } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiButton, EuiButtonProps } from '@elastic/eui';
import type * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import type { ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import { getOverlays } from '../services';
import type { SearchRequest } from '..';
import { IncompleteResultsModal } from './incomplete_results_modal';
import type { SearchResponseIncompleteWarning } from '../search';
import './_incomplete_results_modal.scss';
// @internal
export interface OpenIncompleteResultsModalButtonProps {
theme: ThemeServiceStart;
warning: SearchResponseIncompleteWarning;
size?: EuiButtonProps['size'];
color?: EuiButtonProps['color'];
getRequestMeta: () => {
request: SearchRequest;
response: estypes.SearchResponse<any>;
};
isButtonEmpty?: boolean;
}
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default function OpenIncompleteResultsModalButton({
getRequestMeta,
theme,
warning,
size = 's',
color = 'warning',
isButtonEmpty = false,
}: OpenIncompleteResultsModalButtonProps) {
const onClick = useCallback(() => {
const { request, response } = getRequestMeta();
const modal = getOverlays().openModal(
toMountPoint(
<IncompleteResultsModal
request={request}
response={response}
warning={warning}
onClose={() => modal.close()}
/>,
{ theme$: theme.theme$ }
),
{
className: 'incompleteResultsModal',
}
);
}, [getRequestMeta, theme.theme$, warning]);
const Component = isButtonEmpty ? EuiLink : EuiButton;
return (
<Component
color={color}
size={size}
onClick={onClick}
data-test-subj="openIncompleteResultsModalBtn"
>
<FormattedMessage
id="data.search.searchSource.fetch.incompleteResultsModal.viewDetails"
defaultMessage="View details"
description="Open the modal to show details"
/>
</Component>
);
}

View file

@ -274,7 +274,7 @@ export type {
} from './query';
// TODO: move to @kbn/search-response-warnings
export { OpenIncompleteResultsModalButton } from './incomplete_results_modal';
export { ViewWarningButton } from './search/warnings';
export type { AggsStart } from './search/aggs';

View file

@ -121,7 +121,7 @@ export class DataPublicPlugin
public start(
core: CoreStart,
{ uiActions, fieldFormats, dataViews, screenshotMode }: DataStartDependencies
{ uiActions, fieldFormats, dataViews, inspector, screenshotMode }: DataStartDependencies
): DataPublicPluginStart {
const { uiSettings, notifications, overlays } = core;
setNotifications(notifications);
@ -138,6 +138,7 @@ export class DataPublicPlugin
const search = this.searchService.start(core, {
fieldFormats,
indexPatterns: dataViews,
inspector,
screenshotMode,
scriptedFieldsEnabled: dataViews.scriptedFieldsEnabled,
});

View file

@ -13,7 +13,7 @@ import { CoreSetup, CoreStart } from '@kbn/core/public';
import { coreMock } from '@kbn/core/public/mocks';
import { DataViewsContract } from '@kbn/data-views-plugin/common';
import { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { RequestAdapter } from '@kbn/inspector-plugin/public';
import { Start as InspectorStartContract, RequestAdapter } from '@kbn/inspector-plugin/public';
import { managementPluginMock } from '@kbn/management-plugin/public/mocks';
import { screenshotModePluginMock } from '@kbn/screenshot-mode-plugin/public/mocks';
import type { MockedKeys } from '@kbn/utility-types-jest';
@ -68,6 +68,7 @@ describe('Search service', () => {
data = searchService.start(mockCoreStart, {
fieldFormats: {} as FieldFormatsStart,
indexPatterns: {} as DataViewsContract,
inspector: {} as InspectorStartContract,
screenshotMode: screenshotModePluginMock.createStartContract(),
scriptedFieldsEnabled: true,
});

View file

@ -23,6 +23,7 @@ import { Storage } from '@kbn/kibana-utils-plugin/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
import { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import React from 'react';
import { BehaviorSubject } from 'rxjs';
import {
@ -65,7 +66,7 @@ import { AggsService } from './aggs';
import { createUsageCollector, SearchUsageCollector } from './collectors';
import { getEql, getEsaggs, getEsdsl, getEssql, getEsql } from './expressions';
import { handleWarnings } from './fetch/handle_warnings';
import { handleWarnings } from './warnings';
import { ISearchInterceptor, SearchInterceptor } from './search_interceptor';
import { ISessionsClient, ISessionService, SessionsClient, SessionService } from './session';
import { registerSearchSessionsMgmt } from './session/sessions_mgmt';
@ -85,6 +86,7 @@ export interface SearchServiceSetupDependencies {
export interface SearchServiceStartDependencies {
fieldFormats: FieldFormatsStart;
indexPatterns: DataViewsContract;
inspector: InspectorStartContract;
screenshotMode: ScreenshotModePluginStart;
scriptedFieldsEnabled: boolean;
}
@ -225,6 +227,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
{
fieldFormats,
indexPatterns,
inspector,
screenshotMode,
scriptedFieldsEnabled,
}: SearchServiceStartDependencies
@ -250,8 +253,9 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
request: request.body,
response: rawResponse,
theme,
sessionId: options.sessionId,
requestId: request.id,
inspector: options.inspector,
inspectorService: inspector,
});
}
return response;
@ -299,6 +303,12 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
theme,
callback,
requestId: request.id,
inspector: {
adapter,
title: request.name,
id: request.id,
},
inspectorService: inspector,
});
});
},

View file

@ -119,6 +119,10 @@ export interface SearchResponseIncompleteWarning {
* clusters: cluster details.
*/
clusters: Record<string, ClusterDetails>;
/**
* openInInspector: callback to open warning in inspector
*/
openInInspector: () => void;
}
/**

View file

@ -7,8 +7,11 @@
*/
import { estypes } from '@elastic/elasticsearch';
import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import { extractWarnings } from './extract_warnings';
const mockInspectorService = {} as InspectorStartContract;
describe('extract search response warnings', () => {
describe('single cluster', () => {
it('should extract incomplete warning from response with shard failures', () => {
@ -37,7 +40,7 @@ describe('extract search response warnings', () => {
aggregations: {},
};
expect(extractWarnings(response)).toEqual([
expect(extractWarnings(response, mockInspectorService)).toEqual([
{
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
@ -51,6 +54,7 @@ describe('extract search response warnings', () => {
failures: response._shards.failures,
},
},
openInInspector: expect.any(Function),
},
]);
});
@ -62,7 +66,7 @@ describe('extract search response warnings', () => {
_shards: {} as estypes.ShardStatistics,
hits: { hits: [] },
};
expect(extractWarnings(response)).toEqual([
expect(extractWarnings(response, mockInspectorService)).toEqual([
{
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
@ -76,18 +80,22 @@ describe('extract search response warnings', () => {
failures: response._shards.failures,
},
},
openInInspector: expect.any(Function),
},
]);
});
it('should not include warnings when there are none', () => {
const warnings = extractWarnings({
timed_out: false,
_shards: {
failed: 0,
total: 9000,
},
} as estypes.SearchResponse);
const warnings = extractWarnings(
{
timed_out: false,
_shards: {
failed: 0,
total: 9000,
},
} as estypes.SearchResponse,
mockInspectorService
);
expect(warnings).toEqual([]);
});
@ -177,11 +185,12 @@ describe('extract search response warnings', () => {
aggregations: {},
};
expect(extractWarnings(response)).toEqual([
expect(extractWarnings(response, mockInspectorService)).toEqual([
{
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
clusters: response._clusters.details,
openInInspector: expect.any(Function),
},
]);
});
@ -230,58 +239,62 @@ describe('extract search response warnings', () => {
},
hits: { hits: [] },
};
expect(extractWarnings(response)).toEqual([
expect(extractWarnings(response, mockInspectorService)).toEqual([
{
type: 'incomplete',
message: 'The data might be incomplete or wrong.',
clusters: response._clusters.details,
openInInspector: expect.any(Function),
},
]);
});
it('should not include warnings when there are none', () => {
const warnings = extractWarnings({
took: 10,
timed_out: false,
_shards: {
total: 4,
successful: 4,
skipped: 0,
failed: 0,
},
_clusters: {
total: 2,
successful: 2,
skipped: 0,
details: {
'(local)': {
status: 'successful',
indices: 'kibana_sample_data_logs,kibana_sample_data_flights',
took: 0,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 0,
failed: 0,
const warnings = extractWarnings(
{
took: 10,
timed_out: false,
_shards: {
total: 4,
successful: 4,
skipped: 0,
failed: 0,
},
_clusters: {
total: 2,
successful: 2,
skipped: 0,
details: {
'(local)': {
status: 'successful',
indices: 'kibana_sample_data_logs,kibana_sample_data_flights',
took: 0,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 0,
failed: 0,
},
},
},
remote1: {
status: 'successful',
indices: 'kibana_sample_data_logs,kibana_sample_data_flights',
took: 1,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 0,
failed: 0,
remote1: {
status: 'successful',
indices: 'kibana_sample_data_logs,kibana_sample_data_flights',
took: 1,
timed_out: false,
_shards: {
total: 2,
successful: 2,
skipped: 0,
failed: 0,
},
},
},
},
},
hits: { hits: [] },
} as estypes.SearchResponse);
hits: { hits: [] },
} as estypes.SearchResponse,
mockInspectorService
);
expect(warnings).toEqual([]);
});

View file

@ -9,12 +9,19 @@
import { estypes } from '@elastic/elasticsearch';
import { i18n } from '@kbn/i18n';
import type { ClusterDetails } from '@kbn/es-types';
import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import { RequestAdapter } from '@kbn/inspector-plugin/common/adapters/request';
import type { IInspectorInfo } from '../../../common/search/search_source';
import { SearchResponseWarning } from '../types';
/**
* @internal
*/
export function extractWarnings(rawResponse: estypes.SearchResponse): SearchResponseWarning[] {
export function extractWarnings(
rawResponse: estypes.SearchResponse,
inspectorService: InspectorStartContract,
inspector?: IInspectorInfo
): SearchResponseWarning[] {
const warnings: SearchResponseWarning[] = [];
const isPartial = rawResponse._clusters
@ -48,6 +55,29 @@ export function extractWarnings(rawResponse: estypes.SearchResponse): SearchResp
failures: rawResponse._shards.failures,
},
},
openInInspector: () => {
const adapter = inspector?.adapter ? inspector.adapter : new RequestAdapter();
if (!inspector?.adapter) {
const requestResponder = adapter.start(
i18n.translate('data.search.searchSource.anonymousRequestTitle', {
defaultMessage: 'Request',
})
);
requestResponder.ok({ json: rawResponse });
}
inspectorService.open(
{
requests: adapter,
},
{
options: {
initialRequestId: inspector?.id,
initialTabs: ['clusters', 'response'],
},
}
);
},
});
}

View file

@ -6,20 +6,22 @@
* Side Public License, v 1.
*/
import { estypes } from '@elastic/elasticsearch';
import React from 'react';
import { EuiTextAlign } from '@elastic/eui';
import { estypes } from '@elastic/elasticsearch';
import { ThemeServiceStart } from '@kbn/core/public';
import { toMountPoint } from '@kbn/kibana-react-plugin/public';
import React from 'react';
import type { Start as InspectorStartContract } from '@kbn/inspector-plugin/public';
import { SearchRequest } from '..';
import { getNotifications } from '../../services';
import { OpenIncompleteResultsModalButton } from '../../incomplete_results_modal';
import type { IInspectorInfo } from '../../../common/search/search_source';
import {
SearchResponseIncompleteWarning,
SearchResponseWarning,
WarningHandlerCallback,
} from '../types';
import { extractWarnings } from './extract_warnings';
import { ViewWarningButton } from './view_warning_button';
/**
* @internal
@ -30,17 +32,19 @@ export function handleWarnings({
response,
theme,
callback,
sessionId = '',
requestId,
inspector,
inspectorService,
}: {
request: SearchRequest;
response: estypes.SearchResponse;
theme: ThemeServiceStart;
callback?: WarningHandlerCallback;
sessionId?: string;
requestId?: string;
inspector?: IInspectorInfo;
inspectorService: InspectorStartContract;
}) {
const warnings = extractWarnings(response);
const warnings = extractWarnings(response, inspectorService, inspector);
if (warnings.length === 0) {
return;
}
@ -63,14 +67,7 @@ export function handleWarnings({
title: incompleteWarning.message,
text: toMountPoint(
<EuiTextAlign textAlign="right">
<OpenIncompleteResultsModalButton
theme={theme}
getRequestMeta={() => ({
request,
response,
})}
warning={incompleteWarning}
/>
<ViewWarningButton onClick={incompleteWarning.openInInspector} />
</EuiTextAlign>,
{ theme$: theme.theme$ }
),

View file

@ -7,3 +7,4 @@
*/
export { handleWarnings } from './handle_warnings';
export { ViewWarningButton } from './view_warning_button';

View file

@ -7,13 +7,13 @@
*/
import React from 'react';
import type { OpenIncompleteResultsModalButtonProps } from './open_incomplete_results_modal_button';
import type { Props } from './view_warning_button';
const Fallback = () => <div />;
const LazyOpenModalButton = React.lazy(() => import('./open_incomplete_results_modal_button'));
export const OpenIncompleteResultsModalButton = (props: OpenIncompleteResultsModalButtonProps) => (
const LazyViewWarningButton = React.lazy(() => import('./view_warning_button'));
export const ViewWarningButton = (props: Props) => (
<React.Suspense fallback={<Fallback />}>
<LazyOpenModalButton {...props} />
<LazyViewWarningButton {...props} />
</React.Suspense>
);

View file

@ -0,0 +1,39 @@
/*
* 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 React from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiLink, EuiButton, EuiButtonProps } from '@elastic/eui';
export interface Props {
onClick: () => void;
size?: EuiButtonProps['size'];
color?: EuiButtonProps['color'];
isButtonEmpty?: boolean;
}
// Needed for React.lazy
// eslint-disable-next-line import/no-default-export
export default function ViewWarningButton({
onClick,
size = 's',
color = 'warning',
isButtonEmpty = false,
}: Props) {
const Component = isButtonEmpty ? EuiLink : EuiButton;
return (
<Component color={color} size={size} onClick={onClick} data-test-subj="viewWarningBtn">
<FormattedMessage
id="data.search.searchSource.warning.viewDetailsButtonLabel"
defaultMessage="View details"
description="View warning details button label"
/>
</Component>
);
}

View file

@ -1,36 +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 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 { estypes } from '@elastic/elasticsearch';
export const shardFailureResponse: estypes.SearchResponse<any> = {
_shards: {
total: 2,
successful: 1,
skipped: 0,
failed: 1,
failures: [
{
shard: 0,
index: 'repro2',
node: 'itsmeyournode',
reason: {
type: 'script_exception',
reason: 'runtime error',
script_stack: ["return doc['targetfield'].value;", ' ^---- HERE'],
script: "return doc['targetfield'].value;",
lang: 'painless',
caused_by: {
type: 'illegal_argument_exception',
reason: 'Gimme reason',
},
},
},
],
},
} as unknown as estypes.SearchResponse<any>;

View file

@ -1,184 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShardFailureDescription renders matching snapshot given valid properties 1`] = `
<EuiFlexGroup
alignItems="stretch"
direction="column"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<EuiDescriptionList
columnWidths={
Array [
1,
6,
]
}
compressed={true}
descriptionProps={
Object {
"className": "shardFailureModal__descValue",
}
}
listItems={
Array [
Object {
"description": 0,
"title": "Shard",
},
Object {
"description": "repro2",
"title": "Index",
},
Object {
"description": "script_exception",
"title": "Type",
},
]
}
titleProps={
Object {
"className": "shardFailureModal__descTitle",
}
}
type="responsiveColumn"
/>
</EuiFlexItem>
<EuiFlexItem
css={
Object {
"map": undefined,
"name": "1eu43fv",
"next": undefined,
"styles": "
align-self: flex-start;
",
"toString": [Function],
}
}
grow={false}
>
<EuiButtonEmpty
flush="left"
onClick={[Function]}
size="s"
>
Show details
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
`;
exports[`ShardFailureDescription should show more details when button is pressed 1`] = `
<EuiFlexGroup
alignItems="stretch"
direction="column"
gutterSize="none"
>
<EuiFlexItem
grow={false}
>
<EuiDescriptionList
columnWidths={
Array [
1,
6,
]
}
compressed={true}
descriptionProps={
Object {
"className": "shardFailureModal__descValue",
}
}
listItems={
Array [
Object {
"description": 0,
"title": "Shard",
},
Object {
"description": "repro2",
"title": "Index",
},
Object {
"description": "script_exception",
"title": "Type",
},
Object {
"description": "itsmeyournode",
"title": "Node",
},
Object {
"description": "runtime error",
"title": "Reason",
},
Object {
"description": <EuiCodeBlock
isCopyable={true}
language="java"
paddingSize="s"
>
return doc['targetfield'].value;
^---- HERE
</EuiCodeBlock>,
"title": "Script stack",
},
Object {
"description": <EuiCodeBlock
isCopyable={true}
language="java"
paddingSize="s"
>
return doc['targetfield'].value;
</EuiCodeBlock>,
"title": "Script",
},
Object {
"description": "painless",
"title": "Lang",
},
Object {
"description": "illegal_argument_exception",
"title": "Caused by type",
},
Object {
"description": "Gimme reason",
"title": "Caused by reason",
},
]
}
titleProps={
Object {
"className": "shardFailureModal__descTitle",
}
}
type="responsiveColumn"
/>
</EuiFlexItem>
<EuiFlexItem
css={
Object {
"map": undefined,
"name": "1eu43fv",
"next": undefined,
"styles": "
align-self: flex-start;
",
"toString": [Function],
}
}
grow={false}
>
<EuiButtonEmpty
flush="left"
onClick={[Function]}
size="s"
>
Show less
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
`;

View file

@ -1,89 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`ShardFailureTable renders matching snapshot given valid properties 1`] = `
<EuiInMemoryTable
columns={
Array [
Object {
"mobileOptions": Object {
"header": false,
},
"name": "Reason",
"render": [Function],
},
]
}
css={
Object {
"map": undefined,
"name": "1vv0je1",
"next": undefined,
"styles": "
& .euiTableHeaderCell {
/* Take the element out of the layout */
position: absolute;
/* Keep it vertically inline */
inset-block-start: auto;
/* Chrome requires a left value, and Selenium (used by Kibana's FTR) requires an off-screen position for its .getVisibleText() to not register SR-only text */
inset-inline-start: -10000px;
/* The element must have a size (for some screen readers) */
inline-size: 1px;
block-size: 1px;
/* But reduce the visible size to nothing */
clip: rect(0 0 0 0);
clip-path: inset(50%);
/* And ensure no overflows occur */
overflow: hidden;
/* Chrome requires the negative margin to not cause overflows of parent containers */
margin: -1px;
}
& .euiTableRowCell {
border-top: none;
}
",
"toString": [Function],
}
}
itemId="id"
items={
Array [
Object {
"id": "0",
"index": "repro2",
"node": "itsmeyournode",
"reason": Object {
"caused_by": Object {
"reason": "Gimme reason",
"type": "illegal_argument_exception",
},
"lang": "painless",
"reason": "runtime error",
"script": "return doc['targetfield'].value;",
"script_stack": Array [
"return doc['targetfield'].value;",
" ^---- HERE",
],
"type": "script_exception",
},
"shard": 0,
},
]
}
pagination={false}
responsive={true}
searchFormat="eql"
sorting={
Object {
"sort": Object {
"direction": "desc",
"field": "index",
},
}
}
tableLayout="fixed"
/>
`;

View file

@ -1,28 +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 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 React from 'react';
import { EuiButtonEmpty } from '@elastic/eui';
import { shallowWithIntl } from '@kbn/test-jest-helpers';
import { ShardFailureDescription } from './shard_failure_description';
import { shardFailureResponse } from './__mocks__/shard_failure_response';
describe('ShardFailureDescription', () => {
it('renders matching snapshot given valid properties', () => {
const failure = shardFailureResponse._shards.failures![0];
const component = shallowWithIntl(<ShardFailureDescription {...failure} />);
expect(component).toMatchSnapshot();
});
it('should show more details when button is pressed', async () => {
const failure = shardFailureResponse._shards.failures![0];
const component = shallowWithIntl(<ShardFailureDescription {...failure} />);
await component.find(EuiButtonEmpty).simulate('click');
expect(component).toMatchSnapshot();
});
});

View file

@ -1,130 +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 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 React, { useState } from 'react';
import { estypes } from '@elastic/elasticsearch';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { getFlattenedObject } from '@kbn/std';
import {
EuiButtonEmpty,
EuiCodeBlock,
EuiDescriptionList,
EuiFlexGroup,
EuiFlexItem,
} from '@elastic/eui';
/**
* Provides pretty formatting of a given key string
* e.g. formats "this_key.is_nice" to "This key is nice"
* @param key
*/
export function formatKey(key: string): string {
const nameCapitalized = key.charAt(0).toUpperCase() + key.slice(1);
return nameCapitalized.replace(/[\._]/g, ' ');
}
/**
* Adds a EuiCodeBlock to values of `script` and `script_stack` key
* Values of other keys are handled a strings
* @param value
* @param key
*/
export function formatValueByKey(value: unknown, key: string): string | JSX.Element {
if (key === 'script' || key === 'script_stack') {
const valueScript = Array.isArray(value) ? value.join('\n') : String(value);
return (
<EuiCodeBlock language="java" paddingSize="s" isCopyable>
{valueScript}
</EuiCodeBlock>
);
} else {
return String(value);
}
}
export function ShardFailureDescription(props: estypes.ShardFailure) {
const [showDetails, setShowDetails] = useState<boolean>(false);
const flattendReason = getFlattenedObject(props.reason);
const reasonItems = Object.entries(flattendReason)
.filter(([key]) => key !== 'type')
.map(([key, value]) => ({
title: formatKey(key),
description: formatValueByKey(value, key),
}));
const items = [
{
title: i18n.translate('data.search.searchSource.fetch.shardsFailedModal.shardTitle', {
defaultMessage: 'Shard',
}),
description: props.shard,
},
{
title: i18n.translate('data.search.searchSource.fetch.shardsFailedModal.indexTitle', {
defaultMessage: 'Index',
}),
description: props.index ?? '',
},
{
title: i18n.translate('data.search.searchSource.fetch.shardsFailedModal.reasonTypeTitle', {
defaultMessage: 'Type',
}),
description: props.reason.type,
},
...(showDetails
? [
{
title: i18n.translate('data.search.searchSource.fetch.shardsFailedModal.nodeTitle', {
defaultMessage: 'Node',
}),
description: props.node ?? '',
},
...reasonItems,
]
: []),
];
return (
<EuiFlexGroup direction="column" gutterSize="none" alignItems="stretch">
<EuiFlexItem grow={false}>
<EuiDescriptionList
type="responsiveColumn"
columnWidths={[1, 6]}
listItems={items}
compressed
titleProps={{ className: 'shardFailureModal__descTitle' }}
descriptionProps={{ className: 'shardFailureModal__descValue' }}
/>
</EuiFlexItem>
<EuiFlexItem
grow={false}
css={css`
align-self: flex-start;
`}
>
<EuiButtonEmpty size="s" onClick={() => setShowDetails((prev) => !prev)} flush="left">
{showDetails
? i18n.translate(
'data.search.searchSource.fetch.shardsFailedModal.showLessButtonLabel',
{
defaultMessage: 'Show less',
}
)
: i18n.translate(
'data.search.searchSource.fetch.shardsFailedModal.showMoreButtonLabel',
{
defaultMessage: 'Show details',
}
)}
</EuiButtonEmpty>
</EuiFlexItem>
</EuiFlexGroup>
);
}

View file

@ -1,21 +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 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 React from 'react';
import { shallowWithIntl } from '@kbn/test-jest-helpers';
import { ShardFailureTable } from './shard_failure_table';
import { shardFailureResponse } from './__mocks__/shard_failure_response';
describe('ShardFailureTable', () => {
it('renders matching snapshot given valid properties', () => {
const component = shallowWithIntl(
<ShardFailureTable failures={shardFailureResponse._shards.failures!} />
);
expect(component).toMatchSnapshot();
});
});

View file

@ -1,61 +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 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 React from 'react';
import { estypes } from '@elastic/elasticsearch';
import { i18n } from '@kbn/i18n';
import { css } from '@emotion/react';
import { EuiInMemoryTable, EuiInMemoryTableProps, euiScreenReaderOnly } from '@elastic/eui';
import { ShardFailureDescription } from './shard_failure_description';
export interface ListItem extends estypes.ShardFailure {
id: string;
}
const SORTING: EuiInMemoryTableProps<ListItem>['sorting'] = {
sort: {
field: 'index',
direction: 'desc',
},
};
export function ShardFailureTable({ failures }: { failures: estypes.ShardFailure[] }) {
const itemList = failures.map((failure, idx) => ({ ...{ id: String(idx) }, ...failure }));
const columns = [
{
name: i18n.translate('data.search.searchSource.fetch.shardsFailedModal.tableColReason', {
defaultMessage: 'Reason',
}),
render: (item: ListItem) => {
return <ShardFailureDescription {...item} />;
},
mobileOptions: {
header: false,
},
},
];
return (
<EuiInMemoryTable
itemId="id"
items={itemList}
columns={columns}
pagination={itemList.length > 10}
sorting={SORTING}
css={css`
& .euiTableHeaderCell {
${euiScreenReaderOnly()}
}
& .euiTableRowCell {
border-top: none;
}
`}
/>
);
}

View file

@ -12,7 +12,10 @@ import { DataViewsPublicPluginStart } from '@kbn/data-views-plugin/public';
import { UiActionsSetup, UiActionsStart } from '@kbn/ui-actions-plugin/public';
import { FieldFormatsSetup, FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { Setup as InspectorSetup } from '@kbn/inspector-plugin/public';
import {
Setup as InspectorSetup,
Start as InspectorStartContract,
} from '@kbn/inspector-plugin/public';
import { ScreenshotModePluginStart } from '@kbn/screenshot-mode-plugin/public';
import { SharePluginStart } from '@kbn/share-plugin/public';
import { ManagementSetup } from '@kbn/management-plugin/public';
@ -41,6 +44,7 @@ export interface DataStartDependencies {
uiActions: UiActionsStart;
fieldFormats: FieldFormatsStart;
dataViews: DataViewsPublicPluginStart;
inspector: InspectorStartContract;
screenshotMode: ScreenshotModePluginStart;
share: SharePluginStart;
}

View file

@ -38,7 +38,6 @@
"@kbn/management-plugin",
"@kbn/test-jest-helpers",
"@kbn/core-notifications-browser-mocks",
"@kbn/std",
"@kbn/i18n-react",
"@kbn/analytics",
"@kbn/core-http-browser",

View file

@ -11,12 +11,12 @@ import { estypes } from '@elastic/elasticsearch';
import { EuiSpacer } from '@elastic/eui';
import type { ClusterDetails } from '@kbn/es-types';
import { Request } from '../../../../../../common/adapters/request/types';
import type { RequestDetailsProps } from '../../types';
import type { DetailViewProps } from '../types';
import { getLocalClusterDetails, LOCAL_CLUSTER_KEY } from './local_cluster';
import { ClustersHealth } from './clusters_health';
import { ClustersTable } from './clusters_table';
export class ClustersView extends Component<RequestDetailsProps> {
export class ClustersView extends Component<DetailViewProps> {
static shouldShow = (request: Request) =>
Boolean(
(request.response?.json as { rawResponse?: estypes.SearchResponse })?.rawResponse?._shards ||

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
export type { DetailViewProps } from './types';
export { RequestDetailsRequest } from './req_details_request';
export { RequestDetailsResponse } from './req_details_response';
export { RequestDetailsStats } from './req_details_stats';

View file

@ -7,16 +7,11 @@
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Request } from '../../../../../common/adapters/request/types';
import { RequestDetailsProps } from '../types';
import { DetailViewProps } from './types';
import { RequestCodeViewer } from './req_code_viewer';
export class RequestDetailsRequest extends Component<RequestDetailsProps> {
static propTypes = {
request: PropTypes.object.isRequired,
};
export class RequestDetailsRequest extends Component<DetailViewProps> {
static shouldShow = (request: Request) => Boolean(request && request.json);
render() {

View file

@ -7,16 +7,11 @@
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { Request } from '../../../../../common/adapters/request/types';
import { RequestDetailsProps } from '../types';
import { DetailViewProps } from './types';
import { RequestCodeViewer } from './req_code_viewer';
export class RequestDetailsResponse extends Component<RequestDetailsProps> {
static propTypes = {
request: PropTypes.object.isRequired,
};
export class RequestDetailsResponse extends Component<DetailViewProps> {
static shouldShow = (request: Request) =>
Boolean(RequestDetailsResponse.getResponseJson(request));

View file

@ -7,7 +7,6 @@
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import {
EuiIcon,
EuiIconTip,
@ -18,18 +17,14 @@ import {
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { Request, RequestStatistic } from '../../../../../common/adapters/request/types';
import { RequestDetailsProps } from '../types';
import { DetailViewProps } from './types';
// TODO: Replace by property once available
interface RequestDetailsStatRow extends RequestStatistic {
id: string;
}
export class RequestDetailsStats extends Component<RequestDetailsProps> {
static propTypes = {
request: PropTypes.object.isRequired,
};
export class RequestDetailsStats extends Component<DetailViewProps> {
static shouldShow = (request: Request) =>
Boolean(request.stats && Object.keys(request.stats).length);

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.
*/
import { Request } from '../../../../../common/adapters/request/types';
export interface DetailViewProps {
request: Request;
}

View file

@ -0,0 +1,62 @@
/*
* 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 { getNextTab } from './get_next_tab';
const tabs = [
{
name: 'Tab0',
label: 'Tab0',
component: null,
},
{
name: 'Tab1',
label: 'Tab1',
component: null,
},
{
name: 'Tab2',
label: 'Tab2',
component: null,
},
];
describe('getNextTab', () => {
describe('no currentTab', () => {
test('should return first tab when preferred tabs are not requested', () => {
expect(getNextTab(null, tabs)).toEqual(tabs[0]);
});
test('should return first preferred tab when available', () => {
expect(getNextTab(null, tabs, ['tab1'])).toEqual(tabs[1]);
});
test('should return second preferred tab when first preferred tab is not available', () => {
expect(getNextTab(null, tabs, ['notAvailableTabName', 'tab2'])).toEqual(tabs[2]);
});
test('should return first tab when all preferred tabs are not available', () => {
expect(getNextTab(null, tabs, ['notAvailableTabName'])).toEqual(tabs[0]);
});
});
describe('currentTab', () => {
const currentTab = {
name: 'noLongerAvailableTab',
label: 'noLongerAvailableTab',
component: null,
};
test('should return first tab when preferred tabs are not requested', () => {
expect(getNextTab(currentTab, tabs)).toEqual(tabs[0]);
});
test('should ignore preferred tabs and return first tab', () => {
expect(getNextTab(currentTab, tabs, ['tab1'])).toEqual(tabs[0]);
});
});
});

View file

@ -0,0 +1,29 @@
/*
* 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 { DetailViewData } from './types';
export function getNextTab(
currentTab: DetailViewData | null,
tabs: DetailViewData[],
preferredTabs?: string[]
) {
const firstTab = tabs.length ? tabs[0] : null;
if (currentTab || !preferredTabs) {
return firstTab;
}
const preferredTabName = preferredTabs.find((tabName) => {
return tabs.some(({ name }) => tabName.toLowerCase() === name.toLowerCase());
});
const preferredTab = preferredTabName
? tabs.find(({ name }) => preferredTabName.toLowerCase() === name.toLowerCase())
: undefined;
return preferredTab ? preferredTab : firstTab;
}

View file

@ -6,10 +6,12 @@
* Side Public License, v 1.
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import React, { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiTab, EuiTabs } from '@elastic/eui';
import type { DetailViewData } from './types';
import { getNextTab } from './get_next_tab';
import { Request } from '../../../../common/adapters/request/types';
import {
ClustersView,
@ -17,17 +19,10 @@ import {
RequestDetailsResponse,
RequestDetailsStats,
} from './details';
import { RequestDetailsProps } from './types';
interface RequestDetailsState {
availableDetails: DetailViewData[];
selectedDetail: DetailViewData | null;
}
export interface DetailViewData {
name: string;
label: string;
component: any;
interface Props {
initialTabs?: string[];
request: Request;
}
const DETAILS: DetailViewData[] = [
@ -39,7 +34,7 @@ const DETAILS: DetailViewData[] = [
component: RequestDetailsStats,
},
{
name: 'clusters',
name: 'Clusters',
label: i18n.translate('inspector.requests.clustersTabLabel', {
defaultMessage: 'Clusters',
}),
@ -61,72 +56,47 @@ const DETAILS: DetailViewData[] = [
},
];
export class RequestDetails extends Component<RequestDetailsProps, RequestDetailsState> {
static propTypes = {
request: PropTypes.object.isRequired,
};
export function RequestDetails(props: Props) {
const [availableDetails, setAvailableDetails] = useState<DetailViewData[]>([]);
const [selectedDetail, setSelectedDetail] = useState<DetailViewData | null>(null);
state = {
availableDetails: [],
selectedDetail: null,
};
static getDerivedStateFromProps(nextProps: RequestDetailsProps, prevState: RequestDetailsState) {
const selectedDetail = prevState && prevState.selectedDetail;
const availableDetails = DETAILS.filter(
(detail: DetailViewData) =>
!detail.component.shouldShow || detail.component.shouldShow(nextProps.request)
useEffect(() => {
const nextAvailableDetails = DETAILS.filter((detail: DetailViewData) =>
detail.component.shouldShow?.(props.request)
);
setAvailableDetails(nextAvailableDetails);
// If the previously selected detail is still available we want to stay
// on this tab and not set another selectedDetail.
if (selectedDetail && availableDetails.includes(selectedDetail)) {
return { availableDetails };
if (selectedDetail && nextAvailableDetails.find(({ name }) => name === selectedDetail.name)) {
return;
}
return {
availableDetails,
selectedDetail: availableDetails[0],
};
}
setSelectedDetail(getNextTab(selectedDetail, nextAvailableDetails, props.initialTabs));
selectDetailsTab = (detail: DetailViewData) => {
if (detail !== this.state.selectedDetail) {
this.setState({
selectedDetail: detail,
});
}
};
// do not re-run on selectedDetail change
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.initialTabs, props.request]);
static getSelectedDetailComponent(detail: DetailViewData | null) {
return detail ? detail.component : null;
}
renderDetailTab = (detail: DetailViewData) => {
return (
<EuiTab
key={detail.name}
isSelected={detail === this.state.selectedDetail}
onClick={() => this.selectDetailsTab(detail)}
data-test-subj={`inspectorRequestDetail${detail.name}`}
>
{detail.label}
</EuiTab>
);
};
render() {
const { selectedDetail, availableDetails } = this.state;
const DetailComponent = RequestDetails.getSelectedDetailComponent(selectedDetail);
if (!availableDetails.length || !DetailComponent) {
return null;
}
return (
<>
<EuiTabs size="s">{this.state.availableDetails.map(this.renderDetailTab)}</EuiTabs>
<DetailComponent request={this.props.request} />
</>
);
}
return selectedDetail ? (
<>
<EuiTabs size="s">
{availableDetails.map((detail) => (
<EuiTab
key={detail.name}
isSelected={detail.name === selectedDetail.name}
onClick={() => {
if (detail.name !== selectedDetail.name) {
setSelectedDetail(detail);
}
}}
data-test-subj={`inspectorRequestDetail${detail.name}`}
>
{detail.label}
</EuiTab>
))}
</EuiTabs>
<selectedDetail.component request={props.request} />
</>
) : null;
}

View file

@ -7,7 +7,6 @@
*/
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiEmptyPrompt, EuiSpacer, EuiText, EuiTextColor } from '@elastic/eui';
@ -19,17 +18,29 @@ import { RequestSelector } from './request_selector';
import { RequestDetails } from './request_details';
import { disambiguateRequestNames } from './disambiguate_request_names';
function getInitialRequest(requests: Request[], initialRequestId?: string) {
const initialRequest = initialRequestId
? requests.find(({ id }) => id === initialRequestId)
: undefined;
if (initialRequest) {
return initialRequest;
}
return requests.length ? requests[0] : null;
}
interface RequestViewOptions {
initialRequestId?: string;
initialTabs?: string[];
}
interface RequestSelectorState {
requests: Request[];
request: Request | null;
}
export class RequestsViewComponent extends Component<InspectorViewProps, RequestSelectorState> {
static propTypes = {
adapters: PropTypes.object.isRequired,
title: PropTypes.string.isRequired,
};
constructor(props: InspectorViewProps) {
super(props);
@ -38,7 +49,10 @@ export class RequestsViewComponent extends Component<InspectorViewProps, Request
const requests = this.getRequests();
this.state = {
requests,
request: requests.length ? requests[0] : null,
request: getInitialRequest(
requests,
(this.props.options as RequestViewOptions | undefined)?.initialRequestId
),
};
}
@ -168,7 +182,12 @@ export class RequestsViewComponent extends Component<InspectorViewProps, Request
<EuiSpacer size="m" />
{this.state.request && <RequestDetails request={this.state.request} />}
{this.state.request && (
<RequestDetails
initialTabs={(this.props.options as RequestViewOptions | undefined)?.initialTabs}
request={this.state.request}
/>
)}
</>
);
}

View file

@ -6,8 +6,8 @@
* Side Public License, v 1.
*/
import { Request } from '../../../../common/adapters/request/types';
export interface RequestDetailsProps {
request: Request;
export interface DetailViewData {
name: string;
label: string;
component: any;
}

View file

@ -19,7 +19,7 @@ import { groupBy, escape, uniq, uniqBy } from 'lodash';
import type { Query } from '@kbn/data-plugin/common';
import { SearchRequest } from '@kbn/data-plugin/common';
import { SearchResponseWarning, OpenIncompleteResultsModalButton } from '@kbn/data-plugin/public';
import { SearchResponseWarning, ViewWarningButton } from '@kbn/data-plugin/public';
import { estypes } from '@elastic/elasticsearch';
import { isQueryValid } from '@kbn/visualization-ui-components';
@ -306,18 +306,16 @@ export function getSearchWarningMessages(
fixableInEditor: true,
displayLocations: [{ id: 'toolbar' }, { id: 'embeddableBadge' }],
shortMessage: '',
longMessage: (
longMessage: (closePopover) => (
<>
<EuiText size="s">{warning.message}</EuiText>
<EuiSpacer size="s" />
<OpenIncompleteResultsModalButton
theme={theme}
warning={warning}
<ViewWarningButton
onClick={() => {
closePopover();
warning.openInInspector();
}}
size="m"
getRequestMeta={() => ({
request,
response,
})}
color="primary"
isButtonEmpty={true}
/>

View file

@ -68,6 +68,7 @@ export const MessageList = ({
const onButtonClick = () => setIsPopoverOpen((isOpen) => !isOpen);
const closePopover = () => setIsPopoverOpen(false);
return (
<EuiPopover
panelPaddingSize="none"
@ -121,7 +122,11 @@ export const MessageList = ({
)}
</EuiFlexItem>
<EuiFlexItem grow={1} className="lnsWorkspaceWarningList__description">
<EuiText size="s">{message.longMessage}</EuiText>
<EuiText size="s">
{typeof message.longMessage === 'function'
? message.longMessage(closePopover)
: message.longMessage}
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
</li>

View file

@ -285,7 +285,7 @@ export interface UserMessage {
uniqueId?: string;
severity: 'error' | 'warning' | 'info';
shortMessage: string;
longMessage: React.ReactNode | string;
longMessage: string | React.ReactNode | ((closePopover: () => void) => React.ReactNode);
fixableInEditor: boolean;
displayLocations: UserMessageDisplayLocation[];
}

View file

@ -2061,7 +2061,6 @@
"data.search.searchSource.dataViewDescription": "La vue de données qui a été interrogée.",
"data.search.searchSource.dataViewIdLabel": "ID de vue de données",
"data.search.searchSource.dataViewLabel": "Vue de données",
"data.search.searchSource.fetch.shardsFailedModal.tableColReason": "Raison",
"data.search.searchSource.hitsDescription": "Le nombre de documents renvoyés par la requête.",
"data.search.searchSource.hitsLabel": "Résultats",
"data.search.searchSource.hitsTotalDescription": "Le nombre de documents correspondant à la requête.",

View file

@ -2075,7 +2075,6 @@
"data.search.searchSource.dataViewDescription": "照会されたデータビュー。",
"data.search.searchSource.dataViewIdLabel": "データビューID",
"data.search.searchSource.dataViewLabel": "データビュー",
"data.search.searchSource.fetch.shardsFailedModal.tableColReason": "理由",
"data.search.searchSource.hitsDescription": "クエリにより返されたドキュメントの数です。",
"data.search.searchSource.hitsLabel": "ヒット数",
"data.search.searchSource.hitsTotalDescription": "クエリに一致するドキュメントの数です。",

View file

@ -2075,7 +2075,6 @@
"data.search.searchSource.dataViewDescription": "被查询的数据视图。",
"data.search.searchSource.dataViewIdLabel": "数据视图 ID",
"data.search.searchSource.dataViewLabel": "数据视图",
"data.search.searchSource.fetch.shardsFailedModal.tableColReason": "原因",
"data.search.searchSource.hitsDescription": "查询返回的文档数目。",
"data.search.searchSource.hitsLabel": "命中数",
"data.search.searchSource.hitsTotalDescription": "与查询匹配的文档数目。",