[ML] EuiDataGrid ml/transform components. (#63447)

Cleanup and consolidation of code related to EuiDataGrid. The transform wizard source and pivot preview table as well as the data frame analytics results pages now share a common code base related to data grid tables.

To avoid tight coupling of components and hooks, the hooks are not within the common data grid component. Instead the hooks need to be used on the outer wrapping component and the results will be passed as props to the data grid component. This allows us to pass data from different data sources (transform index source, pivot previews, analytics results) into a shared component.
This commit is contained in:
Walter Rafelsberger 2020-04-24 13:38:27 +02:00 committed by GitHub
parent b3c7002799
commit 4eb971c8c3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 2241 additions and 3956 deletions

View file

@ -6,16 +6,7 @@
import { EuiDataGridSorting } from '@elastic/eui';
import {
getPreviewRequestBody,
PivotAggsConfig,
PivotGroupByConfig,
PIVOT_SUPPORTED_AGGS,
PIVOT_SUPPORTED_GROUP_BY_AGGS,
SimpleQuery,
} from '../../common';
import { multiColumnSortFactory, getPivotPreviewDevConsoleStatement } from './common';
import { multiColumnSortFactory } from './common';
describe('Transform: Define Pivot Common', () => {
test('multiColumnSortFactory()', () => {
@ -65,53 +56,4 @@ describe('Transform: Define Pivot Common', () => {
{ s: 'a', n: 1 },
]);
});
test('getPivotPreviewDevConsoleStatement()', () => {
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
const groupBy: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
aggName: 'the-group-by-agg-name',
dropDownName: 'the-group-by-drop-down-name',
};
const agg: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-agg-field',
aggName: 'the-agg-agg-name',
dropDownName: 'the-agg-drop-down-name',
};
const request = getPreviewRequestBody('the-index-pattern-title', query, [groupBy], [agg]);
const pivotPreviewDevConsoleStatement = getPivotPreviewDevConsoleStatement(request);
expect(pivotPreviewDevConsoleStatement).toBe(`POST _transform/_preview
{
"source": {
"index": [
"the-index-pattern-title"
]
},
"pivot": {
"group_by": {
"the-group-by-agg-name": {
"terms": {
"field": "the-group-by-field"
}
}
},
"aggregations": {
"the-agg-agg-name": {
"avg": {
"field": "the-agg-field"
}
}
}
}
}
`);
});
});

View file

@ -0,0 +1,287 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment-timezone';
import { useEffect, useMemo } from 'react';
import {
EuiDataGridCellValueElementProps,
EuiDataGridSorting,
EuiDataGridStyle,
} from '@elastic/eui';
import {
IndexPattern,
IFieldType,
ES_FIELD_TYPES,
KBN_FIELD_TYPES,
} from '../../../../../../../src/plugins/data/public';
import {
BASIC_NUMERICAL_TYPES,
EXTENDED_NUMERICAL_TYPES,
} from '../../data_frame_analytics/common/fields';
import {
FEATURE_IMPORTANCE,
FEATURE_INFLUENCE,
OUTLIER_SCORE,
} from '../../data_frame_analytics/common/constants';
import { formatHumanReadableDateTimeSeconds } from '../../util/date_utils';
import { getNestedProperty } from '../../util/object_utils';
import { mlFieldFormatService } from '../../services/field_format_service';
import { DataGridItem, IndexPagination, RenderCellValue } from './types';
export const INIT_MAX_COLUMNS = 20;
export const euiDataGridStyle: EuiDataGridStyle = {
border: 'all',
fontSize: 's',
cellPadding: 's',
stripes: false,
rowHover: 'none',
header: 'shade',
};
export const euiDataGridToolbarSettings = {
showColumnSelector: true,
showStyleSelector: false,
showSortSelector: true,
showFullScreenSelector: false,
};
export const getFieldsFromKibanaIndexPattern = (indexPattern: IndexPattern): string[] => {
const allFields = indexPattern.fields.map(f => f.name);
const indexPatternFields: string[] = allFields.filter(f => {
if (indexPattern.metaFields.includes(f)) {
return false;
}
const fieldParts = f.split('.');
const lastPart = fieldParts.pop();
if (lastPart === 'keyword' && allFields.includes(fieldParts.join('.'))) {
return false;
}
return true;
});
return indexPatternFields;
};
export interface FieldTypes {
[key: string]: ES_FIELD_TYPES;
}
export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, resultsField: string) => {
return Object.keys(fieldTypes).map(field => {
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
const isSortable = true;
const type = fieldTypes[field];
const isNumber =
type !== undefined && (BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type));
if (isNumber) {
schema = 'numeric';
}
switch (type) {
case 'date':
schema = 'datetime';
break;
case 'geo_point':
schema = 'json';
break;
case 'boolean':
schema = 'boolean';
break;
}
if (
field === `${resultsField}.${OUTLIER_SCORE}` ||
field.includes(`${resultsField}.${FEATURE_INFLUENCE}`)
) {
schema = 'numeric';
}
if (field.includes(`${resultsField}.${FEATURE_IMPORTANCE}`)) {
schema = 'json';
}
return { id: field, schema, isSortable };
});
};
export const getDataGridSchemaFromKibanaFieldType = (field: IFieldType | undefined) => {
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
switch (field?.type) {
case KBN_FIELD_TYPES.BOOLEAN:
schema = 'boolean';
break;
case KBN_FIELD_TYPES.DATE:
schema = 'datetime';
break;
case KBN_FIELD_TYPES.GEO_POINT:
case KBN_FIELD_TYPES.GEO_SHAPE:
schema = 'json';
break;
case KBN_FIELD_TYPES.NUMBER:
schema = 'numeric';
break;
}
return schema;
};
export const useRenderCellValue = (
indexPattern: IndexPattern | undefined,
pagination: IndexPagination,
tableItems: DataGridItem[],
resultsField?: string,
cellPropsCallback?: (
columnId: string,
cellValue: any,
fullItem: Record<string, any>,
setCellProps: EuiDataGridCellValueElementProps['setCellProps']
) => void
): RenderCellValue => {
const renderCellValue: RenderCellValue = useMemo(() => {
return ({
rowIndex,
columnId,
setCellProps,
}: {
rowIndex: number;
columnId: string;
setCellProps: EuiDataGridCellValueElementProps['setCellProps'];
}) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const fullItem = tableItems[adjustedRowIndex];
if (fullItem === undefined) {
return null;
}
if (indexPattern === undefined) {
return null;
}
let format: any;
if (indexPattern !== undefined) {
format = mlFieldFormatService.getFieldFormatFromIndexPattern(indexPattern, columnId, '');
}
function getCellValue(cId: string) {
if (cId.includes(`.${FEATURE_INFLUENCE}.`) && resultsField !== undefined) {
const results = getNestedProperty(tableItems[adjustedRowIndex], resultsField, null);
return results[cId.replace(`${resultsField}.`, '')];
}
return tableItems.hasOwnProperty(adjustedRowIndex)
? getNestedProperty(tableItems[adjustedRowIndex], cId, null)
: null;
}
const cellValue = getCellValue(columnId);
// React by default doesn't all us to use a hook in a callback.
// However, this one will be passed on to EuiDataGrid and its docs
// recommend wrapping `setCellProps` in a `useEffect()` hook
// so we're ignoring the linting rule here.
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
if (typeof cellPropsCallback === 'function') {
cellPropsCallback(columnId, cellValue, fullItem, setCellProps);
}
}, [columnId, cellValue]);
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
if (cellValue === undefined || cellValue === null) {
return null;
}
if (format !== undefined) {
return format.convert(cellValue, 'text');
}
if (typeof cellValue === 'string' || cellValue === null) {
return cellValue;
}
const field = indexPattern.fields.getByName(columnId);
if (field?.type === KBN_FIELD_TYPES.DATE) {
return formatHumanReadableDateTimeSeconds(moment(cellValue).unix() * 1000);
}
if (typeof cellValue === 'boolean') {
return cellValue ? 'true' : 'false';
}
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
return cellValue;
};
}, [indexPattern?.fields, pagination.pageIndex, pagination.pageSize, tableItems]);
return renderCellValue;
};
/**
* Helper to sort an array of objects based on an EuiDataGrid sorting configuration.
* `sortFn()` is recursive to support sorting on multiple columns.
*
* @param sortingColumns - The EUI data grid sorting configuration
* @returns The sorting function which can be used with an array's sort() function.
*/
export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['columns']) => {
const isString = (arg: any): arg is string => {
return typeof arg === 'string';
};
const sortFn = (a: any, b: any, sortingColumnIndex = 0): number => {
const sort = sortingColumns[sortingColumnIndex];
const aValue = getNestedProperty(a, sort.id, null);
const bValue = getNestedProperty(b, sort.id, null);
if (typeof aValue === 'number' && typeof bValue === 'number') {
if (aValue < bValue) {
return sort.direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sort.direction === 'asc' ? 1 : -1;
}
}
if (isString(aValue) && isString(bValue)) {
if (aValue.localeCompare(bValue) === -1) {
return sort.direction === 'asc' ? -1 : 1;
}
if (aValue.localeCompare(bValue) === 1) {
return sort.direction === 'asc' ? 1 : -1;
}
}
if (sortingColumnIndex + 1 < sortingColumns.length) {
return sortFn(a, b, sortingColumnIndex + 1);
}
return 0;
};
return sortFn;
};

View file

@ -0,0 +1,181 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { useEffect, FC } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiCallOut,
EuiCodeBlock,
EuiCopy,
EuiDataGrid,
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { CoreSetup } from 'src/core/public';
import { INDEX_STATUS } from '../../data_frame_analytics/common';
import { euiDataGridStyle, euiDataGridToolbarSettings } from './common';
import { UseIndexDataReturnType } from './types';
export const DataGridTitle: FC<{ title: string }> = ({ title }) => (
<EuiTitle size="xs">
<span>{title}</span>
</EuiTitle>
);
interface PropsWithoutHeader extends UseIndexDataReturnType {
dataTestSubj: string;
toastNotifications: CoreSetup['notifications']['toasts'];
}
interface PropsWithHeader extends PropsWithoutHeader {
copyToClipboard: string;
copyToClipboardDescription: string;
title: string;
}
function isWithHeader(arg: any): arg is PropsWithHeader {
return typeof arg?.title === 'string' && arg?.title !== '';
}
type Props = PropsWithHeader | PropsWithoutHeader;
export const DataGrid: FC<Props> = props => {
const {
columns,
dataTestSubj,
errorMessage,
invalidSortingColumnns,
noDataMessage,
onChangeItemsPerPage,
onChangePage,
onSort,
pagination,
setVisibleColumns,
renderCellValue,
rowCount,
sortingColumns,
status,
tableItems: data,
toastNotifications,
visibleColumns,
} = props;
useEffect(() => {
if (invalidSortingColumnns.length > 0) {
invalidSortingColumnns.forEach(columnId => {
toastNotifications.addDanger(
i18n.translate('xpack.ml.dataGrid.invalidSortingColumnError', {
defaultMessage: `The column '{columnId}' cannot be used for sorting.`,
values: { columnId },
})
);
});
}
}, [invalidSortingColumnns, toastNotifications]);
if (status === INDEX_STATUS.LOADED && data.length === 0) {
return (
<div data-test-subj={`${dataTestSubj} empty`}>
{isWithHeader(props) && <DataGridTitle title={props.title} />}
<EuiCallOut
title={i18n.translate('xpack.ml.dataGrid.IndexNoDataCalloutTitle', {
defaultMessage: 'Empty index query result.',
})}
color="primary"
>
<p>
{i18n.translate('xpack.ml.dataGrid.IndexNoDataCalloutBody', {
defaultMessage:
'The query for the index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.',
})}
</p>
</EuiCallOut>
</div>
);
}
if (noDataMessage !== '') {
return (
<div data-test-subj={`${dataTestSubj} empty`}>
{isWithHeader(props) && <DataGridTitle title={props.title} />}
<EuiCallOut
title={i18n.translate('xpack.ml.dataGrid.dataGridNoDataCalloutTitle', {
defaultMessage: 'Index preview not available',
})}
color="primary"
>
<p>{noDataMessage}</p>
</EuiCallOut>
</div>
);
}
return (
<div data-test-subj={`${dataTestSubj} ${status === INDEX_STATUS.ERROR ? 'error' : 'loaded'}`}>
{isWithHeader(props) && (
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<DataGridTitle title={props.title} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
beforeMessage={props.copyToClipboardDescription}
textToCopy={props.copyToClipboard}
>
{(copy: () => void) => (
<EuiButtonIcon
onClick={copy}
iconType="copyClipboard"
aria-label={props.copyToClipboardDescription}
/>
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
)}
{status === INDEX_STATUS.ERROR && (
<div data-test-subj={`${dataTestSubj} error`}>
<EuiCallOut
title={i18n.translate('xpack.ml.dataGrid.indexDataError', {
defaultMessage: 'An error occurred loading the index data.',
})}
color="danger"
iconType="cross"
>
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{errorMessage}
</EuiCodeBlock>
</EuiCallOut>
<EuiSpacer size="m" />
</div>
)}
<EuiDataGrid
aria-label={isWithHeader(props) ? props.title : ''}
columns={columns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: [5, 10, 25],
onChangeItemsPerPage,
onChangePage,
}}
/>
</div>
);
};

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;
* you may not use this file except in compliance with the Elastic License.
*/
export {
getDataGridSchemasFromFieldTypes,
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
multiColumnSortFactory,
useRenderCellValue,
} from './common';
export { useDataGrid } from './use_data_grid';
export { DataGrid } from './data_grid';
export {
DataGridItem,
EsSorting,
RenderCellValue,
SearchResponse7,
UseDataGridReturnType,
UseIndexDataReturnType,
} from './types';

View file

@ -0,0 +1,98 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { Dispatch, SetStateAction } from 'react';
import { SearchResponse } from 'elasticsearch';
import { EuiDataGridPaginationProps, EuiDataGridSorting, EuiDataGridColumn } from '@elastic/eui';
import { Dictionary } from '../../../../common/types/common';
import { INDEX_STATUS } from '../../data_frame_analytics/common/analytics';
export type ColumnId = string;
export type DataGridItem = Record<string, any>;
export type IndexPagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
export type OnChangeItemsPerPage = (pageSize: any) => void;
export type OnChangePage = (pageIndex: any) => void;
export type OnSort = (
sc: Array<{
id: string;
direction: 'asc' | 'desc';
}>
) => void;
export type RenderCellValue = ({
rowIndex,
columnId,
setCellProps,
}: {
rowIndex: number;
columnId: string;
setCellProps: any;
}) => any;
export type EsSorting = Dictionary<{
order: 'asc' | 'desc';
}>;
// The types specified in `@types/elasticsearch` are out of date and still have `total: number`.
export interface SearchResponse7 extends SearchResponse<any> {
hits: SearchResponse<any>['hits'] & {
total: {
value: number;
relation: string;
};
};
}
export interface UseIndexDataReturnType
extends Pick<
UseDataGridReturnType,
| 'errorMessage'
| 'invalidSortingColumnns'
| 'noDataMessage'
| 'onChangeItemsPerPage'
| 'onChangePage'
| 'onSort'
| 'pagination'
| 'setPagination'
| 'setVisibleColumns'
| 'rowCount'
| 'sortingColumns'
| 'status'
| 'tableItems'
| 'visibleColumns'
> {
columns: EuiDataGridColumn[];
renderCellValue: RenderCellValue;
}
export interface UseDataGridReturnType {
errorMessage: string;
invalidSortingColumnns: ColumnId[];
noDataMessage: string;
onChangeItemsPerPage: OnChangeItemsPerPage;
onChangePage: OnChangePage;
onSort: OnSort;
pagination: IndexPagination;
resetPagination: () => void;
rowCount: number;
setErrorMessage: Dispatch<SetStateAction<string>>;
setNoDataMessage: Dispatch<SetStateAction<string>>;
setPagination: Dispatch<SetStateAction<IndexPagination>>;
setRowCount: Dispatch<SetStateAction<number>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
setStatus: Dispatch<SetStateAction<INDEX_STATUS>>;
setTableItems: Dispatch<SetStateAction<DataGridItem[]>>;
setVisibleColumns: Dispatch<SetStateAction<ColumnId[]>>;
sortingColumns: EuiDataGridSorting['columns'];
status: INDEX_STATUS;
tableItems: DataGridItem[];
visibleColumns: ColumnId[];
}

View file

@ -0,0 +1,112 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useCallback, useEffect, useState } from 'react';
import { EuiDataGridSorting, EuiDataGridColumn } from '@elastic/eui';
import { INDEX_STATUS } from '../../data_frame_analytics/common';
import { INIT_MAX_COLUMNS } from './common';
import {
ColumnId,
DataGridItem,
IndexPagination,
OnChangeItemsPerPage,
OnChangePage,
OnSort,
UseDataGridReturnType,
} from './types';
export const useDataGrid = (
columns: EuiDataGridColumn[],
defaultPageSize = 5,
defaultVisibleColumnsCount = INIT_MAX_COLUMNS,
defaultVisibleColumnsFilter?: (id: string) => boolean
): UseDataGridReturnType => {
const defaultPagination: IndexPagination = { pageIndex: 0, pageSize: defaultPageSize };
const [noDataMessage, setNoDataMessage] = useState('');
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
const [rowCount, setRowCount] = useState(0);
const [tableItems, setTableItems] = useState<DataGridItem[]>([]);
const [pagination, setPagination] = useState(defaultPagination);
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback(pageSize => {
setPagination(p => {
const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
return { pageIndex, pageSize };
});
}, []);
const onChangePage: OnChangePage = useCallback(
pageIndex => setPagination(p => ({ ...p, pageIndex })),
[]
);
const resetPagination = () => setPagination(defaultPagination);
// Column visibility
const [visibleColumns, setVisibleColumns] = useState<ColumnId[]>([]);
const columnIds = columns.map(c => c.id);
const filteredColumnIds =
defaultVisibleColumnsFilter !== undefined
? columnIds.filter(defaultVisibleColumnsFilter)
: columnIds;
const defaultVisibleColumns = filteredColumnIds.splice(0, defaultVisibleColumnsCount);
useEffect(() => {
setVisibleColumns(defaultVisibleColumns);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultVisibleColumns.join()]);
const [invalidSortingColumnns, setInvalidSortingColumnns] = useState<string[]>([]);
const onSort: OnSort = useCallback(
sc => {
// Check if an unsupported column type for sorting was selected.
const updatedInvalidSortingColumnns = sc.reduce<string[]>((arr, current) => {
const columnType = columns.find(dgc => dgc.id === current.id);
if (columnType?.schema === 'json') {
arr.push(current.id);
}
return arr;
}, []);
setInvalidSortingColumnns(updatedInvalidSortingColumnns);
if (updatedInvalidSortingColumnns.length === 0) {
setSortingColumns(sc);
}
},
[columns]
);
return {
errorMessage,
invalidSortingColumnns,
noDataMessage,
onChangeItemsPerPage,
onChangePage,
onSort,
pagination,
resetPagination,
rowCount,
setErrorMessage,
setNoDataMessage,
setPagination,
setRowCount,
setSortingColumns,
setStatus,
setTableItems,
setVisibleColumns,
sortingColumns,
status,
tableItems,
visibleColumns,
};
};

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
export const DEFAULT_RESULTS_FIELD = 'ml';
export const FEATURE_IMPORTANCE = 'feature_importance';
export const FEATURE_INFLUENCE = 'feature_influence';
export const OUTLIER_SCORE = 'outlier_score';

View file

@ -1,23 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDataGridStyle } from '@elastic/eui';
export const euiDataGridStyle: EuiDataGridStyle = {
border: 'all',
fontSize: 's',
cellPadding: 's',
stripes: false,
rowHover: 'none',
header: 'shade',
};
export const euiDataGridToolbarSettings = {
showColumnSelector: true,
showStyleSelector: false,
showSortSelector: true,
showFullScreenSelector: false,
};

View file

