mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 09:48:58 -04:00
[ML] Data Grid Histograms (#68359)
Adds support for histogram charts to data grid columns. - Adds a toggle button to the data grid's header to enabled/disable column charts. - When enabled, the charts get rendered as part of the data grid header. - Histogram charts will get rendered for fields based on date, number, string and boolean.
This commit is contained in:
parent
639d1e0829
commit
a489e5f0b1
24 changed files with 996 additions and 95 deletions
|
@ -6,6 +6,8 @@
|
|||
|
||||
import euiVars from '@elastic/eui/dist/eui_theme_dark.json';
|
||||
|
||||
import { stringHash } from './string_utils';
|
||||
|
||||
const COLORS = [
|
||||
euiVars.euiColorVis0,
|
||||
euiVars.euiColorVis1,
|
||||
|
@ -33,17 +35,3 @@ export function tabColor(name: string): string {
|
|||
return colorMap[name];
|
||||
}
|
||||
}
|
||||
|
||||
function stringHash(str: string): number {
|
||||
let hash = 0;
|
||||
let chr = 0;
|
||||
if (str.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr; // eslint-disable-line no-bitwise
|
||||
hash |= 0; // eslint-disable-line no-bitwise
|
||||
}
|
||||
return hash < 0 ? hash * -2 : hash;
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { renderTemplate, getMedianStringLength } from './string_utils';
|
||||
import { renderTemplate, getMedianStringLength, stringHash } from './string_utils';
|
||||
|
||||
const strings: string[] = [
|
||||
'foo',
|
||||
|
@ -46,4 +46,12 @@ describe('ML - string utils', () => {
|
|||
expect(result).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('stringHash', () => {
|
||||
test('should return a unique number based off a string', () => {
|
||||
const hash1 = stringHash('the-string-1');
|
||||
const hash2 = stringHash('the-string-2');
|
||||
expect(hash1).not.toBe(hash2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -22,3 +22,20 @@ export function getMedianStringLength(strings: string[]) {
|
|||
const sortedStringLengths = strings.map((s) => s.length).sort((a, b) => a - b);
|
||||
return sortedStringLengths[Math.floor(sortedStringLengths.length / 2)] || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a deterministic number based hash out of a string.
|
||||
*/
|
||||
export function stringHash(str: string): number {
|
||||
let hash = 0;
|
||||
let chr = 0;
|
||||
if (str.length === 0) {
|
||||
return hash;
|
||||
}
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
chr = str.charCodeAt(i);
|
||||
hash = (hash << 5) - hash + chr; // eslint-disable-line no-bitwise
|
||||
hash |= 0; // eslint-disable-line no-bitwise
|
||||
}
|
||||
return hash < 0 ? hash * -2 : hash;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
.mlDataGridChart__histogram {
|
||||
width: 100%;
|
||||
height: $euiSizeXL + $euiSizeXXL;
|
||||
}
|
||||
|
||||
.mlDataGridChart__legend {
|
||||
@include euiTextTruncate;
|
||||
@include euiFontSizeXS;
|
||||
|
||||
color: $euiColorMediumShade;
|
||||
display: block;
|
||||
overflow-x: hidden;
|
||||
margin: $euiSizeXS 0px 0px 0px;
|
||||
font-style: italic;
|
||||
font-weight: normal;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.mlDataGridChart__legend--numeric {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.mlDataGridChart__legendBoolean {
|
||||
width: 100%;
|
||||
td { text-align: center }
|
||||
}
|
||||
|
||||
/* Override to align column header to bottom of cell when no chart is available */
|
||||
.mlDataGrid .euiDataGridHeaderCell__content {
|
||||
margin-top: auto;
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
/*
|
||||
* 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 classNames from 'classnames';
|
||||
|
||||
import { BarSeries, Chart, Settings } from '@elastic/charts';
|
||||
import { EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import './column_chart.scss';
|
||||
|
||||
import { isUnsupportedChartData, useColumnChart, ChartData } from './use_column_chart';
|
||||
|
||||
interface Props {
|
||||
chartData: ChartData;
|
||||
columnType: EuiDataGridColumn;
|
||||
dataTestSubj: string;
|
||||
}
|
||||
|
||||
export const ColumnChart: FC<Props> = ({ chartData, columnType, dataTestSubj }) => {
|
||||
const { data, legendText, xScaleType } = useColumnChart(chartData, columnType);
|
||||
|
||||
return (
|
||||
<div data-test-subj={dataTestSubj}>
|
||||
{!isUnsupportedChartData(chartData) && data.length > 0 && (
|
||||
<div className="mlDataGridChart__histogram" data-test-subj={`${dataTestSubj}-histogram`}>
|
||||
<Chart>
|
||||
<Settings
|
||||
theme={{
|
||||
background: { color: 'transparent' },
|
||||
chartMargins: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 1,
|
||||
},
|
||||
chartPaddings: {
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
},
|
||||
scales: { barsPadding: 0.1 },
|
||||
}}
|
||||
/>
|
||||
<BarSeries
|
||||
id="histogram"
|
||||
name="count"
|
||||
xScaleType={xScaleType}
|
||||
yScaleType="linear"
|
||||
xAccessor="key"
|
||||
yAccessors={['doc_count']}
|
||||
styleAccessor={(d) => d.datum.color}
|
||||
data={data}
|
||||
/>
|
||||
</Chart>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={classNames('mlDataGridChart__legend', {
|
||||
'mlDataGridChart__legend--numeric': columnType.schema === 'number',
|
||||
})}
|
||||
data-test-subj={`${dataTestSubj}-legend`}
|
||||
>
|
||||
{legendText}
|
||||
</div>
|
||||
<div data-test-subj={`${dataTestSubj}-id`}>{columnType.id}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
|
@ -13,6 +13,10 @@ import {
|
|||
EuiDataGridStyle,
|
||||
} from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { CoreSetup } from 'src/core/public';
|
||||
|
||||
import {
|
||||
IndexPattern,
|
||||
IFieldType,
|
||||
|
@ -20,6 +24,8 @@ import {
|
|||
KBN_FIELD_TYPES,
|
||||
} from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
import { extractErrorMessage } from '../../../../common/util/errors';
|
||||
|
||||
import {
|
||||
BASIC_NUMERICAL_TYPES,
|
||||
EXTENDED_NUMERICAL_TYPES,
|
||||
|
@ -37,7 +43,7 @@ import { mlFieldFormatService } from '../../services/field_format_service';
|
|||
|
||||
import { DataGridItem, IndexPagination, RenderCellValue } from './types';
|
||||
|
||||
export const INIT_MAX_COLUMNS = 20;
|
||||
export const INIT_MAX_COLUMNS = 10;
|
||||
|
||||
export const euiDataGridStyle: EuiDataGridStyle = {
|
||||
border: 'all',
|
||||
|
@ -102,6 +108,8 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
|
|||
case 'boolean':
|
||||
schema = 'boolean';
|
||||
break;
|
||||
case 'text':
|
||||
schema = NON_AGGREGATABLE;
|
||||
}
|
||||
|
||||
if (
|
||||
|
@ -122,7 +130,10 @@ export const getDataGridSchemasFromFieldTypes = (fieldTypes: FieldTypes, results
|
|||
});
|
||||
};
|
||||
|
||||
export const getDataGridSchemaFromKibanaFieldType = (field: IFieldType | undefined) => {
|
||||
export const NON_AGGREGATABLE = 'non-aggregatable';
|
||||
export const getDataGridSchemaFromKibanaFieldType = (
|
||||
field: IFieldType | undefined
|
||||
): string | 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;
|
||||
|
@ -143,6 +154,10 @@ export const getDataGridSchemaFromKibanaFieldType = (field: IFieldType | undefin
|
|||
break;
|
||||
}
|
||||
|
||||
if (schema === undefined && field?.aggregatable === false) {
|
||||
return NON_AGGREGATABLE;
|
||||
}
|
||||
|
||||
return schema;
|
||||
};
|
||||
|
||||
|
@ -289,3 +304,17 @@ export const multiColumnSortFactory = (sortingColumns: EuiDataGridSorting['colum
|
|||
|
||||
return sortFn;
|
||||
};
|
||||
|
||||
export const showDataGridColumnChartErrorMessageToast = (
|
||||
e: any,
|
||||
toastNotifications: CoreSetup['notifications']['toasts']
|
||||
) => {
|
||||
const error = extractErrorMessage(e);
|
||||
|
||||
toastNotifications.addDanger(
|
||||
i18n.translate('xpack.ml.dataGrid.columnChart.ErrorMessageToast', {
|
||||
defaultMessage: 'An error occurred fetching the histogram charts data: {error}',
|
||||
values: { error: error !== '' ? error : e },
|
||||
})
|
||||
);
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ import React, { memo, useEffect, FC } from 'react';
|
|||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import {
|
||||
EuiButtonEmpty,
|
||||
EuiButtonIcon,
|
||||
EuiCallOut,
|
||||
EuiCodeBlock,
|
||||
|
@ -27,6 +28,8 @@ import { INDEX_STATUS } from '../../data_frame_analytics/common';
|
|||
|
||||
import { euiDataGridStyle, euiDataGridToolbarSettings } from './common';
|
||||
import { UseIndexDataReturnType } from './types';
|
||||
// TODO Fix row hovering + bar highlighting
|
||||
// import { hoveredRow$ } from './column_chart';
|
||||
|
||||
export const DataGridTitle: FC<{ title: string }> = ({ title }) => (
|
||||
<EuiTitle size="xs">
|
||||
|
@ -54,7 +57,9 @@ type Props = PropsWithHeader | PropsWithoutHeader;
|
|||
export const DataGrid: FC<Props> = memo(
|
||||
(props) => {
|
||||
const {
|
||||
columns,
|
||||
chartsVisible,
|
||||
chartsButtonVisible,
|
||||
columnsWithCharts,
|
||||
dataTestSubj,
|
||||
errorMessage,
|
||||
invalidSortingColumnns,
|
||||
|
@ -70,9 +75,18 @@ export const DataGrid: FC<Props> = memo(
|
|||
status,
|
||||
tableItems: data,
|
||||
toastNotifications,
|
||||
toggleChartVisibility,
|
||||
visibleColumns,
|
||||
} = props;
|
||||
|
||||
// TODO Fix row hovering + bar highlighting
|
||||
// const getRowProps = (item: any) => {
|
||||
// return {
|
||||
// onMouseOver: () => hoveredRow$.next(item),
|
||||
// onMouseLeave: () => hoveredRow$.next(null),
|
||||
// };
|
||||
// };
|
||||
|
||||
useEffect(() => {
|
||||
if (invalidSortingColumnns.length > 0) {
|
||||
invalidSortingColumnns.forEach((columnId) => {
|
||||
|
@ -162,22 +176,50 @@ export const DataGrid: FC<Props> = memo(
|
|||
<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 className="mlDataGrid">
|
||||
<EuiDataGrid
|
||||
aria-label={isWithHeader(props) ? props.title : ''}
|
||||
columns={columnsWithCharts.map((c) => {
|
||||
c.initialWidth = 165;
|
||||
return c;
|
||||
})}
|
||||
columnVisibility={{ visibleColumns, setVisibleColumns }}
|
||||
gridStyle={euiDataGridStyle}
|
||||
rowCount={rowCount}
|
||||
renderCellValue={renderCellValue}
|
||||
sorting={{ columns: sortingColumns, onSort }}
|
||||
toolbarVisibility={{
|
||||
...euiDataGridToolbarSettings,
|
||||
...(chartsButtonVisible
|
||||
? {
|
||||
additionalControls: (
|
||||
<EuiButtonEmpty
|
||||
aria-checked={chartsVisible}
|
||||
className={`euiDataGrid__controlBtn${
|
||||
chartsVisible ? ' euiDataGrid__controlBtn--active' : ''
|
||||
}`}
|
||||
data-test-subj={`${dataTestSubj}HistogramButton`}
|
||||
size="xs"
|
||||
iconType="visBarVertical"
|
||||
color="text"
|
||||
onClick={toggleChartVisibility}
|
||||
>
|
||||
{i18n.translate('xpack.ml.dataGrid.histogramButtonText', {
|
||||
defaultMessage: 'Histogram charts',
|
||||
})}
|
||||
</EuiButtonEmpty>
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
}}
|
||||
pagination={{
|
||||
...pagination,
|
||||
pageSizeOptions: [5, 10, 25],
|
||||
onChangeItemsPerPage,
|
||||
onChangePage,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
@ -186,7 +228,7 @@ export const DataGrid: FC<Props> = memo(
|
|||
|
||||
function pickProps(props: Props) {
|
||||
return [
|
||||
props.columns,
|
||||
props.columnsWithCharts,
|
||||
props.dataTestSubj,
|
||||
props.errorMessage,
|
||||
props.invalidSortingColumnns,
|
||||
|
|
|
@ -9,8 +9,10 @@ export {
|
|||
getDataGridSchemaFromKibanaFieldType,
|
||||
getFieldsFromKibanaIndexPattern,
|
||||
multiColumnSortFactory,
|
||||
showDataGridColumnChartErrorMessageToast,
|
||||
useRenderCellValue,
|
||||
} from './common';
|
||||
export { fetchChartsData, ChartData } from './use_column_chart';
|
||||
export { useDataGrid } from './use_data_grid';
|
||||
export { DataGrid } from './data_grid';
|
||||
export {
|
||||
|
|
|
@ -13,6 +13,8 @@ import { Dictionary } from '../../../../common/types/common';
|
|||
|
||||
import { INDEX_STATUS } from '../../data_frame_analytics/common/analytics';
|
||||
|
||||
import { ChartData } from './use_column_chart';
|
||||
|
||||
export type ColumnId = string;
|
||||
export type DataGridItem = Record<string, any>;
|
||||
|
||||
|
@ -54,6 +56,9 @@ export interface SearchResponse7 extends SearchResponse<any> {
|
|||
export interface UseIndexDataReturnType
|
||||
extends Pick<
|
||||
UseDataGridReturnType,
|
||||
| 'chartsVisible'
|
||||
| 'chartsButtonVisible'
|
||||
| 'columnsWithCharts'
|
||||
| 'errorMessage'
|
||||
| 'invalidSortingColumnns'
|
||||
| 'noDataMessage'
|
||||
|
@ -67,13 +72,16 @@ export interface UseIndexDataReturnType
|
|||
| 'sortingColumns'
|
||||
| 'status'
|
||||
| 'tableItems'
|
||||
| 'toggleChartVisibility'
|
||||
| 'visibleColumns'
|
||||
> {
|
||||
columns: EuiDataGridColumn[];
|
||||
renderCellValue: RenderCellValue;
|
||||
}
|
||||
|
||||
export interface UseDataGridReturnType {
|
||||
chartsVisible: boolean;
|
||||
chartsButtonVisible: boolean;
|
||||
columnsWithCharts: EuiDataGridColumn[];
|
||||
errorMessage: string;
|
||||
invalidSortingColumnns: ColumnId[];
|
||||
noDataMessage: string;
|
||||
|
@ -83,6 +91,7 @@ export interface UseDataGridReturnType {
|
|||
pagination: IndexPagination;
|
||||
resetPagination: () => void;
|
||||
rowCount: number;
|
||||
setColumnCharts: Dispatch<SetStateAction<ChartData[]>>;
|
||||
setErrorMessage: Dispatch<SetStateAction<string>>;
|
||||
setNoDataMessage: Dispatch<SetStateAction<string>>;
|
||||
setPagination: Dispatch<SetStateAction<IndexPagination>>;
|
||||
|
@ -94,5 +103,6 @@ export interface UseDataGridReturnType {
|
|||
sortingColumns: EuiDataGridSorting['columns'];
|
||||
status: INDEX_STATUS;
|
||||
tableItems: DataGridItem[];
|
||||
toggleChartVisibility: () => void;
|
||||
visibleColumns: ColumnId[];
|
||||
}
|
||||
|
|
|
@ -0,0 +1,432 @@
|
|||
/*
|
||||
* 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';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import React from 'react';
|
||||
|
||||
import { useObservable } from 'react-use';
|
||||
|
||||
import { euiPaletteColorBlind, EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { KBN_FIELD_TYPES } from '../../../../../../../src/plugins/data/public';
|
||||
|
||||
import { stringHash } from '../../../../common/util/string_utils';
|
||||
|
||||
import { NON_AGGREGATABLE } from './common';
|
||||
|
||||
export const hoveredRow$ = new BehaviorSubject<any | null>(null);
|
||||
|
||||
const BAR_COLOR = euiPaletteColorBlind()[0];
|
||||
const BAR_COLOR_BLUR = euiPaletteColorBlind({ rotations: 2 })[10];
|
||||
const MAX_CHART_COLUMNS = 20;
|
||||
|
||||
type XScaleType = 'ordinal' | 'time' | 'linear' | undefined;
|
||||
const getXScaleType = (kbnFieldType: KBN_FIELD_TYPES | undefined): XScaleType => {
|
||||
switch (kbnFieldType) {
|
||||
case KBN_FIELD_TYPES.BOOLEAN:
|
||||
case KBN_FIELD_TYPES.IP:
|
||||
case KBN_FIELD_TYPES.STRING:
|
||||
return 'ordinal';
|
||||
case KBN_FIELD_TYPES.DATE:
|
||||
return 'time';
|
||||
case KBN_FIELD_TYPES.NUMBER:
|
||||
return 'linear';
|
||||
}
|
||||
};
|
||||
|
||||
const getFieldType = (schema: EuiDataGridColumn['schema']): KBN_FIELD_TYPES | undefined => {
|
||||
if (schema === NON_AGGREGATABLE) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let fieldType: KBN_FIELD_TYPES;
|
||||
|
||||
switch (schema) {
|
||||
case 'datetime':
|
||||
fieldType = KBN_FIELD_TYPES.DATE;
|
||||
break;
|
||||
case 'numeric':
|
||||
fieldType = KBN_FIELD_TYPES.NUMBER;
|
||||
break;
|
||||
case 'boolean':
|
||||
fieldType = KBN_FIELD_TYPES.BOOLEAN;
|
||||
break;
|
||||
case 'json':
|
||||
fieldType = KBN_FIELD_TYPES.OBJECT;
|
||||
break;
|
||||
default:
|
||||
fieldType = KBN_FIELD_TYPES.STRING;
|
||||
}
|
||||
|
||||
return fieldType;
|
||||
};
|
||||
|
||||
interface NumericColumnStats {
|
||||
interval: number;
|
||||
min: number;
|
||||
max: number;
|
||||
}
|
||||
type NumericColumnStatsMap = Record<string, NumericColumnStats>;
|
||||
const getAggIntervals = async (
|
||||
indexPatternTitle: string,
|
||||
esSearch: (payload: any) => Promise<any>,
|
||||
query: any,
|
||||
columnTypes: EuiDataGridColumn[]
|
||||
): Promise<NumericColumnStatsMap> => {
|
||||
const numericColumns = columnTypes.filter((cT) => {
|
||||
const fieldType = getFieldType(cT.schema);
|
||||
return fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE;
|
||||
});
|
||||
|
||||
if (numericColumns.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const minMaxAggs = numericColumns.reduce((aggs, c) => {
|
||||
const id = stringHash(c.id);
|
||||
aggs[id] = {
|
||||
stats: {
|
||||
field: c.id,
|
||||
},
|
||||
};
|
||||
return aggs;
|
||||
}, {} as Record<string, object>);
|
||||
|
||||
const respStats = await esSearch({
|
||||
index: indexPatternTitle,
|
||||
size: 0,
|
||||
body: {
|
||||
query,
|
||||
aggs: minMaxAggs,
|
||||
size: 0,
|
||||
},
|
||||
});
|
||||
|
||||
return Object.keys(respStats.aggregations).reduce((p, aggName) => {
|
||||
const stats = [respStats.aggregations[aggName].min, respStats.aggregations[aggName].max];
|
||||
if (!stats.includes(null)) {
|
||||
const delta = respStats.aggregations[aggName].max - respStats.aggregations[aggName].min;
|
||||
|
||||
let aggInterval = 1;
|
||||
|
||||
if (delta > MAX_CHART_COLUMNS) {
|
||||
aggInterval = Math.round(delta / MAX_CHART_COLUMNS);
|
||||
}
|
||||
|
||||
if (delta <= 1) {
|
||||
aggInterval = delta / MAX_CHART_COLUMNS;
|
||||
}
|
||||
|
||||
p[aggName] = { interval: aggInterval, min: stats[0], max: stats[1] };
|
||||
}
|
||||
|
||||
return p;
|
||||
}, {} as NumericColumnStatsMap);
|
||||
};
|
||||
|
||||
interface AggHistogram {
|
||||
histogram: {
|
||||
field: string;
|
||||
interval: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface AggCardinality {
|
||||
cardinality: {
|
||||
field: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface AggTerms {
|
||||
terms: {
|
||||
field: string;
|
||||
size: number;
|
||||
};
|
||||
}
|
||||
|
||||
type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms;
|
||||
|
||||
export const fetchChartsData = async (
|
||||
indexPatternTitle: string,
|
||||
esSearch: (payload: any) => Promise<any>,
|
||||
query: any,
|
||||
columnTypes: EuiDataGridColumn[]
|
||||
): Promise<ChartData[]> => {
|
||||
const aggIntervals = await getAggIntervals(indexPatternTitle, esSearch, query, columnTypes);
|
||||
|
||||
const chartDataAggs = columnTypes.reduce((aggs, c) => {
|
||||
const fieldType = getFieldType(c.schema);
|
||||
const id = stringHash(c.id);
|
||||
if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) {
|
||||
if (aggIntervals[id] !== undefined) {
|
||||
aggs[`${id}_histogram`] = {
|
||||
histogram: {
|
||||
field: c.id,
|
||||
interval: aggIntervals[id].interval !== 0 ? aggIntervals[id].interval : 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) {
|
||||
if (fieldType === KBN_FIELD_TYPES.STRING) {
|
||||
aggs[`${id}_cardinality`] = {
|
||||
cardinality: {
|
||||
field: c.id,
|
||||
},
|
||||
};
|
||||
}
|
||||
aggs[`${id}_terms`] = {
|
||||
terms: {
|
||||
field: c.id,
|
||||
size: MAX_CHART_COLUMNS,
|
||||
},
|
||||
};
|
||||
}
|
||||
return aggs;
|
||||
}, {} as Record<string, ChartRequestAgg>);
|
||||
|
||||
if (Object.keys(chartDataAggs).length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const respChartsData = await esSearch({
|
||||
index: indexPatternTitle,
|
||||
size: 0,
|
||||
body: {
|
||||
query,
|
||||
aggs: chartDataAggs,
|
||||
size: 0,
|
||||
},
|
||||
});
|
||||
|
||||
const chartsData: ChartData[] = columnTypes.map(
|
||||
(c): ChartData => {
|
||||
const fieldType = getFieldType(c.schema);
|
||||
const id = stringHash(c.id);
|
||||
|
||||
if (fieldType === KBN_FIELD_TYPES.NUMBER || fieldType === KBN_FIELD_TYPES.DATE) {
|
||||
if (aggIntervals[id] === undefined) {
|
||||
return {
|
||||
type: 'numeric',
|
||||
data: [],
|
||||
interval: 0,
|
||||
stats: [0, 0],
|
||||
id: c.id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: respChartsData.aggregations[`${id}_histogram`].buckets,
|
||||
interval: aggIntervals[id].interval,
|
||||
stats: [aggIntervals[id].min, aggIntervals[id].max],
|
||||
type: 'numeric',
|
||||
id: c.id,
|
||||
};
|
||||
} else if (fieldType === KBN_FIELD_TYPES.STRING || fieldType === KBN_FIELD_TYPES.BOOLEAN) {
|
||||
return {
|
||||
type: fieldType === KBN_FIELD_TYPES.STRING ? 'ordinal' : 'boolean',
|
||||
cardinality:
|
||||
fieldType === KBN_FIELD_TYPES.STRING
|
||||
? respChartsData.aggregations[`${id}_cardinality`].value
|
||||
: 2,
|
||||
data: respChartsData.aggregations[`${id}_terms`].buckets,
|
||||
id: c.id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'unsupported',
|
||||
id: c.id,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return chartsData;
|
||||
};
|
||||
|
||||
interface NumericDataItem {
|
||||
key: number;
|
||||
key_as_string?: string;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
interface NumericChartData {
|
||||
data: NumericDataItem[];
|
||||
id: string;
|
||||
interval: number;
|
||||
stats: [number, number];
|
||||
type: 'numeric';
|
||||
}
|
||||
|
||||
export const isNumericChartData = (arg: any): arg is NumericChartData => {
|
||||
return (
|
||||
arg.hasOwnProperty('data') &&
|
||||
arg.hasOwnProperty('id') &&
|
||||
arg.hasOwnProperty('interval') &&
|
||||
arg.hasOwnProperty('stats') &&
|
||||
arg.hasOwnProperty('type')
|
||||
);
|
||||
};
|
||||
|
||||
interface OrdinalDataItem {
|
||||
key: string;
|
||||
key_as_string?: string;
|
||||
doc_count: number;
|
||||
}
|
||||
|
||||
interface OrdinalChartData {
|
||||
type: 'ordinal' | 'boolean';
|
||||
cardinality: number;
|
||||
data: OrdinalDataItem[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const isOrdinalChartData = (arg: any): arg is OrdinalChartData => {
|
||||
return (
|
||||
arg.hasOwnProperty('data') &&
|
||||
arg.hasOwnProperty('cardinality') &&
|
||||
arg.hasOwnProperty('id') &&
|
||||
arg.hasOwnProperty('type')
|
||||
);
|
||||
};
|
||||
|
||||
interface UnsupportedChartData {
|
||||
id: string;
|
||||
type: 'unsupported';
|
||||
}
|
||||
|
||||
export const isUnsupportedChartData = (arg: any): arg is UnsupportedChartData => {
|
||||
return arg.hasOwnProperty('type') && arg.type === 'unsupported';
|
||||
};
|
||||
|
||||
type ChartDataItem = NumericDataItem | OrdinalDataItem;
|
||||
export type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData;
|
||||
|
||||
type LegendText = string | JSX.Element;
|
||||
const getLegendText = (chartData: ChartData): LegendText => {
|
||||
if (chartData.type === 'unsupported') {
|
||||
return i18n.translate('xpack.ml.dataGridChart.histogramNotAvailable', {
|
||||
defaultMessage: 'Chart not supported.',
|
||||
});
|
||||
}
|
||||
|
||||
if (chartData.data.length === 0) {
|
||||
return i18n.translate('xpack.ml.dataGridChart.notEnoughData', {
|
||||
defaultMessage: `0 documents contain field.`,
|
||||
});
|
||||
}
|
||||
|
||||
if (chartData.type === 'boolean') {
|
||||
return (
|
||||
<table className="mlDataGridChart__legendBoolean">
|
||||
<tbody>
|
||||
<tr>
|
||||
{chartData.data[0] !== undefined && <td>{chartData.data[0].key_as_string}</td>}
|
||||
{chartData.data[1] !== undefined && <td>{chartData.data[1].key_as_string}</td>}
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
if (isOrdinalChartData(chartData) && chartData.cardinality <= MAX_CHART_COLUMNS) {
|
||||
return i18n.translate('xpack.ml.dataGridChart.singleCategoryLegend', {
|
||||
defaultMessage: `{cardinality, plural, one {# category} other {# categories}}`,
|
||||
values: { cardinality: chartData.cardinality },
|
||||
});
|
||||
}
|
||||
|
||||
if (isOrdinalChartData(chartData) && chartData.cardinality > MAX_CHART_COLUMNS) {
|
||||
return i18n.translate('xpack.ml.dataGridChart.topCategoriesLegend', {
|
||||
defaultMessage: `top {MAX_CHART_COLUMNS} of {cardinality} categories`,
|
||||
values: { cardinality: chartData.cardinality, MAX_CHART_COLUMNS },
|
||||
});
|
||||
}
|
||||
|
||||
if (isNumericChartData(chartData)) {
|
||||
const fromValue = Math.round(chartData.stats[0] * 100) / 100;
|
||||
const toValue = Math.round(chartData.stats[1] * 100) / 100;
|
||||
|
||||
return fromValue !== toValue ? `${fromValue} - ${toValue}` : '' + fromValue;
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
interface ColumnChart {
|
||||
data: ChartDataItem[];
|
||||
legendText: LegendText;
|
||||
xScaleType: XScaleType;
|
||||
}
|
||||
|
||||
export const useColumnChart = (
|
||||
chartData: ChartData,
|
||||
columnType: EuiDataGridColumn
|
||||
): ColumnChart => {
|
||||
const fieldType = getFieldType(columnType.schema);
|
||||
|
||||
const hoveredRow = useObservable(hoveredRow$);
|
||||
|
||||
const xScaleType = getXScaleType(fieldType);
|
||||
|
||||
const getColor = (d: ChartDataItem) => {
|
||||
if (hoveredRow === undefined || hoveredRow === null) {
|
||||
return BAR_COLOR;
|
||||
}
|
||||
|
||||
if (
|
||||
isOrdinalChartData(chartData) &&
|
||||
xScaleType === 'ordinal' &&
|
||||
hoveredRow._source[columnType.id] === d.key
|
||||
) {
|
||||
return BAR_COLOR;
|
||||
}
|
||||
|
||||
if (
|
||||
isNumericChartData(chartData) &&
|
||||
xScaleType === 'linear' &&
|
||||
hoveredRow._source[columnType.id] >= +d.key &&
|
||||
hoveredRow._source[columnType.id] < +d.key + chartData.interval
|
||||
) {
|
||||
return BAR_COLOR;
|
||||
}
|
||||
|
||||
if (
|
||||
isNumericChartData(chartData) &&
|
||||
xScaleType === 'time' &&
|
||||
moment(hoveredRow._source[columnType.id]).unix() * 1000 >= +d.key &&
|
||||
moment(hoveredRow._source[columnType.id]).unix() * 1000 < +d.key + chartData.interval
|
||||
) {
|
||||
return BAR_COLOR;
|
||||
}
|
||||
|
||||
return BAR_COLOR_BLUR;
|
||||
};
|
||||
|
||||
let data: ChartDataItem[] = [];
|
||||
|
||||
// The if/else if/else is a work-around because `.map()` doesn't work with union types.
|
||||
// See TS Caveats for details: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-3-3.html#caveats
|
||||
if (isOrdinalChartData(chartData)) {
|
||||
data = chartData.data.map((d: OrdinalDataItem) => ({
|
||||
...d,
|
||||
color: getColor(d),
|
||||
}));
|
||||
} else if (isNumericChartData(chartData)) {
|
||||
data = chartData.data.map((d: NumericDataItem) => ({
|
||||
...d,
|
||||
color: getColor(d),
|
||||
}));
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
legendText: getLegendText(chartData),
|
||||
xScaleType,
|
||||
};
|
||||
};
|
|
@ -4,12 +4,13 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { EuiDataGridSorting, EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import { INDEX_STATUS } from '../../data_frame_analytics/common';
|
||||
|
||||
import { ColumnChart } from './column_chart';
|
||||
import { INIT_MAX_COLUMNS } from './common';
|
||||
import {
|
||||
ColumnId,
|
||||
|
@ -20,6 +21,7 @@ import {
|
|||
OnSort,
|
||||
UseDataGridReturnType,
|
||||
} from './types';
|
||||
import { ChartData } from './use_column_chart';
|
||||
|
||||
export const useDataGrid = (
|
||||
columns: EuiDataGridColumn[],
|
||||
|
@ -33,9 +35,15 @@ export const useDataGrid = (
|
|||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const [status, setStatus] = useState(INDEX_STATUS.UNUSED);
|
||||
const [rowCount, setRowCount] = useState(0);
|
||||
const [columnCharts, setColumnCharts] = useState<ChartData[]>([]);
|
||||
const [tableItems, setTableItems] = useState<DataGridItem[]>([]);
|
||||
const [pagination, setPagination] = useState(defaultPagination);
|
||||
const [sortingColumns, setSortingColumns] = useState<EuiDataGridSorting['columns']>([]);
|
||||
const [chartsVisible, setChartsVisible] = useState(false);
|
||||
|
||||
const toggleChartVisibility = () => {
|
||||
setChartsVisible(!chartsVisible);
|
||||
};
|
||||
|
||||
const onChangeItemsPerPage: OnChangeItemsPerPage = useCallback((pageSize) => {
|
||||
setPagination((p) => {
|
||||
|
@ -87,6 +95,23 @@ export const useDataGrid = (
|
|||
);
|
||||
|
||||
return {
|
||||
chartsVisible,
|
||||
chartsButtonVisible: true,
|
||||
columnsWithCharts: columns.map((c, index) => {
|
||||
const chartData = columnCharts.find((cd) => cd.id === c.id);
|
||||
|
||||
return {
|
||||
...c,
|
||||
display:
|
||||
chartData !== undefined && chartsVisible === true ? (
|
||||
<ColumnChart
|
||||
chartData={chartData}
|
||||
columnType={c}
|
||||
dataTestSubj={`mlDataGridChart-${index}`}
|
||||
/>
|
||||
) : undefined,
|
||||
};
|
||||
}),
|
||||
errorMessage,
|
||||
invalidSortingColumnns,
|
||||
noDataMessage,
|
||||
|
@ -96,6 +121,7 @@ export const useDataGrid = (
|
|||
pagination,
|
||||
resetPagination,
|
||||
rowCount,
|
||||
setColumnCharts,
|
||||
setErrorMessage,
|
||||
setNoDataMessage,
|
||||
setPagination,
|
||||
|
@ -107,6 +133,7 @@ export const useDataGrid = (
|
|||
sortingColumns,
|
||||
status,
|
||||
tableItems,
|
||||
toggleChartVisibility,
|
||||
visibleColumns,
|
||||
};
|
||||
};
|
|
@ -30,7 +30,7 @@ export interface EsDoc extends Record<string, any> {
|
|||
_source: EsDocSource;
|
||||
}
|
||||
|
||||
export const MAX_COLUMNS = 20;
|
||||
export const MAX_COLUMNS = 10;
|
||||
export const DEFAULT_REGRESSION_COLUMNS = 8;
|
||||
|
||||
export const BASIC_NUMERICAL_TYPES = new Set([
|
||||
|
|
|
@ -87,7 +87,8 @@ export const ConfigurationStepForm: FC<CreateAnalyticsStepProps> = ({
|
|||
|
||||
const indexData = useIndexData(
|
||||
currentIndexPattern,
|
||||
savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery
|
||||
savedSearchQuery !== undefined ? savedSearchQuery : jobConfigQuery,
|
||||
toastNotifications
|
||||
);
|
||||
|
||||
const indexPreviewProps = {
|
||||
|
|
|
@ -6,10 +6,16 @@
|
|||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import { CoreSetup } from 'src/core/public';
|
||||
|
||||
import { IndexPattern } from '../../../../../../../../../src/plugins/data/public';
|
||||
import {
|
||||
fetchChartsData,
|
||||
getDataGridSchemaFromKibanaFieldType,
|
||||
getFieldsFromKibanaIndexPattern,
|
||||
showDataGridColumnChartErrorMessageToast,
|
||||
useDataGrid,
|
||||
useRenderCellValue,
|
||||
EsSorting,
|
||||
|
@ -22,15 +28,19 @@ import { ml } from '../../../../services/ml_api_service';
|
|||
|
||||
type IndexSearchResponse = SearchResponse7;
|
||||
|
||||
export const useIndexData = (indexPattern: IndexPattern, query: any): UseIndexDataReturnType => {
|
||||
export const useIndexData = (
|
||||
indexPattern: IndexPattern,
|
||||
query: any,
|
||||
toastNotifications: CoreSetup['notifications']['toasts']
|
||||
): UseIndexDataReturnType => {
|
||||
const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern);
|
||||
|
||||
// EuiDataGrid State
|
||||
const columns = [
|
||||
const columns: EuiDataGridColumn[] = [
|
||||
...indexPatternFields.map((id) => {
|
||||
const field = indexPattern.fields.getByName(id);
|
||||
const schema = getDataGridSchemaFromKibanaFieldType(field);
|
||||
return { id, schema };
|
||||
return { id, schema, isExpandable: schema !== 'boolean' };
|
||||
}),
|
||||
];
|
||||
|
||||
|
@ -93,11 +103,36 @@ export const useIndexData = (indexPattern: IndexPattern, query: any): UseIndexDa
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]);
|
||||
|
||||
const fetchColumnChartsData = async function () {
|
||||
try {
|
||||
const columnChartsData = await fetchChartsData(
|
||||
indexPattern.title,
|
||||
ml.esSearch,
|
||||
query,
|
||||
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
|
||||
);
|
||||
dataGrid.setColumnCharts(columnChartsData);
|
||||
} catch (e) {
|
||||
showDataGridColumnChartErrorMessageToast(e, toastNotifications);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (dataGrid.chartsVisible) {
|
||||
fetchColumnChartsData();
|
||||
}
|
||||
// custom comparison
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
dataGrid.chartsVisible,
|
||||
indexPattern.title,
|
||||
JSON.stringify([query, dataGrid.visibleColumns]),
|
||||
]);
|
||||
|
||||
const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems);
|
||||
|
||||
return {
|
||||
...dataGrid,
|
||||
columns,
|
||||
renderCellValue,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -67,9 +67,20 @@ export const ExplorationResultsTable: FC<Props> = React.memo(
|
|||
setEvaluateSearchQuery(searchQuery);
|
||||
}, [JSON.stringify(searchQuery)]);
|
||||
|
||||
const classificationData = useExplorationResults(indexPattern, jobConfig, searchQuery);
|
||||
const docFieldsCount = classificationData.columns.length;
|
||||
const { columns, errorMessage, status, tableItems, visibleColumns } = classificationData;
|
||||
const classificationData = useExplorationResults(
|
||||
indexPattern,
|
||||
jobConfig,
|
||||
searchQuery,
|
||||
getToastNotifications()
|
||||
);
|
||||
const docFieldsCount = classificationData.columnsWithCharts.length;
|
||||
const {
|
||||
columnsWithCharts,
|
||||
errorMessage,
|
||||
status,
|
||||
tableItems,
|
||||
visibleColumns,
|
||||
} = classificationData;
|
||||
|
||||
if (jobConfig === undefined || classificationData === undefined) {
|
||||
return null;
|
||||
|
@ -140,7 +151,7 @@ export const ExplorationResultsTable: FC<Props> = React.memo(
|
|||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && (
|
||||
{(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) && (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="s" />
|
||||
|
|
|
@ -8,15 +8,20 @@ import { useEffect } from 'react';
|
|||
|
||||
import { EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import { CoreSetup } from 'src/core/public';
|
||||
|
||||
import { IndexPattern } from '../../../../../../../../../../src/plugins/data/public';
|
||||
|
||||
import {
|
||||
fetchChartsData,
|
||||
getDataGridSchemasFromFieldTypes,
|
||||
showDataGridColumnChartErrorMessageToast,
|
||||
useDataGrid,
|
||||
useRenderCellValue,
|
||||
UseIndexDataReturnType,
|
||||
} from '../../../../../components/data_grid';
|
||||
import { SavedSearchQuery } from '../../../../../contexts/ml';
|
||||
import { ml } from '../../../../../services/ml_api_service';
|
||||
|
||||
import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common';
|
||||
import {
|
||||
|
@ -29,7 +34,8 @@ import { sortExplorationResultsFields, ML__ID_COPY } from '../../../../common/fi
|
|||
export const useExplorationResults = (
|
||||
indexPattern: IndexPattern | undefined,
|
||||
jobConfig: DataFrameAnalyticsConfig | undefined,
|
||||
searchQuery: SavedSearchQuery
|
||||
searchQuery: SavedSearchQuery,
|
||||
toastNotifications: CoreSetup['notifications']['toasts']
|
||||
): UseIndexDataReturnType => {
|
||||
const needsDestIndexFields =
|
||||
indexPattern !== undefined && indexPattern.title === jobConfig?.source.index[0];
|
||||
|
@ -66,6 +72,34 @@ export const useExplorationResults = (
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);
|
||||
|
||||
const fetchColumnChartsData = async function () {
|
||||
try {
|
||||
if (jobConfig !== undefined) {
|
||||
const columnChartsData = await fetchChartsData(
|
||||
jobConfig.dest.index,
|
||||
ml.esSearch,
|
||||
searchQuery,
|
||||
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
|
||||
);
|
||||
dataGrid.setColumnCharts(columnChartsData);
|
||||
}
|
||||
} catch (e) {
|
||||
showDataGridColumnChartErrorMessageToast(e, toastNotifications);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (dataGrid.chartsVisible) {
|
||||
fetchColumnChartsData();
|
||||
}
|
||||
// custom comparison
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
dataGrid.chartsVisible,
|
||||
jobConfig?.dest.index,
|
||||
JSON.stringify([searchQuery, dataGrid.visibleColumns]),
|
||||
]);
|
||||
|
||||
const renderCellValue = useRenderCellValue(
|
||||
indexPattern,
|
||||
dataGrid.pagination,
|
||||
|
@ -75,7 +109,6 @@ export const useExplorationResults = (
|
|||
|
||||
return {
|
||||
...dataGrid,
|
||||
columns,
|
||||
renderCellValue,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -53,7 +53,7 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
|
|||
const [searchQuery, setSearchQuery] = useState<SavedSearchQuery>(defaultSearchQuery);
|
||||
const outlierData = useOutlierData(indexPattern, jobConfig, searchQuery);
|
||||
|
||||
const { columns, errorMessage, status, tableItems } = outlierData;
|
||||
const { columnsWithCharts, 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('failed to create query')) {
|
||||
|
@ -98,35 +98,36 @@ export const OutlierExploration: FC<ExplorationProps> = React.memo(({ jobId }) =
|
|||
)}
|
||||
</EuiFlexGroup>
|
||||
<EuiHorizontalRule margin="xs" />
|
||||
{(columns.length > 0 || searchQuery !== defaultSearchQuery) && indexPattern !== undefined && (
|
||||
<>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<ExplorationQueryBar indexPattern={indexPattern} setSearchQuery={setSearchQuery} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="s" />
|
||||
<ColorRangeLegend
|
||||
colorRange={colorRange}
|
||||
title={i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle',
|
||||
{
|
||||
defaultMessage: 'Feature influence score',
|
||||
}
|
||||
)}
|
||||
{(columnsWithCharts.length > 0 || searchQuery !== defaultSearchQuery) &&
|
||||
indexPattern !== undefined && (
|
||||
<>
|
||||
<EuiFlexGroup justifyContent="spaceBetween">
|
||||
<EuiFlexItem>
|
||||
<ExplorationQueryBar indexPattern={indexPattern} setSearchQuery={setSearchQuery} />
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>
|
||||
<EuiSpacer size="s" />
|
||||
<ColorRangeLegend
|
||||
colorRange={colorRange}
|
||||
title={i18n.translate(
|
||||
'xpack.ml.dataframe.analytics.exploration.colorRangeLegendTitle',
|
||||
{
|
||||
defaultMessage: 'Feature influence score',
|
||||
}
|
||||
)}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
{columnsWithCharts.length > 0 && tableItems.length > 0 && (
|
||||
<DataGrid
|
||||
{...outlierData}
|
||||
dataTestSubj="mlExplorationDataGrid"
|
||||
toastNotifications={getToastNotifications()}
|
||||
/>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
<EuiSpacer size="s" />
|
||||
{columns.length > 0 && tableItems.length > 0 && (
|
||||
<DataGrid
|
||||
{...outlierData}
|
||||
dataTestSubj="mlExplorationDataGrid"
|
||||
toastNotifications={getToastNotifications()}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</EuiPanel>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -16,12 +16,16 @@ import {
|
|||
COLOR_RANGE_SCALE,
|
||||
} from '../../../../../components/color_range_legend';
|
||||
import {
|
||||
fetchChartsData,
|
||||
getDataGridSchemasFromFieldTypes,
|
||||
showDataGridColumnChartErrorMessageToast,
|
||||
useDataGrid,
|
||||
useRenderCellValue,
|
||||
UseIndexDataReturnType,
|
||||
} from '../../../../../components/data_grid';
|
||||
import { SavedSearchQuery } from '../../../../../contexts/ml';
|
||||
import { ml } from '../../../../../services/ml_api_service';
|
||||
import { getToastNotifications } from '../../../../../util/dependency_cache';
|
||||
|
||||
import { getIndexData, getIndexFields, DataFrameAnalyticsConfig } from '../../../../common';
|
||||
import { DEFAULT_RESULTS_FIELD, FEATURE_INFLUENCE } from '../../../../common/constants';
|
||||
|
@ -75,6 +79,34 @@ export const useOutlierData = (
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [jobConfig && jobConfig.id, dataGrid.pagination, searchQuery, dataGrid.sortingColumns]);
|
||||
|
||||
const fetchColumnChartsData = async function () {
|
||||
try {
|
||||
if (jobConfig !== undefined) {
|
||||
const columnChartsData = await fetchChartsData(
|
||||
jobConfig.dest.index,
|
||||
ml.esSearch,
|
||||
searchQuery,
|
||||
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
|
||||
);
|
||||
dataGrid.setColumnCharts(columnChartsData);
|
||||
}
|
||||
} catch (e) {
|
||||
showDataGridColumnChartErrorMessageToast(e, getToastNotifications());
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (dataGrid.chartsVisible) {
|
||||
fetchColumnChartsData();
|
||||
}
|
||||
// custom comparison
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
dataGrid.chartsVisible,
|
||||
jobConfig?.dest.index,
|
||||
JSON.stringify([searchQuery, dataGrid.visibleColumns]),
|
||||
]);
|
||||
|
||||
const colorRange = useColorRange(
|
||||
COLOR_RANGE.BLUE,
|
||||
COLOR_RANGE_SCALE.INFLUENCER,
|
||||
|
@ -115,7 +147,6 @@ export const useOutlierData = (
|
|||
|
||||
return {
|
||||
...dataGrid,
|
||||
columns,
|
||||
renderCellValue,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -6,10 +6,14 @@
|
|||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import {
|
||||
fetchChartsData,
|
||||
getDataGridSchemaFromKibanaFieldType,
|
||||
getFieldsFromKibanaIndexPattern,
|
||||
getErrorMessage,
|
||||
showDataGridColumnChartErrorMessageToast,
|
||||
useDataGrid,
|
||||
useRenderCellValue,
|
||||
EsSorting,
|
||||
|
@ -23,6 +27,8 @@ import { isDefaultQuery, matchAllQuery, PivotQuery } from '../common';
|
|||
import { SearchItems } from './use_search_items';
|
||||
import { useApi } from './use_api';
|
||||
|
||||
import { useToastNotifications } from '../app_dependencies';
|
||||
|
||||
type IndexSearchResponse = SearchResponse7;
|
||||
|
||||
export const useIndexData = (
|
||||
|
@ -30,11 +36,12 @@ export const useIndexData = (
|
|||
query: PivotQuery
|
||||
): UseIndexDataReturnType => {
|
||||
const api = useApi();
|
||||
const toastNotifications = useToastNotifications();
|
||||
|
||||
const indexPatternFields = getFieldsFromKibanaIndexPattern(indexPattern);
|
||||
|
||||
// EuiDataGrid State
|
||||
const columns = [
|
||||
const columns: EuiDataGridColumn[] = [
|
||||
...indexPatternFields.map((id) => {
|
||||
const field = indexPattern.fields.getByName(id);
|
||||
const schema = getDataGridSchemaFromKibanaFieldType(field);
|
||||
|
@ -45,8 +52,10 @@ export const useIndexData = (
|
|||
const dataGrid = useDataGrid(columns);
|
||||
|
||||
const {
|
||||
chartsVisible,
|
||||
pagination,
|
||||
resetPagination,
|
||||
setColumnCharts,
|
||||
setErrorMessage,
|
||||
setRowCount,
|
||||
setStatus,
|
||||
|
@ -61,7 +70,7 @@ export const useIndexData = (
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(query)]);
|
||||
|
||||
const getIndexData = async function () {
|
||||
const fetchDataGridData = async function () {
|
||||
setErrorMessage('');
|
||||
setStatus(INDEX_STATUS.LOADING);
|
||||
|
||||
|
@ -92,20 +101,43 @@ export const useIndexData = (
|
|||
} catch (e) {
|
||||
setErrorMessage(getErrorMessage(e));
|
||||
setStatus(INDEX_STATUS.ERROR);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const fetchColumnChartsData = async function () {
|
||||
try {
|
||||
const columnChartsData = await fetchChartsData(
|
||||
indexPattern.title,
|
||||
api.esSearch,
|
||||
isDefaultQuery(query) ? matchAllQuery : query,
|
||||
columns.filter((cT) => dataGrid.visibleColumns.includes(cT.id))
|
||||
);
|
||||
|
||||
setColumnCharts(columnChartsData);
|
||||
} catch (e) {
|
||||
showDataGridColumnChartErrorMessageToast(e, toastNotifications);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getIndexData();
|
||||
fetchDataGridData();
|
||||
// custom comparison
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [indexPattern.title, JSON.stringify([query, pagination, sortingColumns])]);
|
||||
|
||||
useEffect(() => {
|
||||
if (chartsVisible) {
|
||||
fetchColumnChartsData();
|
||||
}
|
||||
// custom comparison
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [chartsVisible, indexPattern.title, JSON.stringify([query, dataGrid.visibleColumns])]);
|
||||
|
||||
const renderCellValue = useRenderCellValue(indexPattern, pagination, tableItems);
|
||||
|
||||
return {
|
||||
...dataGrid,
|
||||
columns,
|
||||
renderCellValue,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -7,6 +7,8 @@
|
|||
import moment from 'moment-timezone';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { EuiDataGridColumn } from '@elastic/eui';
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
|
||||
import { ES_FIELD_TYPES } from '../../../../../../src/plugins/data/common';
|
||||
|
@ -76,7 +78,7 @@ export const usePivotData = (
|
|||
const api = useApi();
|
||||
|
||||
const aggsArr = useMemo(() => dictionaryToArray(aggs), [aggs]);
|
||||
const groupByArr = dictionaryToArray(groupBy);
|
||||
const groupByArr = useMemo(() => dictionaryToArray(groupBy), [groupBy]);
|
||||
|
||||
// Filters mapping properties of type `object`, which get returned for nested field parents.
|
||||
const columnKeys = Object.keys(previewMappings.properties).filter(
|
||||
|
@ -85,7 +87,7 @@ export const usePivotData = (
|
|||
columnKeys.sort(sortColumns(groupByArr));
|
||||
|
||||
// EuiDataGrid State
|
||||
const columns = columnKeys.map((id) => {
|
||||
const columns: EuiDataGridColumn[] = columnKeys.map((id) => {
|
||||
const field = previewMappings.properties[id];
|
||||
|
||||
// Built-in values are ['boolean', 'currency', 'datetime', 'numeric', 'json']
|
||||
|
@ -195,8 +197,7 @@ export const usePivotData = (
|
|||
}, [
|
||||
indexPatternTitle,
|
||||
aggsArr,
|
||||
JSON.stringify(groupByArr),
|
||||
JSON.stringify(query),
|
||||
JSON.stringify([groupByArr, query]),
|
||||
/* eslint-enable react-hooks/exhaustive-deps */
|
||||
]);
|
||||
|
||||
|
@ -251,7 +252,7 @@ export const usePivotData = (
|
|||
|
||||
return {
|
||||
...dataGrid,
|
||||
columns,
|
||||
chartsButtonVisible: false,
|
||||
renderCellValue,
|
||||
};
|
||||
};
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* you may not use this file except in compliance with the Elastic License.
|
||||
*/
|
||||
|
||||
import React, { FC } from 'react';
|
||||
import React, { useMemo, FC } from 'react';
|
||||
|
||||
import { DataGrid } from '../../../../../shared_imports';
|
||||
|
||||
|
@ -24,14 +24,22 @@ interface ExpandedRowPreviewPaneProps {
|
|||
|
||||
export const ExpandedRowPreviewPane: FC<ExpandedRowPreviewPaneProps> = ({ transformConfig }) => {
|
||||
const toastNotifications = useToastNotifications();
|
||||
const { aggList, groupByList, searchQuery } = applyTransformConfigToDefineState(
|
||||
getDefaultStepDefineState({} as SearchItems),
|
||||
transformConfig
|
||||
|
||||
const { aggList, groupByList, searchQuery } = useMemo(
|
||||
() =>
|
||||
applyTransformConfigToDefineState(
|
||||
getDefaultStepDefineState({} as SearchItems),
|
||||
transformConfig
|
||||
),
|
||||
[transformConfig]
|
||||
);
|
||||
const pivotQuery = getPivotQuery(searchQuery);
|
||||
|
||||
const pivotQuery = useMemo(() => getPivotQuery(searchQuery), [searchQuery]);
|
||||
|
||||
const indexPatternTitle = Array.isArray(transformConfig.source.index)
|
||||
? transformConfig.source.index.join(',')
|
||||
: transformConfig.source.index;
|
||||
|
||||
const pivotPreviewProps = usePivotData(indexPatternTitle, pivotQuery, aggList, groupByList);
|
||||
|
||||
return (
|
||||
|
|
|
@ -14,13 +14,16 @@ export {
|
|||
} from '../../../../src/plugins/es_ui_shared/public';
|
||||
|
||||
export {
|
||||
fetchChartsData,
|
||||
getErrorMessage,
|
||||
extractErrorMessage,
|
||||
getDataGridSchemaFromKibanaFieldType,
|
||||
getFieldsFromKibanaIndexPattern,
|
||||
multiColumnSortFactory,
|
||||
showDataGridColumnChartErrorMessageToast,
|
||||
useDataGrid,
|
||||
useRenderCellValue,
|
||||
ChartData,
|
||||
DataGrid,
|
||||
EsSorting,
|
||||
RenderCellValue,
|
||||
|
|
|
@ -147,9 +147,25 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
progress: '100',
|
||||
},
|
||||
indexPreview: {
|
||||
columns: 20,
|
||||
columns: 10,
|
||||
rows: 5,
|
||||
},
|
||||
histogramCharts: [
|
||||
{ chartAvailable: false, id: 'category', legend: 'Chart not supported.' },
|
||||
{ chartAvailable: true, id: 'currency', legend: '1 category' },
|
||||
{
|
||||
chartAvailable: false,
|
||||
id: 'customer_birth_date',
|
||||
legend: '0 documents contain field.',
|
||||
},
|
||||
{ chartAvailable: false, id: 'customer_first_name', legend: 'Chart not supported.' },
|
||||
{ chartAvailable: false, id: 'customer_full_name', legend: 'Chart not supported.' },
|
||||
{ chartAvailable: true, id: 'customer_gender', legend: '2 categories' },
|
||||
{ chartAvailable: true, id: 'customer_id', legend: 'top 20 of 46 categories' },
|
||||
{ chartAvailable: false, id: 'customer_last_name', legend: 'Chart not supported.' },
|
||||
{ chartAvailable: true, id: 'customer_phone', legend: '1 category' },
|
||||
{ chartAvailable: true, id: 'day_of_week', legend: '7 categories' },
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
@ -229,9 +245,10 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
progress: '100',
|
||||
},
|
||||
indexPreview: {
|
||||
columns: 20,
|
||||
columns: 10,
|
||||
rows: 5,
|
||||
},
|
||||
histogramCharts: [],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
@ -289,6 +306,16 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
await transform.wizard.assertAdvancedQueryEditorSwitchCheckState(false);
|
||||
});
|
||||
|
||||
it('enables the index preview histogram charts', async () => {
|
||||
await transform.wizard.enableIndexPreviewHistogramCharts();
|
||||
});
|
||||
|
||||
it('displays the index preview histogram charts', async () => {
|
||||
await transform.wizard.assertIndexPreviewHistogramCharts(
|
||||
testData.expected.histogramCharts
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the group by entries', async () => {
|
||||
for (const [index, entry] of testData.groupByEntries.entries()) {
|
||||
await transform.wizard.assertGroupByInputExists();
|
||||
|
@ -323,6 +350,7 @@ export default function ({ getService }: FtrProviderContext) {
|
|||
});
|
||||
|
||||
it('shows the pivot preview', async () => {
|
||||
await transform.wizard.assertPivotPreviewChartHistogramButtonMissing();
|
||||
await transform.wizard.assertPivotPreviewColumnValues(
|
||||
testData.expected.pivotPreview.column,
|
||||
testData.expected.pivotPreview.values
|
||||
|
|
|
@ -76,6 +76,12 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
|
|||
await testSubjects.existOrFail(selector);
|
||||
},
|
||||
|
||||
async assertPivotPreviewChartHistogramButtonMissing() {
|
||||
// the button should not exist because histogram charts
|
||||
// for the pivot preview are not supported yet
|
||||
await testSubjects.missingOrFail('transformPivotPreviewHistogramButton');
|
||||
},
|
||||
|
||||
async parseEuiDataGrid(tableSubj: string) {
|
||||
const table = await testSubjects.find(`~${tableSubj}`);
|
||||
const $ = await table.parseDomContent();
|
||||
|
@ -155,6 +161,58 @@ export function TransformWizardProvider({ getService }: FtrProviderContext) {
|
|||
await this.assertPivotPreviewExists('empty');
|
||||
},
|
||||
|
||||
async assertIndexPreviewHistogramChartButtonExists() {
|
||||
await testSubjects.existOrFail('transformIndexPreviewHistogramButton');
|
||||
},
|
||||
|
||||
async enableIndexPreviewHistogramCharts() {
|
||||
await this.assertIndexPreviewHistogramChartButtonCheckState(false);
|
||||
await testSubjects.click('transformIndexPreviewHistogramButton');
|
||||
await this.assertIndexPreviewHistogramChartButtonCheckState(true);
|
||||
},
|
||||
|
||||
async assertIndexPreviewHistogramChartButtonCheckState(expectedCheckState: boolean) {
|
||||
const actualCheckState =
|
||||
(await testSubjects.getAttribute(
|
||||
'transformIndexPreviewHistogramButton',
|
||||
'aria-checked'
|
||||
)) === 'true';
|
||||
expect(actualCheckState).to.eql(
|
||||
expectedCheckState,
|
||||
`Chart histogram button check state should be '${expectedCheckState}' (got '${actualCheckState}')`
|
||||
);
|
||||
},
|
||||
|
||||
async assertIndexPreviewHistogramCharts(
|
||||
expectedHistogramCharts: Array<{ chartAvailable: boolean; id: string; legend: string }>
|
||||
) {
|
||||
// For each chart, get the content of each header cell and assert
|
||||
// the legend text and column id and if the chart should be present or not.
|
||||
await retry.tryForTime(5000, async () => {
|
||||
for (const [index, expected] of expectedHistogramCharts.entries()) {
|
||||
await testSubjects.existOrFail(`mlDataGridChart-${index}`);
|
||||
|
||||
if (expected.chartAvailable) {
|
||||
await testSubjects.existOrFail(`mlDataGridChart-${index}-histogram`);
|
||||
} else {
|
||||
await testSubjects.missingOrFail(`mlDataGridChart-${index}-histogram`);
|
||||
}
|
||||
|
||||
const actualLegend = await testSubjects.getVisibleText(`mlDataGridChart-${index}-legend`);
|
||||
expect(actualLegend).to.eql(
|
||||
expected.legend,
|
||||
`Legend text for column '${index}' should be '${expected.legend}' (got '${actualLegend}')`
|
||||
);
|
||||
|
||||
const actualId = await testSubjects.getVisibleText(`mlDataGridChart-${index}-id`);
|
||||
expect(actualId).to.eql(
|
||||
expected.id,
|
||||
`Id text for column '${index}' should be '${expected.id}' (got '${actualId}')`
|
||||
);
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async assertQueryInputExists() {
|
||||
await testSubjects.existOrFail('transformQueryInput');
|
||||
},
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue