[ML] Improves error handling for transform wizard when Kibana index pattern or saved search fails to load. (#93915) (#95755)

Improves error handling for the transform wizard when Kibana index pattern or saved search fails to load.
Previously a non-existing index pattern or saved search or corrupt saved object could cause the page to end up blank. Improved error reporting will catch the problem and display an error callout.
This commit is contained in:
Walter Rafelsberger 2021-03-30 14:26:35 +02:00 committed by GitHub
parent 03607cab36
commit 92bde82256
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
16 changed files with 126 additions and 43 deletions

View file

@ -8,6 +8,7 @@
import type { SearchResponse7 } from '../../../ml/common';
import type { EsIndex } from '../types/es_index';
import { isPopulatedObject } from '../utils/object_utils';
// To be able to use the type guards on the client side, we need to make sure we don't import
// the code of '@kbn/config-schema' but just its types, otherwise the client side code will
@ -25,13 +26,9 @@ import type {
import type { GetTransformsStatsResponseSchema } from './transforms_stats';
import type { PostTransformsUpdateResponseSchema } from './update_transforms';
const isBasicObject = (arg: any) => {
return typeof arg === 'object' && arg !== null;
};
const isGenericResponseSchema = <T>(arg: any): arg is T => {
return (
isBasicObject(arg) &&
isPopulatedObject(arg) &&
{}.hasOwnProperty.call(arg, 'count') &&
{}.hasOwnProperty.call(arg, 'transforms') &&
Array.isArray(arg.transforms)
@ -52,7 +49,7 @@ export const isDeleteTransformsResponseSchema = (
arg: any
): arg is DeleteTransformsResponseSchema => {
return (
isBasicObject(arg) &&
isPopulatedObject(arg) &&
Object.values(arg).every((d) => ({}.hasOwnProperty.call(d, 'transformDeleted')))
);
};
@ -62,7 +59,7 @@ export const isEsIndices = (arg: any): arg is EsIndex[] => {
};
export const isEsSearchResponse = (arg: any): arg is SearchResponse7 => {
return isBasicObject(arg) && {}.hasOwnProperty.call(arg, 'hits');
return isPopulatedObject(arg) && {}.hasOwnProperty.call(arg, 'hits');
};
export const isFieldHistogramsResponseSchema = (arg: any): arg is FieldHistogramsResponseSchema => {
@ -79,7 +76,7 @@ export const isPostTransformsPreviewResponseSchema = (
arg: any
): arg is PostTransformsPreviewResponseSchema => {
return (
isBasicObject(arg) &&
isPopulatedObject(arg) &&
{}.hasOwnProperty.call(arg, 'generated_dest_index') &&
{}.hasOwnProperty.call(arg, 'preview') &&
typeof arg.generated_dest_index !== undefined &&
@ -90,12 +87,12 @@ export const isPostTransformsPreviewResponseSchema = (
export const isPostTransformsUpdateResponseSchema = (
arg: any
): arg is PostTransformsUpdateResponseSchema => {
return isBasicObject(arg) && {}.hasOwnProperty.call(arg, 'id') && typeof arg.id === 'string';
return isPopulatedObject(arg) && {}.hasOwnProperty.call(arg, 'id') && typeof arg.id === 'string';
};
export const isPutTransformsResponseSchema = (arg: any): arg is PutTransformsResponseSchema => {
return (
isBasicObject(arg) &&
isPopulatedObject(arg) &&
{}.hasOwnProperty.call(arg, 'transformsCreated') &&
{}.hasOwnProperty.call(arg, 'errors') &&
Array.isArray(arg.transformsCreated) &&
@ -104,7 +101,7 @@ export const isPutTransformsResponseSchema = (arg: any): arg is PutTransformsRes
};
const isGenericSuccessResponseSchema = (arg: any) =>
isBasicObject(arg) && Object.values(arg).every((d) => ({}.hasOwnProperty.call(d, 'success')));
isPopulatedObject(arg) && Object.values(arg).every((d) => ({}.hasOwnProperty.call(d, 'success')));
export const isStartTransformsResponseSchema = (arg: any): arg is StartTransformsResponseSchema => {
return isGenericSuccessResponseSchema(arg);

View file

@ -0,0 +1,21 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { isIndexPattern } from './index_pattern';
describe('index_pattern', () => {
test('isIndexPattern()', () => {
expect(isIndexPattern(0)).toBe(false);
expect(isIndexPattern('')).toBe(false);
expect(isIndexPattern(null)).toBe(false);
expect(isIndexPattern({})).toBe(false);
expect(isIndexPattern({ attribute: 'value' })).toBe(false);
expect(
isIndexPattern({ fields: [], title: 'Index Pattern Title', getComputedFields: () => {} })
).toBe(true);
});
});

View file

@ -0,0 +1,23 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { IndexPattern } from '../../../../../src/plugins/data/common';
import { isPopulatedObject } from '../utils/object_utils';
// Custom minimal type guard for IndexPattern to check against the attributes used in transforms code.
export function isIndexPattern(arg: any): arg is IndexPattern {
return (
isPopulatedObject(arg) &&
'getComputedFields' in arg &&
typeof arg.getComputedFields === 'function' &&
{}.hasOwnProperty.call(arg, 'title') &&
typeof arg.title === 'string' &&
{}.hasOwnProperty.call(arg, 'fields') &&
Array.isArray(arg.fields)
);
}

View file

@ -5,7 +5,7 @@
* 2.0.
*/
import { getNestedProperty } from './object_utils';
import { getNestedProperty, isPopulatedObject } from './object_utils';
describe('object_utils', () => {
test('getNestedProperty()', () => {
@ -68,4 +68,12 @@ describe('object_utils', () => {
expect(typeof test11).toBe('number');
expect(test11).toBe(0);
});
test('isPopulatedObject()', () => {
expect(isPopulatedObject(0)).toBe(false);
expect(isPopulatedObject('')).toBe(false);
expect(isPopulatedObject(null)).toBe(false);
expect(isPopulatedObject({})).toBe(false);
expect(isPopulatedObject({ attribute: 'value' })).toBe(true);
});
});

View file

@ -51,3 +51,7 @@ export const setNestedProperty = (obj: Record<string, any>, accessor: string, va
return obj;
};
export const isPopulatedObject = <T = Record<string, any>>(arg: any): arg is T => {
return typeof arg === 'object' && arg !== null && Object.keys(arg).length > 0;
};

View file

@ -10,6 +10,8 @@ import { render, unmountComponentAtNode } from 'react-dom';
import { Router, Route, Switch } from 'react-router-dom';
import { ScopedHistory } from 'kibana/public';
import { EuiErrorBoundary } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { KibanaContextProvider } from '../../../../../src/plugins/kibana_react/public';
@ -64,13 +66,15 @@ export const renderApp = (element: HTMLElement, appDependencies: AppDependencies
const I18nContext = appDependencies.i18n.Context;
render(
<KibanaContextProvider services={appDependencies}>
<AuthorizationProvider privilegesEndpoint={`${API_BASE_PATH}privileges`}>
<I18nContext>
<App history={appDependencies.history} />
</I18nContext>
</AuthorizationProvider>
</KibanaContextProvider>,
<EuiErrorBoundary>
<KibanaContextProvider services={appDependencies}>
<AuthorizationProvider privilegesEndpoint={`${API_BASE_PATH}privileges`}>
<I18nContext>
<App history={appDependencies.history} />
</I18nContext>
</AuthorizationProvider>
</KibanaContextProvider>
</EuiErrorBoundary>,
element
);

View file

@ -17,12 +17,13 @@ import type {
PutTransformsPivotRequestSchema,
PutTransformsRequestSchema,
} from '../../../common/api_schemas/transforms';
import { isPopulatedObject } from '../../../common/utils/object_utils';
import { DateHistogramAgg, HistogramAgg, TermsAgg } from '../../../common/types/pivot_group_by';
import { isIndexPattern } from '../../../common/types/index_pattern';
import type { SavedSearchQuery } from '../hooks/use_search_items';
import type { StepDefineExposedState } from '../sections/create_transform/components/step_define';
import type { StepDetailsExposedState } from '../sections/create_transform/components/step_details';
import { isPopulatedObject } from './utils/object_utils';
import {
getEsAggFromAggConfig,
@ -83,9 +84,14 @@ export function getCombinedRuntimeMappings(
}
// And runtime field mappings defined by index pattern
if (indexPattern !== undefined) {
const ipRuntimeMappings = indexPattern.getComputedFields().runtimeFields;
combinedRuntimeMappings = { ...combinedRuntimeMappings, ...ipRuntimeMappings };
if (isIndexPattern(indexPattern)) {
const computedFields = indexPattern.getComputedFields();
if (computedFields?.runtimeFields !== undefined) {
const ipRuntimeMappings = computedFields.runtimeFields;
if (isPopulatedObject(ipRuntimeMappings)) {
combinedRuntimeMappings = { ...combinedRuntimeMappings, ...ipRuntimeMappings };
}
}
}
if (isPopulatedObject(combinedRuntimeMappings)) {

View file

@ -1,10 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const isPopulatedObject = <T = Record<string, any>>(arg: any): arg is T => {
return typeof arg === 'object' && arg !== null && Object.keys(arg).length > 0;
};

View file

@ -15,6 +15,8 @@ import {
import { matchAllQuery } from '../../common';
import { isIndexPattern } from '../../../../common/types/index_pattern';
export type SavedSearchQuery = object;
type IndexPatternId = string;
@ -79,10 +81,6 @@ export function loadCurrentSavedSearch(savedSearches: any, savedSearchId: SavedS
return currentSavedSearch;
}
function isIndexPattern(arg: any): arg is IndexPattern {
return arg !== undefined;
}
export interface SearchItems {
indexPattern: IndexPattern;
savedSearch: any;

View file

@ -7,6 +7,10 @@
import { useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { isIndexPattern } from '../../../../common/types/index_pattern';
import { createSavedSearchesLoader } from '../../../shared_imports';
import { useAppDependencies } from '../../app_dependencies';
@ -22,6 +26,7 @@ import {
export const useSearchItems = (defaultSavedObjectId: string | undefined) => {
const [savedObjectId, setSavedObjectId] = useState(defaultSavedObjectId);
const [error, setError] = useState<string | undefined>();
const appDeps = useAppDependencies();
const indexPatterns = appDeps.data.indexPatterns;
@ -52,7 +57,17 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => {
// Just let fetchedSavedSearch stay undefined in case it doesn't exist.
}
if (!isIndexPattern(fetchedIndexPattern) && fetchedSavedSearch === undefined) {
setError(
i18n.translate('xpack.transform.searchItems.errorInitializationTitle', {
defaultMessage: `An error occurred initializing the Kibana index pattern or saved search.`,
})
);
return;
}
setSearchItems(createSearchItems(fetchedIndexPattern, fetchedSavedSearch, uiSettings));
setError(undefined);
}
useEffect(() => {
@ -64,6 +79,7 @@ export const useSearchItems = (defaultSavedObjectId: string | undefined) => {
}, [savedObjectId]);
return {
error,
getIndexPatternIdByTitle,
loadIndexPatterns,
searchItems,

View file

@ -56,9 +56,16 @@ export const CloneTransformSection: FC<Props> = ({ match, location }) => {
const [transformConfig, setTransformConfig] = useState<TransformPivotConfig>();
const [errorMessage, setErrorMessage] = useState<string>();
const [isInitialized, setIsInitialized] = useState(false);
const { searchItems, setSavedObjectId } = useSearchItems(undefined);
const { error: searchItemsError, searchItems, setSavedObjectId } = useSearchItems(undefined);
const fetchTransformConfig = async () => {
if (searchItemsError !== undefined) {
setTransformConfig(undefined);
setErrorMessage(searchItemsError);
setIsInitialized(true);
return;
}
const transformConfigs = await api.getTransform(transformId);
if (isHttpFetchError(transformConfigs)) {
setTransformConfig(undefined);

View file

@ -47,7 +47,7 @@ import {
PutTransformsPivotRequestSchema,
} from '../../../../../../common/api_schemas/transforms';
import type { RuntimeField } from '../../../../../../../../../src/plugins/data/common/index_patterns';
import { isPopulatedObject } from '../../../../common/utils/object_utils';
import { isPopulatedObject } from '../../../../../../common/utils/object_utils';
import { isLatestTransform } from '../../../../../../common/types/transform';
export interface StepDetailsExposedState {

View file

@ -16,7 +16,7 @@ import { getFilterAggTypeConfig } from '../config';
import type { FilterAggType, PivotAggsConfigFilter } from '../types';
import type { RuntimeMappings } from '../../types';
import { getKibanaFieldTypeFromEsType } from '../../get_pivot_dropdown_options';
import { isPopulatedObject } from '../../../../../../../common/utils/object_utils';
import { isPopulatedObject } from '../../../../../../../../../common/utils/object_utils';
/**
* Resolves supported filters for provided field.

View file

@ -71,7 +71,12 @@ export function getPivotDropdownOptions(
const indexPatternFields = indexPattern.fields
.filter(
(field) =>
field.aggregatable === true && !ignoreFieldNames.includes(field.name) && !field.runtimeField
field.aggregatable === true &&
!ignoreFieldNames.includes(field.name) &&
!field.runtimeField &&
// runtime fix, we experienced Kibana index patterns with `undefined` type for fields
// even when the TS interface is a non-optional `string`.
typeof field.type !== 'undefined'
)
.map((field): Field => ({ name: field.name, type: field.type as KBN_FIELD_TYPES }));

View file

@ -24,7 +24,7 @@ import {
} from '../../../../../../../common/types/transform';
import { LatestFunctionConfig } from '../../../../../../../common/api_schemas/transforms';
import { isPopulatedObject } from '../../../../../common/utils/object_utils';
import { isPopulatedObject } from '../../../../../../../common/utils/object_utils';
export interface ErrorMessage {
query: string;

View file

@ -12,6 +12,7 @@ import { FormattedMessage } from '@kbn/i18n/react';
import {
EuiButtonEmpty,
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiPageContent,
@ -39,7 +40,7 @@ export const CreateTransformSection: FC<Props> = ({ match }) => {
const { esTransform } = useDocumentationLinks();
const { searchItems } = useSearchItems(match.params.savedObjectId);
const { error: searchItemsError, searchItems } = useSearchItems(match.params.savedObjectId);
return (
<PrivilegesWrapper privileges={APP_CREATE_TRANSFORM_CLUSTER_PRIVILEGES}>
@ -71,6 +72,9 @@ export const CreateTransformSection: FC<Props> = ({ match }) => {
</EuiTitle>
<EuiPageContentBody>
<EuiSpacer size="l" />
{searchItemsError !== undefined && (
<EuiCallOut title={searchItemsError} color="danger" iconType="alert" />
)}
{searchItems !== undefined && <Wizard searchItems={searchItems} />}
</EuiPageContentBody>
</EuiPageContent>