@ -4,18 +4,22 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { getNestedProperty } from '../../util/object_utils';
import {
DataFrameAnalyticsConfig,
getNumTopFeatureImportanceValues,
getPredictedFieldName,
getDependentVar,
getPredictionFieldName,
isClassificationAnalysis,
isOutlierAnalysis,
isRegressionAnalysis,
DataFrameAnalyticsConfig,
} from './analytics';
import { Field } from '../../../../common/types/fields';
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import { newJobCapsService } from '../../services/new_job_capabilities_service';
import { FEATURE_IMPORTANCE, FEATURE_INFLUENCE, OUTLIER_SCORE } from './constants';
export type EsId = string;
export type EsDocSource = Record<string, any>;
export type EsFieldName = string;
@ -42,7 +46,7 @@ export const EXTENDED_NUMERICAL_TYPES = new Set([
ES_FIELD_TYPES.SCALED_FLOAT,
]);
const ML__ID_COPY = 'ml__id_copy';
export const ML__ID_COPY = 'ml__id_copy';
export const isKeywordAndTextType = (fieldName: string): boolean => {
const { fields } = newJobCapsService;
@ -64,145 +68,60 @@ export const isKeywordAndTextType = (fieldName: string): boolean => {
};
// Used to sort columns:
// - Anchor on the left ml.outlier_score, ml.is_training, <predictedField>, <actual>
// - string based columns are moved to the left
// - followed by the outlier_score column
// - feature_influence fields get moved next to the corresponding field column
// - feature_influence/feature_importance fields get moved next to the corresponding field column
// - overall fields get sorted alphabetically
export const sortColumns = (obj: EsDocSource, resultsField: string) => (a: string, b: string) => {
const typeofA = typeof obj[a];
const typeofB = typeof obj[b];
if (typeofA !== 'string' && typeofB === 'string') {
return 1;
}
if (typeofA === 'string' && typeofB !== 'string') {
return -1;
}
if (typeofA === 'string' && typeofB === 'string') {
return a.localeCompare(b);
}
if (a === `${resultsField}.outlier_score`) {
return -1;
}
if (b === `${resultsField}.outlier_score`) {
return 1;
}
const tokensA = a.split('.');
const prefixA = tokensA[0];
const tokensB = b.split('.');
const prefixB = tokensB[0];
if (prefixA === resultsField && tokensA.length > 1 && prefixB !== resultsField) {
tokensA.shift();
tokensA.shift();
if (tokensA.join('.') === b) return 1;
return tokensA.join('.').localeCompare(b);
}
if (prefixB === resultsField && tokensB.length > 1 && prefixA !== resultsField) {
tokensB.shift();
tokensB.shift();
if (tokensB.join('.') === a) return -1;
return a.localeCompare(tokensB.join('.'));
}
return a.localeCompare(b);
};
export const sortRegressionResultsFields = (
export const sortExplorationResultsFields = (
a: string,
b: string,
jobConfig: DataFrameAnalyticsConfig
) => {
const dependentVariable = getDependentVar(jobConfig.analysis);
const resultsField = jobConfig.dest.results_field;
const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true);
if (a === `${resultsField}.is_training`) {
return -1;
}
if (b === `${resultsField}.is_training`) {
return 1;
}
if (a === predictedField) {
return -1;
}
if (b === predictedField) {
return 1;
}
if (a === dependentVariable || a === dependentVariable.replace(/\.keyword$/, '')) {
return -1;
}
if (b === dependentVariable || b === dependentVariable.replace(/\.keyword$/, '')) {
return 1;
if (isOutlierAnalysis(jobConfig.analysis)) {
if (a === `${resultsField}.${OUTLIER_SCORE}`) {
return -1;
}
if (b === `${resultsField}.${OUTLIER_SCORE}`) {
return 1;
}
}
if (a === `${resultsField}.prediction_probability`) {
return -1;
}
if (b === `${resultsField}.prediction_probability`) {
return 1;
if (isClassificationAnalysis(jobConfig.analysis) || isRegressionAnalysis(jobConfig.analysis)) {
const dependentVariable = getDependentVar(jobConfig.analysis);
const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true);
if (a === `${resultsField}.is_training`) {
return -1;
}
if (b === `${resultsField}.is_training`) {
return 1;
}
if (a === predictedField) {
return -1;
}
if (b === predictedField) {
return 1;
}
if (a === dependentVariable || a === dependentVariable.replace(/\.keyword$/, '')) {
return -1;
}
if (b === dependentVariable || b === dependentVariable.replace(/\.keyword$/, '')) {
return 1;
}
if (a === `${resultsField}.prediction_probability`) {
return -1;
}
if (b === `${resultsField}.prediction_probability`) {
return 1;
}
}
return a.localeCompare(b);
};
// Used to sort columns:
// Anchor on the left ml.is_training, <predictedField>, <actual>
export const sortRegressionResultsColumns = (
obj: EsDocSource,
jobConfig: DataFrameAnalyticsConfig
) => (a: string, b: string) => {
const dependentVariable = getDependentVar(jobConfig.analysis);
const resultsField = jobConfig.dest.results_field;
const predictedField = getPredictedFieldName(resultsField, jobConfig.analysis, true);
const typeofA = typeof obj[a];
const typeofB = typeof obj[b];
if (a === `${resultsField}.is_training`) {
return -1;
}
if (b === `${resultsField}.is_training`) {
return 1;
}
if (a === predictedField) {
return -1;
}
if (b === predictedField) {
return 1;
}
if (a === dependentVariable) {
return -1;
}
if (b === dependentVariable) {
return 1;
}
if (a === `${resultsField}.prediction_probability`) {
return -1;
}
if (b === `${resultsField}.prediction_probability`) {
return 1;
}
if (typeofA !== 'string' && typeofB === 'string') {
return 1;
}
if (typeofA === 'string' && typeofB !== 'string') {
return -1;
}
if (typeofA === 'string' && typeofB === 'string') {
return a.localeCompare(b);
}
const typeofA = typeof a;
const typeofB = typeof b;
const tokensA = a.split('.');
const prefixA = tokensA[0];
@ -223,25 +142,19 @@ export const sortRegressionResultsColumns = (
return a.localeCompare(tokensB.join('.'));
}
if (typeofA !== 'string' && typeofB === 'string') {
return 1;
}
if (typeofA === 'string' && typeofB !== 'string') {
return -1;
}
if (typeofA === 'string' && typeofB === 'string') {
return a.localeCompare(b);
}
return a.localeCompare(b);
};
export function getFlattenedFields(obj: EsDocSource, resultsField: string): EsFieldName[] {
const flatDocFields: EsFieldName[] = [];
const newDocFields = Object.keys(obj);
newDocFields.forEach(f => {
const fieldValue = getNestedProperty(obj, f);
if (typeof fieldValue !== 'object' || fieldValue === null || Array.isArray(fieldValue)) {
flatDocFields.push(f);
} else {
const innerFields = getFlattenedFields(fieldValue, resultsField);
const flattenedFields = innerFields.map(d => `${f}.${d}`);
flatDocFields.push(...flattenedFields);
}
});
return flatDocFields.filter(f => f !== ML__ID_COPY);
}
export const getDefaultFieldsFromJobCaps = (
fields: Field[],
jobConfig: DataFrameAnalyticsConfig,
@ -259,49 +172,72 @@ export const getDefaultFieldsFromJobCaps = (
return fieldsObj;
}
const dependentVariable = getDependentVar(jobConfig.analysis);
const type = newJobCapsService.getFieldById(dependentVariable)?.type;
const predictionFieldName = getPredictionFieldName(jobConfig.analysis);
const numTopFeatureImportanceValues = getNumTopFeatureImportanceValues(jobConfig.analysis);
// default is 'ml'
const resultsField = jobConfig.dest.results_field;
const defaultPredictionField = `${dependentVariable}_prediction`;
const predictedField = `${resultsField}.${
predictionFieldName ? predictionFieldName : defaultPredictionField
}`;
const featureImportanceFields = [];
const featureInfluenceFields = [];
const allFields: any = [];
let type: ES_FIELD_TYPES | undefined;
let predictedField: string | undefined;
if ((numTopFeatureImportanceValues ?? 0) > 0) {
featureImportanceFields.push({
id: `${resultsField}.feature_importance`,
name: `${resultsField}.feature_importance`,
type: KBN_FIELD_TYPES.NUMBER,
});
if (isOutlierAnalysis(jobConfig.analysis)) {
// Only need to add these fields if we didn't use dest index pattern to get the fields
if (needsDestIndexFields === true) {
allFields.push({
id: `${resultsField}.${OUTLIER_SCORE}`,
name: `${resultsField}.${OUTLIER_SCORE}`,
type: KBN_FIELD_TYPES.NUMBER,
});
featureInfluenceFields.push(
...fields
.filter(d => !jobConfig.analyzed_fields.excludes.includes(d.id))
.map(d => ({
id: `${resultsField}.${FEATURE_INFLUENCE}.${d.id}`,
name: `${resultsField}.${FEATURE_INFLUENCE}.${d.name}`,
type: KBN_FIELD_TYPES.NUMBER,
}))
);
}
}
let allFields: any = [];
// Only need to add these fields if we didn't use dest index pattern to get the fields
if (needsDestIndexFields === true) {
allFields.push(
{
id: `${resultsField}.is_training`,
name: `${resultsField}.is_training`,
type: ES_FIELD_TYPES.BOOLEAN,
},
{ id: predictedField, name: predictedField, type }
);
if (isClassificationAnalysis(jobConfig.analysis) || isRegressionAnalysis(jobConfig.analysis)) {
const dependentVariable = getDependentVar(jobConfig.analysis);
type = newJobCapsService.getFieldById(dependentVariable)?.type;
const predictionFieldName = getPredictionFieldName(jobConfig.analysis);
const numTopFeatureImportanceValues = getNumTopFeatureImportanceValues(jobConfig.analysis);
const defaultPredictionField = `${dependentVariable}_prediction`;
predictedField = `${resultsField}.${
predictionFieldName ? predictionFieldName : defaultPredictionField
}`;
if ((numTopFeatureImportanceValues ?? 0) > 0 && needsDestIndexFields === true) {
featureImportanceFields.push({
id: `${resultsField}.${FEATURE_IMPORTANCE}`,
name: `${resultsField}.${FEATURE_IMPORTANCE}`,
type: KBN_FIELD_TYPES.UNKNOWN,
});
}
// Only need to add these fields if we didn't use dest index pattern to get the fields
if (needsDestIndexFields === true) {
allFields.push(
{
id: `${resultsField}.is_training`,
name: `${resultsField}.is_training`,
type: ES_FIELD_TYPES.BOOLEAN,
},
{ id: predictedField, name: predictedField, type }
);
}
}
allFields.push(...fields, ...featureImportanceFields);
allFields.push(...fields, ...featureImportanceFields, ...featureInfluenceFields);
allFields.sort(({ name: a }: { name: string }, { name: b }: { name: string }) =>
sortRegressionResultsFields(a, b, jobConfig)
sortExplorationResultsFields(a, b, jobConfig)
);
// Remove feature_importance fields provided by dest index since feature_importance is an array the path is not valid
if (needsDestIndexFields === false) {
allFields = allFields.filter((field: any) => !field.name.includes('.feature_importance.'));
}
let selectedFields = allFields.filter(
(field: any) => field.name === predictedField || !field.name.includes('.keyword')
@ -317,145 +253,3 @@ export const getDefaultFieldsFromJobCaps = (
depVarType: type,
};
};
export const getDefaultClassificationFields = (
docs: EsDoc[],
jobConfig: DataFrameAnalyticsConfig
): EsFieldName[] => {
if (docs.length === 0) {
return [];
}
const resultsField = jobConfig.dest.results_field;
const newDocFields = getFlattenedFields(docs[0]._source, resultsField);
return newDocFields
.filter(k => {
if (k === `${resultsField}.is_training`) {
return true;
}
// predicted value of dependent variable
if (k === getPredictedFieldName(resultsField, jobConfig.analysis, true)) {
return true;
}
// actual value of dependent variable
if (k === getDependentVar(jobConfig.analysis)) {
return true;
}
if (k === `${resultsField}.prediction_probability`) {
return true;
}
if (k.split('.')[0] === resultsField) {
return false;
}
return docs.some(row => row._source[k] !== null);
})
.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig))
.slice(0, DEFAULT_REGRESSION_COLUMNS);
};
export const getDefaultRegressionFields = (
docs: EsDoc[],
jobConfig: DataFrameAnalyticsConfig
): EsFieldName[] => {
const resultsField = jobConfig.dest.results_field;
if (docs.length === 0) {
return [];
}
const newDocFields = getFlattenedFields(docs[0]._source, resultsField);
return newDocFields
.filter(k => {
if (k === `${resultsField}.is_training`) {
return true;
}
// predicted value of dependent variable
if (k === getPredictedFieldName(resultsField, jobConfig.analysis)) {
return true;
}
// actual value of dependent variable
if (k === getDependentVar(jobConfig.analysis)) {
return true;
}
if (k.split('.')[0] === resultsField) {
return false;
}
return docs.some(row => row._source[k] !== null);
})
.sort((a, b) => sortRegressionResultsFields(a, b, jobConfig))
.slice(0, DEFAULT_REGRESSION_COLUMNS);
};
export const getDefaultSelectableFields = (docs: EsDoc[], resultsField: string): EsFieldName[] => {
if (docs.length === 0) {
return [];
}
const newDocFields = getFlattenedFields(docs[0]._source, resultsField);
return newDocFields.filter(k => {
if (k === `${resultsField}.outlier_score`) {
return true;
}
if (k.split('.')[0] === resultsField) {
return false;
}
return docs.some(row => row._source[k] !== null);
});
};
export const toggleSelectedFieldSimple = (
selectedFields: EsFieldName[],
column: EsFieldName
): EsFieldName[] => {
const index = selectedFields.indexOf(column);
if (index === -1) {
selectedFields.push(column);
} else {
selectedFields.splice(index, 1);
}
return selectedFields;
};
// Fields starting with 'ml' or custom result name not included in newJobCapsService fields so
// need to recreate the field with correct type and add to selected fields
export const toggleSelectedField = (
selectedFields: Field[],
column: EsFieldName,
resultsField: string,
depVarType?: ES_FIELD_TYPES
): Field[] => {
const index = selectedFields.map(field => field.name).indexOf(column);
if (index === -1) {
const columnField = newJobCapsService.getFieldById(column);
if (columnField !== null) {
selectedFields.push(columnField);
} else {
const resultFieldPattern = `^${resultsField}\.`;
const regex = new RegExp(resultFieldPattern);
const isResultField = column.match(regex) !== null;
let newField;
if (isResultField && column.includes('is_training')) {
newField = {
id: column,
name: column,
type: ES_FIELD_TYPES.BOOLEAN,
};
} else if (isResultField && depVarType !== undefined) {
newField = {
id: column,
name: column,
type: depVarType,
};
}
if (newField) selectedFields.push(newField);
}
} else {
selectedFields.splice(index, 1);
}
return selectedFields;
};

View file

@ -0,0 +1,68 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { getErrorMessage } from '../../../../common/util/errors';
import { EsSorting, SearchResponse7, UseDataGridReturnType } from '../../components/data_grid';
import { ml } from '../../services/ml_api_service';
import { isKeywordAndTextType } from '../common/fields';
import { SavedSearchQuery } from '../../contexts/ml';
import { DataFrameAnalyticsConfig, INDEX_STATUS } from './analytics';
export const getIndexData = async (
jobConfig: DataFrameAnalyticsConfig | undefined,
dataGrid: UseDataGridReturnType,
searchQuery: SavedSearchQuery
) => {
if (jobConfig !== undefined) {
const {
pagination,
setErrorMessage,
setRowCount,
setStatus,
setTableItems,
sortingColumns,
} = dataGrid;
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
try {
const sort: EsSorting = sortingColumns
.map(column => {
const { id } = column;
column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id;
return column;
})
.reduce((s, column) => {
s[column.id] = { order: column.direction };
return s;
}, {} as EsSorting);
const { pageIndex, pageSize } = pagination;
const resp: SearchResponse7 = await ml.esSearch({
index: jobConfig.dest.index,
body: {
query: searchQuery,
from: pageIndex * pageSize,
size: pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
});
setRowCount(resp.hits.total.value);
const docs = resp.hits.hits.map(d => d._source);
setTableItems(docs);
setStatus(INDEX_STATUS.LOADED);
} catch (e) {
setErrorMessage(getErrorMessage(e));
setStatus(INDEX_STATUS.ERROR);
}
}
};

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
import { newJobCapsService } from '../../services/new_job_capabilities_service';
import { getDefaultFieldsFromJobCaps, DataFrameAnalyticsConfig } from '../common';
export interface FieldTypes {
[key: string]: ES_FIELD_TYPES;
}
export const getIndexFields = (
jobConfig: DataFrameAnalyticsConfig | undefined,
needsDestIndexFields: boolean
) => {
const { fields } = newJobCapsService;
if (jobConfig !== undefined) {
const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps(
fields,
jobConfig,
needsDestIndexFields
);
const types: FieldTypes = {};
const allFields: string[] = [];
docFields.forEach(field => {
types[field.id] = field.type;
allFields.push(field.id);
});
return {
defaultSelectedFields: defaultSelected.map(field => field.id),
fieldTypes: types,
tableFields: allFields,
};
} else {
return {
defaultSelectedFields: [],
fieldTypes: {},
tableFields: [],
};
}
};

View file

@ -30,16 +30,8 @@ export {
} from './analytics';
export {
getDefaultSelectableFields,
getDefaultRegressionFields,
getDefaultClassificationFields,
getDefaultFieldsFromJobCaps,
getFlattenedFields,
sortColumns,
sortRegressionResultsColumns,
sortRegressionResultsFields,
toggleSelectedField,
toggleSelectedFieldSimple,
sortExplorationResultsFields,
EsId,
EsDoc,
EsDocSource,
@ -47,4 +39,7 @@ export {
MAX_COLUMNS,
} from './fields';
export { euiDataGridStyle, euiDataGridToolbarSettings } from './data_grid';
export { getIndexData } from './get_index_data';
export { getIndexFields } from './get_index_fields';
export { useResultsViewConfig } from './use_results_view_config';

View file

@ -0,0 +1,104 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { IndexPattern } from '../../../../../../../src/plugins/data/public';
import { getErrorMessage } from '../../../../common/util/errors';
import { getIndexPatternIdFromName } from '../../util/index_utils';
import { ml } from '../../services/ml_api_service';
import { newJobCapsService } from '../../services/new_job_capabilities_service';
import { useMlContext } from '../../contexts/ml';
import { DataFrameAnalyticsConfig } from '../common';
import { isGetDataFrameAnalyticsStatsResponseOk } from '../pages/analytics_management/services/analytics_service/get_analytics';
import { DATA_FRAME_TASK_STATE } from '../pages/analytics_management/components/analytics_list/common';
export const useResultsViewConfig = (jobId: string) => {
const mlContext = useMlContext();
const [indexPattern, setIndexPattern] = useState<IndexPattern | undefined>(undefined);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState<undefined | string>(
undefined
);
const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState<undefined | string>(undefined);
const [jobStatus, setJobStatus] = useState<DATA_FRAME_TASK_STATE | undefined>(undefined);
// get analytics configuration, index pattern and field caps
useEffect(() => {
(async function() {
setIsLoadingJobConfig(false);
try {
const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId);
const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId);
const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats)
? analyticsStats.data_frame_analytics[0]
: undefined;
if (stats !== undefined && stats.state) {
setJobStatus(stats.state);
}
if (
Array.isArray(analyticsConfigs.data_frame_analytics) &&
analyticsConfigs.data_frame_analytics.length > 0
) {
const jobConfigUpdate = analyticsConfigs.data_frame_analytics[0];
try {
const destIndex = Array.isArray(jobConfigUpdate.dest.index)
? jobConfigUpdate.dest.index[0]
: jobConfigUpdate.dest.index;
const destIndexPatternId = getIndexPatternIdFromName(destIndex) || destIndex;
let indexP: IndexPattern | undefined;
try {
indexP = await mlContext.indexPatterns.get(destIndexPatternId);
} catch (e) {
indexP = undefined;
}
if (indexP === undefined) {
const sourceIndex = jobConfigUpdate.source.index[0];
const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex;
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
}
if (indexP !== undefined) {
await newJobCapsService.initializeFromIndexPattern(indexP, false, false);
setJobConfig(analyticsConfigs.data_frame_analytics[0]);
setIndexPattern(indexP);
setIsInitialized(true);
setIsLoadingJobConfig(false);
}
} catch (e) {
setJobCapsServiceErrorMessage(getErrorMessage(e));
setIsLoadingJobConfig(false);
}
}
} catch (e) {
setJobConfigErrorMessage(getErrorMessage(e));
setIsLoadingJobConfig(false);
}
})();
}, []);
return {
indexPattern,
isInitialized,
isLoadingJobConfig,
jobCapsServiceErrorMessage,
jobConfig,
jobConfigErrorMessage,
jobStatus,
};
};

View file

@ -4,183 +4,30 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, Fragment, useState, useEffect } from 'react';
import { EuiCallOut, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { ml } from '../../../../../services/ml_api_service';
import { DataFrameAnalyticsConfig } from '../../../../common';
import { ExplorationPageWrapper } from '../exploration_page_wrapper';
import { EvaluatePanel } from './evaluate_panel';
import { ResultsTable } from './results_table';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics';
import { LoadingPanel } from '../loading_panel';
import { getIndexPatternIdFromName } from '../../../../../util/index_utils';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
import { useMlContext } from '../../../../../contexts/ml';
import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics';
export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle', {
defaultMessage: 'Destination index for classification job ID {jobId}',
values: { jobId },
})}
</span>
</EuiTitle>
);
const jobConfigErrorTitle = i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationFetchError',
{
defaultMessage:
'Unable to fetch results. An error occurred loading the job configuration data.',
}
);
const jobCapsErrorTitle = i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.jobCapsFetchError',
{
defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.",
}
);
interface Props {
jobId: string;
}
export const ClassificationExploration: FC<Props> = ({ jobId }) => {
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
const [jobStatus, setJobStatus] = useState<DATA_FRAME_TASK_STATE | undefined>(undefined);
const [indexPattern, setIndexPattern] = useState<IndexPattern | undefined>(undefined);
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState<undefined | string>(undefined);
const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState<undefined | string>(
undefined
);
const [searchQuery, setSearchQuery] = useState<ResultsSearchQuery>(defaultSearchQuery);
const mlContext = useMlContext();
const loadJobConfig = async () => {
setIsLoadingJobConfig(true);
try {
const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId);
const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId);
const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats)
? analyticsStats.data_frame_analytics[0]
: undefined;
if (stats !== undefined && stats.state) {
setJobStatus(stats.state);
}
if (
Array.isArray(analyticsConfigs.data_frame_analytics) &&
analyticsConfigs.data_frame_analytics.length > 0
) {
setJobConfig(analyticsConfigs.data_frame_analytics[0]);
setIsLoadingJobConfig(false);
} else {
setJobConfigErrorMessage(
i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationNoResultsMessage',
{
defaultMessage: 'No results found.',
}
)
);
}
} catch (e) {
if (e.message !== undefined) {
setJobConfigErrorMessage(e.message);
} else {
setJobConfigErrorMessage(JSON.stringify(e));
}
setIsLoadingJobConfig(false);
}
};
useEffect(() => {
loadJobConfig();
}, []);
const initializeJobCapsService = async () => {
if (jobConfig !== undefined) {
try {
const destIndex = Array.isArray(jobConfig.dest.index)
? jobConfig.dest.index[0]
: jobConfig.dest.index;
const destIndexPatternId = getIndexPatternIdFromName(destIndex) || destIndex;
let indexP: IndexPattern | undefined;
try {
indexP = await mlContext.indexPatterns.get(destIndexPatternId);
} catch (e) {
indexP = undefined;
}
if (indexP === undefined) {
const sourceIndex = jobConfig.source.index[0];
const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex;
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
}
if (indexP !== undefined) {
setIndexPattern(indexP);
await newJobCapsService.initializeFromIndexPattern(indexP, false, false);
}
setIsInitialized(true);
} catch (e) {
if (e.message !== undefined) {
setJobCapsServiceErrorMessage(e.message);
} else {
setJobCapsServiceErrorMessage(JSON.stringify(e));
}
}
}
};
useEffect(() => {
initializeJobCapsService();
}, [jobConfig && jobConfig.id]);
if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) {
return (
<EuiPanel grow={false}>
<ExplorationTitle jobId={jobId} />
<EuiSpacer />
<EuiCallOut
title={jobConfigErrorMessage ? jobConfigErrorTitle : jobCapsErrorTitle}
color="danger"
iconType="cross"
>
<p>{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}
return (
<Fragment>
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && (
<EvaluatePanel jobConfig={jobConfig} jobStatus={jobStatus} searchQuery={searchQuery} />
<ExplorationPageWrapper
jobId={jobId}
title={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle',
{
defaultMessage: 'Destination index for classification job ID {jobId}',
values: { jobId },
}
)}
<EuiSpacer />
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false &&
jobConfig !== undefined &&
indexPattern !== undefined &&
isInitialized === true && (
<ResultsTable
jobConfig={jobConfig}
indexPattern={indexPattern}
jobStatus={jobStatus}
setEvaluateSearchQuery={setSearchQuery}
/>
)}
</Fragment>
EvaluatePanel={EvaluatePanel}
/>
);
};

View file

@ -1,135 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiDataGrid, EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { euiDataGridStyle, euiDataGridToolbarSettings } from '../../../../common';
import { mlFieldFormatService } from '../../../../../services/field_format_service';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
type TableItem = Record<string, any>;
interface ExplorationDataGridProps {
colorRange?: (d: number) => string;
columns: any[];
indexPattern: IndexPattern;
pagination: Pagination;
resultsField: string;
rowCount: number;
selectedFields: string[];
setPagination: Dispatch<SetStateAction<Pagination>>;
setSelectedFields: Dispatch<SetStateAction<string[]>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
sortingColumns: EuiDataGridSorting['columns'];
tableItems: TableItem[];
}
export const ClassificationExplorationDataGrid: FC<ExplorationDataGridProps> = ({
columns,
indexPattern,
pagination,
resultsField,
rowCount,
selectedFields,
setPagination,
setSelectedFields,
setSortingColumns,
sortingColumns,
tableItems,
}) => {
const renderCellValue = useMemo(() => {
return ({ rowIndex, columnId }: { rowIndex: number; columnId: string; setCellProps: any }) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const fullItem = tableItems[adjustedRowIndex];
if (fullItem === undefined) {
return null;
}
let format: any;
if (indexPattern !== undefined) {
format = mlFieldFormatService.getFieldFormatFromIndexPattern(indexPattern, columnId, '');
}
const cellValue =
fullItem.hasOwnProperty(columnId) && fullItem[columnId] !== undefined
? fullItem[columnId]
: null;
if (format !== undefined) {
return format.convert(cellValue, 'text');
}
if (typeof cellValue === 'string' || cellValue === null) {
return cellValue;
}
if (typeof cellValue === 'boolean') {
return cellValue ? 'true' : 'false';
}
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
return cellValue;
};
}, [resultsField, rowCount, tableItems, pagination.pageIndex, pagination.pageSize]);
const onChangeItemsPerPage = useCallback(
pageSize => {
setPagination(p => {
const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
return { pageIndex, pageSize };
});
},
[setPagination]
);
const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
setPagination,
]);
const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]);
return (
<EuiDataGrid
aria-label={i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.dataGridAriaLabel',
{
defaultMessage: 'Classification results table',
}
)}
columns={columns}
columnVisibility={{
visibleColumns: selectedFields,
setVisibleColumns: setSelectedFields,
}}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: PAGE_SIZE_OPTIONS,
onChangeItemsPerPage,
onChangePage,
}}
/>
);
};

