[Infra] Display friendlier Lens generated error messages (#189099)

closes [#189064](https://github.com/elastic/kibana/issues/189064)

## Summary

This PR changes Lens embeddable to support creating custom error
messages by using the new `customBadgeMessages` prop.

<img width="500px" alt="image"
src="https://github.com/user-attachments/assets/d48d86fa-b4a7-4dd5-ad84-9fe4bf144672">

The custom error message will only be displayed in the context of the
hosts view and asset details, which is where this new prop is being
used. Everywhere else will display the default error handling provided
by Lens

<img width="500px" alt="image"
src="https://github.com/user-attachments/assets/38ecaee9-5f25-4d34-85e4-f176095982c5">


### How to test
- Start a local kibana and es instances
- Run `node scripts/synthtrace infra_hosts_with_apm_hosts --live`
- Change the the index patter in Settings to `metrics-apm`
- Navigate to Infrastructure > Hosts View and open the asset details
flyout
- The missing field message should be displayed as the screenshot above
- Open a chart in lens
  - The default Lens message will be shown

---------

Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
Carlos Crespo 2024-07-29 12:34:02 +02:00 committed by GitHub
parent 829d22cd70
commit c07595fe00
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 233 additions and 15 deletions

View file

@ -107,7 +107,7 @@ export const MessageList = ({
closePopover={closePopover}
>
<ul className="lnsWorkspaceWarningList">
{messages.map((message, index) => (
{messages.map(({ hidePopoverIcon = false, ...message }, index) => (
<li
key={index}
className="lnsWorkspaceWarningList__item"
@ -121,13 +121,15 @@ export const MessageList = ({
responsive={false}
className="lnsWorkspaceWarningList__textItem"
>
<EuiFlexItem grow={false}>
{message.severity === 'error' ? (
<EuiIcon type="error" color="danger" />
) : (
<EuiIcon type="alert" color="warning" />
)}
</EuiFlexItem>
{!hidePopoverIcon && (
<EuiFlexItem grow={false}>
{message.severity === 'error' ? (
<EuiIcon type="error" color="danger" />
) : (
<EuiIcon type="alert" color="warning" />
)}
</EuiFlexItem>
)}
<EuiFlexItem grow={1} className="lnsWorkspaceWarningList__description">
<EuiText size="s">{message.longMessage}</EuiText>
</EuiFlexItem>

View file

@ -32,6 +32,7 @@ import { act } from 'react-dom/test-utils';
import { inspectorPluginMock } from '@kbn/inspector-plugin/public/mocks';
import { Visualization } from '../types';
import { createMockDatasource, createMockVisualization } from '../mocks';
import { FIELD_NOT_FOUND, FIELD_WRONG_TYPE } from '../user_messages_ids';
jest.mock('@kbn/inspector-plugin/public', () => ({
isAvailable: false,
@ -272,6 +273,90 @@ describe('embeddable', () => {
expect(expressionRenderer).toHaveBeenCalledTimes(0);
});
it('should override embeddableBadge message', async () => {
const getBadgeMessage = jest.fn(
(): ReturnType<NonNullable<LensEmbeddableInput['onBeforeBadgesRender']>> => [
{
uniqueId: FIELD_NOT_FOUND,
severity: 'warning',
fixableInEditor: true,
displayLocations: [
{ id: 'embeddableBadge' },
{ id: 'dimensionButton', dimensionId: '1' },
],
longMessage: 'custom',
shortMessage: '',
hidePopoverIcon: true,
},
]
);
const embeddable = new Embeddable(
getEmbeddableProps({
datasourceMap: {
...defaultDatasourceMap,
[defaultDatasourceId]: {
...defaultDatasourceMap[defaultDatasourceId],
getUserMessages: jest.fn(() => [
{
uniqueId: FIELD_NOT_FOUND,
severity: 'error',
fixableInEditor: true,
displayLocations: [
{ id: 'embeddableBadge' },
{ id: 'dimensionButton', dimensionId: '1' },
],
longMessage: 'original',
shortMessage: '',
},
{
uniqueId: FIELD_WRONG_TYPE,
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
longMessage: 'original',
shortMessage: '',
},
]),
},
},
}),
{
onBeforeBadgesRender: getBadgeMessage as LensEmbeddableInput['onBeforeBadgesRender'],
} as LensEmbeddableInput
);
const getUserMessagesSpy = jest.spyOn(embeddable, 'getUserMessages');
await embeddable.initializeSavedVis({} as LensEmbeddableInput);
embeddable.render(mountpoint);
expect(getUserMessagesSpy.mock.results.flatMap((r) => r.value)).toEqual(
expect.arrayContaining([
{
uniqueId: FIELD_WRONG_TYPE,
severity: 'error',
fixableInEditor: true,
displayLocations: [{ id: 'visualization' }],
longMessage: 'original',
shortMessage: '',
},
{
uniqueId: FIELD_NOT_FOUND,
severity: 'warning',
fixableInEditor: true,
displayLocations: [
{ id: 'embeddableBadge' },
{ id: 'dimensionButton', dimensionId: '1' },
],
longMessage: 'custom',
shortMessage: '',
hidePopoverIcon: true,
},
])
);
});
it('should not render the vis if loaded saved object conflicts', async () => {
attributeService.unwrapAttributes = jest.fn(
(_input: LensByValueInput | LensByReferenceInput) => {

View file

@ -185,6 +185,7 @@ interface LensBaseEmbeddableInput extends EmbeddableInput {
data: Simplify<LensTableRowContextMenuEvent['data'] & PreventableEvent>
) => void;
abortController?: AbortController;
onBeforeBadgesRender?: (userMessages: UserMessage[]) => UserMessage[];
}
export type LensByValueInput = {
@ -610,6 +611,22 @@ export class Embeddable
private fullAttributes: LensSavedObjectAttributes | undefined;
private handleExternalUserMessage = (messages: UserMessage[]) => {
if (this.input.onBeforeBadgesRender) {
// we need something else to better identify those errors
const [messagesToHandle, originalMessages] = partition(messages, (message) =>
message.displayLocations.some((location) => location.id === 'embeddableBadge')
);
if (messagesToHandle.length > 0) {
const customBadgeMessages = this.input.onBeforeBadgesRender(messagesToHandle);
return [...originalMessages, ...customBadgeMessages];
}
}
return messages;
};
public getUserMessages: UserMessagesGetter = (locationId, filters) => {
const userMessages: UserMessage[] = [];
userMessages.push(
@ -637,8 +654,9 @@ export class Embeddable
);
if (!this.savedVis) {
return userMessages;
return this.handleExternalUserMessage(userMessages);
}
const mergedSearchContext = this.getMergedSearchContext();
const framePublicAPI: FramePublicAPI = {
@ -683,10 +701,12 @@ export class Embeddable
}) ?? [])
);
return filterAndSortUserMessages(
[...userMessages, ...Object.values(this.additionalUserMessages)],
locationId,
filters ?? {}
return this.handleExternalUserMessage(
filterAndSortUserMessages(
[...userMessages, ...Object.values(this.additionalUserMessages)],
locationId,
filters ?? {}
)
);
};

View file

@ -33,6 +33,7 @@ export type {
Visualization,
VisualizationSuggestion,
Suggestion,
UserMessage,
} from './types';
export type {
LegacyMetricState as MetricState,

View file

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

View file

@ -8,6 +8,7 @@
export const HOST_METRICS_DOC_HREF = 'https://ela.st/docs-infra-host-metrics';
export const HOST_METRICS_DOTTED_LINES_DOC_HREF = 'https://ela.st/docs-infra-why-dotted';
export const CONTAINER_METRICS_DOC_HREF = 'https://ela.st/docs-infra-docker-container-metrics';
export const HOST_MISSING_FIELDS = 'https://ela.st/hosts-missing-fields';
export const KPI_CHART_HEIGHT = 150;
export const METRIC_CHART_HEIGHT = 300;

View file

@ -4,15 +4,25 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiPanel, EuiToolTip, type EuiPanelProps } from '@elastic/eui';
import React, { useCallback } from 'react';
import {
EuiPanel,
EuiToolTip,
EuiText,
EuiHorizontalRule,
EuiLink,
type EuiPanelProps,
} from '@elastic/eui';
import { Action } from '@kbn/ui-actions-plugin/public';
import { UserMessage } from '@kbn/lens-plugin/public';
import { FormattedMessage } from '@kbn/i18n-react';
import { css } from '@emotion/react';
import { useLensAttributes, type UseLensAttributesParams } from '../../hooks/use_lens_attributes';
import type { BaseChartProps } from './types';
import type { TooltipContentProps } from './metric_explanation/tooltip_content';
import { LensWrapper } from './lens_wrapper';
import { ChartLoadError } from './chart_load_error';
import { HOST_MISSING_FIELDS } from '../../common/visualizations/constants';
const MIN_HEIGHT = 300;
@ -54,6 +64,54 @@ export const LensChart = React.memo(
searchSessionId,
});
const handleBeforeBadgesRender = useCallback((messages: UserMessage[]) => {
const missingFieldsMessage = messages.find(
(m) => m.uniqueId === 'field_not_found' && m.severity === 'error'
);
return missingFieldsMessage
? [
{
...missingFieldsMessage,
severity: 'warning' as const,
hidePopoverIcon: true,
longMessage: (
<>
<EuiText size="s">
<strong>
<FormattedMessage
id="xpack.infra.lens.customErrorHandler.title"
defaultMessage="No results found"
/>
</strong>
</EuiText>
<EuiHorizontalRule margin="s" />
<EuiText size="xs" data-test-subj="infraLensCustomErrorHanlderText">
<p>
<FormattedMessage
id="xpack.infra.lens.customErrorHandler.description"
defaultMessage="To display this chart, please ensure you are collecting the following fields:"
/>
</p>
<p>{missingFieldsMessage && missingFieldsMessage.longMessage}</p>
</EuiText>
<EuiHorizontalRule margin="s" />
<EuiLink
data-test-subj="infraLensCustomErrorHanlderLink"
href={HOST_MISSING_FIELDS}
target="_blank"
>
<FormattedMessage
id="xpack.infra.customErrorHandler.learnMoreLink"
defaultMessage="Learn more"
/>
</EuiLink>
</>
),
},
]
: messages;
}, []);
const lens = (
<LensWrapper
id={id}
@ -70,6 +128,7 @@ export const LensChart = React.memo(
onBrushEnd={onBrushEnd}
searchSessionId={searchSessionId}
onFilter={onFilter}
onBeforeBadgesRender={handleBeforeBadgesRender}
/>
);
const content = !toolTip ? (

View file

@ -47,6 +47,7 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
const kibanaServer = getService('kibanaServer');
const esArchiver = getService('esArchiver');
const infraSynthtraceKibanaClient = getService('infraSynthtraceKibanaClient');
const infraSourceConfigurationForm = getService('infraSourceConfigurationForm');
const esClient = getService('es');
const retry = getService('retry');
const testSubjects = getService('testSubjects');
@ -102,6 +103,42 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => {
};
describe('Node Details', () => {
describe('#Missing fields', function () {
before(async () => {
await pageObjects.common.navigateToUrlWithBrowserHistory('infraOps', '/settings');
const metricIndicesInput = await infraSourceConfigurationForm.getMetricIndicesInput();
await metricIndicesInput.clearValueWithKeyboard({ charByChar: true });
await metricIndicesInput.type('metrics-apm*');
await infraSourceConfigurationForm.saveInfraSettings();
await pageObjects.infraHome.waitForLoading();
await pageObjects.infraHome.getInfraMissingMetricsIndicesCallout();
await navigateToNodeDetails('Jennys-MBP.fritz.box', 'Jennys-MBP.fritz.box', 'host');
await pageObjects.header.waitUntilLoadingHasFinished();
});
after(async () => {
await pageObjects.common.navigateToUrlWithBrowserHistory('infraOps', '/settings');
const metricIndicesInput = await infraSourceConfigurationForm.getMetricIndicesInput();
await metricIndicesInput.clearValueWithKeyboard({ charByChar: true });
await metricIndicesInput.type('metrics-*,metricbeat-*');
await infraSourceConfigurationForm.saveInfraSettings();
await pageObjects.infraHome.waitForLoading();
await pageObjects.infraHome.getInfraMissingRemoteClusterIndicesCallout();
});
describe('KPIs', () => {
it('should render custom badge message', async () => {
await pageObjects.assetDetails.getAssetDetailsKPIMissingFieldMessageExists('cpuUsage');
});
});
});
describe('#With Asset Details', () => {
before(async () => {
await Promise.all([

View file

@ -40,6 +40,18 @@ export function AssetDetailsProvider({ getService }: FtrProviderContext) {
return div.getAttribute('title');
},
async getAssetDetailsKPIMissingFieldMessageExists(type: string) {
const element = await testSubjects.find(`infraAssetDetailsKPI${type}`);
const badge = await element.findByTestSubject('lens-message-list-trigger');
await badge.click();
await testSubjects.existOrFail('lens-message-list-warning');
await testSubjects.existOrFail('infraLensCustomErrorHanlderText');
await badge.click();
},
async overviewAlertsTitleExists() {
return testSubjects.existOrFail('infraAssetDetailsAlertsTitle');
},