[ML] Add Index data visualizer grid embeddable as extra view within Discover (#107184)

* [ML] Initial embed

* [ML] Initial embed props

* [ML] Add top nav link to data viz

* Add visible fields

* Add add data service to register links

* Renames, refactor, use constants

* Renames, refactor, use constants

* Update tests and mocks

* Embeddable

* Update hook to update upon time udpate

* Add filter support to query

* Refactor filter utilities

* Add filter support for embeddable

* Fix saved search data undefined

* Prototype aggregated view/document view switcher

* Prototype flyout

* Prototype save document view option in storage

* Fix filter and query conflict with saved search

* Minor styling edits

* [ML] Initial embed

* [ML] Initial embed props

* Add embeddable 1

* Add visible fields

* Embeddable 2

* Add filter support to query

* Refactor filter utilities

* Add filter support for embeddable

* Fix saved search data undefined

* Prototype aggregated view/document view switcher

* Prototype flyout

* Prototype save document view option in storage

* Fix filter and query conflict with saved search

* Minor styling edits

* Fix missing code after conflicts

* Remove dv locator and flyout

* Make types happy

* Fix types

* Rename toggle option

* Resolve conflicts

* [ML] Reduce size of chart

* [ML] Unbold name, switch icons of show distributions

* [ML] Make size consistent

* [ML] Make page size 25

* [ML] Switch to arrow right and down

* [ML] Make legend font smaller

* [ML] Add user setting

* [ML] Add show preview by default setting

* [ML] Match icon

* Add panels around the subcontent

* Add preference for aggregated vs doc

* Fix types

* Fix types, add constants for adv settings

* Change to data view type

* Temp fix for Kibana/EUI table overflow issue

* Modify line height so text is not cut off, modify widths for varying screen sizes

* Different width padders for different screens

* Fix CI

* Merge latest, move button to the right

* Fix width for bar charts previews

* Fix toggle buttons, fix maps

* Delete unused file

* Fix boolean styling

* Change to enum, discover mode

* Hide field stats

* Hide field stats

* Persist show mini preview/distribution settings

* Remove window size, use size observer instead

* Default to document view

* Remove bold, switch icon

* Set fixed width for top values, reduce font size in table

* Fix custom url tests

* Update width styling for panels

* Fix missing flag for Discover sidebar, jest tests

* Fix max width

* Workaround for sorting

* Fix import

* Fix styling

* Make height uniform, center alignment, fix map and keyword map not same size

Move styling

* Revert "Make height uniform, center alignment, fix map and keyword map not same size"

This reverts commit 8fc42e2f

* Revert "Make height uniform, center alignment, fix map and keyword map not same size"

This reverts commit 8fc42e2f

* Uniform height, left aligned, flex grid

* Switch top values to have labels

* Center content

* Replace fixed widths with percentage

* Fix table missing field types

* Add dashboard embeddable and filter support

* Fix file data viz styling and tests, lean up imports, remove hard coded pixels

* Add search panel/kql filter bar

* Temporarily fix scrolling

* New kql filters for data visualizer

* Set map height so it will fit the sampler shard size text

* Use eui progress labels

* Fix spacer

* Add beta badge

* Temporarily fix scrolling

* Fix grow for Top Values for

* [ML] Update functional tests to reflect new arrow icons

* [ML] Add filter buttons and KQL bars

* [ML] Update filter bar onChange behavior

* [ML] Update top values filter onChange behavior

* [ML] Update search filters when opening saved search

* [ML] Clean up

* [ML] Remove fit content for height

* [ML] Fix boolean legend

* [ML] Fix header section when browser width is small to large and when index pattern title is too large

* [ML] Hide expander icon when dimension is xs or s & css fixes

* [ML] Delete embeddables because they are not use

* [ML] Rename view mode, refactor to separate hook, add error prompt if can't show, rename wrapper, clean up & fix tests

* [ML] Make doc count 0 for empty fields, update t/f test

* [ML] Add unit testing for search utils

* Fix missing unsubscribe for embeddable output

* Remove redundant onAddFilter for this PR, fix width

* Rename Field Stats to Field stats to match convention

* [ML] Fix expand all/collapse all behavior to override individual setting

* [ML] Fix functional tests should be 0/0%

* [ML] Fix docs content spacing, rename classnames, add filters to Discover, lens, and maps

* [ML] Fix doc count for fields that exists but have no stats

* [ML] Fix icon styling to match Discover but have text/keyword/histogram

* [ML] Fix doc count for fields that exists but have no stats

* [ML] Rename classnames to BEM style

* Resolve latest changes

* [ML] Add tests for data viz in Discover

* Update tests & dashboard behavior to reflect new advanced settings

* Update telemetry

* Remove workaround after eui bump fix

* Fix missing bool clause

* Add login

* Fix saved search attributes broken with latest changes

* Update tests

* Fix import

* Match the no results found

* Fix query util to return search source's results right away. Fix texts.

* Rename old test suits to farequoteDataViewTestData

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Quynh Nguyen 2021-10-21 12:32:21 -05:00 committed by GitHub
parent 7f83ec09d6
commit 6600f1ad78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2470 additions and 475 deletions

View file

@ -10,7 +10,7 @@ exports[`FieldTypeIcon render component when type matches a field type 1`] = `
>
<FieldTypeIconContainer
ariaLabel="keyword type"
iconType="tokenKeyword"
iconType="tokenString"
needsAria={false}
/>
</EuiToolTip>

View file

@ -48,7 +48,7 @@ export const typeToEuiIconMap: Record<string, { iconType: string; color?: string
_source: { iconType: 'editorCodeBlock', color: 'gray' },
string: { iconType: 'tokenString' },
text: { iconType: 'tokenString' },
keyword: { iconType: 'tokenKeyword' },
keyword: { iconType: 'tokenString' },
nested: { iconType: 'tokenNested' },
};

View file

@ -7,7 +7,6 @@
import { parse } from 'query-string';
import { createContext, useCallback, useContext, useMemo } from 'react';
// @ts-ignore
import { decode } from 'rison-node';
export interface Dictionary<TValue> {

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export const DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE = 'data_visualizer_grid';

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import React from 'react';
import { EuiLoadingSpinner, EuiSpacer, EuiText } from '@elastic/eui';
export const EmbeddableLoading = () => {
return (
<EuiText textAlign="center">
<EuiSpacer size="l" />
<EuiLoadingSpinner size="l" />
<EuiSpacer size="l" />
</EuiText>
);
};

View file

@ -0,0 +1,234 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Observable, Subject } from 'rxjs';
import { CoreStart } from 'kibana/public';
import ReactDOM from 'react-dom';
import React, { Suspense, useCallback, useState } from 'react';
import useObservable from 'react-use/lib/useObservable';
import { EuiEmptyPrompt, EuiIcon, EuiSpacer, EuiText } from '@elastic/eui';
import { Filter } from '@kbn/es-query';
import { Required } from 'utility-types';
import { FormattedMessage } from '@kbn/i18n/react';
import {
Embeddable,
EmbeddableInput,
EmbeddableOutput,
IContainer,
} from '../../../../../../../../src/plugins/embeddable/public';
import { KibanaContextProvider } from '../../../../../../../../src/plugins/kibana_react/public';
import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants';
import { EmbeddableLoading } from './embeddable_loading_fallback';
import { DataVisualizerStartDependencies } from '../../../../plugin';
import {
IndexPattern,
IndexPatternField,
Query,
} from '../../../../../../../../src/plugins/data/common';
import { SavedSearch } from '../../../../../../../../src/plugins/discover/public';
import {
DataVisualizerTable,
ItemIdToExpandedRowMap,
} from '../../../common/components/stats_table';
import { FieldVisConfig } from '../../../common/components/stats_table/types';
import { getDefaultDataVisualizerListState } from '../../components/index_data_visualizer_view/index_data_visualizer_view';
import { DataVisualizerTableState } from '../../../../../common';
import { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state';
import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row';
import { useDataVisualizerGridData } from './use_data_visualizer_grid_data';
export type DataVisualizerGridEmbeddableServices = [CoreStart, DataVisualizerStartDependencies];
export interface DataVisualizerGridEmbeddableInput extends EmbeddableInput {
indexPattern: IndexPattern;
savedSearch?: SavedSearch;
query?: Query;
visibleFieldNames?: string[];
filters?: Filter[];
showPreviewByDefault?: boolean;
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}
export type DataVisualizerGridEmbeddableOutput = EmbeddableOutput;
export type IDataVisualizerGridEmbeddable = typeof DataVisualizerGridEmbeddable;
const restorableDefaults = getDefaultDataVisualizerListState();
export const EmbeddableWrapper = ({
input,
onOutputChange,
}: {
input: DataVisualizerGridEmbeddableInput;
onOutputChange?: (ouput: any) => void;
}) => {
const [dataVisualizerListState, setDataVisualizerListState] =
useState<Required<DataVisualizerIndexBasedAppState>>(restorableDefaults);
const onTableChange = useCallback(
(update: DataVisualizerTableState) => {
setDataVisualizerListState({ ...dataVisualizerListState, ...update });
if (onOutputChange) {
onOutputChange(update);
}
},
[dataVisualizerListState, onOutputChange]
);
const { configs, searchQueryLanguage, searchString, extendedColumns, loaded } =
useDataVisualizerGridData(input, dataVisualizerListState);
const getItemIdToExpandedRowMap = useCallback(
function (itemIds: string[], items: FieldVisConfig[]): ItemIdToExpandedRowMap {
return itemIds.reduce((m: ItemIdToExpandedRowMap, fieldName: string) => {
const item = items.find((fieldVisConfig) => fieldVisConfig.fieldName === fieldName);
if (item !== undefined) {
m[fieldName] = (
<IndexBasedDataVisualizerExpandedRow
item={item}
indexPattern={input.indexPattern}
combinedQuery={{ searchQueryLanguage, searchString }}
onAddFilter={input.onAddFilter}
/>
);
}
return m;
}, {} as ItemIdToExpandedRowMap);
},
[input, searchQueryLanguage, searchString]
);
if (
loaded &&
(configs.length === 0 ||
// FIXME: Configs might have a placeholder document count stats field
// This will be removed in the future
(configs.length === 1 && configs[0].fieldName === undefined))
) {
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
flex: '1 0 100%',
textAlign: 'center',
}}
>
<EuiText size="xs" color="subdued">
<EuiIcon type="visualizeApp" size="m" color="subdued" />
<EuiSpacer size="m" />
<FormattedMessage
id="xpack.dataVisualizer.index.embeddableNoResultsMessage"
defaultMessage="No results found"
/>
</EuiText>
</div>
);
}
return (
<DataVisualizerTable<FieldVisConfig>
items={configs}
pageState={dataVisualizerListState}
updatePageState={onTableChange}
getItemIdToExpandedRowMap={getItemIdToExpandedRowMap}
extendedColumns={extendedColumns}
showPreviewByDefault={input?.showPreviewByDefault}
onChange={onOutputChange}
/>
);
};
export const IndexDataVisualizerViewWrapper = (props: {
id: string;
embeddableContext: InstanceType<IDataVisualizerGridEmbeddable>;
embeddableInput: Readonly<Observable<DataVisualizerGridEmbeddableInput>>;
onOutputChange?: (output: any) => void;
}) => {
const { embeddableInput, onOutputChange } = props;
const input = useObservable(embeddableInput);
if (input && input.indexPattern) {
return <EmbeddableWrapper input={input} onOutputChange={onOutputChange} />;
} else {
return (
<EuiEmptyPrompt
iconType="alert"
iconColor="danger"
title={
<h2>
<FormattedMessage
id="xpack.dataVisualizer.index.embeddableErrorTitle"
defaultMessage="Error loading embeddable"
/>
</h2>
}
body={
<p>
<FormattedMessage
id="xpack.dataVisualizer.index.embeddableErrorDescription"
defaultMessage="There was an error loading the embeddable. Please check if all the required input is valid."
/>
</p>
}
/>
);
}
};
export class DataVisualizerGridEmbeddable extends Embeddable<
DataVisualizerGridEmbeddableInput,
DataVisualizerGridEmbeddableOutput
> {
private node?: HTMLElement;
private reload$ = new Subject();
public readonly type: string = DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE;
constructor(
initialInput: DataVisualizerGridEmbeddableInput,
public services: DataVisualizerGridEmbeddableServices,
parent?: IContainer
) {
super(initialInput, {}, parent);
}
public render(node: HTMLElement) {
super.render(node);
this.node = node;
const I18nContext = this.services[0].i18n.Context;
ReactDOM.render(
<I18nContext>
<KibanaContextProvider services={{ ...this.services[0], ...this.services[1] }}>
<Suspense fallback={<EmbeddableLoading />}>
<IndexDataVisualizerViewWrapper
id={this.input.id}
embeddableContext={this}
embeddableInput={this.getInput$()}
onOutputChange={(output) => this.updateOutput(output)}
/>
</Suspense>
</KibanaContextProvider>
</I18nContext>,
node
);
}
public destroy() {
super.destroy();
if (this.node) {
ReactDOM.unmountComponentAtNode(this.node);
}
}
public reload() {
this.reload$.next();
}
public supportedTriggers() {
return [];
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { i18n } from '@kbn/i18n';
import { StartServicesAccessor } from 'kibana/public';
import {
EmbeddableFactoryDefinition,
IContainer,
} from '../../../../../../../../src/plugins/embeddable/public';
import { DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE } from './constants';
import {
DataVisualizerGridEmbeddableInput,
DataVisualizerGridEmbeddableServices,
} from './grid_embeddable';
import { DataVisualizerPluginStart, DataVisualizerStartDependencies } from '../../../../plugin';
export class DataVisualizerGridEmbeddableFactory
implements EmbeddableFactoryDefinition<DataVisualizerGridEmbeddableInput>
{
public readonly type = DATA_VISUALIZER_GRID_EMBEDDABLE_TYPE;
public readonly grouping = [
{
id: 'data_visualizer_grid',
getDisplayName: () => 'Data Visualizer Grid',
},
];
constructor(
private getStartServices: StartServicesAccessor<
DataVisualizerStartDependencies,
DataVisualizerPluginStart
>
) {}
public async isEditable() {
return false;
}
public canCreateNew() {
return false;
}
public getDisplayName() {
return i18n.translate('xpack.dataVisualizer.index.components.grid.displayName', {
defaultMessage: 'Data visualizer grid',
});
}
public getDescription() {
return i18n.translate('xpack.dataVisualizer.index.components.grid.description', {
defaultMessage: 'Visualize data',
});
}
private async getServices(): Promise<DataVisualizerGridEmbeddableServices> {
const [coreStart, pluginsStart] = await this.getStartServices();
return [coreStart, pluginsStart];
}
public async create(initialInput: DataVisualizerGridEmbeddableInput, parent?: IContainer) {
const [coreStart, pluginsStart] = await this.getServices();
const { DataVisualizerGridEmbeddable } = await import('./grid_embeddable');
return new DataVisualizerGridEmbeddable(initialInput, [coreStart, pluginsStart], parent);
}
}

View file

@ -0,0 +1,8 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
export { DataVisualizerGridEmbeddable } from './grid_embeddable';
export { DataVisualizerGridEmbeddableFactory } from './grid_embeddable_factory';

View file

@ -0,0 +1,587 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { Required } from 'utility-types';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { merge } from 'rxjs';
import { EuiTableActionsColumnType } from '@elastic/eui/src/components/basic_table/table_types';
import { i18n } from '@kbn/i18n';
import { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state';
import { useDataVisualizerKibana } from '../../../kibana_context';
import { getEsQueryFromSavedSearch } from '../../utils/saved_search_utils';
import { MetricFieldsStats } from '../../../common/components/stats_table/components/field_count_stats';
import { DataLoader } from '../../data_loader/data_loader';
import { useTimefilter } from '../../hooks/use_time_filter';
import { dataVisualizerRefresh$ } from '../../services/timefilter_refresh_service';
import { TimeBuckets } from '../../services/time_buckets';
import {
DataViewField,
KBN_FIELD_TYPES,
UI_SETTINGS,
} from '../../../../../../../../src/plugins/data/common';
import { extractErrorProperties } from '../../utils/error_utils';
import { FieldVisConfig } from '../../../common/components/stats_table/types';
import { FieldRequestConfig, JOB_FIELD_TYPES } from '../../../../../common';
import { kbnTypeToJobType } from '../../../common/util/field_types_utils';
import { getActions } from '../../../common/components/field_data_row/action_menu';
import { DataVisualizerGridEmbeddableInput } from './grid_embeddable';
import { getDefaultPageState } from '../../components/index_data_visualizer_view/index_data_visualizer_view';
const defaults = getDefaultPageState();
export const useDataVisualizerGridData = (
input: DataVisualizerGridEmbeddableInput,
dataVisualizerListState: Required<DataVisualizerIndexBasedAppState>
) => {
const { services } = useDataVisualizerKibana();
const { notifications, uiSettings } = services;
const { toasts } = notifications;
const { samplerShardSize, visibleFieldTypes, showEmptyFields } = dataVisualizerListState;
const [lastRefresh, setLastRefresh] = useState(0);
const {
currentSavedSearch,
currentIndexPattern,
currentQuery,
currentFilters,
visibleFieldNames,
} = useMemo(
() => ({
currentSavedSearch: input?.savedSearch,
currentIndexPattern: input.indexPattern,
currentQuery: input?.query,
visibleFieldNames: input?.visibleFieldNames ?? [],
currentFilters: input?.filters,
}),
[input]
);
const { searchQueryLanguage, searchString, searchQuery } = useMemo(() => {
const searchData = getEsQueryFromSavedSearch({
indexPattern: currentIndexPattern,
uiSettings,
savedSearch: currentSavedSearch,
query: currentQuery,
filters: currentFilters,
});
if (searchData === undefined || dataVisualizerListState.searchString !== '') {
return {
searchQuery: dataVisualizerListState.searchQuery,
searchString: dataVisualizerListState.searchString,
searchQueryLanguage: dataVisualizerListState.searchQueryLanguage,
};
} else {
return {
searchQuery: searchData.searchQuery,
searchString: searchData.searchString,
searchQueryLanguage: searchData.queryLanguage,
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
currentSavedSearch,
currentIndexPattern,
dataVisualizerListState,
currentQuery,
currentFilters,
]);
const [overallStats, setOverallStats] = useState(defaults.overallStats);
const [documentCountStats, setDocumentCountStats] = useState(defaults.documentCountStats);
const [metricConfigs, setMetricConfigs] = useState(defaults.metricConfigs);
const [metricsLoaded, setMetricsLoaded] = useState(defaults.metricsLoaded);
const [metricsStats, setMetricsStats] = useState<undefined | MetricFieldsStats>();
const [nonMetricConfigs, setNonMetricConfigs] = useState(defaults.nonMetricConfigs);
const [nonMetricsLoaded, setNonMetricsLoaded] = useState(defaults.nonMetricsLoaded);
const dataLoader = useMemo(
() => new DataLoader(currentIndexPattern, toasts),
[currentIndexPattern, toasts]
);
const timefilter = useTimefilter({
timeRangeSelector: currentIndexPattern?.timeFieldName !== undefined,
autoRefreshSelector: true,
});
useEffect(() => {
const timeUpdateSubscription = merge(
timefilter.getTimeUpdate$(),
dataVisualizerRefresh$
).subscribe(() => {
setLastRefresh(Date.now());
});
return () => {
timeUpdateSubscription.unsubscribe();
};
});
const getTimeBuckets = useCallback(() => {
return new TimeBuckets({
[UI_SETTINGS.HISTOGRAM_MAX_BARS]: uiSettings.get(UI_SETTINGS.HISTOGRAM_MAX_BARS),
[UI_SETTINGS.HISTOGRAM_BAR_TARGET]: uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET),
dateFormat: uiSettings.get('dateFormat'),
'dateFormat:scaled': uiSettings.get('dateFormat:scaled'),
});
}, [uiSettings]);
const indexPatternFields: DataViewField[] = useMemo(
() => currentIndexPattern.fields,
[currentIndexPattern]
);
async function loadOverallStats() {
const tf = timefilter as any;
let earliest;
let latest;
const activeBounds = tf.getActiveBounds();
if (currentIndexPattern.timeFieldName !== undefined && activeBounds === undefined) {
return;
}
if (currentIndexPattern.timeFieldName !== undefined) {
earliest = activeBounds.min.valueOf();
latest = activeBounds.max.valueOf();
}
try {
const allStats = await dataLoader.loadOverallData(
searchQuery,
samplerShardSize,
earliest,
latest
);
// Because load overall stats perform queries in batches
// there could be multiple errors
if (Array.isArray(allStats.errors) && allStats.errors.length > 0) {
allStats.errors.forEach((err: any) => {
dataLoader.displayError(extractErrorProperties(err));
});
}
setOverallStats(allStats);
} catch (err) {
dataLoader.displayError(err.body ?? err);
}
}
const createMetricCards = useCallback(() => {
const configs: FieldVisConfig[] = [];
const aggregatableExistsFields: any[] = overallStats.aggregatableExistsFields || [];
const allMetricFields = indexPatternFields.filter((f) => {
return (
f.type === KBN_FIELD_TYPES.NUMBER &&
f.displayName !== undefined &&
dataLoader.isDisplayField(f.displayName) === true
);
});
const metricExistsFields = allMetricFields.filter((f) => {
return aggregatableExistsFields.find((existsF) => {
return existsF.fieldName === f.spec.name;
});
});
// Add a config for 'document count', identified by no field name if indexpattern is time based.
if (currentIndexPattern.timeFieldName !== undefined) {
configs.push({
type: JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
loading: true,
aggregatable: true,
});
}
if (metricsLoaded === false) {
setMetricsLoaded(true);
return;
}
let aggregatableFields: any[] = overallStats.aggregatableExistsFields;
if (allMetricFields.length !== metricExistsFields.length && metricsLoaded === true) {
aggregatableFields = aggregatableFields.concat(overallStats.aggregatableNotExistsFields);
}
const metricFieldsToShow =
metricsLoaded === true && showEmptyFields === true ? allMetricFields : metricExistsFields;
metricFieldsToShow.forEach((field) => {
const fieldData = aggregatableFields.find((f) => {
return f.fieldName === field.spec.name;
});
const metricConfig: FieldVisConfig = {
...(fieldData ? fieldData : {}),
fieldFormat: currentIndexPattern.getFormatterForField(field),
type: JOB_FIELD_TYPES.NUMBER,
loading: true,
aggregatable: true,
deletable: field.runtimeField !== undefined,
};
if (field.displayName !== metricConfig.fieldName) {
metricConfig.displayName = field.displayName;
}
configs.push(metricConfig);
});
setMetricsStats({
totalMetricFieldsCount: allMetricFields.length,
visibleMetricsCount: metricFieldsToShow.length,
});
setMetricConfigs(configs);
}, [
currentIndexPattern,
dataLoader,
indexPatternFields,
metricsLoaded,
overallStats,
showEmptyFields,
]);
const createNonMetricCards = useCallback(() => {
const allNonMetricFields = indexPatternFields.filter((f) => {
return (
f.type !== KBN_FIELD_TYPES.NUMBER &&
f.displayName !== undefined &&
dataLoader.isDisplayField(f.displayName) === true
);
});
// Obtain the list of all non-metric fields which appear in documents
// (aggregatable or not aggregatable).
const populatedNonMetricFields: any[] = []; // Kibana index pattern non metric fields.
let nonMetricFieldData: any[] = []; // Basic non metric field data loaded from requesting overall stats.
const aggregatableExistsFields: any[] = overallStats.aggregatableExistsFields || [];
const nonAggregatableExistsFields: any[] = overallStats.nonAggregatableExistsFields || [];
allNonMetricFields.forEach((f) => {
const checkAggregatableField = aggregatableExistsFields.find(
(existsField) => existsField.fieldName === f.spec.name
);
if (checkAggregatableField !== undefined) {
populatedNonMetricFields.push(f);
nonMetricFieldData.push(checkAggregatableField);
} else {
const checkNonAggregatableField = nonAggregatableExistsFields.find(
(existsField) => existsField.fieldName === f.spec.name
);
if (checkNonAggregatableField !== undefined) {
populatedNonMetricFields.push(f);
nonMetricFieldData.push(checkNonAggregatableField);
}
}
});
if (nonMetricsLoaded === false) {
setNonMetricsLoaded(true);
return;
}
if (allNonMetricFields.length !== nonMetricFieldData.length && showEmptyFields === true) {
// Combine the field data obtained from Elasticsearch into a single array.
nonMetricFieldData = nonMetricFieldData.concat(
overallStats.aggregatableNotExistsFields,
overallStats.nonAggregatableNotExistsFields
);
}
const nonMetricFieldsToShow = showEmptyFields ? allNonMetricFields : populatedNonMetricFields;
const configs: FieldVisConfig[] = [];
nonMetricFieldsToShow.forEach((field) => {
const fieldData = nonMetricFieldData.find((f) => f.fieldName === field.spec.name);
const nonMetricConfig = {
...(fieldData ? fieldData : {}),
fieldFormat: currentIndexPattern.getFormatterForField(field),
aggregatable: field.aggregatable,
scripted: field.scripted,
loading: fieldData?.existsInDocs,
deletable: field.runtimeField !== undefined,
};
// Map the field type from the Kibana index pattern to the field type
// used in the data visualizer.
const dataVisualizerType = kbnTypeToJobType(field);
if (dataVisualizerType !== undefined) {
nonMetricConfig.type = dataVisualizerType;
} else {
// Add a flag to indicate that this is one of the 'other' Kibana
// field types that do not yet have a specific card type.
nonMetricConfig.type = field.type;
nonMetricConfig.isUnsupportedType = true;
}
if (field.displayName !== nonMetricConfig.fieldName) {
nonMetricConfig.displayName = field.displayName;
}
configs.push(nonMetricConfig);
});
setNonMetricConfigs(configs);
}, [
currentIndexPattern,
dataLoader,
indexPatternFields,
nonMetricsLoaded,
overallStats,
showEmptyFields,
]);
async function loadMetricFieldStats() {
// Only request data for fields that exist in documents.
if (metricConfigs.length === 0) {
return;
}
const configsToLoad = metricConfigs.filter(
(config) => config.existsInDocs === true && config.loading === true
);
if (configsToLoad.length === 0) {
return;
}
// Pass the field name, type and cardinality in the request.
// Top values will be obtained on a sample if cardinality > 100000.
const existMetricFields: FieldRequestConfig[] = configsToLoad.map((config) => {
const props = { fieldName: config.fieldName, type: config.type, cardinality: 0 };
if (config.stats !== undefined && config.stats.cardinality !== undefined) {
props.cardinality = config.stats.cardinality;
}
return props;
});
// Obtain the interval to use for date histogram aggregations
// (such as the document count chart). Aim for 75 bars.
const buckets = getTimeBuckets();
const tf = timefilter as any;
let earliest: number | undefined;
let latest: number | undefined;
if (currentIndexPattern.timeFieldName !== undefined) {
earliest = tf.getActiveBounds().min.valueOf();
latest = tf.getActiveBounds().max.valueOf();
}
const bounds = tf.getActiveBounds();
const BAR_TARGET = 75;
buckets.setInterval('auto');
buckets.setBounds(bounds);
buckets.setBarTarget(BAR_TARGET);
const aggInterval = buckets.getInterval();
try {
const metricFieldStats = await dataLoader.loadFieldStats(
searchQuery,
samplerShardSize,
earliest,
latest,
existMetricFields,
aggInterval.asMilliseconds()
);
// Add the metric stats to the existing stats in the corresponding config.
const configs: FieldVisConfig[] = [];
metricConfigs.forEach((config) => {
const configWithStats = { ...config };
if (config.fieldName !== undefined) {
configWithStats.stats = {
...configWithStats.stats,
...metricFieldStats.find(
(fieldStats: any) => fieldStats.fieldName === config.fieldName
),
};
configWithStats.loading = false;
configs.push(configWithStats);
} else {
// Document count card.
configWithStats.stats = metricFieldStats.find(
(fieldStats: any) => fieldStats.fieldName === undefined
);
if (configWithStats.stats !== undefined) {
// Add earliest / latest of timefilter for setting x axis domain.
configWithStats.stats.timeRangeEarliest = earliest;
configWithStats.stats.timeRangeLatest = latest;
}
setDocumentCountStats(configWithStats);
}
});
setMetricConfigs(configs);
} catch (err) {
dataLoader.displayError(err);
}
}
async function loadNonMetricFieldStats() {
// Only request data for fields that exist in documents.
if (nonMetricConfigs.length === 0) {
return;
}
const configsToLoad = nonMetricConfigs.filter(
(config) => config.existsInDocs === true && config.loading === true
);
if (configsToLoad.length === 0) {
return;
}
// Pass the field name, type and cardinality in the request.
// Top values will be obtained on a sample if cardinality > 100000.
const existNonMetricFields: FieldRequestConfig[] = configsToLoad.map((config) => {
const props = { fieldName: config.fieldName, type: config.type, cardinality: 0 };
if (config.stats !== undefined && config.stats.cardinality !== undefined) {
props.cardinality = config.stats.cardinality;
}
return props;
});
const tf = timefilter as any;
let earliest;
let latest;
if (currentIndexPattern.timeFieldName !== undefined) {
earliest = tf.getActiveBounds().min.valueOf();
latest = tf.getActiveBounds().max.valueOf();
}
try {
const nonMetricFieldStats = await dataLoader.loadFieldStats(
searchQuery,
samplerShardSize,
earliest,
latest,
existNonMetricFields
);
// Add the field stats to the existing stats in the corresponding config.
const configs: FieldVisConfig[] = [];
nonMetricConfigs.forEach((config) => {
const configWithStats = { ...config };
if (config.fieldName !== undefined) {
configWithStats.stats = {
...configWithStats.stats,
...nonMetricFieldStats.find(
(fieldStats: any) => fieldStats.fieldName === config.fieldName
),
};
}
configWithStats.loading = false;
configs.push(configWithStats);
});
setNonMetricConfigs(configs);
} catch (err) {
dataLoader.displayError(err);
}
}
useEffect(() => {
loadOverallStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [searchQuery, samplerShardSize, lastRefresh]);
useEffect(() => {
createMetricCards();
createNonMetricCards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overallStats, showEmptyFields]);
useEffect(() => {
loadMetricFieldStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricConfigs]);
useEffect(() => {
loadNonMetricFieldStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nonMetricConfigs]);
useEffect(() => {
createMetricCards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricsLoaded]);
useEffect(() => {
createNonMetricCards();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [nonMetricsLoaded]);
const configs = useMemo(() => {
let combinedConfigs = [...nonMetricConfigs, ...metricConfigs];
if (visibleFieldTypes && visibleFieldTypes.length > 0) {
combinedConfigs = combinedConfigs.filter(
(config) => visibleFieldTypes.findIndex((field) => field === config.type) > -1
);
}
if (visibleFieldNames && visibleFieldNames.length > 0) {
combinedConfigs = combinedConfigs.filter(
(config) => visibleFieldNames.findIndex((field) => field === config.fieldName) > -1
);
}
return combinedConfigs;
}, [nonMetricConfigs, metricConfigs, visibleFieldTypes, visibleFieldNames]);
// Some actions open up fly-out or popup
// This variable is used to keep track of them and clean up when unmounting
const actionFlyoutRef = useRef<() => void | undefined>();
useEffect(() => {
const ref = actionFlyoutRef;
return () => {
// Clean up any of the flyout/editor opened from the actions
if (ref.current) {
ref.current();
}
};
}, []);
// Inject custom action column for the index based visualizer
// Hide the column completely if no access to any of the plugins
const extendedColumns = useMemo(() => {
const actions = getActions(
input.indexPattern,
{ lens: services.lens },
{
searchQueryLanguage,
searchString,
},
actionFlyoutRef
);
if (!Array.isArray(actions) || actions.length < 1) return;
const actionColumn: EuiTableActionsColumnType<FieldVisConfig> = {
name: i18n.translate('xpack.dataVisualizer.index.dataGrid.actionsColumnLabel', {
defaultMessage: 'Actions',
}),
actions,
width: '70px',
};
return [actionColumn];
}, [input.indexPattern, services, searchQueryLanguage, searchString]);
return {
configs,
searchQueryLanguage,
searchString,
searchQuery,
extendedColumns,
documentCountStats,
metricsStats,
loaded: metricsLoaded && nonMetricsLoaded,
};
};

View file

@ -0,0 +1,24 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { CoreSetup } from 'kibana/public';
import { EmbeddableSetup } from '../../../../../../../src/plugins/embeddable/public';
import { DataVisualizerGridEmbeddableFactory } from './grid_embeddable/grid_embeddable_factory';
import { DataVisualizerPluginStart, DataVisualizerStartDependencies } from '../../../plugin';
export function registerEmbeddables(
embeddable: EmbeddableSetup,
core: CoreSetup<DataVisualizerStartDependencies, DataVisualizerPluginStart>
) {
const dataVisualizerGridEmbeddableFactory = new DataVisualizerGridEmbeddableFactory(
core.getStartServices
);
embeddable.registerEmbeddableFactory(
dataVisualizerGridEmbeddableFactory.type,
dataVisualizerGridEmbeddableFactory
);
}

View file

@ -9,7 +9,6 @@ import React, { FC, useCallback, useEffect, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
import { parse, stringify } from 'query-string';
import { isEqual } from 'lodash';
// @ts-ignore
import { encode } from 'rison-node';
import { SimpleSavedObject } from 'kibana/public';
import { i18n } from '@kbn/i18n';
@ -29,7 +28,7 @@ import {
isRisonSerializationRequired,
} from '../common/util/url_state';
import { useDataVisualizerKibana } from '../kibana_context';
import { IndexPattern } from '../../../../../../src/plugins/data/common';
import { DataView } from '../../../../../../src/plugins/data/common';
import { ResultLink } from '../common/components/results_links';
export type IndexDataVisualizerSpec = typeof IndexDataVisualizer;
@ -51,9 +50,7 @@ export const DataVisualizerUrlStateContextProvider: FC<DataVisualizerUrlStateCon
const history = useHistory();
const { search: searchString } = useLocation();
const [currentIndexPattern, setCurrentIndexPattern] = useState<IndexPattern | undefined>(
undefined
);
const [currentIndexPattern, setCurrentIndexPattern] = useState<DataView | undefined>(undefined);
const [currentSavedSearch, setCurrentSavedSearch] = useState<SimpleSavedObject<unknown> | null>(
null
);

View file

@ -4,8 +4,6 @@
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
// @ts-ignore
import { encode } from 'rison-node';
import { stringify } from 'query-string';
import { SerializableRecord } from '@kbn/utility-types';

View file

@ -6,7 +6,7 @@
*/
import {
getQueryFromSavedSearch,
getQueryFromSavedSearchObject,
createMergedEsQuery,
getEsQueryFromSavedSearch,
} from './saved_search_utils';
@ -82,9 +82,9 @@ const kqlSavedSearch: SavedSearch = {
},
};
describe('getQueryFromSavedSearch()', () => {
describe('getQueryFromSavedSearchObject()', () => {
it('should return parsed searchSourceJSON with query and filter', () => {
expect(getQueryFromSavedSearch(luceneSavedSearchObj)).toEqual({
expect(getQueryFromSavedSearchObject(luceneSavedSearchObj)).toEqual({
filter: [
{
$state: { store: 'appState' },
@ -106,7 +106,7 @@ describe('getQueryFromSavedSearch()', () => {
query: { language: 'lucene', query: 'responsetime:>50' },
version: true,
});
expect(getQueryFromSavedSearch(kqlSavedSearch)).toEqual({
expect(getQueryFromSavedSearchObject(kqlSavedSearch)).toEqual({
filter: [
{
$state: { store: 'appState' },
@ -130,7 +130,7 @@ describe('getQueryFromSavedSearch()', () => {
});
});
it('should return undefined if invalid searchSourceJSON', () => {
expect(getQueryFromSavedSearch(luceneInvalidSavedSearchObj)).toEqual(undefined);
expect(getQueryFromSavedSearchObject(luceneInvalidSavedSearchObj)).toEqual(undefined);
});
});

View file

@ -16,17 +16,31 @@ import {
Filter,
} from '@kbn/es-query';
import { isSavedSearchSavedObject, SavedSearchSavedObject } from '../../../../common/types';
import { IndexPattern } from '../../../../../../../src/plugins/data/common';
import { IndexPattern, SearchSource } from '../../../../../../../src/plugins/data/common';
import { SEARCH_QUERY_LANGUAGE, SearchQueryLanguage } from '../types/combined_query';
import { SavedSearch } from '../../../../../../../src/plugins/discover/public';
import { getEsQueryConfig } from '../../../../../../../src/plugins/data/common';
import { FilterManager } from '../../../../../../../src/plugins/data/public';
const DEFAULT_QUERY = {
bool: {
must: [
{
match_all: {},
},
],
},
};
export function getDefaultQuery() {
return cloneDeep(DEFAULT_QUERY);
}
/**
* Parse the stringified searchSourceJSON
* from a saved search or saved search object
*/
export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject | SavedSearch) {
export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObject | SavedSearch) {
const search = isSavedSearchSavedObject(savedSearch)
? savedSearch?.attributes?.kibanaSavedObjectMeta
: // @ts-expect-error kibanaSavedObjectMeta does exist
@ -69,20 +83,22 @@ export function createMergedEsQuery(
if (query.query !== '') {
combinedQuery = toElasticsearchQuery(ast, indexPattern);
}
const filterQuery = buildQueryFromFilters(filters, indexPattern);
if (combinedQuery.bool !== undefined) {
const filterQuery = buildQueryFromFilters(filters, indexPattern);
if (Array.isArray(combinedQuery.bool.filter) === false) {
combinedQuery.bool.filter =
combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter];
if (Array.isArray(combinedQuery.bool.filter) === false) {
combinedQuery.bool.filter =
combinedQuery.bool.filter === undefined ? [] : [combinedQuery.bool.filter];
}
if (Array.isArray(combinedQuery.bool.must_not) === false) {
combinedQuery.bool.must_not =
combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not];
}
combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter];
combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not];
}
if (Array.isArray(combinedQuery.bool.must_not) === false) {
combinedQuery.bool.must_not =
combinedQuery.bool.must_not === undefined ? [] : [combinedQuery.bool.must_not];
}
combinedQuery.bool.filter = [...combinedQuery.bool.filter, ...filterQuery.filter];
combinedQuery.bool.must_not = [...combinedQuery.bool.must_not, ...filterQuery.must_not];
} else {
combinedQuery = buildEsQuery(
indexPattern,
@ -115,10 +131,31 @@ export function getEsQueryFromSavedSearch({
}) {
if (!indexPattern || !savedSearch) return;
const savedSearchData = getQueryFromSavedSearch(savedSearch);
const userQuery = query;
const userFilters = filters;
// If saved search has a search source with nested parent
// e.g. a search coming from Dashboard saved search embeddable
// which already combines both the saved search's original query/filters and the Dashboard's
// then no need to process any further
if (
savedSearch &&
'searchSource' in savedSearch &&
savedSearch?.searchSource instanceof SearchSource &&
savedSearch.searchSource.getParent() !== undefined &&
userQuery
) {
return {
searchQuery: savedSearch.searchSource.getSearchRequestBody()?.query ?? getDefaultQuery(),
searchString: userQuery.query,
queryLanguage: userQuery.language as SearchQueryLanguage,
};
}
// If saved search is an json object with the original query and filter
// retrieve the parsed query and filter
const savedSearchData = getQueryFromSavedSearchObject(savedSearch);
// If no saved search available, use user's query and filters
if (!savedSearchData && userQuery) {
if (filterManager && userFilters) filterManager.setFilters(userFilters);
@ -137,7 +174,8 @@ export function getEsQueryFromSavedSearch({
};
}
// If saved search available, merge saved search with latest user query or filters differ from extracted saved search data
// If saved search available, merge saved search with latest user query or filters
// which might differ from extracted saved search data
if (savedSearchData) {
const currentQuery = userQuery ?? savedSearchData?.query;
const currentFilters = userFilters ?? savedSearchData?.filter;
@ -158,17 +196,3 @@ export function getEsQueryFromSavedSearch({
};
}
}
const DEFAULT_QUERY = {
bool: {
must: [
{
match_all: {},
},
],
},
};
export function getDefaultQuery() {
return cloneDeep(DEFAULT_QUERY);
}

View file

@ -6,7 +6,7 @@
*/
import { CoreSetup, CoreStart } from 'kibana/public';
import type { EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import type { EmbeddableSetup, EmbeddableStart } from '../../../../src/plugins/embeddable/public';
import type { SharePluginStart } from '../../../../src/plugins/share/public';
import { Plugin } from '../../../../src/core/public';
@ -21,9 +21,11 @@ import type { IndexPatternFieldEditorStart } from '../../../../src/plugins/index
import { getFileDataVisualizerComponent, getIndexDataVisualizerComponent } from './api';
import { getMaxBytesFormatted } from './application/common/util/get_max_bytes';
import { registerHomeAddData, registerHomeFeatureCatalogue } from './register_home';
import { registerEmbeddables } from './application/index_data_visualizer/embeddables';
export interface DataVisualizerSetupDependencies {
home?: HomePublicPluginSetup;
embeddable: EmbeddableSetup;
}
export interface DataVisualizerStartDependencies {
data: DataPublicPluginStart;
@ -56,6 +58,9 @@ export class DataVisualizerPlugin
registerHomeAddData(plugins.home);
registerHomeFeatureCatalogue(plugins.home);
}
if (plugins.embeddable) {
registerEmbeddables(plugins.embeddable, core);
}
}
public start(core: CoreStart, plugins: DataVisualizerStartDependencies) {

View file

@ -6,9 +6,18 @@
"declaration": true,
"declarationMap": true
},
"include": ["common/**/*", "public/**/*", "server/**/*"],
"include": [
"../../../typings/**/*",
"common/**/*",
"public/**/*",
"scripts/**/*",
"server/**/*",
"types/**/*"
],
"references": [
{ "path": "../../../src/core/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_utils/tsconfig.json" },
{ "path": "../../../src/plugins/kibana_react/tsconfig.json" },
{ "path": "../../../src/plugins/data/tsconfig.json" },
{ "path": "../../../src/plugins/usage_collection/tsconfig.json" },
{ "path": "../../../src/plugins/custom_integrations/tsconfig.json" },