View file

@ -1,292 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState, Dispatch, SetStateAction } from 'react';
import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { SearchResponse } from 'elasticsearch';
import { cloneDeep } from 'lodash';
import { SORT_DIRECTION } from '../../../../../components/ml_in_memory_table';
import { ml } from '../../../../../services/ml_api_service';
import { getNestedProperty } from '../../../../../util/object_utils';
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
import { isKeywordAndTextType } from '../../../../common/fields';
import { Dictionary } from '../../../../../../../common/types/common';
import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public';
import {
defaultSearchQuery,
ResultsSearchQuery,
isResultsSearchBoolQuery,
LoadExploreDataArg,
} from '../../../../common/analytics';
import {
getDefaultFieldsFromJobCaps,
getDependentVar,
getFlattenedFields,
getPredictedFieldName,
DataFrameAnalyticsConfig,
EsFieldName,
INDEX_STATUS,
} from '../../../../common';
import { SavedSearchQuery } from '../../../../../contexts/ml';
export type TableItem = Record<string, any>;
type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
export interface UseExploreDataReturnType {
errorMessage: string;
fieldTypes: { [key: string]: ES_FIELD_TYPES };
pagination: Pagination;
rowCount: number;
searchQuery: SavedSearchQuery;
selectedFields: EsFieldName[];
setFilterByIsTraining: Dispatch<SetStateAction<undefined | boolean>>;
setPagination: Dispatch<SetStateAction<Pagination>>;
setSearchQuery: Dispatch<SetStateAction<SavedSearchQuery>>;
setSelectedFields: Dispatch<SetStateAction<EsFieldName[]>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
sortingColumns: EuiDataGridSorting['columns'];
status: INDEX_STATUS;
tableFields: string[];
tableItems: TableItem[];
}
type EsSorting = Dictionary<{
order: 'asc' | 'desc';
}>;
// The types specified in `@types/elasticsearch` are out of date and still have `total: number`.
interface SearchResponse7 extends SearchResponse<any> {
hits: SearchResponse<any>['hits'] & {
total: {
value: number;
relation: string;
};
};
}
export const useExploreData = (
jobConfig: DataFrameAnalyticsConfig,
needsDestIndexFields: boolean
): UseExploreDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
const [tableFields, setTableFields] = useState<string[]>([]);
const [tableItems, setTableItems] = useState<TableItem[]>([]);
const [fieldTypes, setFieldTypes] = useState<{ [key: string]: ES_FIELD_TYPES }>({});
const [rowCount, setRowCount] = useState(0);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
const [filterByIsTraining, setFilterByIsTraining] = useState<undefined | boolean>(undefined);
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const predictedFieldName = getPredictedFieldName(
jobConfig.dest.results_field,
jobConfig.analysis
);
const dependentVariable = getDependentVar(jobConfig.analysis);
const getDefaultSelectedFields = () => {
const { fields } = newJobCapsService;
if (selectedFields.length === 0 && jobConfig !== undefined) {
const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps(
fields,
jobConfig,
needsDestIndexFields
);
const types: { [key: string]: ES_FIELD_TYPES } = {};
const allFields: string[] = [];
docFields.forEach(field => {
types[field.id] = field.type;
allFields.push(field.id);
});
setFieldTypes(types);
setSelectedFields(defaultSelected.map(field => field.id));
setTableFields(allFields);
}
};
const loadExploreData = async ({
filterByIsTraining: isTraining,
searchQuery: incomingQuery,
}: LoadExploreDataArg) => {
if (jobConfig !== undefined) {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
try {
const resultsField = jobConfig.dest.results_field;
const searchQueryClone: ResultsSearchQuery = cloneDeep(incomingQuery);
let query: ResultsSearchQuery;
const { pageIndex, pageSize } = pagination;
// If filterByIsTraining is defined - add that in to the final query
const trainingQuery =
isTraining !== undefined
? {
term: { [`${resultsField}.is_training`]: { value: isTraining } },
}
: undefined;
if (JSON.stringify(incomingQuery) === JSON.stringify(defaultSearchQuery)) {
const existsQuery = {
exists: {
field: resultsField,
},
};
query = {
bool: {
must: [existsQuery],
},
};
if (trainingQuery !== undefined && isResultsSearchBoolQuery(query)) {
query.bool.must.push(trainingQuery);
}
} else if (isResultsSearchBoolQuery(searchQueryClone)) {
if (searchQueryClone.bool.must === undefined) {
searchQueryClone.bool.must = [];
}
searchQueryClone.bool.must.push({
exists: {
field: resultsField,
},
});
if (trainingQuery !== undefined) {
searchQueryClone.bool.must.push(trainingQuery);
}
query = searchQueryClone;
} else {
query = searchQueryClone;
}
const sort: EsSorting = sortingColumns
.map(column => {
const { id } = column;
column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id;
return column;
})
.reduce((s, column) => {
s[column.id] = { order: column.direction };
return s;
}, {} as EsSorting);
const resp: SearchResponse7 = await ml.esSearch({
index: jobConfig.dest.index,
body: {
query,
from: pageIndex * pageSize,
size: pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
});
setRowCount(resp.hits.total.value);
const docs = resp.hits.hits;
if (docs.length === 0) {
setTableItems([]);
setStatus(INDEX_STATUS.LOADED);
return;
}
// Create a version of the doc's source with flattened field names.
// This avoids confusion later on if a field name has dots in its name
// or is a nested fields when displaying it via EuiInMemoryTable.
const flattenedFields = getFlattenedFields(docs[0]._source, resultsField);
const transformedTableItems = docs.map(doc => {
const item: TableItem = {};
flattenedFields.forEach(ff => {
item[ff] = getNestedProperty(doc._source, ff);
if (item[ff] === undefined) {
// If the attribute is undefined, it means it was not a nested property
// but had dots in its actual name. This selects the property by its
// full name and assigns it to `item[ff]`.
item[ff] = doc._source[`"${ff}"`];
}
if (item[ff] === undefined) {
const parts = ff.split('.');
if (parts[0] === resultsField && parts.length >= 2) {
parts.shift();
if (doc._source[resultsField] !== undefined) {
item[ff] = doc._source[resultsField][parts.join('.')];
}
}
}
});
return item;
});
setTableItems(transformedTableItems);
setStatus(INDEX_STATUS.LOADED);
} catch (e) {
if (e.message !== undefined) {
setErrorMessage(e.message);
} else {
setErrorMessage(JSON.stringify(e));
}
setTableItems([]);
setStatus(INDEX_STATUS.ERROR);
}
}
};
useEffect(() => {
getDefaultSelectedFields();
}, [jobConfig && jobConfig.id]);
// By default set sorting to descending on the prediction field (`<dependent_varible or prediction_field_name>_prediction`).
useEffect(() => {
const sortByField = isKeywordAndTextType(dependentVariable)
? `${predictedFieldName}.keyword`
: predictedFieldName;
const direction = SORT_DIRECTION.DESC;
setSortingColumns([{ id: sortByField, direction }]);
}, [jobConfig && jobConfig.id]);
useEffect(() => {
loadExploreData({ filterByIsTraining, searchQuery });
}, [
filterByIsTraining,
jobConfig && jobConfig.id,
pagination,
searchQuery,
selectedFields,
sortingColumns,
]);
return {
errorMessage,
fieldTypes,
pagination,
searchQuery,
selectedFields,
rowCount,
setFilterByIsTraining,
setPagination,
setSelectedFields,
setSortingColumns,
setSearchQuery,
sortingColumns,
status,
tableItems,
tableFields,
};
};

View file

@ -15,7 +15,7 @@ interface Props {
export const ErrorCallout: FC<Props> = ({ error }) => {
let errorCallout = (
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.generalError', {
title={i18n.translate('xpack.ml.dataframe.analytics.errorCallout.generalErrorTitle', {
defaultMessage: 'An error occurred loading the data.',
})}
color="danger"
@ -28,14 +28,14 @@ export const ErrorCallout: FC<Props> = ({ error }) => {
if (error.includes('index_not_found')) {
errorCallout = (
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.evaluateError', {
title={i18n.translate('xpack.ml.dataframe.analytics.errorCallout.evaluateErrorTitle', {
defaultMessage: 'An error occurred loading the data.',
})}
color="danger"
iconType="cross"
>
<p>
{i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.noIndexCalloutBody', {
{i18n.translate('xpack.ml.dataframe.analytics.errorCallout.noIndexCalloutBody', {
defaultMessage:
'The query for the index returned no results. Please make sure the destination index exists and contains documents.',
})}
@ -46,16 +46,13 @@ export const ErrorCallout: FC<Props> = ({ error }) => {
// Job was started but no results have been written yet
errorCallout = (
<EuiCallOut
title={i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutTitle',
{
defaultMessage: 'Empty index query result.',
}
)}
title={i18n.translate('xpack.ml.dataframe.analytics.errorCallout.noDataCalloutTitle', {
defaultMessage: 'Empty index query result.',
})}
color="primary"
>
<p>
{i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutBody', {
{i18n.translate('xpack.ml.dataframe.analytics.errorCallout.noDataCalloutBody', {
defaultMessage:
'The query for the index returned no results. Please make sure the job has completed and the index contains documents.',
})}
@ -66,22 +63,16 @@ export const ErrorCallout: FC<Props> = ({ error }) => {
// query bar syntax is incorrect
errorCallout = (
<EuiCallOut
title={i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.queryParsingErrorMessage',
{
defaultMessage: 'Unable to parse query.',
}
)}
title={i18n.translate('xpack.ml.dataframe.analytics.errorCallout.queryParsingErrorTitle', {
defaultMessage: 'Unable to parse query.',
})}
color="primary"
>
<p>
{i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.queryParsingErrorBody',
{
defaultMessage:
'The query syntax is invalid and returned no results. Please check the query syntax and try again.',
}
)}
{i18n.translate('xpack.ml.dataframe.analytics.errorCallout.queryParsingErrorBody', {
defaultMessage:
'The query syntax is invalid and returned no results. Please check the query syntax and try again.',
})}
</p>
</EuiCallOut>
);

View file

@ -1,161 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiDataGrid, EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { euiDataGridStyle, euiDataGridToolbarSettings } from '../../../../common';
import { mlFieldFormatService } from '../../../../../services/field_format_service';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
const FEATURE_INFLUENCE = 'feature_influence';
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
type TableItem = Record<string, any>;
interface ExplorationDataGridProps {
colorRange: (d: number) => string;
columns: any[];
indexPattern: IndexPattern;
pagination: Pagination;
resultsField: string;
rowCount: number;
selectedFields: string[];
setPagination: Dispatch<SetStateAction<Pagination>>;
setSelectedFields: Dispatch<SetStateAction<string[]>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
sortingColumns: EuiDataGridSorting['columns'];
tableItems: TableItem[];
}
export const ExplorationDataGrid: FC<ExplorationDataGridProps> = ({
colorRange,
columns,
indexPattern,
pagination,
resultsField,
rowCount,
selectedFields,
setPagination,
setSelectedFields,
setSortingColumns,
sortingColumns,
tableItems,
}) => {
const renderCellValue = useMemo(() => {
return ({
rowIndex,
columnId,
setCellProps,
}: {
rowIndex: number;
columnId: string;
setCellProps: any;
}) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const fullItem = tableItems[adjustedRowIndex];
if (fullItem === undefined) {
return null;
}
let format: any;
if (indexPattern !== undefined) {
format = mlFieldFormatService.getFieldFormatFromIndexPattern(indexPattern, columnId, '');
}
const cellValue =
fullItem.hasOwnProperty(columnId) && fullItem[columnId] !== undefined
? fullItem[columnId]
: null;
const split = columnId.split('.');
let backgroundColor;
// column with feature values get color coded by its corresponding influencer value
if (fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${columnId}`] !== undefined) {
backgroundColor = colorRange(fullItem[`${resultsField}.${FEATURE_INFLUENCE}.${columnId}`]);
}
// column with influencer values get color coded by its own value
if (split.length > 2 && split[0] === resultsField && split[1] === FEATURE_INFLUENCE) {
backgroundColor = colorRange(cellValue);
}
if (backgroundColor !== undefined) {
setCellProps({
style: { backgroundColor },
});
}
if (format !== undefined) {
return format.convert(cellValue, 'text');
}
if (typeof cellValue === 'string' || cellValue === null) {
return cellValue;
}
if (typeof cellValue === 'boolean') {
return cellValue ? 'true' : 'false';
}
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
return cellValue;
};
}, [resultsField, rowCount, tableItems, pagination.pageIndex, pagination.pageSize]);
const onChangeItemsPerPage = useCallback(
pageSize => {
setPagination(p => {
const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
return { pageIndex, pageSize };
});
},
[setPagination]
);
const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
setPagination,
]);
const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]);
return (
<EuiDataGrid
aria-label={i18n.translate('xpack.ml.dataframe.analytics.exploration.dataGridAriaLabel', {
defaultMessage: 'Outlier detection results table',
})}
columns={columns}
columnVisibility={{
visibleColumns: selectedFields,
setVisibleColumns: setSelectedFields,
}}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: PAGE_SIZE_OPTIONS,
onChangeItemsPerPage,
onChangePage,
}}
/>
);
};

View file

@ -0,0 +1,76 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, useState } from 'react';
import { EuiSpacer } from '@elastic/eui';
import { useResultsViewConfig, DataFrameAnalyticsConfig } from '../../../../common';
import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { ExplorationResultsTable } from '../exploration_results_table';
import { JobConfigErrorCallout } from '../job_config_error_callout';
import { LoadingPanel } from '../loading_panel';
export interface EvaluatePanelProps {
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
searchQuery: ResultsSearchQuery;
}
interface Props {
jobId: string;
title: string;
EvaluatePanel: FC<EvaluatePanelProps>;
}
export const ExplorationPageWrapper: FC<Props> = ({ jobId, title, EvaluatePanel }) => {
const {
indexPattern,
isInitialized,
isLoadingJobConfig,
jobCapsServiceErrorMessage,
jobConfig,
jobConfigErrorMessage,
jobStatus,
} = useResultsViewConfig(jobId);
const [searchQuery, setSearchQuery] = useState<ResultsSearchQuery>(defaultSearchQuery);
if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) {
return (
<JobConfigErrorCallout
jobCapsServiceErrorMessage={jobCapsServiceErrorMessage}
jobConfigErrorMessage={jobConfigErrorMessage}
title={title}
/>
);
}
return (
<>
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && (
<EvaluatePanel jobConfig={jobConfig} jobStatus={jobStatus} searchQuery={searchQuery} />
)}
<EuiSpacer />
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false &&
jobConfig !== undefined &&
indexPattern !== undefined &&
isInitialized === true && (
<ExplorationResultsTable
jobConfig={jobConfig}
indexPattern={indexPattern}
jobStatus={jobStatus}
setEvaluateSearchQuery={setSearchQuery}
title={title}
/>
)}
</>
);
};

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { useExploreData, TableItem } from './use_explore_data';
export { EvaluatePanelProps, ExplorationPageWrapper } from './exploration_page_wrapper';

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useEffect } from 'react';
import React, { Fragment, FC, useEffect, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiCallOut,
@ -12,17 +12,15 @@ import {
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiProgress,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import {
BASIC_NUMERICAL_TYPES,
EXTENDED_NUMERICAL_TYPES,
sortRegressionResultsFields,
} from '../../../../common/fields';
import { DataGrid } from '../../../../../components/data_grid';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { getToastNotifications } from '../../../../../util/dependency_cache';
import {
DataFrameAnalyticsConfig,
@ -33,20 +31,20 @@ import {
} from '../../../../common';
import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { useExploreData } from './use_explore_data'; // TableItem
import { ExplorationTitle } from './classification_exploration';
import { ClassificationExplorationDataGrid } from './classification_exploration_data_grid';
import { ExplorationTitle } from '../exploration_title';
import { ExplorationQueryBar } from '../exploration_query_bar';
import { useExplorationResults } from './use_exploration_results';
const showingDocs = i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.documentsShownHelpText',
'xpack.ml.dataframe.analytics.explorationResults.documentsShownHelpText',
{
defaultMessage: 'Showing documents for which predictions exist',
}
);
const showingFirstDocs = i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.firstDocumentsShownHelpText',
'xpack.ml.dataframe.analytics.explorationResults.firstDocumentsShownHelpText',
{
defaultMessage: 'Showing first {searchSize} documents for which predictions exist',
values: { searchSize: SEARCH_SIZE },
@ -58,71 +56,22 @@ interface Props {
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
setEvaluateSearchQuery: React.Dispatch<React.SetStateAction<object>>;
title: string;
}
export const ResultsTable: FC<Props> = React.memo(
({ indexPattern, jobConfig, jobStatus, setEvaluateSearchQuery }) => {
const needsDestIndexFields = indexPattern && indexPattern.title === jobConfig.source.index[0];
const resultsField = jobConfig.dest.results_field;
const {
errorMessage,
fieldTypes,
pagination,
searchQuery,
selectedFields,
rowCount,
setPagination,
setSearchQuery,
setSelectedFields,
setSortingColumns,
sortingColumns,
status,
tableFields,
tableItems,
} = useExploreData(jobConfig, needsDestIndexFields);
export const ExplorationResultsTable: FC<Props> = React.memo(
({ indexPattern, jobConfig, jobStatus, setEvaluateSearchQuery, title }) => {
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
useEffect(() => {
setEvaluateSearchQuery(searchQuery);
}, [JSON.stringify(searchQuery)]);
const columns = tableFields
.sort((a: any, b: any) => sortRegressionResultsFields(a, b, jobConfig))
.map((field: any) => {
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
let isSortable = true;
const type = fieldTypes[field];
const isNumber =
type !== undefined &&
(BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type));
const classificationData = useExplorationResults(indexPattern, jobConfig, searchQuery);
const docFieldsCount = classificationData.columns.length;
const { columns, errorMessage, status, tableItems, visibleColumns } = classificationData;
if (isNumber) {
schema = 'numeric';
}
switch (type) {
case 'date':
schema = 'datetime';
break;
case 'geo_point':
schema = 'json';
break;
case 'boolean':
schema = 'boolean';
break;
}
if (field === `${resultsField}.feature_importance`) {
isSortable = false;
}
return { id: field, schema, isSortable };
});
const docFieldsCount = tableFields.length;
if (jobConfig === undefined) {
if (jobConfig === undefined || classificationData === undefined) {
return null;
}
// if it's a searchBar syntax error leave the table visible so they can try again
@ -131,7 +80,7 @@ export const ResultsTable: FC<Props> = React.memo(
<EuiPanel grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
<ExplorationTitle title={title} />
</EuiFlexItem>
{jobStatus !== undefined && (
<EuiFlexItem grow={false}>
@ -156,13 +105,13 @@ export const ResultsTable: FC<Props> = React.memo(
<EuiPanel
grow={false}
id="mlDataFrameAnalyticsTableResultsPanel"
data-test-subj="mlDFAnalyticsClassificationExplorationTablePanel"
data-test-subj="mlDFAnalyticsExplorationTablePanel"
>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
<ExplorationTitle title={title} />
</EuiFlexItem>
{jobStatus !== undefined && (
<EuiFlexItem grow={false}>
@ -177,11 +126,11 @@ export const ResultsTable: FC<Props> = React.memo(
{docFieldsCount > MAX_COLUMNS && (
<EuiText size="s">
{i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.fieldSelection',
'xpack.ml.dataframe.analytics.explorationResults.fieldSelection',
{
defaultMessage:
'{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected',
values: { selectedFieldsLength: selectedFields.length, docFieldsCount },
values: { selectedFieldsLength: visibleColumns.length, docFieldsCount },
}
)}
</EuiText>
@ -190,10 +139,6 @@ export const ResultsTable: FC<Props> = React.memo(
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{status === INDEX_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
{status !== INDEX_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
@ -213,18 +158,10 @@ export const ResultsTable: FC<Props> = React.memo(
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<ClassificationExplorationDataGrid
columns={columns}
indexPattern={indexPattern}
pagination={pagination}
resultsField={jobConfig.dest.results_field}
rowCount={rowCount}
selectedFields={selectedFields}
setPagination={setPagination}
setSelectedFields={setSelectedFields}
setSortingColumns={setSortingColumns}
sortingColumns={sortingColumns}
tableItems={tableItems}
<DataGrid
{...classificationData}
dataTestSubj="mlExplorationDataGrid"
toastNotifications={getToastNotifications()}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { SourceIndexPreview } from './source_index_preview';
export { ExplorationResultsTable } from './exploration_results_table';

View file

@ -0,0 +1,72 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect } from 'react';
import { EuiDataGridColumn } from '@elastic/eui';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import {
getDataGridSchemasFromFieldTypes,
useDataGrid,
useRenderCellValue,
UseIndexDataReturnType,
} from '../../../../../components/data_grid';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common';
import { DEFAULT_RESULTS_FIELD, FEATURE_IMPORTANCE } from '../../../../common/constants';
import { sortExplorationResultsFields, ML__ID_COPY } from '../../../../common/fields';
export const useExplorationResults = (
indexPattern: IndexPattern | undefined,
jobConfig: DataFrameAnalyticsConfig | undefined,
searchQuery: SavedSearchQuery
): UseIndexDataReturnType => {
const needsDestIndexFields =
indexPattern !== undefined && indexPattern.title === jobConfig?.source.index[0];
const columns: EuiDataGridColumn[] = [];
if (jobConfig !== undefined) {
const resultsField = jobConfig.dest.results_field;
const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields);
columns.push(
...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) =>
sortExplorationResultsFields(a.id, b.id, jobConfig)
)
);
}
const dataGrid = useDataGrid(
columns,
25,
// reduce default selected rows from 20 to 8 for performance reasons.
8,
// by default, hide feature-importance columns and the doc id copy
d => !d.includes(`.${FEATURE_IMPORTANCE}.`) && d !== ML__ID_COPY
);
useEffect(() => {
getIndexData(jobConfig, dataGrid, searchQuery);
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);
const renderCellValue = useRenderCellValue(
indexPattern,
dataGrid.pagination,
dataGrid.tableItems,
jobConfig?.dest.results_field ?? DEFAULT_RESULTS_FIELD
);
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -0,0 +1,15 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiTitle } from '@elastic/eui';
export const ExplorationTitle: FC<{ title: string }> = ({ title }) => (
<EuiTitle size="xs">
<span>{title}</span>
</EuiTitle>
);

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { PivotPreview } from './pivot_preview';
export { ExplorationTitle } from './exploration_title';

View file

@ -4,4 +4,4 @@
* you may not use this file except in compliance with the Elastic License.
*/
export { ExplorationDataGrid } from './exploration_data_grid';
export { JobConfigErrorCallout } from './job_config_error_callout';

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import { EuiCallOut, EuiPanel, EuiSpacer } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { ExplorationTitle } from '../exploration_title';
const jobConfigErrorTitle = i18n.translate('xpack.ml.dataframe.analytics.jobConfig.errorTitle', {
defaultMessage: 'Unable to fetch results. An error occurred loading the job configuration data.',
});
const jobCapsErrorTitle = i18n.translate('xpack.ml.dataframe.analytics.jobCaps.errorTitle', {
defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.",
});
interface Props {
jobCapsServiceErrorMessage: string | undefined;
jobConfigErrorMessage: string | undefined;
title: string;
}
export const JobConfigErrorCallout: FC<Props> = ({
jobCapsServiceErrorMessage,
jobConfigErrorMessage,
title,
}) => {
return (
<EuiPanel grow={false}>
<ExplorationTitle title={title} />
<EuiSpacer />
<EuiCallOut
title={jobConfigErrorMessage ? jobConfigErrorTitle : jobCapsErrorTitle}
color="danger"
iconType="cross"
>
<p>{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
};

View file

@ -4,9 +4,20 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { DataFrameAnalyticsConfig } from '../../../../common';
import { DataGridItem } from '../../../../../components/data_grid';
const OUTLIER_SCORE = 'outlier_score';
import { DataFrameAnalyticsConfig } from '../../../../common';
import { FEATURE_INFLUENCE, OUTLIER_SCORE } from '../../../../common/constants';
export const getOutlierScoreFieldName = (jobConfig: DataFrameAnalyticsConfig) =>
`${jobConfig.dest.results_field}.${OUTLIER_SCORE}`;
export const getFeatureCount = (resultsField: string, tableItems: DataGridItem[] = []) => {
if (tableItems.length === 0) {
return 0;
}
return Object.keys(tableItems[0]).filter(key =>
key.includes(`${resultsField}.${FEATURE_INFLUENCE}.`)
).length;
};

View file

@ -4,7 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import React, { useState, FC } from 'react';
import { i18n } from '@kbn/i18n';
@ -15,127 +15,51 @@ import {
EuiHorizontalRule,
EuiPanel,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import {
useColorRange,
ColorRangeLegend,
COLOR_RANGE,
COLOR_RANGE_SCALE,
} from '../../../../../components/color_range_legend';
import { ColorRangeLegend } from '../../../../../components/color_range_legend';
import { DataGrid } from '../../../../../components/data_grid';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { getToastNotifications } from '../../../../../util/dependency_cache';
import { sortColumns, INDEX_STATUS, defaultSearchQuery } from '../../../../common';
import { defaultSearchQuery, useResultsViewConfig, INDEX_STATUS } from '../../../../common';
import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns';
import { useExploreData, TableItem } from '../../hooks/use_explore_data';
import { ExplorationDataGrid } from '../exploration_data_grid';
import { ExplorationQueryBar } from '../exploration_query_bar';
import { ExplorationTitle } from '../exploration_title';
const FEATURE_INFLUENCE = 'feature_influence';
import { getFeatureCount } from './common';
import { useOutlierData } from './use_outlier_data';
const ExplorationTitle: FC<{ jobId: string }> = ({ jobId }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', {
defaultMessage: 'Outlier detection job ID {jobId}',
values: { jobId },
})}
</span>
</EuiTitle>
);
export type TableItem = Record<string, any>;
interface ExplorationProps {
jobId: string;
}
const getFeatureCount = (resultsField: string, tableItems: TableItem[] = []) => {
if (tableItems.length === 0) {
return 0;
}
return Object.keys(tableItems[0]).filter(key =>
key.includes(`${resultsField}.${FEATURE_INFLUENCE}.`)
).length;
};
export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) => {
const {
errorMessage,
indexPattern,
jobConfig,
jobStatus,
pagination,
searchQuery,
selectedFields,
setPagination,
setSearchQuery,
setSelectedFields,
setSortingColumns,
sortingColumns,
rowCount,
status,
tableFields,
tableItems,
} = useExploreData(jobId);
const explorationTitle = i18n.translate('xpack.ml.dataframe.analytics.exploration.jobIdTitle', {
defaultMessage: 'Outlier detection job ID {jobId}',
values: { jobId },
});
const columns = [];
const { indexPattern, jobConfig, jobStatus } = useResultsViewConfig(jobId);
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
const outlierData = useOutlierData(indexPattern, jobConfig, searchQuery);
if (
jobConfig !== undefined &&
indexPattern !== undefined &&
selectedFields.length > 0 &&
tableItems.length > 0
) {
const resultsField = jobConfig.dest.results_field;
const removePrefix = new RegExp(`^${resultsField}\.${FEATURE_INFLUENCE}\.`, 'g');
columns.push(
...tableFields.sort(sortColumns(tableItems[0], resultsField)).map(id => {
const idWithoutPrefix = id.replace(removePrefix, '');
const field = indexPattern.fields.getByName(idWithoutPrefix);
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
switch (field?.type) {
case 'date':
schema = 'datetime';
break;
case 'geo_point':
schema = 'json';
break;
case 'number':
schema = 'numeric';
break;
}
if (id === `${resultsField}.outlier_score`) {
schema = 'numeric';
}
return { id, schema };
})
);
}
const colorRange = useColorRange(
COLOR_RANGE.BLUE,
COLOR_RANGE_SCALE.INFLUENCER,
jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1
);
if (jobConfig === undefined || indexPattern === undefined) {
return null;
}
const { columns, errorMessage, status, tableItems } = outlierData;
// if it's a searchBar syntax error leave the table visible so they can try again
if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) {
return (
<EuiPanel grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
<ExplorationTitle title={explorationTitle} />
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.exploration.indexError', {
defaultMessage: 'An error occurred loading the index data.',
@ -149,17 +73,11 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
);
}
let tableError =
status === INDEX_STATUS.ERROR && errorMessage.includes('parsing_exception')
? errorMessage
: undefined;
if (status === INDEX_STATUS.LOADED && tableItems.length === 0 && tableError === undefined) {
tableError = i18n.translate('xpack.ml.dataframe.analytics.exploration.noDataCalloutBody', {
defaultMessage:
'The query for the index returned no results. Please make sure the index contains documents and your query is not too restrictive.',
});
}
const colorRange = useColorRange(
COLOR_RANGE.BLUE,
COLOR_RANGE_SCALE.INFLUENCER,
jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, tableItems) : 1
);
return (
<EuiPanel data-test-subj="mlDFAnalyticsOutlierExplorationTablePanel">
@ -170,7 +88,7 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
gutterSize="s"
>
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
<ExplorationTitle title={explorationTitle} />
</EuiFlexItem>
{jobStatus !== undefined && (
<EuiFlexItem grow={false}>
@ -179,7 +97,7 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
)}
</EuiFlexGroup>
<EuiHorizontalRule margin="xs" />
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && (
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && indexPattern !== undefined && (
<>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
@ -200,19 +118,10 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
</EuiFlexGroup>
<EuiSpacer size="s" />
{columns.length > 0 && tableItems.length > 0 && (
<ExplorationDataGrid
colorRange={colorRange}
columns={columns}
indexPattern={indexPattern}
pagination={pagination}
resultsField={jobConfig.dest.results_field}
rowCount={rowCount}
selectedFields={selectedFields}
setPagination={setPagination}
setSelectedFields={setSelectedFields}
setSortingColumns={setSortingColumns}
sortingColumns={sortingColumns}
tableItems={tableItems}
<DataGrid
{...outlierData}
dataTestSubj="mlExplorationDataGrid"
toastNotifications={getToastNotifications()}
/>
)}
</>

View file

@ -0,0 +1,33 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { DataFrameAnalyticsConfig } from '../../../../common';
import { getOutlierScoreFieldName } from './common';
describe('Data Frame Analytics: <Exploration /> common utils', () => {
test('getOutlierScoreFieldName()', () => {
const jobConfig: DataFrameAnalyticsConfig = {
id: 'the-id',
analysis: { outlier_detection: {} },
dest: {
index: 'the-dest-index',
results_field: 'the-results-field',
},
source: {
index: 'the-source-index',
},
analyzed_fields: { includes: [], excludes: [] },
model_memory_limit: '50mb',
create_time: 1234,
version: '1.0.0',
};
const outlierScoreFieldName = getOutlierScoreFieldName(jobConfig);
expect(outlierScoreFieldName).toMatch('the-results-field.outlier_score');
});
});

View file

@ -0,0 +1,117 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect } from 'react';
import { EuiDataGridColumn } from '@elastic/eui';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import {
useColorRange,
COLOR_RANGE,
COLOR_RANGE_SCALE,
} from '../../../../../components/color_range_legend';
import {
getDataGridSchemasFromFieldTypes,
useDataGrid,
useRenderCellValue,
UseIndexDataReturnType,
} from '../../../../../components/data_grid';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common';
import { DEFAULT_RESULTS_FIELD, FEATURE_INFLUENCE } from '../../../../common/constants';
import { sortExplorationResultsFields, ML__ID_COPY } from '../../../../common/fields';
import { getFeatureCount, getOutlierScoreFieldName } from './common';
export const useOutlierData = (
indexPattern: IndexPattern | undefined,
jobConfig: DataFrameAnalyticsConfig | undefined,
searchQuery: SavedSearchQuery
): UseIndexDataReturnType => {
const needsDestIndexFields =
indexPattern !== undefined && indexPattern.title === jobConfig?.source.index[0];
const columns: EuiDataGridColumn[] = [];
if (jobConfig !== undefined && indexPattern !== undefined) {
const resultsField = jobConfig.dest.results_field;
const { fieldTypes } = getIndexFields(jobConfig, needsDestIndexFields);
columns.push(
...getDataGridSchemasFromFieldTypes(fieldTypes, resultsField).sort((a: any, b: any) =>
sortExplorationResultsFields(a.id, b.id, jobConfig)
)
);
}
const dataGrid = useDataGrid(
columns,
25,
// reduce default selected rows from 20 to 8 for performance reasons.
8,
// by default, hide feature-influence columns and the doc id copy
d => !d.includes(`.${FEATURE_INFLUENCE}.`) && d !== ML__ID_COPY
);
// initialize sorting: reverse sort on outlier score column
useEffect(() => {
if (jobConfig !== undefined) {
dataGrid.setSortingColumns([{ id: getOutlierScoreFieldName(jobConfig), direction: 'desc' }]);
}
}, [jobConfig && jobConfig.id]);
useEffect(() => {
getIndexData(jobConfig, dataGrid, searchQuery);
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);
const colorRange = useColorRange(
COLOR_RANGE.BLUE,
COLOR_RANGE_SCALE.INFLUENCER,
jobConfig !== undefined ? getFeatureCount(jobConfig.dest.results_field, dataGrid.tableItems) : 1
);
const renderCellValue = useRenderCellValue(
indexPattern,
dataGrid.pagination,
dataGrid.tableItems,
jobConfig?.dest.results_field ?? DEFAULT_RESULTS_FIELD,
(columnId, cellValue, fullItem, setCellProps) => {
const resultsField = jobConfig?.dest.results_field ?? '';
const split = columnId.split('.');
let backgroundColor;
// column with feature values get color coded by its corresponding influencer value
if (
fullItem[resultsField] !== undefined &&
fullItem[resultsField][`${FEATURE_INFLUENCE}.${columnId}`] !== undefined
) {
backgroundColor = colorRange(fullItem[resultsField][`${FEATURE_INFLUENCE}.${columnId}`]);
}
// column with influencer values get color coded by its own value
if (split.length > 2 && split[0] === resultsField && split[1] === FEATURE_INFLUENCE) {
backgroundColor = colorRange(cellValue);
}
if (backgroundColor !== undefined) {
setCellProps({
style: { backgroundColor },
});
}
}
);
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -235,7 +235,7 @@ export const EvaluatePanel: FC<Props> = ({ jobConfig, jobStatus, searchQuery })
href={`${ELASTIC_WEBSITE_URL}guide/en/machine-learning/${DOC_LINK_VERSION}/ml-dfanalytics-evaluate.html#ml-dfanalytics-regression-evaluation`}
>
{i18n.translate(
'xpack.ml.dataframe.analytics.classificationExploration.regressionDocsLink',
'xpack.ml.dataframe.analytics.regressionExploration.regressionDocsLink',
{
defaultMessage: 'Regression evaluation docs ',
}

View file

@ -4,174 +4,27 @@
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC, Fragment, useState, useEffect } from 'react';
import { EuiCallOut, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui';
import React, { FC } from 'react';
import { i18n } from '@kbn/i18n';
import { ml } from '../../../../../services/ml_api_service';
import { DataFrameAnalyticsConfig } from '../../../../common';
import { ExplorationPageWrapper } from '../exploration_page_wrapper';
import { EvaluatePanel } from './evaluate_panel';
import { ResultsTable } from './results_table';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { ResultsSearchQuery, defaultSearchQuery } from '../../../../common/analytics';
import { LoadingPanel } from '../loading_panel';
import { getIndexPatternIdFromName } from '../../../../../util/index_utils';
import { IIndexPattern } from '../../../../../../../../../../src/plugins/data/common/index_patterns';
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
import { useMlContext } from '../../../../../contexts/ml';
import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics';
export const ExplorationTitle: React.FC<{ jobId: string }> = ({ jobId }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.tableJobIdTitle', {
defaultMessage: 'Destination index for regression job ID {jobId}',
values: { jobId },
})}
</span>
</EuiTitle>
);
const jobConfigErrorTitle = i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.jobConfigurationFetchError',
{
defaultMessage:
'Unable to fetch results. An error occurred loading the job configuration data.',
}
);
const jobCapsErrorTitle = i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.jobCapsFetchError',
{
defaultMessage: "Unable to fetch results. An error occurred loading the index's field data.",
}
);
interface Props {
jobId: string;
}
export const RegressionExploration: FC<Props> = ({ jobId }) => {
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
const [jobStatus, setJobStatus] = useState<DATA_FRAME_TASK_STATE | undefined>(undefined);
const [indexPattern, setIndexPattern] = useState<any | undefined>(undefined);
const [isLoadingJobConfig, setIsLoadingJobConfig] = useState<boolean>(false);
const [isInitialized, setIsInitialized] = useState<boolean>(false);
const [jobConfigErrorMessage, setJobConfigErrorMessage] = useState<undefined | string>(undefined);
const [jobCapsServiceErrorMessage, setJobCapsServiceErrorMessage] = useState<undefined | string>(
undefined
);
const [searchQuery, setSearchQuery] = useState<ResultsSearchQuery>(defaultSearchQuery);
const mlContext = useMlContext();
const loadJobConfig = async () => {
setIsLoadingJobConfig(true);
try {
const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId);
const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId);
const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats)
? analyticsStats.data_frame_analytics[0]
: undefined;
if (stats !== undefined && stats.state) {
setJobStatus(stats.state);
}
if (
Array.isArray(analyticsConfigs.data_frame_analytics) &&
analyticsConfigs.data_frame_analytics.length > 0
) {
setJobConfig(analyticsConfigs.data_frame_analytics[0]);
setIsLoadingJobConfig(false);
}
} catch (e) {
if (e.message !== undefined) {
setJobConfigErrorMessage(e.message);
} else {
setJobConfigErrorMessage(JSON.stringify(e));
}
setIsLoadingJobConfig(false);
}
};
useEffect(() => {
loadJobConfig();
}, []);
const initializeJobCapsService = async () => {
if (jobConfig !== undefined) {
try {
const destIndex = Array.isArray(jobConfig.dest.index)
? jobConfig.dest.index[0]
: jobConfig.dest.index;
const destIndexPatternId = getIndexPatternIdFromName(destIndex) || destIndex;
let indexP: IIndexPattern | undefined;
try {
indexP = await mlContext.indexPatterns.get(destIndexPatternId);
} catch (e) {
indexP = undefined;
}
if (indexP === undefined) {
const sourceIndex = jobConfig.source.index[0];
const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex;
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
}
if (indexP !== undefined) {
setIndexPattern(indexP);
await newJobCapsService.initializeFromIndexPattern(indexP, false, false);
}
setIsInitialized(true);
} catch (e) {
if (e.message !== undefined) {
setJobCapsServiceErrorMessage(e.message);
} else {
setJobCapsServiceErrorMessage(JSON.stringify(e));
}
}
}
};
useEffect(() => {
initializeJobCapsService();
}, [jobConfig && jobConfig.id]);
if (jobConfigErrorMessage !== undefined || jobCapsServiceErrorMessage !== undefined) {
return (
<EuiPanel grow={false}>
<ExplorationTitle jobId={jobId} />
<EuiSpacer />
<EuiCallOut
title={jobConfigErrorMessage ? jobConfigErrorTitle : jobCapsErrorTitle}
color="danger"
iconType="cross"
>
<p>{jobConfigErrorMessage ? jobConfigErrorMessage : jobCapsServiceErrorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}
return (
<Fragment>
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false && jobConfig !== undefined && isInitialized === true && (
<EvaluatePanel jobConfig={jobConfig} jobStatus={jobStatus} searchQuery={searchQuery} />
)}
<EuiSpacer />
{isLoadingJobConfig === true && jobConfig === undefined && <LoadingPanel />}
{isLoadingJobConfig === false &&
jobConfig !== undefined &&
indexPattern !== undefined &&
isInitialized === true && (
<ResultsTable
jobConfig={jobConfig}
indexPattern={indexPattern}
jobStatus={jobStatus}
setEvaluateSearchQuery={setSearchQuery}
/>
)}
</Fragment>
<ExplorationPageWrapper
jobId={jobId}
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.tableJobIdTitle', {
defaultMessage: 'Destination index for regression job ID {jobId}',
values: { jobId },
})}
EvaluatePanel={EvaluatePanel}
/>
);
};

View file

@ -1,135 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Dispatch, FC, SetStateAction, useCallback, useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiDataGrid, EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { euiDataGridStyle, euiDataGridToolbarSettings } from '../../../../common';
import { mlFieldFormatService } from '../../../../../services/field_format_service';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
const PAGE_SIZE_OPTIONS = [5, 10, 25, 50];
type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
type TableItem = Record<string, any>;
interface ExplorationDataGridProps {
colorRange?: (d: number) => string;
columns: any[];
indexPattern: IndexPattern;
pagination: Pagination;
resultsField: string;
rowCount: number;
selectedFields: string[];
setPagination: Dispatch<SetStateAction<Pagination>>;
setSelectedFields: Dispatch<SetStateAction<string[]>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
sortingColumns: EuiDataGridSorting['columns'];
tableItems: TableItem[];
}
export const RegressionExplorationDataGrid: FC<ExplorationDataGridProps> = ({
columns,
indexPattern,
pagination,
resultsField,
rowCount,
selectedFields,
setPagination,
setSelectedFields,
setSortingColumns,
sortingColumns,
tableItems,
}) => {
const renderCellValue = useMemo(() => {
return ({ rowIndex, columnId }: { rowIndex: number; columnId: string; setCellProps: any }) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const fullItem = tableItems[adjustedRowIndex];
if (fullItem === undefined) {
return null;
}
let format: any;
if (indexPattern !== undefined) {
format = mlFieldFormatService.getFieldFormatFromIndexPattern(indexPattern, columnId, '');
}
const cellValue =
fullItem.hasOwnProperty(columnId) && fullItem[columnId] !== undefined
? fullItem[columnId]
: null;
if (format !== undefined) {
return format.convert(cellValue, 'text');
}
if (typeof cellValue === 'string' || cellValue === null) {
return cellValue;
}
if (typeof cellValue === 'boolean') {
return cellValue ? 'true' : 'false';
}
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
return cellValue;
};
}, [resultsField, rowCount, tableItems, pagination.pageIndex, pagination.pageSize]);
const onChangeItemsPerPage = useCallback(
pageSize => {
setPagination(p => {
const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
return { pageIndex, pageSize };
});
},
[setPagination]
);
const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
setPagination,
]);
const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]);
return (
<EuiDataGrid
aria-label={i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.dataGridAriaLabel',
{
defaultMessage: 'Regression results table',
}
)}
columns={columns}
columnVisibility={{
visibleColumns: selectedFields,
setVisibleColumns: setSelectedFields,
}}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: PAGE_SIZE_OPTIONS,
onChangeItemsPerPage,
onChangePage,
}}
/>
);
};

View file

@ -1,233 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { Fragment, FC, useEffect } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiCallOut,
EuiFlexGroup,
EuiFlexItem,
EuiFormRow,
EuiPanel,
EuiProgress,
EuiSpacer,
EuiText,
} from '@elastic/eui';
import {
BASIC_NUMERICAL_TYPES,
EXTENDED_NUMERICAL_TYPES,
sortRegressionResultsFields,
} from '../../../../common/fields';
import {
DataFrameAnalyticsConfig,
MAX_COLUMNS,
INDEX_STATUS,
SEARCH_SIZE,
defaultSearchQuery,
} from '../../../../common';
import { getTaskStateBadge } from '../../../analytics_management/components/analytics_list/columns';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { useExploreData } from './use_explore_data';
import { ExplorationTitle } from './regression_exploration';
import { RegressionExplorationDataGrid } from './regression_exploration_data_grid';
import { ExplorationQueryBar } from '../exploration_query_bar';
const showingDocs = i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.documentsShownHelpText',
{
defaultMessage: 'Showing documents for which predictions exist',
}
);
const showingFirstDocs = i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.firstDocumentsShownHelpText',
{
defaultMessage: 'Showing first {searchSize} documents for which predictions exist',
values: { searchSize: SEARCH_SIZE },
}
);
interface Props {
indexPattern: IndexPattern;
jobConfig: DataFrameAnalyticsConfig;
jobStatus?: DATA_FRAME_TASK_STATE;
setEvaluateSearchQuery: React.Dispatch<React.SetStateAction<object>>;
}
export const ResultsTable: FC<Props> = React.memo(
({ indexPattern, jobConfig, jobStatus, setEvaluateSearchQuery }) => {
const needsDestIndexFields = indexPattern && indexPattern.title === jobConfig.source.index[0];
const resultsField = jobConfig.dest.results_field;
const {
errorMessage,
fieldTypes,
pagination,
searchQuery,
selectedFields,
rowCount,
setPagination,
setSearchQuery,
setSelectedFields,
setSortingColumns,
sortingColumns,
status,
tableFields,
tableItems,
} = useExploreData(jobConfig, needsDestIndexFields);
useEffect(() => {
setEvaluateSearchQuery(searchQuery);
}, [JSON.stringify(searchQuery)]);
const columns = tableFields
.sort((a: any, b: any) => sortRegressionResultsFields(a, b, jobConfig))
.map((field: any) => {
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
let isSortable = true;
const type = fieldTypes[field];
const isNumber =
type !== undefined &&
(BASIC_NUMERICAL_TYPES.has(type) || EXTENDED_NUMERICAL_TYPES.has(type));
if (isNumber) {
schema = 'numeric';
}
switch (type) {
case 'date':
schema = 'datetime';
break;
case 'geo_point':
schema = 'json';
break;
case 'boolean':
schema = 'boolean';
break;
}
if (field === `${resultsField}.feature_importance`) {
isSortable = false;
}
return { id: field, schema, isSortable };
});
const docFieldsCount = tableFields.length;
if (jobConfig === undefined) {
return null;
}
// if it's a searchBar syntax error leave the table visible so they can try again
if (status === INDEX_STATUS.ERROR && !errorMessage.includes('parsing_exception')) {
return (
<EuiPanel grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
</EuiFlexItem>
{jobStatus !== undefined && (
<EuiFlexItem grow={false}>
<span>{getTaskStateBadge(jobStatus)}</span>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiCallOut
title={i18n.translate('xpack.ml.dataframe.analytics.regressionExploration.indexError', {
defaultMessage: 'An error occurred loading the index data.',
})}
color="danger"
iconType="cross"
>
<p>{errorMessage}</p>
</EuiCallOut>
</EuiPanel>
);
}
return (
<EuiPanel grow={false} data-test-subj="mlDFAnalyticsRegressionExplorationTablePanel">
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween" responsive={false}>
<EuiFlexItem grow={false}>
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<ExplorationTitle jobId={jobConfig.id} />
</EuiFlexItem>
{jobStatus !== undefined && (
<EuiFlexItem grow={false}>
<span>{getTaskStateBadge(jobStatus)}</span>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiFlexGroup alignItems="center" gutterSize="xs" responsive={false}>
<EuiFlexItem style={{ textAlign: 'right' }}>
{docFieldsCount > MAX_COLUMNS && (
<EuiText size="s">
{i18n.translate(
'xpack.ml.dataframe.analytics.regressionExploration.fieldSelection',
{
defaultMessage:
'{selectedFieldsLength, number} of {docFieldsCount, number} {docFieldsCount, plural, one {field} other {fields}} selected',
values: { selectedFieldsLength: selectedFields.length, docFieldsCount },
}
)}
</EuiText>
)}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
{status === INDEX_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
{status !== INDEX_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && (
<EuiFlexGroup direction="column">
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<ExplorationQueryBar
indexPattern={indexPattern}
setSearchQuery={setSearchQuery}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiFormRow
helpText={tableItems.length === SEARCH_SIZE ? showingFirstDocs : showingDocs}
>
<Fragment />
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<RegressionExplorationDataGrid
columns={columns}
indexPattern={indexPattern}
pagination={pagination}
resultsField={jobConfig.dest.results_field}
rowCount={rowCount}
selectedFields={selectedFields}
setPagination={setPagination}
setSelectedFields={setSelectedFields}
setSortingColumns={setSortingColumns}
sortingColumns={sortingColumns}
tableItems={tableItems}
/>
</EuiFlexItem>
</EuiFlexGroup>
)}
</EuiPanel>
);
}
);

View file

@ -1,291 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState, Dispatch, SetStateAction } from 'react';
import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { SearchResponse } from 'elasticsearch';
import { cloneDeep } from 'lodash';
import { ml } from '../../../../../services/ml_api_service';
import { getNestedProperty } from '../../../../../util/object_utils';
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
import { SORT_DIRECTION } from '../../../../../components/ml_in_memory_table';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import {
getDefaultFieldsFromJobCaps,
getDependentVar,
getFlattenedFields,
getPredictedFieldName,
DataFrameAnalyticsConfig,
EsFieldName,
INDEX_STATUS,
} from '../../../../common';
import { Dictionary } from '../../../../../../../common/types/common';
import { isKeywordAndTextType } from '../../../../common/fields';
import { ES_FIELD_TYPES } from '../../../../../../../../../../src/plugins/data/public';
import {
LoadExploreDataArg,
defaultSearchQuery,
ResultsSearchQuery,
isResultsSearchBoolQuery,
} from '../../../../common/analytics';
export type TableItem = Record<string, any>;
type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
export interface UseExploreDataReturnType {
errorMessage: string;
fieldTypes: { [key: string]: ES_FIELD_TYPES };
pagination: Pagination;
rowCount: number;
searchQuery: SavedSearchQuery;
selectedFields: EsFieldName[];
setFilterByIsTraining: Dispatch<SetStateAction<undefined | boolean>>;
setPagination: Dispatch<SetStateAction<Pagination>>;
setSearchQuery: Dispatch<SetStateAction<SavedSearchQuery>>;
setSelectedFields: Dispatch<SetStateAction<EsFieldName[]>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
sortingColumns: EuiDataGridSorting['columns'];
status: INDEX_STATUS;
tableFields: string[];
tableItems: TableItem[];
}
type EsSorting = Dictionary<{
order: 'asc' | 'desc';
}>;
// The types specified in `@types/elasticsearch` are out of date and still have `total: number`.
interface SearchResponse7 extends SearchResponse<any> {
hits: SearchResponse<any>['hits'] & {
total: {
value: number;
relation: string;
};
};
}
export const useExploreData = (
jobConfig: DataFrameAnalyticsConfig,
needsDestIndexFields: boolean
): UseExploreDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
const [tableFields, setTableFields] = useState<string[]>([]);
const [tableItems, setTableItems] = useState<TableItem[]>([]);
const [fieldTypes, setFieldTypes] = useState<{ [key: string]: ES_FIELD_TYPES }>({});
const [rowCount, setRowCount] = useState(0);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
const [filterByIsTraining, setFilterByIsTraining] = useState<undefined | boolean>(undefined);
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const predictedFieldName = getPredictedFieldName(
jobConfig.dest.results_field,
jobConfig.analysis
);
const dependentVariable = getDependentVar(jobConfig.analysis);
const getDefaultSelectedFields = () => {
const { fields } = newJobCapsService;
if (selectedFields.length === 0 && jobConfig !== undefined) {
const { selectedFields: defaultSelected, docFields } = getDefaultFieldsFromJobCaps(
fields,
jobConfig,
needsDestIndexFields
);
const types: { [key: string]: ES_FIELD_TYPES } = {};
const allFields: string[] = [];
docFields.forEach(field => {
types[field.id] = field.type;
allFields.push(field.id);
});
setFieldTypes(types);
setSelectedFields(defaultSelected.map(field => field.id));
setTableFields(allFields);
}
};
const loadExploreData = async ({
filterByIsTraining: isTraining,
searchQuery: incomingQuery,
}: LoadExploreDataArg) => {
if (jobConfig !== undefined) {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
try {
const resultsField = jobConfig.dest.results_field;
const searchQueryClone: ResultsSearchQuery = cloneDeep(incomingQuery);
let query: ResultsSearchQuery;
const { pageIndex, pageSize } = pagination;
// If filterByIsTraining is defined - add that in to the final query
const trainingQuery =
isTraining !== undefined
? {
term: { [`${resultsField}.is_training`]: { value: isTraining } },
}
: undefined;
if (JSON.stringify(incomingQuery) === JSON.stringify(defaultSearchQuery)) {
const existsQuery = {
exists: {
field: resultsField,
},
};
query = {
bool: {
must: [existsQuery],
},
};
if (trainingQuery !== undefined && isResultsSearchBoolQuery(query)) {
query.bool.must.push(trainingQuery);
}
} else if (isResultsSearchBoolQuery(searchQueryClone)) {
if (searchQueryClone.bool.must === undefined) {
searchQueryClone.bool.must = [];
}
searchQueryClone.bool.must.push({
exists: {
field: resultsField,
},
});
if (trainingQuery !== undefined) {
searchQueryClone.bool.must.push(trainingQuery);
}
query = searchQueryClone;
} else {
query = searchQueryClone;
}
const sort: EsSorting = sortingColumns
.map(column => {
const { id } = column;
column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id;
return column;
})
.reduce((s, column) => {
s[column.id] = { order: column.direction };
return s;
}, {} as EsSorting);
const resp: SearchResponse7 = await ml.esSearch({
index: jobConfig.dest.index,
body: {
query,
from: pageIndex * pageSize,
size: pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
});
setRowCount(resp.hits.total.value);
const docs = resp.hits.hits;
if (docs.length === 0) {
setTableItems([]);
setStatus(INDEX_STATUS.LOADED);
return;
}
// Create a version of the doc's source with flattened field names.
// This avoids confusion later on if a field name has dots in its name
// or is a nested fields when displaying it via EuiInMemoryTable.
const flattenedFields = getFlattenedFields(docs[0]._source, resultsField);
const transformedTableItems = docs.map(doc => {
const item: TableItem = {};
flattenedFields.forEach(ff => {
item[ff] = getNestedProperty(doc._source, ff);
if (item[ff] === undefined) {
// If the attribute is undefined, it means it was not a nested property
// but had dots in its actual name. This selects the property by its
// full name and assigns it to `item[ff]`.
item[ff] = doc._source[`"${ff}"`];
}
if (item[ff] === undefined) {
const parts = ff.split('.');
if (parts[0] === resultsField && parts.length >= 2) {
parts.shift();
if (doc._source[resultsField] !== undefined) {
item[ff] = doc._source[resultsField][parts.join('.')];
}
}
}
});
return item;
});
setTableItems(transformedTableItems);
setStatus(INDEX_STATUS.LOADED);
} catch (e) {
if (e.message !== undefined) {
setErrorMessage(e.message);
} else {
setErrorMessage(JSON.stringify(e));
}
setTableItems([]);
setStatus(INDEX_STATUS.ERROR);
}
}
};
useEffect(() => {
getDefaultSelectedFields();
}, [jobConfig && jobConfig.id]);
// By default set sorting to descending on the prediction field (`<dependent_varible or prediction_field_name>_prediction`).
useEffect(() => {
const sortByField = isKeywordAndTextType(dependentVariable)
? `${predictedFieldName}.keyword`
: predictedFieldName;
const direction = SORT_DIRECTION.DESC;
setSortingColumns([{ id: sortByField, direction }]);
}, [jobConfig && jobConfig.id]);
useEffect(() => {
loadExploreData({ filterByIsTraining, searchQuery });
}, [
filterByIsTraining,
jobConfig && jobConfig.id,
pagination,
searchQuery,
selectedFields,
sortingColumns,
]);
return {
errorMessage,
fieldTypes,
pagination,
searchQuery,
selectedFields,
rowCount,
setFilterByIsTraining,
setPagination,
setSelectedFields,
setSortingColumns,
setSearchQuery,
sortingColumns,
status,
tableItems,
tableFields,
};
};

View file

@ -1,267 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState, Dispatch, SetStateAction } from 'react';
import { SearchResponse } from 'elasticsearch';
import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
import { Dictionary } from '../../../../../../../common/types/common';
import { SavedSearchQuery } from '../../../../../contexts/ml';
import { ml } from '../../../../../services/ml_api_service';
import { newJobCapsService } from '../../../../../services/new_job_capabilities_service';
import { getIndexPatternIdFromName } from '../../../../../util/index_utils';
import { getNestedProperty } from '../../../../../util/object_utils';
import { useMlContext } from '../../../../../contexts/ml';
import { isGetDataFrameAnalyticsStatsResponseOk } from '../../../analytics_management/services/analytics_service/get_analytics';
import {
getDefaultSelectableFields,
getFlattenedFields,
DataFrameAnalyticsConfig,
EsFieldName,
INDEX_STATUS,
MAX_COLUMNS,
defaultSearchQuery,
} from '../../../../common';
import { isKeywordAndTextType } from '../../../../common/fields';
import { getOutlierScoreFieldName } from './common';
import { DATA_FRAME_TASK_STATE } from '../../../analytics_management/components/analytics_list/common';
export type TableItem = Record<string, any>;
type Pagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
interface UseExploreDataReturnType {
errorMessage: string;
indexPattern: IndexPattern | undefined;
jobConfig: DataFrameAnalyticsConfig | undefined;
jobStatus: DATA_FRAME_TASK_STATE | undefined;
pagination: Pagination;
searchQuery: SavedSearchQuery;
selectedFields: EsFieldName[];
setJobConfig: Dispatch<SetStateAction<DataFrameAnalyticsConfig | undefined>>;
setPagination: Dispatch<SetStateAction<Pagination>>;
setSearchQuery: Dispatch<SetStateAction<SavedSearchQuery>>;
setSelectedFields: Dispatch<SetStateAction<EsFieldName[]>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
rowCount: number;
sortingColumns: EuiDataGridSorting['columns'];
status: INDEX_STATUS;
tableFields: string[];
tableItems: TableItem[];
}
type EsSorting = Dictionary<{
order: 'asc' | 'desc';
}>;
// The types specified in `@types/elasticsearch` are out of date and still have `total: number`.
interface SearchResponse7 extends SearchResponse<any> {
hits: SearchResponse<any>['hits'] & {
total: {
value: number;
relation: string;
};
};
}
export const useExploreData = (jobId: string): UseExploreDataReturnType => {
const mlContext = useMlContext();
const [indexPattern, setIndexPattern] = useState<IndexPattern | undefined>(undefined);
const [jobConfig, setJobConfig] = useState<DataFrameAnalyticsConfig | undefined>(undefined);
const [jobStatus, setJobStatus] = useState<DATA_FRAME_TASK_STATE | undefined>(undefined);
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
const [selectedFields, setSelectedFields] = useState([] as EsFieldName[]);
const [tableFields, setTableFields] = useState<string[]>([]);
const [tableItems, setTableItems] = useState<TableItem[]>([]);
const [rowCount, setRowCount] = useState(0);
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 });
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
// get analytics configuration
useEffect(() => {
(async function() {
const analyticsConfigs = await ml.dataFrameAnalytics.getDataFrameAnalytics(jobId);
const analyticsStats = await ml.dataFrameAnalytics.getDataFrameAnalyticsStats(jobId);
const stats = isGetDataFrameAnalyticsStatsResponseOk(analyticsStats)
? analyticsStats.data_frame_analytics[0]
: undefined;
if (stats !== undefined && stats.state) {
setJobStatus(stats.state);
}
if (
Array.isArray(analyticsConfigs.data_frame_analytics) &&
analyticsConfigs.data_frame_analytics.length > 0
) {
setJobConfig(analyticsConfigs.data_frame_analytics[0]);
}
})();
}, []);
// get index pattern and field caps
useEffect(() => {
(async () => {
if (jobConfig !== undefined) {
try {
const destIndex = Array.isArray(jobConfig.dest.index)
? jobConfig.dest.index[0]
: jobConfig.dest.index;
const destIndexPatternId = getIndexPatternIdFromName(destIndex) || destIndex;
let indexP: IndexPattern | undefined;
try {
indexP = await mlContext.indexPatterns.get(destIndexPatternId);
} catch (e) {
indexP = undefined;
}
if (indexP === undefined) {
const sourceIndex = jobConfig.source.index[0];
const sourceIndexPatternId = getIndexPatternIdFromName(sourceIndex) || sourceIndex;
indexP = await mlContext.indexPatterns.get(sourceIndexPatternId);
}
if (indexP !== undefined) {
setIndexPattern(indexP);
await newJobCapsService.initializeFromIndexPattern(indexP, false, false);
}
} catch (e) {
// eslint-disable-next-line
console.log('Error loading index field data', e);
}
}
})();
}, [jobConfig && jobConfig.id]);
// initialize sorting: reverse sort on outlier score column
useEffect(() => {
if (jobConfig !== undefined) {
setSortingColumns([{ id: getOutlierScoreFieldName(jobConfig), direction: 'desc' }]);
}
}, [jobConfig && jobConfig.id]);
// update data grid data
useEffect(() => {
(async () => {
if (jobConfig !== undefined) {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
try {
const resultsField = jobConfig.dest.results_field;
const sort: EsSorting = sortingColumns
.map(column => {
const { id } = column;
column.id = isKeywordAndTextType(id) ? `${id}.keyword` : id;
return column;
})
.reduce((s, column) => {
s[column.id] = { order: column.direction };
return s;
}, {} as EsSorting);
const { pageIndex, pageSize } = pagination;
const resp: SearchResponse7 = await ml.esSearch({
index: jobConfig.dest.index,
body: {
query: searchQuery,
from: pageIndex * pageSize,
size: pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
});
setRowCount(resp.hits.total.value);
const docs = resp.hits.hits;
if (docs.length === 0) {
setTableItems([]);
setStatus(INDEX_STATUS.LOADED);
return;
}
if (selectedFields.length === 0) {
const newSelectedFields = getDefaultSelectableFields(docs, resultsField);
setSelectedFields(newSelectedFields.sort().splice(0, MAX_COLUMNS));
}
// Create a version of the doc's source with flattened field names.
// This avoids confusion later on if a field name has dots in its name
// or is a nested fields when displaying it via EuiInMemoryTable.
const flattenedFields = getFlattenedFields(docs[0]._source, resultsField);
const transformedTableItems = docs.map(doc => {
const item: TableItem = {};
flattenedFields.forEach(ff => {
item[ff] = getNestedProperty(doc._source, ff);
if (item[ff] === undefined) {
// If the attribute is undefined, it means it was not a nested property
// but had dots in its actual name. This selects the property by its
// full name and assigns it to `item[ff]`.
item[ff] = doc._source[`"${ff}"`];
}
if (item[ff] === undefined) {
const parts = ff.split('.');
if (parts[0] === resultsField && parts.length >= 2) {
parts.shift();
if (doc._source[resultsField] !== undefined) {
item[ff] = doc._source[resultsField][parts.join('.')];
}
}
}
});
return item;
});
setTableFields(flattenedFields);
setTableItems(transformedTableItems);
setStatus(INDEX_STATUS.LOADED);
} catch (e) {
if (e.message !== undefined) {
setErrorMessage(e.message);
} else {
setErrorMessage(JSON.stringify(e));
}
setTableItems([]);
setStatus(INDEX_STATUS.ERROR);
}
}
})();
}, [jobConfig && jobConfig.id, pagination, searchQuery, selectedFields, sortingColumns]);
return {
errorMessage,
indexPattern,
jobConfig,
jobStatus,
pagination,
rowCount,
searchQuery,
selectedFields,
setJobConfig,
setPagination,
setSearchQuery,
setSelectedFields,
setSortingColumns,
sortingColumns,
status,
tableFields,
tableItems,
};
};

View file

@ -11,6 +11,7 @@ import { i18n } from '@kbn/i18n';
import { DeepReadonly } from '../../../../../../../common/types/common';
import { DataFrameAnalyticsConfig, isOutlierAnalysis } from '../../../../common';
import { isClassificationAnalysis, isRegressionAnalysis } from '../../../../common/analytics';
import { DEFAULT_RESULTS_FIELD } from '../../../../common/constants';
import {
CreateAnalyticsFormProps,
DEFAULT_NUM_TOP_FEATURE_IMPORTANCE_VALUES,
@ -214,7 +215,7 @@ const getAnalyticsJobMeta = (config: CloneDataFrameAnalyticsConfig): AnalyticsJo
},
results_field: {
optional: true,
defaultValue: 'ml',
defaultValue: DEFAULT_RESULTS_FIELD,
},
},
model_memory_limit: {

View file

@ -4,6 +4,7 @@
* you may not use this file except in compliance with the Elastic License.
*/
// actual mocks
export const expandLiteralStrings = jest.fn();
export const XJsonMode = jest.fn();
export const useRequest = jest.fn(() => ({
@ -11,5 +12,20 @@ export const useRequest = jest.fn(() => ({
error: null,
data: undefined,
}));
export { mlInMemoryTableBasicFactory } from '../../../ml/public/application/components/ml_in_memory_table';
export const SORT_DIRECTION = { ASC: 'asc' };
// just passing through the reimports
export { getErrorMessage } from '../../../ml/common/util/errors';
export {
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
multiColumnSortFactory,
useDataGrid,
useRenderCellValue,
DataGrid,
EsSorting,
RenderCellValue,
SearchResponse7,
UseDataGridReturnType,
UseIndexDataReturnType,
} from '../../../ml/public/application/components/data_grid';
export { INDEX_STATUS } from '../../../ml/public/application/data_frame_analytics/common';

View file

@ -0,0 +1,93 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import {
getPreviewRequestBody,
PivotAggsConfig,
PivotGroupByConfig,
PIVOT_SUPPORTED_AGGS,
PIVOT_SUPPORTED_GROUP_BY_AGGS,
SimpleQuery,
} from '../common';
import { getIndexDevConsoleStatement, getPivotPreviewDevConsoleStatement } from './data_grid';
describe('Transform: Data Grid', () => {
test('getPivotPreviewDevConsoleStatement()', () => {
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
const groupBy: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
aggName: 'the-group-by-agg-name',
dropDownName: 'the-group-by-drop-down-name',
};
const agg: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-agg-field',
aggName: 'the-agg-agg-name',
dropDownName: 'the-agg-drop-down-name',
};
const request = getPreviewRequestBody('the-index-pattern-title', query, [groupBy], [agg]);
const pivotPreviewDevConsoleStatement = getPivotPreviewDevConsoleStatement(request);
expect(pivotPreviewDevConsoleStatement).toBe(`POST _transform/_preview
{
"source": {
"index": [
"the-index-pattern-title"
]
},
"pivot": {
"group_by": {
"the-group-by-agg-name": {
"terms": {
"field": "the-group-by-field"
}
}
},
"aggregations": {
"the-agg-agg-name": {
"avg": {
"field": "the-agg-field"
}
}
}
}
}
`);
});
});
describe('Transform: Index Preview Common', () => {
test('getIndexDevConsoleStatement()', () => {
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
const indexPreviewDevConsoleStatement = getIndexDevConsoleStatement(
query,
'the-index-pattern-title'
);
expect(indexPreviewDevConsoleStatement).toBe(`GET the-index-pattern-title/_search
{
"query": {
"query_string": {
"query": "*",
"default_operator": "AND"
}
}
}
`);
});
});

View file

@ -4,22 +4,21 @@
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDataGridStyle } from '@elastic/eui';
import { PivotQuery } from './request';
import { PreviewRequestBody } from './transform';
export const INIT_MAX_COLUMNS = 20;
export const euiDataGridStyle: EuiDataGridStyle = {
border: 'all',
fontSize: 's',
cellPadding: 's',
stripes: false,
rowHover: 'highlight',
header: 'shade',
export const getPivotPreviewDevConsoleStatement = (request: PreviewRequestBody) => {
return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`;
};
export const euiDataGridToolbarSettings = {
showColumnSelector: true,
showStyleSelector: false,
showSortSelector: true,
showFullScreenSelector: false,
export const getIndexDevConsoleStatement = (query: PivotQuery, indexPatternTitle: string) => {
return `GET ${indexPatternTitle}/_search\n${JSON.stringify(
{
query,
},
null,
2
)}\n`;
};

View file

@ -5,7 +5,11 @@
*/
export { AggName, isAggName } from './aggregations';
export { euiDataGridStyle, euiDataGridToolbarSettings, INIT_MAX_COLUMNS } from './data_grid';
export {
getIndexDevConsoleStatement,
getPivotPreviewDevConsoleStatement,
INIT_MAX_COLUMNS,
} from './data_grid';
export {
getDefaultSelectableFields,
getFlattenedFields,

View file

@ -1,60 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { EuiDataGridSorting } from '@elastic/eui';
import { getNestedProperty } from '../../../../common/utils/object_utils';
import { PreviewRequestBody } from '../../common';
/**
* Helper to sort an array of objects based on an EuiDataGrid sorting configuration.
* `sortFn()` is recursive to support sorting on multiple columns.
*
* @param sortingColumns - The EUI data grid sorting configuration
* @returns The sorting function which can be used with an array's sort() function.
*/
export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['columns']) => {
const isString = (arg: any): arg is string => {
return typeof arg === 'string';
};
const sortFn = (a: any, b: any, sortingColumnIndex = 0): number => {
const sort = sortingColumns[sortingColumnIndex];
const aValue = getNestedProperty(a, sort.id, null);
const bValue = getNestedProperty(b, sort.id, null);
if (typeof aValue === 'number' && typeof bValue === 'number') {
if (aValue < bValue) {
return sort.direction === 'asc' ? -1 : 1;
}
if (aValue > bValue) {
return sort.direction === 'asc' ? 1 : -1;
}
}
if (isString(aValue) && isString(bValue)) {
if (aValue.localeCompare(bValue) === -1) {
return sort.direction === 'asc' ? -1 : 1;
}
if (aValue.localeCompare(bValue) === 1) {
return sort.direction === 'asc' ? 1 : -1;
}
}
if (sortingColumnIndex + 1 < sortingColumns.length) {
return sortFn(a, b, sortingColumnIndex + 1);
}
return 0;
};
return sortFn;
};
export const getPivotPreviewDevConsoleStatement = (request: PreviewRequestBody) => {
return `POST _transform/_preview\n${JSON.stringify(request, null, 2)}\n`;
};

View file

@ -1,55 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, wait } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import {
getPivotQuery,
PivotAggsConfig,
PivotGroupByConfig,
PIVOT_SUPPORTED_AGGS,
PIVOT_SUPPORTED_GROUP_BY_AGGS,
} from '../../common';
import { PivotPreview } from './pivot_preview';
jest.mock('../../../shared_imports');
jest.mock('../../../app/app_dependencies');
describe('Transform: <PivotPreview />', () => {
// Using the async/await wait()/done() pattern to avoid act() errors.
test('Minimal initialization', async done => {
// Arrange
const groupBy: PivotGroupByConfig = {
agg: PIVOT_SUPPORTED_GROUP_BY_AGGS.TERMS,
field: 'the-group-by-field',
aggName: 'the-group-by-agg-name',
dropDownName: 'the-group-by-drop-down-name',
};
const agg: PivotAggsConfig = {
agg: PIVOT_SUPPORTED_AGGS.AVG,
field: 'the-agg-field',
aggName: 'the-group-by-agg-name',
dropDownName: 'the-group-by-drop-down-name',
};
const props = {
aggs: { 'the-agg-name': agg },
groupBy: { 'the-group-by-name': groupBy },
indexPatternTitle: 'the-index-pattern-title',
query: getPivotQuery('the-query'),
};
const { getByText } = render(<PivotPreview {...props} />);
// Act
// Assert
expect(getByText('Transform pivot preview')).toBeInTheDocument();
await wait();
done();
});
});

View file

@ -1,345 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment-timezone';
import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiCallOut,
EuiCodeBlock,
EuiCopy,
EuiDataGrid,
EuiDataGridSorting,
EuiFlexGroup,
EuiFlexItem,
EuiProgress,
EuiTitle,
} from '@elastic/eui';
import { ES_FIELD_TYPES } from '../../../../../../../src/plugins/data/common';
import { dictionaryToArray } from '../../../../common/types/common';
import { formatHumanReadableDateTimeSeconds } from '../../../../common/utils/date_utils';
import { getNestedProperty } from '../../../../common/utils/object_utils';
import {
euiDataGridStyle,
euiDataGridToolbarSettings,
EsFieldName,
PreviewRequestBody,
PivotAggsConfigDict,
PivotGroupByConfig,
PivotGroupByConfigDict,
PivotQuery,
INIT_MAX_COLUMNS,
} from '../../common';
import { SearchItems } from '../../hooks/use_search_items';
import { getPivotPreviewDevConsoleStatement, multiColumnSortFactory } from './common';
import { PIVOT_PREVIEW_STATUS, usePivotPreviewData } from './use_pivot_preview_data';
function sortColumns(groupByArr: PivotGroupByConfig[]) {
return (a: string, b: string) => {
// make sure groupBy fields are always most left columns
if (groupByArr.some(d => d.aggName === a) && groupByArr.some(d => d.aggName === b)) {
return a.localeCompare(b);
}
if (groupByArr.some(d => d.aggName === a)) {
return -1;
}
if (groupByArr.some(d => d.aggName === b)) {
return 1;
}
return a.localeCompare(b);
};
}
interface PreviewTitleProps {
previewRequest: PreviewRequestBody;
}
const PreviewTitle: FC<PreviewTitleProps> = ({ previewRequest }) => {
const euiCopyText = i18n.translate('xpack.transform.pivotPreview.copyClipboardTooltip', {
defaultMessage: 'Copy Dev Console statement of the pivot preview to the clipboard.',
});
return (
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.transform.pivotPreview.PivotPreviewTitle', {
defaultMessage: 'Transform pivot preview',
})}
</span>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
beforeMessage={euiCopyText}
textToCopy={getPivotPreviewDevConsoleStatement(previewRequest)}
>
{(copy: () => void) => (
<EuiButtonIcon onClick={copy} iconType="copyClipboard" aria-label={euiCopyText} />
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
);
};
interface ErrorMessageProps {
message: string;
}
const ErrorMessage: FC<ErrorMessageProps> = ({ message }) => (
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{message}
</EuiCodeBlock>
);
interface PivotPreviewProps {
aggs: PivotAggsConfigDict;
groupBy: PivotGroupByConfigDict;
indexPatternTitle: SearchItems['indexPattern']['title'];
query: PivotQuery;
showHeader?: boolean;
}
const defaultPagination = { pageIndex: 0, pageSize: 5 };
export const PivotPreview: FC<PivotPreviewProps> = React.memo(
({ aggs, groupBy, indexPatternTitle, query, showHeader = true }) => {
const {
previewData: data,
previewMappings,
errorMessage,
previewRequest,
status,
} = usePivotPreviewData(indexPatternTitle, query, aggs, groupBy);
const groupByArr = dictionaryToArray(groupBy);
// Filters mapping properties of type `object`, which get returned for nested field parents.
const columnKeys = Object.keys(previewMappings.properties).filter(
key => previewMappings.properties[key].type !== 'object'
);
columnKeys.sort(sortColumns(groupByArr));
// Column visibility
const [visibleColumns, setVisibleColumns] = useState<EsFieldName[]>([]);
useEffect(() => {
setVisibleColumns(columnKeys.splice(0, INIT_MAX_COLUMNS));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [columnKeys.join()]);
const [pagination, setPagination] = useState(defaultPagination);
// Reset pagination if data changes. This is to avoid ending up with an empty table
// when for example the user selected a page that is not available with the updated data.
useEffect(() => {
setPagination(defaultPagination);
}, [data.length]);
// EuiDataGrid State
const dataGridColumns = columnKeys.map(id => {
const field = previewMappings.properties[id];
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
switch (field?.type) {
case ES_FIELD_TYPES.GEO_POINT:
case ES_FIELD_TYPES.GEO_SHAPE:
schema = 'json';
break;
case ES_FIELD_TYPES.BOOLEAN:
schema = 'boolean';
break;
case ES_FIELD_TYPES.DATE:
case ES_FIELD_TYPES.DATE_NANOS:
schema = 'datetime';
break;
case ES_FIELD_TYPES.BYTE:
case ES_FIELD_TYPES.DOUBLE:
case ES_FIELD_TYPES.FLOAT:
case ES_FIELD_TYPES.HALF_FLOAT:
case ES_FIELD_TYPES.INTEGER:
case ES_FIELD_TYPES.LONG:
case ES_FIELD_TYPES.SCALED_FLOAT:
case ES_FIELD_TYPES.SHORT:
schema = 'numeric';
break;
// keep schema undefined for text based columns
case ES_FIELD_TYPES.KEYWORD:
case ES_FIELD_TYPES.TEXT:
break;
}
return { id, schema };
});
const onChangeItemsPerPage = useCallback(
pageSize => {
setPagination(p => {
const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
return { pageIndex, pageSize };
});
},
[setPagination]
);
const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
setPagination,
]);
// Sorting config
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const onSort = useCallback(sc => setSortingColumns(sc), [setSortingColumns]);
if (sortingColumns.length > 0) {
data.sort(multiColumnSortFactory(sortingColumns));
}
const pageData = data.slice(
pagination.pageIndex * pagination.pageSize,
(pagination.pageIndex + 1) * pagination.pageSize
);
const renderCellValue = useMemo(() => {
return ({
rowIndex,
columnId,
setCellProps,
}: {
rowIndex: number;
columnId: string;
setCellProps: any;
}) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const cellValue = pageData.hasOwnProperty(adjustedRowIndex)
? getNestedProperty(pageData[adjustedRowIndex], columnId, null)
: null;
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
if (cellValue === undefined || cellValue === null) {
return null;
}
if (
[ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.DATE_NANOS].includes(
previewMappings.properties[columnId].type
)
) {
return formatHumanReadableDateTimeSeconds(moment(cellValue).unix() * 1000);
}
if (previewMappings.properties[columnId].type === ES_FIELD_TYPES.BOOLEAN) {
return cellValue ? 'true' : 'false';
}
return cellValue;
};
}, [pageData, pagination.pageIndex, pagination.pageSize, previewMappings.properties]);
if (status === PIVOT_PREVIEW_STATUS.ERROR) {
return (
<div data-test-subj="transformPivotPreview error">
<PreviewTitle previewRequest={previewRequest} />
<EuiCallOut
title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewError', {
defaultMessage: 'An error occurred loading the pivot preview.',
})}
color="danger"
iconType="cross"
>
<ErrorMessage message={errorMessage} />
</EuiCallOut>
</div>
);
}
if (data.length === 0) {
let noDataMessage = i18n.translate(
'xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody',
{
defaultMessage:
'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.',
}
);
const aggsArr = dictionaryToArray(aggs);
if (aggsArr.length === 0 || groupByArr.length === 0) {
noDataMessage = i18n.translate(
'xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody',
{
defaultMessage: 'Please choose at least one group-by field and aggregation.',
}
);
}
return (
<div data-test-subj="transformPivotPreview empty">
<PreviewTitle previewRequest={previewRequest} />
<EuiCallOut
title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewNoDataCalloutTitle', {
defaultMessage: 'Pivot preview not available',
})}
color="primary"
>
<p>{noDataMessage}</p>
</EuiCallOut>
</div>
);
}
if (columnKeys.length === 0) {
return null;
}
return (
<div data-test-subj="transformPivotPreview loaded">
{showHeader && (
<>
<PreviewTitle previewRequest={previewRequest} />
<div className="transform__progress">
{status === PIVOT_PREVIEW_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
{status !== PIVOT_PREVIEW_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
</div>
</>
)}
{dataGridColumns.length > 0 && data.length > 0 && (
<EuiDataGrid
aria-label="Source index preview"
columns={dataGridColumns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={euiDataGridStyle}
rowCount={data.length}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: [5, 10, 25],
onChangeItemsPerPage,
onChangePage,
}}
/>
)}
</div>
);
}
);

View file

@ -1,69 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React, { FC } from 'react';
import ReactDOM from 'react-dom';
import { SimpleQuery } from '../../common';
import {
PIVOT_PREVIEW_STATUS,
usePivotPreviewData,
UsePivotPreviewDataReturnType,
} from './use_pivot_preview_data';
jest.mock('../../hooks/use_api');
type Callback = () => void;
interface TestHookProps {
callback: Callback;
}
const TestHook: FC<TestHookProps> = ({ callback }) => {
callback();
return null;
};
const testHook = (callback: Callback) => {
const container = document.createElement('div');
document.body.appendChild(container);
ReactDOM.render(<TestHook callback={callback} />, container);
};
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
let pivotPreviewObj: UsePivotPreviewDataReturnType;
describe('usePivotPreviewData', () => {
test('indexPattern not defined', () => {
testHook(() => {
pivotPreviewObj = usePivotPreviewData('the-title', query, {}, {});
});
expect(pivotPreviewObj.errorMessage).toBe('');
expect(pivotPreviewObj.status).toBe(PIVOT_PREVIEW_STATUS.UNUSED);
expect(pivotPreviewObj.previewData).toEqual([]);
});
test('indexPattern set triggers loading', () => {
testHook(() => {
pivotPreviewObj = usePivotPreviewData('the-title', query, {}, {});
});
expect(pivotPreviewObj.errorMessage).toBe('');
// ideally this should be LOADING instead of UNUSED but jest/enzyme/hooks doesn't
// trigger that state upate yet.
expect(pivotPreviewObj.status).toBe(PIVOT_PREVIEW_STATUS.UNUSED);
expect(pivotPreviewObj.previewData).toEqual([]);
});
// TODO add more tests to check data retrieved via `api.esSearch()`.
// This needs more investigation in regards to jest/enzyme's React Hooks support.
});

View file

@ -1,91 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState } from 'react';
import { dictionaryToArray } from '../../../../common/types/common';
import { useApi } from '../../hooks/use_api';
import { IndexPattern } from '../../../../../../../src/plugins/data/public';
import {
getPreviewRequestBody,
PreviewRequestBody,
PivotAggsConfigDict,
PivotGroupByConfigDict,
PivotQuery,
PreviewData,
PreviewMappings,
} from '../../common';
export enum PIVOT_PREVIEW_STATUS {
UNUSED,
LOADING,
LOADED,
ERROR,
}
export interface UsePivotPreviewDataReturnType {
errorMessage: string;
status: PIVOT_PREVIEW_STATUS;
previewData: PreviewData;
previewMappings: PreviewMappings;
previewRequest: PreviewRequestBody;
}
export const usePivotPreviewData = (
indexPatternTitle: IndexPattern['title'],
query: PivotQuery,
aggs: PivotAggsConfigDict,
groupBy: PivotGroupByConfigDict
): UsePivotPreviewDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(PIVOT_PREVIEW_STATUS.UNUSED);
const [previewData, setPreviewData] = useState<PreviewData>([]);
const [previewMappings, setPreviewMappings] = useState<PreviewMappings>({ properties: {} });
const api = useApi();
const aggsArr = dictionaryToArray(aggs);
const groupByArr = dictionaryToArray(groupBy);
const previewRequest = getPreviewRequestBody(indexPatternTitle, query, groupByArr, aggsArr);
const getPreviewData = async () => {
if (aggsArr.length === 0 || groupByArr.length === 0) {
setPreviewData([]);
return;
}
setErrorMessage('');
setStatus(PIVOT_PREVIEW_STATUS.LOADING);
try {
const resp = await api.getTransformsPreview(previewRequest);
setPreviewData(resp.preview);
setPreviewMappings(resp.generated_dest_index.mappings);
setStatus(PIVOT_PREVIEW_STATUS.LOADED);
} catch (e) {
setErrorMessage(JSON.stringify(e, null, 2));
setPreviewData([]);
setPreviewMappings({ properties: {} });
setStatus(PIVOT_PREVIEW_STATUS.ERROR);
}
};
useEffect(() => {
getPreviewData();
// custom comparison
/* eslint-disable react-hooks/exhaustive-deps */
}, [
indexPatternTitle,
JSON.stringify(aggsArr),
JSON.stringify(groupByArr),
JSON.stringify(query),
/* eslint-enable react-hooks/exhaustive-deps */
]);
return { errorMessage, status, previewData, previewMappings, previewRequest };
};

View file

@ -0,0 +1,85 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, wait } from '@testing-library/react';
import { renderHook } from '@testing-library/react-hooks';
import '@testing-library/jest-dom/extend-expect';
import { CoreSetup } from 'src/core/public';
import { DataGrid, UseIndexDataReturnType, INDEX_STATUS } from '../../shared_imports';
import { SimpleQuery } from '../common';
import { SearchItems } from './use_search_items';
import { useIndexData } from './use_index_data';
jest.mock('../../shared_imports');
jest.mock('../app_dependencies');
jest.mock('./use_api');
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
describe('Transform: useIndexData()', () => {
test('indexPattern set triggers loading', async done => {
const { result, waitForNextUpdate } = renderHook(() =>
useIndexData(
({
id: 'the-id',
title: 'the-title',
fields: [],
} as unknown) as SearchItems['indexPattern'],
query
)
);
const IndexObj: UseIndexDataReturnType = result.current;
await waitForNextUpdate();
expect(IndexObj.errorMessage).toBe('');
expect(IndexObj.status).toBe(INDEX_STATUS.LOADING);
expect(IndexObj.tableItems).toEqual([]);
done();
});
});
describe('Transform: <DataGrid /> with useIndexData()', () => {
// Using the async/await wait()/done() pattern to avoid act() errors.
test('Minimal initialization', async done => {
// Arrange
const indexPattern = {
title: 'the-index-pattern-title',
fields: [] as any[],
} as SearchItems['indexPattern'];
const Wrapper = () => {
const props = {
...useIndexData(indexPattern, { match_all: {} }),
copyToClipboard: 'the-copy-to-clipboard-code',
copyToClipboardDescription: 'the-copy-to-clipboard-description',
dataTestSubj: 'the-data-test-subj',
title: 'the-index-preview-title',
toastNotifications: {} as CoreSetup['notifications']['toasts'],
};
return <DataGrid {...props} />;
};
const { getByText } = render(<Wrapper />);
// Act
// Assert
expect(getByText('the-index-preview-title')).toBeInTheDocument();
await wait();
done();
});
});

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect } from 'react';
import {
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
getErrorMessage,
useDataGrid,
useRenderCellValue,
EsSorting,
SearchResponse7,
UseIndexDataReturnType,
INDEX_STATUS,
} from '../../shared_imports';
import { isDefaultQuery, matchAllQuery, PivotQuery } from '../common';
import { SearchItems } from './use_search_items';
import { useApi } from './use_api';
type IndexSearchResponse = SearchResponse7;
export const useIndexData = (
indexPattern: SearchItems['indexPattern'],
query: PivotQuery
): UseIndexDataReturnType => {
const api = useApi();
const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern);
// EuiDataGrid State
const columns = [
...indexPatternFields.map(id => {
const field = indexPattern.fields.getByName(id);
const schema = getDataGridSchemaFromKibanaFieldType(field);
return { id, schema };
}),
];
const dataGrid = useDataGrid(columns);
const {
pagination,
resetPagination,
setErrorMessage,
setRowCount,
setStatus,
setTableItems,
sortingColumns,
tableItems,
} = dataGrid;
useEffect(() => {
resetPagination();
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(query)]);
const getIndexData = async function() {
setErrorMessage('');
setStatus(INDEX_STATUS.LOADING);
const sort: EsSorting = sortingColumns.reduce((s, column) => {
s[column.id] = { order: column.direction };
return s;
}, {} as EsSorting);
const esSearchRequest = {
index: indexPattern.title,
body: {
// Instead of using the default query (`*`), fall back to a more efficient `match_all` query.
query: isDefaultQuery(query) ? matchAllQuery : query,
from: pagination.pageIndex * pagination.pageSize,
size: pagination.pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
};
try {
const resp: IndexSearchResponse = await api.esSearch(esSearchRequest);
const docs = resp.hits.hits.map(d => d._source);
setRowCount(resp.hits.total.value);
setTableItems(docs);
setStatus(INDEX_STATUS.LOADED);
} catch (e) {
setErrorMessage(getErrorMessage(e));
setStatus(INDEX_STATUS.ERROR);
}
};
useEffect(() => {
getIndexData();
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]);
const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems);
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -0,0 +1,240 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment-timezone';
import { useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common';
import { dictionaryToArray } from '../../../common/types/common';
import { formatHumanReadableDateTimeSeconds } from '../../../common/utils/date_utils';
import { getNestedProperty } from '../../../common/utils/object_utils';
import {
getErrorMessage,
multiColumnSortFactory,
useDataGrid,
RenderCellValue,
UseIndexDataReturnType,
INDEX_STATUS,
} from '../../shared_imports';
import {
getPreviewRequestBody,
PivotAggsConfigDict,
PivotGroupByConfigDict,
PivotGroupByConfig,
PivotQuery,
PreviewMappings,
} from '../common';
import { SearchItems } from './use_search_items';
import { useApi } from './use_api';
function sortColumns(groupByArr: PivotGroupByConfig[]) {
return (a: string, b: string) => {
// make sure groupBy fields are always most left columns
if (groupByArr.some(d => d.aggName === a) && groupByArr.some(d => d.aggName === b)) {
return a.localeCompare(b);
}
if (groupByArr.some(d => d.aggName === a)) {
return -1;
}
if (groupByArr.some(d => d.aggName === b)) {
return 1;
}
return a.localeCompare(b);
};
}
export const usePivotData = (
indexPatternTitle: SearchItems['indexPattern']['title'],
query: PivotQuery,
aggs: PivotAggsConfigDict,
groupBy: PivotGroupByConfigDict
): UseIndexDataReturnType => {
const [previewMappings, setPreviewMappings] = useState<PreviewMappings>({ properties: {} });
const api = useApi();
const aggsArr = dictionaryToArray(aggs);
const groupByArr = dictionaryToArray(groupBy);
// Filters mapping properties of type `object`, which get returned for nested field parents.
const columnKeys = Object.keys(previewMappings.properties).filter(
key => previewMappings.properties[key].type !== 'object'
);
columnKeys.sort(sortColumns(groupByArr));
// EuiDataGrid State
const columns = columnKeys.map(id => {
const field = previewMappings.properties[id];
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
switch (field?.type) {
case ES_FIELD_TYPES.GEO_POINT:
case ES_FIELD_TYPES.GEO_SHAPE:
schema = 'json';
break;
case ES_FIELD_TYPES.BOOLEAN:
schema = 'boolean';
break;
case ES_FIELD_TYPES.DATE:
case ES_FIELD_TYPES.DATE_NANOS:
schema = 'datetime';
break;
case ES_FIELD_TYPES.BYTE:
case ES_FIELD_TYPES.DOUBLE:
case ES_FIELD_TYPES.FLOAT:
case ES_FIELD_TYPES.HALF_FLOAT:
case ES_FIELD_TYPES.INTEGER:
case ES_FIELD_TYPES.LONG:
case ES_FIELD_TYPES.SCALED_FLOAT:
case ES_FIELD_TYPES.SHORT:
schema = 'numeric';
break;
// keep schema undefined for text based columns
case ES_FIELD_TYPES.KEYWORD:
case ES_FIELD_TYPES.TEXT:
break;
}
return { id, schema };
});
const dataGrid = useDataGrid(columns);
const {
pagination,
resetPagination,
setErrorMessage,
setNoDataMessage,
setRowCount,
setStatus,
setTableItems,
sortingColumns,
tableItems,
} = dataGrid;
const previewRequest = getPreviewRequestBody(indexPatternTitle, query, groupByArr, aggsArr);
const getPreviewData = async () => {
if (aggsArr.length === 0 || groupByArr.length === 0) {
setTableItems([]);
setRowCount(0);
setNoDataMessage(
i18n.translate('xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody', {
defaultMessage: 'Please choose at least one group-by field and aggregation.',
})
);
return;
}
setErrorMessage('');
setNoDataMessage('');
setStatus(INDEX_STATUS.LOADING);
try {
const resp = await api.getTransformsPreview(previewRequest);
setTableItems(resp.preview);
setRowCount(resp.preview.length);
setPreviewMappings(resp.generated_dest_index.mappings);
setStatus(INDEX_STATUS.LOADED);
if (resp.preview.length === 0) {
setNoDataMessage(
i18n.translate('xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody', {
defaultMessage:
'The preview request did not return any data. Please ensure the optional query returns data and that values exist for the field used by group-by and aggregation fields.',
})
);
}
} catch (e) {
setErrorMessage(getErrorMessage(e));
setTableItems([]);
setRowCount(0);
setPreviewMappings({ properties: {} });
setStatus(INDEX_STATUS.ERROR);
}
};
useEffect(() => {
resetPagination();
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [JSON.stringify(query)]);
useEffect(() => {
getPreviewData();
// custom comparison
/* eslint-disable react-hooks/exhaustive-deps */
}, [
indexPatternTitle,
JSON.stringify(aggsArr),
JSON.stringify(groupByArr),
JSON.stringify(query),
/* eslint-enable react-hooks/exhaustive-deps */
]);
if (sortingColumns.length > 0) {
tableItems.sort(multiColumnSortFactory(sortingColumns));
}
const pageData = tableItems.slice(
pagination.pageIndex * pagination.pageSize,
(pagination.pageIndex + 1) * pagination.pageSize
);
const renderCellValue: RenderCellValue = useMemo(() => {
return ({
rowIndex,
columnId,
setCellProps,
}: {
rowIndex: number;
columnId: string;
setCellProps: any;
}) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const cellValue = pageData.hasOwnProperty(adjustedRowIndex)
? getNestedProperty(pageData[adjustedRowIndex], columnId, null)
: null;
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
if (cellValue === undefined || cellValue === null) {
return null;
}
if (
[ES_FIELD_TYPES.DATE, ES_FIELD_TYPES.DATE_NANOS].includes(
previewMappings.properties[columnId].type
)
) {
return formatHumanReadableDateTimeSeconds(moment(cellValue).unix() * 1000);
}
if (previewMappings.properties[columnId].type === ES_FIELD_TYPES.BOOLEAN) {
return cellValue ? 'true' : 'false';
}
return cellValue;
};
}, [pageData, pagination.pageIndex, pagination.pageSize, previewMappings.properties]);
return {
...dataGrid,
columns,
renderCellValue,
};
};

View file

@ -1,71 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Transform: <ExpandedRow /> Test against strings, objects and arrays. 1`] = `
<EuiText>
<span
key="name"
>
<EuiBadge>
name
:
</EuiBadge>
<small>
the-name
  
</small>
</span>
<span
key="nested.inner1"
>
<EuiBadge>
nested.inner1
:
</EuiBadge>
<small>
the-inner-1
  
</small>
</span>
<span
key="nested.inner2"
>
<EuiBadge>
nested.inner2
:
</EuiBadge>
<small>
the-inner-2
  
</small>
</span>
<span
key="arrayString"
>
<EuiBadge>
arrayString
:
</EuiBadge>
<small>
["the-array-string-1","the-array-string-2"]
  
</small>
</span>
<span
key="arrayObject"
>
<EuiBadge>
arrayObject
:
</EuiBadge>
<small>
[{"object1":"the-object-1"},{"object2":"the-objects-2"}]
  
</small>
</span>
</EuiText>
`;

View file

@ -1,35 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { SimpleQuery } from '../../../../common';
import { getSourceIndexDevConsoleStatement } from './common';
describe('Transform: Source Index Preview Common', () => {
test('getSourceIndexDevConsoleStatement()', () => {
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
const sourceIndexPreviewDevConsoleStatement = getSourceIndexDevConsoleStatement(
query,
'the-index-pattern-title'
);
expect(sourceIndexPreviewDevConsoleStatement).toBe(`GET the-index-pattern-title/_search
{
"query": {
"query_string": {
"query": "*",
"default_operator": "AND"
}
}
}
`);
});
});

View file

@ -1,17 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { PivotQuery } from '../../../../common';
export const getSourceIndexDevConsoleStatement = (query: PivotQuery, indexPatternTitle: string) => {
return `GET ${indexPatternTitle}/_search\n${JSON.stringify(
{
query,
},
null,
2
)}\n`;
};

View file

@ -1,46 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { shallow } from 'enzyme';
import React from 'react';
import { getNestedProperty } from '../../../../../../common/utils/object_utils';
import { getFlattenedFields } from '../../../../common';
import { ExpandedRow } from './expanded_row';
describe('Transform: <ExpandedRow />', () => {
test('Test against strings, objects and arrays.', () => {
const source = {
name: 'the-name',
nested: {
inner1: 'the-inner-1',
inner2: 'the-inner-2',
},
arrayString: ['the-array-string-1', 'the-array-string-2'],
arrayObject: [{ object1: 'the-object-1' }, { object2: 'the-objects-2' }],
} as Record<string, any>;
const flattenedSource = getFlattenedFields(source).reduce((p, c) => {
p[c] = getNestedProperty(source, c);
if (p[c] === undefined) {
p[c] = source[`"${c}"`];
}
return p;
}, {} as Record<string, any>);
const props = {
item: {
_id: 'the-id',
_source: flattenedSource,
},
};
const wrapper = shallow(<ExpandedRow {...props} />);
expect(wrapper).toMatchSnapshot();
});
});

View file

@ -1,22 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { EuiBadge, EuiText } from '@elastic/eui';
import { EsDoc } from '../../../../common';
export const ExpandedRow: React.FC<{ item: EsDoc }> = ({ item }) => (
<EuiText>
{Object.entries(item._source).map(([k, value]) => (
<span key={k}>
<EuiBadge>{k}:</EuiBadge>
<small> {typeof value === 'string' ? value : JSON.stringify(value)}&nbsp;&nbsp;</small>
</span>
))}
</EuiText>
);

View file

@ -1,38 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import React from 'react';
import { render, wait } from '@testing-library/react';
import '@testing-library/jest-dom/extend-expect';
import { getPivotQuery } from '../../../../common';
import { SearchItems } from '../../../../hooks/use_search_items';
import { SourceIndexPreview } from './source_index_preview';
jest.mock('../../../../../shared_imports');
jest.mock('../../../../../app/app_dependencies');
describe('Transform: <SourceIndexPreview />', () => {
// Using the async/await wait()/done() pattern to avoid act() errors.
test('Minimal initialization', async done => {
// Arrange
const props = {
indexPattern: {
title: 'the-index-pattern-title',
fields: [] as any[],
} as SearchItems['indexPattern'],
query: getPivotQuery('the-query'),
};
const { getByText } = render(<SourceIndexPreview {...props} />);
// Act
// Assert
expect(getByText(`Source index ${props.indexPattern.title}`)).toBeInTheDocument();
await wait();
done();
});
});

View file

@ -1,293 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import moment from 'moment-timezone';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { i18n } from '@kbn/i18n';
import {
EuiButtonIcon,
EuiCallOut,
EuiCodeBlock,
EuiCopy,
EuiDataGrid,
EuiFlexGroup,
EuiFlexItem,
EuiProgress,
EuiSpacer,
EuiTitle,
} from '@elastic/eui';
import { KBN_FIELD_TYPES } from '../../../../../../../../../src/plugins/data/common';
import { formatHumanReadableDateTimeSeconds } from '../../../../../../common/utils/date_utils';
import { getNestedProperty } from '../../../../../../common/utils/object_utils';
import {
euiDataGridStyle,
euiDataGridToolbarSettings,
EsFieldName,
PivotQuery,
INIT_MAX_COLUMNS,
} from '../../../../common';
import { SearchItems } from '../../../../hooks/use_search_items';
import { useToastNotifications } from '../../../../app_dependencies';
import { getSourceIndexDevConsoleStatement } from './common';
import { SOURCE_INDEX_STATUS, useSourceIndexData } from './use_source_index_data';
interface SourceIndexPreviewTitle {
indexPatternTitle: string;
}
const SourceIndexPreviewTitle: React.FC<SourceIndexPreviewTitle> = ({ indexPatternTitle }) => (
<EuiTitle size="xs">
<span>
{i18n.translate('xpack.transform.sourceIndexPreview.sourceIndexPatternTitle', {
defaultMessage: 'Source index {indexPatternTitle}',
values: { indexPatternTitle },
})}
</span>
</EuiTitle>
);
interface Props {
indexPattern: SearchItems['indexPattern'];
query: PivotQuery;
}
export const SourceIndexPreview: React.FC<Props> = React.memo(({ indexPattern, query }) => {
const toastNotifications = useToastNotifications();
const allFields = indexPattern.fields.map(f => f.name);
const indexPatternFields: string[] = allFields.filter(f => {
if (indexPattern.metaFields.includes(f)) {
return false;
}
const fieldParts = f.split('.');
const lastPart = fieldParts.pop();
if (lastPart === 'keyword' && allFields.includes(fieldParts.join('.'))) {
return false;
}
return true;
});
// Column visibility
const [visibleColumns, setVisibleColumns] = useState<EsFieldName[]>([]);
useEffect(() => {
setVisibleColumns(indexPatternFields.splice(0, INIT_MAX_COLUMNS));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexPatternFields.join()]);
const {
errorMessage,
pagination,
setPagination,
setSortingColumns,
rowCount,
sortingColumns,
status,
tableItems: data,
} = useSourceIndexData(indexPattern, query);
// EuiDataGrid State
const dataGridColumns = [
...indexPatternFields.map(id => {
const field = indexPattern.fields.getByName(id);
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
// To fall back to the default string schema it needs to be undefined.
let schema;
switch (field?.type) {
case KBN_FIELD_TYPES.BOOLEAN:
schema = 'boolean';
break;
case KBN_FIELD_TYPES.DATE:
schema = 'datetime';
break;
case KBN_FIELD_TYPES.GEO_POINT:
case KBN_FIELD_TYPES.GEO_SHAPE:
schema = 'json';
break;
case KBN_FIELD_TYPES.NUMBER:
schema = 'numeric';
break;
}
return { id, schema };
}),
];
const onSort = useCallback(
(sc: Array<{ id: string; direction: 'asc' | 'desc' }>) => {
// Check if an unsupported column type for sorting was selected.
const invalidSortingColumnns = sc.reduce<string[]>((arr, current) => {
const columnType = dataGridColumns.find(dgc => dgc.id === current.id);
if (columnType?.schema === 'json') {
arr.push(current.id);
}
return arr;
}, []);
if (invalidSortingColumnns.length === 0) {
setSortingColumns(sc);
} else {
invalidSortingColumnns.forEach(columnId => {
toastNotifications.addDanger(
i18n.translate('xpack.transform.sourceIndexPreview.invalidSortingColumnError', {
defaultMessage: `The column '{columnId}' cannot be used for sorting.`,
values: { columnId },
})
);
});
}
},
[dataGridColumns, setSortingColumns, toastNotifications]
);
const onChangeItemsPerPage = useCallback(
pageSize => {
setPagination(p => {
const pageIndex = Math.floor((p.pageSize * p.pageIndex) / pageSize);
return { pageIndex, pageSize };
});
},
[setPagination]
);
const onChangePage = useCallback(pageIndex => setPagination(p => ({ ...p, pageIndex })), [
setPagination,
]);
const renderCellValue = useMemo(() => {
return ({
rowIndex,
columnId,
setCellProps,
}: {
rowIndex: number;
columnId: string;
setCellProps: any;
}) => {
const adjustedRowIndex = rowIndex - pagination.pageIndex * pagination.pageSize;
const cellValue = data.hasOwnProperty(adjustedRowIndex)
? getNestedProperty(data[adjustedRowIndex], columnId, null)
: null;
if (typeof cellValue === 'object' && cellValue !== null) {
return JSON.stringify(cellValue);
}
if (cellValue === undefined || cellValue === null) {
return null;
}
const field = indexPattern.fields.getByName(columnId);
if (field?.type === KBN_FIELD_TYPES.DATE) {
return formatHumanReadableDateTimeSeconds(moment(cellValue).unix() * 1000);
}
if (field?.type === KBN_FIELD_TYPES.BOOLEAN) {
return cellValue ? 'true' : 'false';
}
return cellValue;
};
}, [data, indexPattern.fields, pagination.pageIndex, pagination.pageSize]);
if (status === SOURCE_INDEX_STATUS.LOADED && data.length === 0) {
return (
<div data-test-subj="transformSourceIndexPreview empty">
<SourceIndexPreviewTitle indexPatternTitle={indexPattern.title} />
<EuiCallOut
title={i18n.translate(
'xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle',
{
defaultMessage: 'Empty source index query result.',
}
)}
color="primary"
>
<p>
{i18n.translate('xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody', {
defaultMessage:
'The query for the source index returned no results. Please make sure you have sufficient permissions, the index contains documents and your query is not too restrictive.',
})}
</p>
</EuiCallOut>
</div>
);
}
const euiCopyText = i18n.translate('xpack.transform.sourceIndexPreview.copyClipboardTooltip', {
defaultMessage: 'Copy Dev Console statement of the source index preview to the clipboard.',
});
return (
<div
data-test-subj={`transformSourceIndexPreview ${
status === SOURCE_INDEX_STATUS.ERROR ? 'error' : 'loaded'
}`}
>
<EuiFlexGroup alignItems="center" justifyContent="spaceBetween">
<EuiFlexItem>
<SourceIndexPreviewTitle indexPatternTitle={indexPattern.title} />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiCopy
beforeMessage={euiCopyText}
textToCopy={getSourceIndexDevConsoleStatement(query, indexPattern.title)}
>
{(copy: () => void) => (
<EuiButtonIcon onClick={copy} iconType="copyClipboard" aria-label={euiCopyText} />
)}
</EuiCopy>
</EuiFlexItem>
</EuiFlexGroup>
<div className="transform__progress">
{status === SOURCE_INDEX_STATUS.LOADING && <EuiProgress size="xs" color="accent" />}
{status !== SOURCE_INDEX_STATUS.LOADING && (
<EuiProgress size="xs" color="accent" max={1} value={0} />
)}
</div>
{status === SOURCE_INDEX_STATUS.ERROR && (
<div data-test-subj="transformSourceIndexPreview error">
<EuiCallOut
title={i18n.translate('xpack.transform.sourceIndexPreview.sourceIndexPatternError', {
defaultMessage: 'An error occurred loading the source index data.',
})}
color="danger"
iconType="cross"
>
<EuiCodeBlock language="json" fontSize="s" paddingSize="s" isCopyable>
{errorMessage}
</EuiCodeBlock>
</EuiCallOut>
<EuiSpacer size="m" />
</div>
)}
<EuiDataGrid
aria-label="Source index preview"
columns={dataGridColumns}
columnVisibility={{ visibleColumns, setVisibleColumns }}
gridStyle={euiDataGridStyle}
rowCount={rowCount}
renderCellValue={renderCellValue}
sorting={{ columns: sortingColumns, onSort }}
toolbarVisibility={euiDataGridToolbarSettings}
pagination={{
...pagination,
pageSizeOptions: [5, 10, 25],
onChangeItemsPerPage,
onChangePage,
}}
/>
</div>
);
});

View file

@ -1,43 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { renderHook } from '@testing-library/react-hooks';
import '@testing-library/jest-dom/extend-expect';
import { SimpleQuery } from '../../../../common';
import {
SOURCE_INDEX_STATUS,
useSourceIndexData,
UseSourceIndexDataReturnType,
} from './use_source_index_data';
jest.mock('../../../../hooks/use_api');
const query: SimpleQuery = {
query_string: {
query: '*',
default_operator: 'AND',
},
};
describe('useSourceIndexData', () => {
test('indexPattern set triggers loading', async done => {
const { result, waitForNextUpdate } = renderHook(() =>
useSourceIndexData({ id: 'the-id', title: 'the-title', fields: [] }, query)
);
const sourceIndexObj: UseSourceIndexDataReturnType = result.current;
await waitForNextUpdate();
expect(sourceIndexObj.errorMessage).toBe('');
expect(sourceIndexObj.status).toBe(SOURCE_INDEX_STATUS.LOADING);
expect(sourceIndexObj.tableItems).toEqual([]);
done();
});
// TODO add more tests to check data retrieved via `api.esSearch()`.
// This needs more investigation in regards to jest's React Hooks support.
});

View file

@ -1,143 +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;
* you may not use this file except in compliance with the Elastic License.
*/
import { useEffect, useState, Dispatch, SetStateAction } from 'react';
import { SearchResponse } from 'elasticsearch';
import { EuiDataGridPaginationProps, EuiDataGridSorting } from '@elastic/eui';
import { IIndexPattern } from 'src/plugins/data/public';
import { Dictionary } from '../../../../../../common/types/common';
import { isDefaultQuery, matchAllQuery, EsDocSource, PivotQuery } from '../../../../common';
import { useApi } from '../../../../hooks/use_api';
export enum SOURCE_INDEX_STATUS {
UNUSED,
LOADING,
LOADED,
ERROR,
}
type EsSorting = Dictionary<{
order: 'asc' | 'desc';
}>;
interface ErrorResponse {
request: Dictionary<any>;
response: Dictionary<any>;
body: {
statusCode: number;
error: string;
message: string;
};
name: string;
req: Dictionary<any>;
res: Dictionary<any>;
}
const isErrorResponse = (arg: any): arg is ErrorResponse => {
return arg?.body?.error !== undefined && arg?.body?.message !== undefined;
};
// The types specified in `@types/elasticsearch` are out of date and still have `total: number`.
interface SearchResponse7 extends SearchResponse<any> {
hits: SearchResponse<any>['hits'] & {
total: {
value: number;
relation: string;
};
};
}
type SourceIndexSearchResponse = SearchResponse7;
type SourceIndexPagination = Pick<EuiDataGridPaginationProps, 'pageIndex' | 'pageSize'>;
const defaultPagination: SourceIndexPagination = { pageIndex: 0, pageSize: 5 };
export interface UseSourceIndexDataReturnType {
errorMessage: string;
pagination: SourceIndexPagination;
setPagination: Dispatch<SetStateAction<SourceIndexPagination>>;
setSortingColumns: Dispatch<SetStateAction<EuiDataGridSorting['columns']>>;
rowCount: number;
sortingColumns: EuiDataGridSorting['columns'];
status: SOURCE_INDEX_STATUS;
tableItems: EsDocSource[];
}
export const useSourceIndexData = (
indexPattern: IIndexPattern,
query: PivotQuery
): UseSourceIndexDataReturnType => {
const [errorMessage, setErrorMessage] = useState('');
const [status, setStatus] = useState(SOURCE_INDEX_STATUS.UNUSED);
const [pagination, setPagination] = useState(defaultPagination);
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
const [rowCount, setRowCount] = useState(0);
const [tableItems, setTableItems] = useState<EsDocSource[]>([]);
const api = useApi();
useEffect(() => {
setPagination(defaultPagination);
}, [query]);
const getSourceIndexData = async function() {
setErrorMessage('');
setStatus(SOURCE_INDEX_STATUS.LOADING);
const sort: EsSorting = sortingColumns.reduce((s, column) => {
s[column.id] = { order: column.direction };
return s;
}, {} as EsSorting);
const esSearchRequest = {
index: indexPattern.title,
body: {
// Instead of using the default query (`*`), fall back to a more efficient `match_all` query.
query: isDefaultQuery(query) ? matchAllQuery : query,
from: pagination.pageIndex * pagination.pageSize,
size: pagination.pageSize,
...(Object.keys(sort).length > 0 ? { sort } : {}),
},
};
try {
const resp: SourceIndexSearchResponse = await api.esSearch(esSearchRequest);
const docs = resp.hits.hits.map(d => d._source);
setRowCount(resp.hits.total.value);
setTableItems(docs);
setStatus(SOURCE_INDEX_STATUS.LOADED);
} catch (e) {
if (isErrorResponse(e)) {
setErrorMessage(`${e.body.error}: ${e.body.message}`);
} else {
setErrorMessage(JSON.stringify(e, null, 2));
}
setStatus(SOURCE_INDEX_STATUS.ERROR);
}
};
useEffect(() => {
getSourceIndexData();
// custom comparison
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]);
return {
errorMessage,
pagination,
setPagination,
setSortingColumns,
rowCount,
sortingColumns,
status,
tableItems,
};
};

View file

@ -35,19 +35,19 @@ import {
import { useXJsonMode } from '../../../../../../../../../src/plugins/es_ui_shared/static/ace_x_json/hooks';
import { PivotPreview } from '../../../../components/pivot_preview';
import { DataGrid } from '../../../../../shared_imports';
import {
getIndexDevConsoleStatement,
getPivotPreviewDevConsoleStatement,
} from '../../../../common/data_grid';
import { useDocumentationLinks } from '../../../../hooks/use_documentation_links';
import { SavedSearchQuery, SearchItems } from '../../../../hooks/use_search_items';
import { useIndexData } from '../../../../hooks/use_index_data';
import { usePivotData } from '../../../../hooks/use_pivot_data';
import { useToastNotifications } from '../../../../app_dependencies';
import { TransformPivotConfig } from '../../../../common';
import { dictionaryToArray, Dictionary } from '../../../../../../common/types/common';
import { DropDown } from '../aggregation_dropdown';
import { AggListForm } from '../aggregation_list';
import { GroupByListForm } from '../group_by_list';
import { SourceIndexPreview } from '../source_index_preview';
import { SwitchModal } from './switch_modal';
import {
getPivotQuery,
getPreviewRequestBody,
@ -61,11 +61,17 @@ import {
PivotGroupByConfig,
PivotGroupByConfigDict,
PivotSupportedGroupByAggs,
TransformPivotConfig,
PIVOT_SUPPORTED_AGGS,
PIVOT_SUPPORTED_GROUP_BY_AGGS,
} from '../../../../common';
import { DropDown } from '../aggregation_dropdown';
import { AggListForm } from '../aggregation_list';
import { GroupByListForm } from '../group_by_list';
import { getPivotDropdownOptions } from './common';
import { SwitchModal } from './switch_modal';
export interface StepDefineExposedState {
aggList: PivotAggsConfigDict;
@ -296,7 +302,6 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange,
return;
}
} catch (e) {
console.log('Invalid syntax', JSON.stringify(e, null, 2)); // eslint-disable-line no-console
setErrorMessage({ query: query.query as string, message: e.message });
}
};
@ -593,6 +598,9 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange,
/* eslint-enable react-hooks/exhaustive-deps */
]);
const indexPreviewProps = useIndexData(indexPattern, pivotQuery);
const pivotPreviewProps = usePivotData(indexPattern.title, pivotQuery, aggList, groupByList);
// TODO This should use the actual value of `indices.query.bool.max_clause_count`
const maxIndexFields = 1024;
const numIndexFields = indexPattern.fields.length;
@ -973,13 +981,37 @@ export const StepDefineForm: FC<Props> = React.memo(({ overrides = {}, onChange,
</EuiFlexItem>
<EuiFlexItem grow={false} style={{ maxWidth: 'calc(100% - 468px)' }}>
<SourceIndexPreview indexPattern={searchItems.indexPattern} query={pivotQuery} />
<DataGrid
{...indexPreviewProps}
copyToClipboard={getIndexDevConsoleStatement(pivotQuery, indexPattern.title)}
copyToClipboardDescription={i18n.translate(
'xpack.transform.indexPreview.copyClipboardTooltip',
{
defaultMessage: 'Copy Dev Console statement of the index preview to the clipboard.',
}
)}
dataTestSubj="transformIndexPreview"
title={i18n.translate('xpack.transform.indexPreview.indexPatternTitle', {
defaultMessage: 'Index {indexPatternTitle}',
values: { indexPatternTitle: indexPattern.title },
})}
toastNotifications={toastNotifications}
/>
<EuiHorizontalRule />
<PivotPreview
aggs={aggList}
groupBy={groupByList}
indexPatternTitle={searchItems.indexPattern.title}
query={pivotQuery}
<DataGrid
{...pivotPreviewProps}
copyToClipboard={getPivotPreviewDevConsoleStatement(previewRequest)}
copyToClipboardDescription={i18n.translate(
'xpack.transform.pivotPreview.copyClipboardTooltip',
{
defaultMessage: 'Copy Dev Console statement of the pivot preview to the clipboard.',
}
)}
dataTestSubj="transformPivotPreview"
title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewTitle', {
defaultMessage: 'Transform pivot preview',
})}
toastNotifications={toastNotifications}
/>
</EuiFlexItem>
</EuiFlexGroup>

View file

@ -17,8 +17,19 @@ import {
EuiText,
} from '@elastic/eui';
import { getPivotQuery, isDefaultQuery, isMatchAllQuery } from '../../../../common';
import { PivotPreview } from '../../../../components/pivot_preview';
import { dictionaryToArray } from '../../../../../../common/types/common';
import { DataGrid } from '../../../../../shared_imports';
import { useToastNotifications } from '../../../../app_dependencies';
import {
getPivotQuery,
getPivotPreviewDevConsoleStatement,
getPreviewRequestBody,
isDefaultQuery,
isMatchAllQuery,
} from '../../../../common';
import { usePivotData } from '../../../../hooks/use_pivot_data';
import { SearchItems } from '../../../../hooks/use_search_items';
import { AggListSummary } from '../aggregation_list';
@ -35,8 +46,25 @@ export const StepDefineSummary: FC<Props> = ({
formState: { searchString, searchQuery, groupByList, aggList },
searchItems,
}) => {
const toastNotifications = useToastNotifications();
const pivotAggsArr = dictionaryToArray(aggList);
const pivotGroupByArr = dictionaryToArray(groupByList);
const pivotQuery = getPivotQuery(searchQuery);
const previewRequest = getPreviewRequestBody(
searchItems.indexPattern.title,
pivotQuery,
pivotGroupByArr,
pivotAggsArr
);
const pivotPreviewProps = usePivotData(
searchItems.indexPattern.title,
pivotQuery,
aggList,
groupByList
);
return (
<EuiFlexGroup>
<EuiFlexItem grow={false} style={{ minWidth: '420px' }}>
@ -117,11 +145,20 @@ export const StepDefineSummary: FC<Props> = ({
<EuiFlexItem>
<EuiText>
<PivotPreview
aggs={aggList}
groupBy={groupByList}
indexPatternTitle={searchItems.indexPattern.title}
query={pivotQuery}
<DataGrid
{...pivotPreviewProps}
copyToClipboard={getPivotPreviewDevConsoleStatement(previewRequest)}
copyToClipboardDescription={i18n.translate(
'xpack.transform.pivotPreview.copyClipboardTooltip',
{
defaultMessage: 'Copy Dev Console statement of the pivot preview to the clipboard.',
}
)}
dataTestSubj="transformPivotPreview"
title={i18n.translate('xpack.transform.pivotPreview.PivotPreviewTitle', {
defaultMessage: 'Transform pivot preview',
})}
toastNotifications={toastNotifications}
/>
</EuiText>
</EuiFlexItem>

View file

@ -6,37 +6,39 @@
import React, { FC } from 'react';
import { SearchItems } from '../../../../hooks/use_search_items';
import { DataGrid } from '../../../../../shared_imports';
import { useToastNotifications } from '../../../../app_dependencies';
import { getPivotQuery, TransformPivotConfig } from '../../../../common';
import { usePivotData } from '../../../../hooks/use_pivot_data';
import { SearchItems } from '../../../../hooks/use_search_items';
import {
applyTransformConfigToDefineState,
getDefaultStepDefineState,
} from '../../../create_transform/components/step_define/';
import { PivotPreview } from '../../../../components/pivot_preview';
interface Props {
interface ExpandedRowPreviewPaneProps {
transformConfig: TransformPivotConfig;
}
export const ExpandedRowPreviewPane: FC<Props> = ({ transformConfig }) => {
const previewConfig = applyTransformConfigToDefineState(
export const ExpandedRowPreviewPane: FC<ExpandedRowPreviewPaneProps> = ({ transformConfig }) => {
const toastNotifications = useToastNotifications();
const { aggList, groupByList, searchQuery } = applyTransformConfigToDefineState(
getDefaultStepDefineState({} as SearchItems),
transformConfig
);
const pivotQuery = getPivotQuery(searchQuery);
const indexPatternTitle = Array.isArray(transformConfig.source.index)
? transformConfig.source.index.join(',')
: transformConfig.source.index;
const pivotPreviewProps = usePivotData(indexPatternTitle, pivotQuery, aggList, groupByList);
return (
<PivotPreview
aggs={previewConfig.aggList}
groupBy={previewConfig.groupByList}
indexPatternTitle={indexPatternTitle}
query={getPivotQuery(previewConfig.searchQuery)}
showHeader={false}
<DataGrid
{...pivotPreviewProps}
dataTestSubj="transformPivotPreview"
toastNotifications={toastNotifications}
/>
);
};

View file

@ -17,3 +17,18 @@ export {
} from '../../../../src/plugins/es_ui_shared/public/request/np_ready_request';
export { getErrorMessage } from '../../ml/common/util/errors';
export {
getDataGridSchemaFromKibanaFieldType,
getFieldsFromKibanaIndexPattern,
multiColumnSortFactory,
useDataGrid,
useRenderCellValue,
DataGrid,
EsSorting,
RenderCellValue,
SearchResponse7,
UseDataGridReturnType,
UseIndexDataReturnType,
} from '../../ml/public/application/components/data_grid';
export { INDEX_STATUS } from '../../ml/public/application/data_frame_analytics/common';

View file

@ -9443,14 +9443,8 @@
"xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixLabel": "分類混同行列",
"xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixPredictedLabel": "予測されたラベル",
"xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip": "マルチクラス混同行列には、分析が実際のクラスで正しくデータポイントを分類した発生数と、別のクラスで誤分類した発生数が含まれます。",
"xpack.ml.dataframe.analytics.classificationExploration.documentsShownHelpText": "予測があるドキュメントを示す",
"xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle": "分類ジョブID {jobId}の評価",
"xpack.ml.dataframe.analytics.classificationExploration.firstDocumentsShownHelpText": "予測がある最初の{searchSize}のドキュメントを示す",
"xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました",
"xpack.ml.dataframe.analytics.classificationExploration.jobCapsFetchError": "結果を取得できません。インデックスのフィールドデータの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationFetchError": "結果を取得できません。ジョブ構成データの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationNoResultsMessage": "結果が見つかりませんでした。",
"xpack.ml.dataframe.analytics.classificationExploration.regressionDocsLink": "回帰評価ドキュメント ",
"xpack.ml.dataframe.analytics.classificationExploration.showActions": "アクションを表示",
"xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "すべての列を表示",
"xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle": "分類ジョブID {jobId}のデスティネーションインデックス",
@ -9525,31 +9519,17 @@
"xpack.ml.dataframe.analytics.create.startDataFrameAnalyticsSuccessMessage": "データフレーム分析 {jobId} の開始リクエストが受け付けられました。",
"xpack.ml.dataframe.analytics.create.trainingPercentLabel": "トレーニングパーセンテージ",
"xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle": "機能影響スコア",
"xpack.ml.dataframe.analytics.exploration.dataGridAriaLabel": "外れ値検出結果表",
"xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "実験的",
"xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "データフレーム分析は実験段階の機能です。フィードバックをお待ちしています。",
"xpack.ml.dataframe.analytics.exploration.indexError": "インデックスデータの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.exploration.jobIdTitle": "外れ値検出ジョブID {jobId}",
"xpack.ml.dataframe.analytics.exploration.noDataCalloutBody": "インデックスのクエリが結果を返しませんでした。インデックスにドキュメントが含まれていて、クエリ要件が妥当であることを確認してください。",
"xpack.ml.dataframe.analytics.exploration.title": "分析の探索",
"xpack.ml.dataframe.analytics.regressionExploration.documentsShownHelpText": "予測があるドキュメントを示す",
"xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "データの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle": "回帰ジョブID {jobId}の評価",
"xpack.ml.dataframe.analytics.regressionExploration.fieldSelection": "{docFieldsCount, number} 件中 showing {selectedFieldsLength, number} 件の{docFieldsCount, plural, one {フィールド} other {フィールド}}",
"xpack.ml.dataframe.analytics.regressionExploration.firstDocumentsShownHelpText": "予測がある最初の{searchSize}のドキュメントを示す",
"xpack.ml.dataframe.analytics.regressionExploration.generalError": "データの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# doc} other {# docs}}が評価されました",
"xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "一般化エラー",
"xpack.ml.dataframe.analytics.regressionExploration.indexError": "インデックスデータの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.regressionExploration.jobCapsFetchError": "結果を取得できません。インデックスのフィールドデータの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.regressionExploration.jobConfigurationFetchError": "結果を取得できません。ジョブ構成データの読み込み中にエラーが発生しました。",
"xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorText": "平均二乗エラー",
"xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorTooltipContent": "回帰分析モデルの実行の効果を測定します。真値と予測値の間の差異の二乗平均合計。",
"xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutBody": "インデックスのクエリが結果を返しませんでした。ジョブが完了済みで、インデックスにドキュメントがあることを確認してください。",
"xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutTitle": "空のインデックスクエリ結果。",
"xpack.ml.dataframe.analytics.regressionExploration.noIndexCalloutBody": "インデックスのクエリが結果を返しませんでした。デスティネーションインデックスが存在し、ドキュメントがあることを確認してください。",
"xpack.ml.dataframe.analytics.regressionExploration.queryParsingErrorBody": "クエリ構文が無効であり、結果を返しませんでした。クエリ構文を確認し、再試行してください。",
"xpack.ml.dataframe.analytics.regressionExploration.queryParsingErrorMessage": "クエリをパースできません。",
"xpack.ml.dataframe.analytics.regressionExploration.rSquaredText": "R の二乗",
"xpack.ml.dataframe.analytics.regressionExploration.rSquaredTooltipContent": "適合度を表します。モデルによる観察された結果の複製の効果を測定します。",
"xpack.ml.dataframe.analytics.regressionExploration.tableJobIdTitle": "回帰ジョブID {jobId}のデスティネーションインデックス",
@ -15567,19 +15547,11 @@
"xpack.transform.newTransform.searchSelection.savedObjectType.indexPattern": "インデックスパターン",
"xpack.transform.newTransform.searchSelection.savedObjectType.search": "保存検索",
"xpack.transform.pivotPreview.copyClipboardTooltip": "ピボットプレビューの開発コンソールステートメントをクリップボードにコピーします。",
"xpack.transform.pivotPreview.PivotPreviewError": "ピボットプレビューの読み込み中にエラーが発生しました。",
"xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody": "group-by フィールドと集約を 1 つ以上選んでください。",
"xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "プレビューリクエストはデータを返しませんでした。オプションのクエリがデータを返し、グループ分け基準により使用されるフィールドと集約フィールドに値が存在することを確認してください。",
"xpack.transform.pivotPreview.PivotPreviewNoDataCalloutTitle": "ピボットプレビューを利用できません",
"xpack.transform.pivotPreview.PivotPreviewTitle": "ピボットプレビューを変換",
"xpack.transform.progress": "進捗",
"xpack.transform.sourceIndex": "ソースインデックス",
"xpack.transform.sourceIndexPreview.copyClipboardTooltip": "ソースインデックスプレビューの開発コンソールステートメントをクリップボードにコピーします。",
"xpack.transform.sourceIndexPreview.invalidSortingColumnError": "列「{columnId}」は並べ替えに使用できません。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody": "ソースインデックスのクエリが結果を返しませんでした。インデックスにドキュメントが含まれていて、クエリ要件が妥当であることを確認してください。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle": "ソースインデックスクエリの結果がありません",
"xpack.transform.sourceIndexPreview.sourceIndexPatternError": "ソースインデックスデータの読み込み中にエラーが発生しました。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternTitle": "ソースインデックス {indexPatternTitle}",
"xpack.transform.statsBar.batchTransformsLabel": "一斉",
"xpack.transform.statsBar.continuousTransformsLabel": "連続",
"xpack.transform.statsBar.failedTransformsLabel": "失敗",

View file

@ -9446,14 +9446,8 @@
"xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixLabel": "分类混淆矩阵",
"xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixPredictedLabel": "预测标签",
"xpack.ml.dataframe.analytics.classificationExploration.confusionMatrixTooltip": "多类混淆矩阵包含分析使用数据点的实际类正确分类数据点的次数以及分析使用其他类错误分类这些数据点的次数",
"xpack.ml.dataframe.analytics.classificationExploration.documentsShownHelpText": "正在显示有相关预测存在的文档",
"xpack.ml.dataframe.analytics.classificationExploration.evaluateJobIdTitle": "分类作业 ID {jobId} 的评估",
"xpack.ml.dataframe.analytics.classificationExploration.firstDocumentsShownHelpText": "正在显示有相关预测存在的前 {searchSize} 个文档",
"xpack.ml.dataframe.analytics.classificationExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估",
"xpack.ml.dataframe.analytics.classificationExploration.jobCapsFetchError": "无法提取结果。加载索引的字段数据时发生错误。",
"xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationFetchError": "无法提取结果。加载作业配置数据时发生错误。",
"xpack.ml.dataframe.analytics.classificationExploration.jobConfigurationNoResultsMessage": "未找到结果。",
"xpack.ml.dataframe.analytics.classificationExploration.regressionDocsLink": "回归评估文档 ",
"xpack.ml.dataframe.analytics.classificationExploration.showActions": "显示操作",
"xpack.ml.dataframe.analytics.classificationExploration.showAllColumns": "显示所有列",
"xpack.ml.dataframe.analytics.classificationExploration.tableJobIdTitle": "分类作业 ID {jobId} 的目标索引",
@ -9528,31 +9522,17 @@
"xpack.ml.dataframe.analytics.create.startDataFrameAnalyticsSuccessMessage": "数据帧分析 {jobId} 启动请求已确认。",
"xpack.ml.dataframe.analytics.create.trainingPercentLabel": "训练百分比",
"xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle": "功能影响分数",
"xpack.ml.dataframe.analytics.exploration.dataGridAriaLabel": "离群值检测结果表",
"xpack.ml.dataframe.analytics.exploration.experimentalBadgeLabel": "实验性",
"xpack.ml.dataframe.analytics.exploration.experimentalBadgeTooltipContent": "数据帧分析为实验功能。我们很乐意听取您的反馈意见。",
"xpack.ml.dataframe.analytics.exploration.indexError": "加载索引数据时出错。",
"xpack.ml.dataframe.analytics.exploration.jobIdTitle": "离群值检测作业 ID {jobId}",
"xpack.ml.dataframe.analytics.exploration.noDataCalloutBody": "该索引的查询未返回结果。请确保索引包含文档且您的查询限制不过于严格。",
"xpack.ml.dataframe.analytics.exploration.title": "分析浏览",
"xpack.ml.dataframe.analytics.regressionExploration.documentsShownHelpText": "正在显示有相关预测存在的文档",
"xpack.ml.dataframe.analytics.regressionExploration.evaluateError": "加载数据时出错。",
"xpack.ml.dataframe.analytics.regressionExploration.evaluateJobIdTitle": "回归作业 ID {jobId} 的评估",
"xpack.ml.dataframe.analytics.regressionExploration.fieldSelection": "已选择 {docFieldsCount, number} 个{docFieldsCount, plural, one {字段} other {字段}}中的 {selectedFieldsLength, number} 个",
"xpack.ml.dataframe.analytics.regressionExploration.firstDocumentsShownHelpText": "正在显示有相关预测存在的前 {searchSize} 个文档",
"xpack.ml.dataframe.analytics.regressionExploration.generalError": "加载数据时出错。",
"xpack.ml.dataframe.analytics.regressionExploration.generalizationDocsCount": "{docsCount, plural, one {# 个文档} other {# 个文档}}已评估",
"xpack.ml.dataframe.analytics.regressionExploration.generalizationErrorTitle": "泛化误差",
"xpack.ml.dataframe.analytics.regressionExploration.indexError": "加载索引数据时出错。",
"xpack.ml.dataframe.analytics.regressionExploration.jobCapsFetchError": "无法提取结果。加载索引的字段数据时发生错误。",
"xpack.ml.dataframe.analytics.regressionExploration.jobConfigurationFetchError": "无法提取结果。加载作业配置数据时发生错误。",
"xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorText": "均方误差",
"xpack.ml.dataframe.analytics.regressionExploration.meanSquaredErrorTooltipContent": "度量回归分析模型的表现。真实值与预测值之差的平均平方和。",
"xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutBody": "该索引的查询未返回结果。请确保作业已完成且索引包含文档。",
"xpack.ml.dataframe.analytics.regressionExploration.noDataCalloutTitle": "空的索引查询结果。",
"xpack.ml.dataframe.analytics.regressionExploration.noIndexCalloutBody": "该索引的查询未返回结果。请确保目标索引存在且包含文档。",
"xpack.ml.dataframe.analytics.regressionExploration.queryParsingErrorBody": "查询语法无效,未返回任何结果。请检查查询语法并重试。",
"xpack.ml.dataframe.analytics.regressionExploration.queryParsingErrorMessage": "无法解析查询。",
"xpack.ml.dataframe.analytics.regressionExploration.rSquaredText": "R 平方",
"xpack.ml.dataframe.analytics.regressionExploration.rSquaredTooltipContent": "表示拟合优度。度量模型复制被观察结果的优良性。",
"xpack.ml.dataframe.analytics.regressionExploration.tableJobIdTitle": "回归作业 ID {jobId} 的目标索引",
@ -15571,19 +15551,11 @@
"xpack.transform.newTransform.searchSelection.savedObjectType.indexPattern": "索引模式",
"xpack.transform.newTransform.searchSelection.savedObjectType.search": "已保存搜索",
"xpack.transform.pivotPreview.copyClipboardTooltip": "将透视预览的开发控制台语句复制到剪贴板。",
"xpack.transform.pivotPreview.PivotPreviewError": "加载数据透视表预览时出错。",
"xpack.transform.pivotPreview.PivotPreviewIncompleteConfigCalloutBody": "请至少选择一个分组依据字段和聚合。",
"xpack.transform.pivotPreview.PivotPreviewNoDataCalloutBody": "预览请求未返回任何数据。请确保可选查询返回数据且存在分组依据和聚合字段使用的字段的值。",
"xpack.transform.pivotPreview.PivotPreviewNoDataCalloutTitle": "数据透视表预览不可用",
"xpack.transform.pivotPreview.PivotPreviewTitle": "转换数据透视表预览",
"xpack.transform.progress": "进度",
"xpack.transform.sourceIndex": "源索引",
"xpack.transform.sourceIndexPreview.copyClipboardTooltip": "将源索引预览的开发控制台语句复制到剪贴板。",
"xpack.transform.sourceIndexPreview.invalidSortingColumnError": "列“{columnId}”无法用于排序。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutBody": "源索引的查询未返回结果。请确保索引包含文档且您的查询限制不过于严格。",
"xpack.transform.sourceIndexPreview.SourceIndexNoDataCalloutTitle": "源索引查询结果为空。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternError": "加载源索引数据时出错。",
"xpack.transform.sourceIndexPreview.sourceIndexPatternTitle": "源索引 {indexPatternTitle}",
"xpack.transform.statsBar.batchTransformsLabel": "批量",
"xpack.transform.statsBar.continuousTransformsLabel": "连续",
"xpack.transform.statsBar.failedTransformsLabel": "失败",

View file

@ -90,7 +90,7 @@ export default function({ getService }: FtrProviderContext) {
mode: 'batch',
progress: '100',
},
sourcePreview: {
indexPreview: {
columns: 20,
rows: 5,
},
@ -144,7 +144,7 @@ export default function({ getService }: FtrProviderContext) {
mode: 'batch',
progress: '100',
},
sourcePreview: {
indexPreview: {
columns: 20,
rows: 5,
},
@ -180,14 +180,14 @@ export default function({ getService }: FtrProviderContext) {
await transform.wizard.assertDefineStepActive();
});
it('loads the source index preview', async () => {
await transform.wizard.assertSourceIndexPreviewLoaded();
it('loads the index preview', async () => {
await transform.wizard.assertIndexPreviewLoaded();
});
it('shows the source index preview', async () => {
await transform.wizard.assertSourceIndexPreview(
testData.expected.sourcePreview.columns,
testData.expected.sourcePreview.rows
it('shows the index preview', async () => {
await transform.wizard.assertIndexPreview(
testData.expected.indexPreview.columns,
testData.expected.indexPreview.rows
);
});

View file

@ -65,7 +65,7 @@ export default function({ getService }: FtrProviderContext) {
progress: '100',
},
sourceIndex: 'ft_farequote',
sourcePreview: {
indexPreview: {
column: 2,
values: ['ASA'],
},
@ -101,14 +101,14 @@ export default function({ getService }: FtrProviderContext) {
await transform.wizard.assertDefineStepActive();
});
it('loads the source index preview', async () => {
await transform.wizard.assertSourceIndexPreviewLoaded();
it('loads the index preview', async () => {
await transform.wizard.assertIndexPreviewLoaded();
});
it('shows the filtered source index preview', async () => {
await transform.wizard.assertSourceIndexPreviewColumnValues(
testData.expected.sourcePreview.column,
testData.expected.sourcePreview.values
it('shows the filtered index preview', async () => {
await transform.wizard.assertIndexPreviewColumnValues(
testData.expected.indexPreview.column,
testData.expected.indexPreview.values
);
});

View file

@ -41,7 +41,7 @@ export function MachineLearningDataFrameAnalyticsProvider(
},
async assertRegressionTablePanelExists() {
await testSubjects.existOrFail('mlDFAnalyticsRegressionExplorationTablePanel');
await testSubjects.existOrFail('mlDFAnalyticsExplorationTablePanel');
},
async assertClassificationEvaluatePanelElementsExists() {
@ -50,7 +50,7 @@ export function MachineLearningDataFrameAnalyticsProvider(
},
async assertClassificationTablePanelExists() {
await testSubjects.existOrFail('mlDFAnalyticsClassificationExplorationTablePanel');
await testSubjects.existOrFail('mlDFAnalyticsExplorationTablePanel');
},
async assertOutlierTablePanelExists() {

View file

@ -52,8 +52,8 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
await this.assertDetailsSummaryExists();
},
async assertSourceIndexPreviewExists(subSelector?: string) {
let selector = 'transformSourceIndexPreview';
async assertIndexPreviewExists(subSelector?: string) {
let selector = 'transformIndexPreview';
if (subSelector !== undefined) {
selector = `${selector} ${subSelector}`;
} else {
@ -62,8 +62,8 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
await testSubjects.existOrFail(selector);
},
async assertSourceIndexPreviewLoaded() {
await this.assertSourceIndexPreviewExists('loaded');
async assertIndexPreviewLoaded() {
await this.assertIndexPreviewExists('loaded');
},
async assertPivotPreviewExists(subSelector?: string) {
@ -124,10 +124,10 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
});
},
async assertSourceIndexPreview(columns: number, rows: number) {
async assertIndexPreview(columns: number, rows: number) {
await retry.tryForTime(2000, async () => {
// get a 2D array of rows and cell values
const rowsData = await this.parseEuiDataGrid('transformSourceIndexPreview');
const rowsData = await this.parseEuiDataGrid('transformIndexPreview');
expect(rowsData).to.length(
rows,
@ -143,8 +143,8 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
});
},
async assertSourceIndexPreviewColumnValues(column: number, values: string[]) {
await this.assertEuiDataGridColumnValues('transformSourceIndexPreview', column, values);
async assertIndexPreviewColumnValues(column: number, values: string[]) {
await this.assertEuiDataGridColumnValues('transformIndexPreview', column, values);
},
async assertPivotPreviewColumnValues(column: number, values: string[]) {