[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

@ -19,5 +19,6 @@ export const DOC_TABLE_LEGACY = 'doc_table:legacy';
export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch';
export const SEARCH_FIELDS_FROM_SOURCE = 'discover:searchFieldsFromSource';
export const MAX_DOC_FIELDS_DISPLAYED = 'discover:maxDocFieldsDisplayed';
export const SHOW_FIELD_STATISTICS = 'discover:showFieldStatistics';
export const SHOW_MULTIFIELDS = 'discover:showMultiFields';
export const SEARCH_EMBEDDABLE_TYPE = 'search';

View file

@ -19,6 +19,7 @@ import { discoverServiceMock } from '../../../../../__mocks__/services';
import { FetchStatus } from '../../../../types';
import { Chart } from './point_series';
import { DiscoverChart } from './discover_chart';
import { VIEW_MODE } from '../view_mode_toggle';
setHeaderActionMenuMounter(jest.fn());
@ -94,6 +95,8 @@ function getProps(timefield?: string) {
state: { columns: [] },
stateContainer: {} as GetStateReturn,
timefield,
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
setDiscoverViewMode: jest.fn(),
};
}

View file

@ -23,6 +23,8 @@ import { DiscoverHistogram } from './histogram';
import { DataCharts$, DataTotalHits$ } from '../../services/use_saved_search';
import { DiscoverServices } from '../../../../../build_services';
import { useChartPanels } from './use_chart_panels';
import { VIEW_MODE, DocumentViewModeToggle } from '../view_mode_toggle';
import { SHOW_FIELD_STATISTICS } from '../../../../../../common';
const DiscoverHistogramMemoized = memo(DiscoverHistogram);
export const CHART_HIDDEN_KEY = 'discover:chartHidden';
@ -36,6 +38,8 @@ export function DiscoverChart({
state,
stateContainer,
timefield,
viewMode,
setDiscoverViewMode,
}: {
resetSavedSearch: () => void;
savedSearch: SavedSearch;
@ -45,8 +49,11 @@ export function DiscoverChart({
state: AppState;
stateContainer: GetStateReturn;
timefield?: string;
viewMode: VIEW_MODE;
setDiscoverViewMode: (viewMode: VIEW_MODE) => void;
}) {
const [showChartOptionsPopover, setShowChartOptionsPopover] = useState(false);
const showViewModeToggle = services.uiSettings.get(SHOW_FIELD_STATISTICS) ?? false;
const { data, storage } = services;
@ -108,6 +115,16 @@ export function DiscoverChart({
onResetQuery={resetSavedSearch}
/>
</EuiFlexItem>
{showViewModeToggle && (
<EuiFlexItem grow={false}>
<DocumentViewModeToggle
viewMode={viewMode}
setDiscoverViewMode={setDiscoverViewMode}
/>
</EuiFlexItem>
)}
{timefield && (
<EuiFlexItem className="dscResultCount__toggle" grow={false}>
<EuiPopover

View file

@ -4,6 +4,10 @@ discover-app {
flex-grow: 1;
}
.dscAppWrapper {
overflow-y: hidden;
}
.dscPage {
@include euiBreakpoint('m', 'l', 'xl') {
@include kibanaFullBodyHeight();

View file

@ -6,9 +6,8 @@
* Side Public License, v 1.
*/
import './discover_layout.scss';
import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
EuiSpacer,
EuiButtonIcon,
EuiFlexGroup,
EuiFlexItem,
@ -17,6 +16,7 @@ import {
EuiPage,
EuiPageBody,
EuiPageContent,
EuiSpacer,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { METRIC_TYPE } from '@kbn/analytics';
@ -26,7 +26,7 @@ import { LoadingSpinner } from '../loading_spinner/loading_spinner';
import { esFilters, IndexPatternField } from '../../../../../../../data/public';
import { DiscoverSidebarResponsive } from '../sidebar';
import { DiscoverLayoutProps } from './types';
import { SEARCH_FIELDS_FROM_SOURCE } from '../../../../../../common';
import { SEARCH_FIELDS_FROM_SOURCE, SHOW_FIELD_STATISTICS } from '../../../../../../common';
import { popularizeField } from '../../../../helpers/popularize_field';
import { DiscoverTopNav } from '../top_nav/discover_topnav';
import { DocViewFilterFn, ElasticSearchHit } from '../../../../doc_views/doc_views_types';
@ -43,6 +43,8 @@ import {
SavedSearchURLConflictCallout,
useSavedSearchAliasMatchRedirect,
} from '../../../../../saved_searches';
import { DiscoverDataVisualizerGrid } from '../../../../components/data_visualizer_grid';
import { VIEW_MODE } from '../view_mode_toggle';
/**
* Local storage key for sidebar persistence state
@ -52,6 +54,7 @@ export const SIDEBAR_CLOSED_KEY = 'discover:sidebarClosed';
const SidebarMemoized = React.memo(DiscoverSidebarResponsive);
const TopNavMemoized = React.memo(DiscoverTopNav);
const DiscoverChartMemoized = React.memo(DiscoverChart);
const DataVisualizerGridMemoized = React.memo(DiscoverDataVisualizerGrid);
export function DiscoverLayout({
indexPattern,
@ -83,6 +86,19 @@ export function DiscoverLayout({
const { main$, charts$, totalHits$ } = savedSearchData$;
const [expandedDoc, setExpandedDoc] = useState<ElasticSearchHit | undefined>(undefined);
const [inspectorSession, setInspectorSession] = useState<InspectorSession | undefined>(undefined);
const viewMode = useMemo(() => {
if (uiSettings.get(SHOW_FIELD_STATISTICS) !== true) return VIEW_MODE.DOCUMENT_LEVEL;
return state.viewMode ?? VIEW_MODE.DOCUMENT_LEVEL;
}, [uiSettings, state.viewMode]);
const setDiscoverViewMode = useCallback(
(mode: VIEW_MODE) => {
stateContainer.setAppState({ viewMode: mode });
},
[stateContainer]
);
const fetchCounter = useRef<number>(0);
const dataState: DataMainMsg = useDataState(main$);
@ -213,6 +229,7 @@ export function DiscoverLayout({
trackUiMetric={trackUiMetric}
useNewFieldsApi={useNewFieldsApi}
onEditRuntimeField={onEditRuntimeField}
viewMode={viewMode}
/>
</EuiFlexItem>
<EuiHideFor sizes={['xs', 's']}>
@ -279,22 +296,36 @@ export function DiscoverLayout({
services={services}
stateContainer={stateContainer}
timefield={timeField}
viewMode={viewMode}
setDiscoverViewMode={setDiscoverViewMode}
/>
</EuiFlexItem>
<EuiHorizontalRule margin="none" />
<DiscoverDocuments
documents$={savedSearchData$.documents$}
expandedDoc={expandedDoc}
indexPattern={indexPattern}
navigateTo={navigateTo}
onAddFilter={onAddFilter as DocViewFilterFn}
savedSearch={savedSearch}
services={services}
setExpandedDoc={setExpandedDoc}
state={state}
stateContainer={stateContainer}
/>
{viewMode === VIEW_MODE.DOCUMENT_LEVEL ? (
<DiscoverDocuments
documents$={savedSearchData$.documents$}
expandedDoc={expandedDoc}
indexPattern={indexPattern}
navigateTo={navigateTo}
onAddFilter={onAddFilter as DocViewFilterFn}
savedSearch={savedSearch}
services={services}
setExpandedDoc={setExpandedDoc}
state={state}
stateContainer={stateContainer}
/>
) : (
<DataVisualizerGridMemoized
savedSearch={savedSearch}
services={services}
indexPattern={indexPattern}
query={state.query}
filters={state.filters}
columns={columns}
stateContainer={stateContainer}
onAddFilter={onAddFilter}
/>
)}
</EuiFlexGroup>
)}
</EuiPageContent>

View file

@ -19,6 +19,7 @@ import {
EuiFlexGroup,
EuiFlexItem,
EuiSpacer,
EuiHorizontalRule,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
@ -251,6 +252,11 @@ export interface DiscoverFieldProps {
* @param fieldName name of the field to delete
*/
onDeleteField?: (fieldName: string) => void;
/**
* Optionally show or hide field stats in the popover
*/
showFieldStats?: boolean;
}
function DiscoverFieldComponent({
@ -266,6 +272,7 @@ function DiscoverFieldComponent({
multiFields,
onEditField,
onDeleteField,
showFieldStats,
}: DiscoverFieldProps) {
const [infoIsOpen, setOpen] = useState(false);
@ -362,15 +369,27 @@ function DiscoverFieldComponent({
const details = getDetails(field);
return (
<>
<DiscoverFieldDetails
indexPattern={indexPattern}
field={field}
details={details}
onAddFilter={onAddFilter}
/>
{showFieldStats && (
<>
<EuiTitle size="xxxs">
<h5>
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', {
defaultMessage: 'Top 5 values',
})}
</h5>
</EuiTitle>
<DiscoverFieldDetails
indexPattern={indexPattern}
field={field}
details={details}
onAddFilter={onAddFilter}
/>
</>
)}
{multiFields && (
<>
<EuiSpacer size="m" />
{showFieldStats && <EuiSpacer size="m" />}
<MultiFields
multiFields={multiFields}
alwaysShowActionButton={alwaysShowActionButton}
@ -378,6 +397,7 @@ function DiscoverFieldComponent({
/>
</>
)}
{(showFieldStats || multiFields) && <EuiHorizontalRule margin="m" />}
<DiscoverFieldVisualize
field={field}
indexPattern={indexPattern}
@ -388,7 +408,6 @@ function DiscoverFieldComponent({
</>
);
};
return (
<EuiPopover
display="block"
@ -418,13 +437,6 @@ function DiscoverFieldComponent({
panelClassName="dscSidebarItem__fieldPopoverPanel"
>
{popoverTitle}
<EuiTitle size="xxxs">
<h5>
{i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', {
defaultMessage: 'Top 5 values',
})}
</h5>
</EuiTitle>
{infoIsOpen && renderPopover()}
</EuiPopover>
);

View file

@ -7,7 +7,7 @@
*/
import React, { useEffect, useState } from 'react';
import { EuiButton, EuiPopoverFooter } from '@elastic/eui';
import { EuiButton } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { METRIC_TYPE, UiCounterMetricType } from '@kbn/analytics';
import type { IndexPattern, IndexPatternField } from 'src/plugins/data/common';
@ -46,21 +46,19 @@ export const DiscoverFieldVisualize: React.FC<Props> = React.memo(
};
return (
<EuiPopoverFooter>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click */}
<EuiButton
fullWidth
size="s"
href={visualizeInfo.href}
onClick={handleVisualizeLinkClick}
data-test-subj={`fieldVisualize-${field.name}`}
>
<FormattedMessage
id="discover.fieldChooser.visualizeButton.label"
defaultMessage="Visualize"
/>
</EuiButton>
</EuiPopoverFooter>
// eslint-disable-next-line @elastic/eui/href-or-on-click
<EuiButton
fullWidth
size="s"
href={visualizeInfo.href}
onClick={handleVisualizeLinkClick}
data-test-subj={`fieldVisualize-${field.name}`}
>
<FormattedMessage
id="discover.fieldChooser.visualizeButton.label"
defaultMessage="Visualize"
/>
</EuiButton>
);
}
);

View file

@ -22,6 +22,7 @@ import { DiscoverSidebarComponent as DiscoverSidebar } from './discover_sidebar'
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { discoverServiceMock as mockDiscoverServices } from '../../../../../__mocks__/services';
import { stubLogstashIndexPattern } from '../../../../../../../data/common/stubs';
import { VIEW_MODE } from '../view_mode_toggle';
jest.mock('../../../../../kibana_services', () => ({
getServices: () => mockDiscoverServices,
@ -65,6 +66,7 @@ function getCompProps(): DiscoverSidebarProps {
setFieldFilter: jest.fn(),
onEditRuntimeField: jest.fn(),
editField: jest.fn(),
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
};
}

View file

@ -40,6 +40,7 @@ import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list';
import { DiscoverSidebarResponsiveProps } from './discover_sidebar_responsive';
import { DiscoverIndexPatternManagement } from './discover_index_pattern_management';
import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { VIEW_MODE } from '../view_mode_toggle';
/**
* Default number of available fields displayed and added on scroll
@ -77,6 +78,10 @@ export interface DiscoverSidebarProps extends Omit<DiscoverSidebarResponsiveProp
* hits fetched from ES, displayed in the doc table
*/
documents?: ElasticSearchHit[];
/**
* Discover view mode
*/
viewMode: VIEW_MODE;
}
export function DiscoverSidebarComponent({
@ -100,6 +105,7 @@ export function DiscoverSidebarComponent({
setFieldEditorRef,
closeFlyout,
editField,
viewMode,
}: DiscoverSidebarProps) {
const [fields, setFields] = useState<IndexPatternField[] | null>(null);
@ -205,6 +211,8 @@ export function DiscoverSidebarComponent({
return result;
}, [fields]);
const showFieldStats = useMemo(() => viewMode === VIEW_MODE.DOCUMENT_LEVEL, [viewMode]);
const calculateMultiFields = () => {
if (!useNewFieldsApi || !fields) {
return undefined;
@ -407,6 +415,7 @@ export function DiscoverSidebarComponent({
multiFields={multiFields?.get(field.name)}
onEditField={canEditIndexPatternField ? editField : undefined}
onDeleteField={canEditIndexPatternField ? deleteField : undefined}
showFieldStats={showFieldStats}
/>
</li>
);
@ -466,6 +475,7 @@ export function DiscoverSidebarComponent({
multiFields={multiFields?.get(field.name)}
onEditField={canEditIndexPatternField ? editField : undefined}
onDeleteField={canEditIndexPatternField ? deleteField : undefined}
showFieldStats={showFieldStats}
/>
</li>
);
@ -494,6 +504,7 @@ export function DiscoverSidebarComponent({
multiFields={multiFields?.get(field.name)}
onEditField={canEditIndexPatternField ? editField : undefined}
onDeleteField={canEditIndexPatternField ? deleteField : undefined}
showFieldStats={showFieldStats}
/>
</li>
);

View file

@ -26,6 +26,7 @@ import { ElasticSearchHit } from '../../../../doc_views/doc_views_types';
import { FetchStatus } from '../../../../types';
import { DataDocuments$ } from '../../services/use_saved_search';
import { stubLogstashIndexPattern } from '../../../../../../../data/common/stubs';
import { VIEW_MODE } from '../view_mode_toggle';
const mockServices = {
history: () => ({
@ -103,6 +104,7 @@ function getCompProps(): DiscoverSidebarResponsiveProps {
state: {},
trackUiMetric: jest.fn(),
onEditRuntimeField: jest.fn(),
viewMode: VIEW_MODE.DOCUMENT_LEVEL,
};
}

View file

@ -37,6 +37,7 @@ import { AppState } from '../../services/discover_state';
import { DiscoverIndexPatternManagement } from './discover_index_pattern_management';
import { DataDocuments$ } from '../../services/use_saved_search';
import { calcFieldCounts } from '../../utils/calc_field_counts';
import { VIEW_MODE } from '../view_mode_toggle';
export interface DiscoverSidebarResponsiveProps {
/**
@ -106,6 +107,10 @@ export interface DiscoverSidebarResponsiveProps {
* callback to execute on edit runtime field
*/
onEditRuntimeField: () => void;
/**
* Discover view mode
*/
viewMode: VIEW_MODE;
}
/**

View file

@ -16,6 +16,7 @@ import { SavedSearch } from '../../../../../saved_searches';
import { onSaveSearch } from './on_save_search';
import { GetStateReturn } from '../../services/discover_state';
import { openOptionsPopover } from './open_options_popover';
import type { TopNavMenuData } from '../../../../../../../navigation/public';
/**
* Helper function to build the top nav links
@ -38,7 +39,7 @@ export const getTopNavLinks = ({
onOpenInspector: () => void;
searchSource: ISearchSource;
onOpenSavedSearch: (id: string) => void;
}) => {
}): TopNavMenuData[] => {
const options = {
id: 'options',
label: i18n.translate('discover.localMenu.localMenu.optionsTitle', {

View file

@ -0,0 +1 @@
@import 'view_mode_toggle';

View file

@ -0,0 +1,12 @@
.dscViewModeToggle {
padding-right: $euiSize;
}
.fieldStatsButton {
display: flex;
align-items: center;
}
.fieldStatsBetaBadge {
margin-left: $euiSizeXS;
}

View file

@ -0,0 +1,12 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export enum VIEW_MODE {
DOCUMENT_LEVEL = 'documents',
AGGREGATED_LEVEL = 'aggregated',
}

View file

@ -0,0 +1,10 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { DocumentViewModeToggle } from './view_mode_toggle';
export { VIEW_MODE } from './constants';

View file

@ -0,0 +1,66 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EuiButtonGroup, EuiBetaBadge } from '@elastic/eui';
import React, { useMemo } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import { VIEW_MODE } from './constants';
import './_index.scss';
export const DocumentViewModeToggle = ({
viewMode,
setDiscoverViewMode,
}: {
viewMode: VIEW_MODE;
setDiscoverViewMode: (viewMode: VIEW_MODE) => void;
}) => {
const toggleButtons = useMemo(
() => [
{
id: VIEW_MODE.DOCUMENT_LEVEL,
label: i18n.translate('discover.viewModes.document.label', {
defaultMessage: 'Documents',
}),
'data-test-subj': 'dscViewModeDocumentButton',
},
{
id: VIEW_MODE.AGGREGATED_LEVEL,
label: (
<div className="fieldStatsButton" data-test-subj="dscViewModeFieldStatsButton">
<FormattedMessage
id="discover.viewModes.fieldStatistics.label"
defaultMessage="Field statistics"
/>
<EuiBetaBadge
label={i18n.translate('discover.viewModes.fieldStatistics.betaTitle', {
defaultMessage: 'Beta',
})}
size="s"
iconType="beaker"
className="fieldStatsBetaBadge"
/>
</div>
),
},
],
[]
);
return (
<EuiButtonGroup
className={'dscViewModeToggle'}
legend={i18n.translate('discover.viewModes.legend', { defaultMessage: 'View modes' })}
buttonSize={'compressed'}
options={toggleButtons}
idSelected={viewMode}
onChange={(id: string) => setDiscoverViewMode(id as VIEW_MODE)}
data-test-subj={'dscViewModeToggle'}
/>
);
};

View file

@ -35,6 +35,7 @@ import { DiscoverGridSettings } from '../../../components/discover_grid/types';
import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from '../../../../url_generator';
import { SavedSearch } from '../../../../saved_searches';
import { handleSourceColumnState } from '../../../helpers/state_helpers';
import { VIEW_MODE } from '../components/view_mode_toggle';
export interface AppState {
/**
@ -73,6 +74,14 @@ export interface AppState {
* id of the used saved query
*/
savedQuery?: string;
/**
* Table view: Documents vs Field Statistics
*/
viewMode?: VIEW_MODE;
/**
* Hide mini distribution/preview charts when in Field Statistics mode
*/
hideAggregatedPreview?: boolean;
}
interface GetStateParams {

View file

@ -31,6 +31,7 @@ describe('getStateDefaults', () => {
"default_column",
],
"filters": undefined,
"hideAggregatedPreview": undefined,
"hideChart": undefined,
"index": "index-pattern-with-timefield-id",
"interval": "auto",
@ -42,6 +43,7 @@ describe('getStateDefaults', () => {
"desc",
],
],
"viewMode": undefined,
}
`);
});
@ -61,12 +63,14 @@ describe('getStateDefaults', () => {
"default_column",
],
"filters": undefined,
"hideAggregatedPreview": undefined,
"hideChart": undefined,
"index": "the-index-pattern-id",
"interval": "auto",
"query": undefined,
"savedQuery": undefined,
"sort": Array [],
"viewMode": undefined,
}
`);
});

View file

@ -60,6 +60,8 @@ export function getStateDefaults({
interval: 'auto',
filters: cloneDeep(searchSource.getOwnField('filter')),
hideChart: chartHidden ? chartHidden : undefined,
viewMode: undefined,
hideAggregatedPreview: undefined,
savedQuery: undefined,
} as AppState;
if (savedSearch.grid) {
@ -68,6 +70,13 @@ export function getStateDefaults({
if (savedSearch.hideChart !== undefined) {
defaultState.hideChart = savedSearch.hideChart;
}
if (savedSearch.viewMode) {
defaultState.viewMode = savedSearch.viewMode;
}
if (savedSearch.hideAggregatedPreview) {
defaultState.hideAggregatedPreview = savedSearch.hideAggregatedPreview;
}
return defaultState;
}

View file

@ -52,6 +52,14 @@ export async function persistSavedSearch(
savedSearch.hideChart = state.hideChart;
}
if (state.viewMode) {
savedSearch.viewMode = state.viewMode;
}
if (state.hideAggregatedPreview) {
savedSearch.hideAggregatedPreview = state.hideAggregatedPreview;
}
try {
const id = await saveSavedSearch(savedSearch, saveOptions, services.core.savedObjects.client);
if (id) {

View file

@ -0,0 +1,196 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Filter } from '@kbn/es-query';
import { IndexPatternField, IndexPattern, DataView, Query } from '../../../../../data/common';
import { DiscoverServices } from '../../../build_services';
import {
EmbeddableInput,
EmbeddableOutput,
ErrorEmbeddable,
IEmbeddable,
isErrorEmbeddable,
} from '../../../../../embeddable/public';
import { SavedSearch } from '../../../saved_searches';
import { GetStateReturn } from '../../apps/main/services/discover_state';
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 interface DataVisualizerGridEmbeddableOutput extends EmbeddableOutput {
showDistributions?: boolean;
}
export interface DiscoverDataVisualizerGridProps {
/**
* Determines which columns are displayed
*/
columns: string[];
/**
* The used index pattern
*/
indexPattern: DataView;
/**
* Saved search description
*/
searchDescription?: string;
/**
* Saved search title
*/
searchTitle?: string;
/**
* Discover plugin services
*/
services: DiscoverServices;
/**
* Optional saved search
*/
savedSearch?: SavedSearch;
/**
* Optional query to update the table content
*/
query?: Query;
/**
* Filters query to update the table content
*/
filters?: Filter[];
stateContainer?: GetStateReturn;
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}
export const DiscoverDataVisualizerGrid = (props: DiscoverDataVisualizerGridProps) => {
const {
services,
indexPattern,
savedSearch,
query,
columns,
filters,
stateContainer,
onAddFilter,
} = props;
const { uiSettings } = services;
const [embeddable, setEmbeddable] = useState<
| ErrorEmbeddable
| IEmbeddable<DataVisualizerGridEmbeddableInput, DataVisualizerGridEmbeddableOutput>
| undefined
>();
const embeddableRoot: React.RefObject<HTMLDivElement> = useRef<HTMLDivElement>(null);
const showPreviewByDefault = useMemo(
() =>
stateContainer ? !stateContainer.appStateContainer.getState().hideAggregatedPreview : true,
[stateContainer]
);
useEffect(() => {
const sub = embeddable?.getOutput$().subscribe((output: DataVisualizerGridEmbeddableOutput) => {
if (output.showDistributions !== undefined && stateContainer) {
stateContainer.setAppState({ hideAggregatedPreview: !output.showDistributions });
}
});
return () => {
sub?.unsubscribe();
};
}, [embeddable, stateContainer]);
useEffect(() => {
if (embeddable && !isErrorEmbeddable(embeddable)) {
// Update embeddable whenever one of the important input changes
embeddable.updateInput({
indexPattern,
savedSearch,
query,
filters,
visibleFieldNames: columns,
onAddFilter,
});
embeddable.reload();
}
}, [embeddable, indexPattern, savedSearch, query, columns, filters, onAddFilter]);
useEffect(() => {
if (showPreviewByDefault && embeddable && !isErrorEmbeddable(embeddable)) {
// Update embeddable whenever one of the important input changes
embeddable.updateInput({
showPreviewByDefault,
});
embeddable.reload();
}
}, [showPreviewByDefault, uiSettings, embeddable]);
useEffect(() => {
return () => {
// Clean up embeddable upon unmounting
embeddable?.destroy();
};
}, [embeddable]);
useEffect(() => {
let unmounted = false;
const loadEmbeddable = async () => {
if (services.embeddable) {
const factory = services.embeddable.getEmbeddableFactory<
DataVisualizerGridEmbeddableInput,
DataVisualizerGridEmbeddableOutput
>('data_visualizer_grid');
if (factory) {
// Initialize embeddable with information available at mount
const initializedEmbeddable = await factory.create({
id: 'discover_data_visualizer_grid',
indexPattern,
savedSearch,
query,
showPreviewByDefault,
onAddFilter,
});
if (!unmounted) {
setEmbeddable(initializedEmbeddable);
}
}
}
};
loadEmbeddable();
return () => {
unmounted = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [services.embeddable, showPreviewByDefault]);
// We can only render after embeddable has already initialized
useEffect(() => {
if (embeddableRoot.current && embeddable) {
embeddable.render(embeddableRoot.current);
}
}, [embeddable, embeddableRoot, uiSettings]);
return (
<div
data-test-subj="dscFieldStatsEmbeddedContent"
ref={embeddableRoot}
style={{ height: '100%', overflowY: 'auto', overflowX: 'hidden' }}
// Match the scroll bar of the Discover doc table
className="kbnDocTableWrapper"
/>
);
};

View file

@ -0,0 +1,31 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import {
DiscoverDataVisualizerGrid,
DiscoverDataVisualizerGridProps,
} from './data_visualizer_grid';
export function FieldStatsTableEmbeddable(renderProps: DiscoverDataVisualizerGridProps) {
return (
<I18nProvider>
<DiscoverDataVisualizerGrid
savedSearch={renderProps.savedSearch}
services={renderProps.services}
indexPattern={renderProps.indexPattern}
query={renderProps.query}
filters={renderProps.filters}
columns={renderProps.columns}
stateContainer={renderProps.stateContainer}
onAddFilter={renderProps.onAddFilter}
/>
</I18nProvider>
);
}

View file

@ -0,0 +1,9 @@
/*
* 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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { DiscoverDataVisualizerGrid } from './data_visualizer_grid';

View file

@ -19,12 +19,12 @@ import { SEARCH_EMBEDDABLE_TYPE } from './constants';
import { APPLY_FILTER_TRIGGER, esFilters, FilterManager } from '../../../../data/public';
import { DiscoverServices } from '../../build_services';
import {
Query,
TimeRange,
Filter,
IndexPattern,
ISearchSource,
IndexPatternField,
ISearchSource,
Query,
TimeRange,
} from '../../../../data/common';
import { ElasticSearchHit } from '../doc_views/doc_views_types';
import { SavedSearchEmbeddableComponent } from './saved_search_embeddable_component';
@ -35,6 +35,7 @@ import {
DOC_TABLE_LEGACY,
SAMPLE_SIZE_SETTING,
SEARCH_FIELDS_FROM_SOURCE,
SHOW_FIELD_STATISTICS,
SORT_DEFAULT_ORDER_SETTING,
} from '../../../common';
import * as columnActions from '../apps/main/components/doc_table/actions/columns';
@ -45,6 +46,8 @@ import { DocTableProps } from '../apps/main/components/doc_table/doc_table_wrapp
import { getDefaultSort } from '../apps/main/components/doc_table';
import { SortOrder } from '../apps/main/components/doc_table/components/table_header/helpers';
import { updateSearchSource } from './helpers/update_search_source';
import { VIEW_MODE } from '../apps/main/components/view_mode_toggle';
import { FieldStatsTableEmbeddable } from '../components/data_visualizer_grid/field_stats_table_embeddable';
export type SearchProps = Partial<DiscoverGridProps> &
Partial<DocTableProps> & {
@ -379,6 +382,28 @@ export class SavedSearchEmbeddable
if (!this.searchProps) {
return;
}
if (
this.services.uiSettings.get(SHOW_FIELD_STATISTICS) === true &&
this.savedSearch.viewMode === VIEW_MODE.AGGREGATED_LEVEL &&
searchProps.services &&
searchProps.indexPattern &&
Array.isArray(searchProps.columns)
) {
ReactDOM.render(
<FieldStatsTableEmbeddable
services={searchProps.services}
indexPattern={searchProps.indexPattern}
columns={searchProps.columns}
savedSearch={this.savedSearch}
filters={this.input.filters}
query={this.input.query}
onAddFilter={searchProps.onFilter}
/>,
domNode
);
return;
}
const useLegacyTable = this.services.uiSettings.get(DOC_TABLE_LEGACY);
const props = {
searchProps,

View file

@ -37,6 +37,7 @@ import { UrlForwardingStart } from '../../url_forwarding/public';
import { NavigationPublicPluginStart } from '../../navigation/public';
import { IndexPatternFieldEditorStart } from '../../index_pattern_field_editor/public';
import { FieldFormatsStart } from '../../field_formats/public';
import { EmbeddableStart } from '../../embeddable/public';
import type { SpacesApi } from '../../../../x-pack/plugins/spaces/public';
@ -47,6 +48,7 @@ export interface DiscoverServices {
core: CoreStart;
data: DataPublicPluginStart;
docLinks: DocLinksStart;
embeddable: EmbeddableStart;
history: () => History;
theme: ChartsPluginStart['theme'];
filterManager: FilterManager;
@ -83,6 +85,7 @@ export function buildServices(
core,
data: plugins.data,
docLinks: core.docLinks,
embeddable: plugins.embeddable,
theme: plugins.charts.theme,
fieldFormats: plugins.fieldFormats,
filterManager: plugins.data.query.filterManager,

View file

@ -348,6 +348,11 @@ export class DiscoverPlugin
await depsStart.data.indexPatterns.clearCache();
const { renderApp } = await import('./application');
// FIXME: Temporarily hide overflow-y in Discover app when Field Stats table is shown
// due to EUI bug https://github.com/elastic/eui/pull/5152
params.element.classList.add('dscAppWrapper');
const unmount = renderApp(params.element);
return () => {
unlistenParentHistory();

View file

@ -101,6 +101,7 @@ describe('getSavedSearch', () => {
],
"description": "description",
"grid": Object {},
"hideAggregatedPreview": undefined,
"hideChart": false,
"id": "ccf1af80-2297-11ec-86e0-1155ffb9c7a7",
"searchSource": Object {
@ -138,6 +139,7 @@ describe('getSavedSearch', () => {
],
],
"title": "test1",
"viewMode": undefined,
}
`);
});

View file

@ -54,6 +54,7 @@ describe('saved_searches_utils', () => {
],
"description": "foo",
"grid": Object {},
"hideAggregatedPreview": undefined,
"hideChart": true,
"id": "id",
"searchSource": SearchSource {
@ -74,6 +75,7 @@ describe('saved_searches_utils', () => {
"sharingSavedObjectProps": Object {},
"sort": Array [],
"title": "saved search",
"viewMode": undefined,
}
`);
});
@ -122,6 +124,7 @@ describe('saved_searches_utils', () => {
],
"description": "description",
"grid": Object {},
"hideAggregatedPreview": undefined,
"hideChart": true,
"kibanaSavedObjectMeta": Object {
"searchSourceJSON": "{}",
@ -133,6 +136,7 @@ describe('saved_searches_utils', () => {
],
],
"title": "title",
"viewMode": undefined,
}
`);
});

View file

@ -41,6 +41,8 @@ export const fromSavedSearchAttributes = (
description: attributes.description,
grid: attributes.grid,
hideChart: attributes.hideChart,
viewMode: attributes.viewMode,
hideAggregatedPreview: attributes.hideAggregatedPreview,
});
export const toSavedSearchAttributes = (
@ -54,4 +56,6 @@ export const toSavedSearchAttributes = (
description: savedSearch.description ?? '',
grid: savedSearch.grid ?? {},
hideChart: savedSearch.hideChart ?? false,
viewMode: savedSearch.viewMode,
hideAggregatedPreview: savedSearch.hideAggregatedPreview,
});

View file

@ -8,6 +8,7 @@
import type { ISearchSource } from '../../../data/public';
import { DiscoverGridSettingsColumn } from '../application/components/discover_grid/types';
import { VIEW_MODE } from '../application/apps/main/components/view_mode_toggle';
/** @internal **/
export interface SavedSearchAttributes {
@ -22,6 +23,8 @@ export interface SavedSearchAttributes {
kibanaSavedObjectMeta: {
searchSourceJSON: string;
};
viewMode?: VIEW_MODE;
hideAggregatedPreview?: boolean;
}
/** @internal **/
@ -44,4 +47,6 @@ export interface SavedSearch {
aliasTargetId?: string;
errorJSON?: string;
};
viewMode?: VIEW_MODE;
hideAggregatedPreview?: boolean;
}

View file

@ -32,7 +32,9 @@ export const searchSavedObjectType: SavedObjectsType = {
properties: {
columns: { type: 'keyword', index: false, doc_values: false },
description: { type: 'text' },
viewMode: { type: 'keyword', index: false, doc_values: false },
hideChart: { type: 'boolean', index: false, doc_values: false },
hideAggregatedPreview: { type: 'boolean', index: false, doc_values: false },
hits: { type: 'integer', index: false, doc_values: false },
kibanaSavedObjectMeta: {
properties: {

View file

@ -26,6 +26,7 @@ import {
SEARCH_FIELDS_FROM_SOURCE,
MAX_DOC_FIELDS_DISPLAYED,
SHOW_MULTIFIELDS,
SHOW_FIELD_STATISTICS,
} from '../common';
export const getUiSettings: () => Record<string, UiSettingsParams> = () => ({
@ -172,6 +173,7 @@ export const getUiSettings: () => Record<string, UiSettingsParams> = () => ({
name: 'discover:useLegacyDataGrid',
},
},
[MODIFY_COLUMNS_ON_SWITCH]: {
name: i18n.translate('discover.advancedSettings.discover.modifyColumnsOnSwitchTitle', {
defaultMessage: 'Modify columns when changing data views',
@ -201,6 +203,24 @@ export const getUiSettings: () => Record<string, UiSettingsParams> = () => ({
category: ['discover'],
schema: schema.boolean(),
},
[SHOW_FIELD_STATISTICS]: {
name: i18n.translate('discover.advancedSettings.discover.showFieldStatistics', {
defaultMessage: 'Show field statistics',
}),
description: i18n.translate(
'discover.advancedSettings.discover.showFieldStatisticsDescription',
{
defaultMessage: `Enable "Field statistics" table in Discover.`,
}
),
value: false,
category: ['discover'],
schema: schema.boolean(),
metric: {
type: METRIC_TYPE.CLICK,
name: 'discover:showFieldStatistics',
},
},
[SHOW_MULTIFIELDS]: {
name: i18n.translate('discover.advancedSettings.discover.showMultifields', {
defaultMessage: 'Show multi-fields',

View file

@ -448,6 +448,10 @@ export const stackManagementSchema: MakeSchemaFrom<UsageStats> = {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'discover:showFieldStatistics': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },
},
'discover:showMultiFields': {
type: 'boolean',
_meta: { description: 'Non-default value of setting.' },

View file

@ -31,6 +31,7 @@ export interface UsageStats {
'doc_table:legacy': boolean;
'discover:modifyColumnsOnSwitch': boolean;
'discover:searchFieldsFromSource': boolean;
'discover:showFieldStatistics': boolean;
'discover:showMultiFields': boolean;
'discover:maxDocFieldsDisplayed': number;
'securitySolution:rulesTableRefresh': string;

View file

@ -7689,6 +7689,12 @@
"description": "Non-default value of setting."
}
},
"discover:showFieldStatistics": {
"type": "boolean",
"_meta": {
"description": "Non-default value of setting."
}
},
"discover:showMultiFields": {
"type": "boolean",
"_meta": {

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { FtrService } from '../ftr_provider_context';
export class DiscoverPageObject extends FtrService {
@ -307,6 +308,13 @@ export class DiscoverPageObject extends FtrService {
return await this.testSubjects.click('collapseSideBarButton');
}
public async closeSidebar() {
await this.retry.tryForTime(2 * 1000, async () => {
await this.toggleSidebarCollapse();
await this.testSubjects.missingOrFail('discover-sidebar');
});
}
public async getAllFieldNames() {
const sidebar = await this.testSubjects.find('discover-sidebar');
const $ = await sidebar.parseDomContent();
@ -545,4 +553,37 @@ export class DiscoverPageObject extends FtrService {
public async clearSavedQuery() {
await this.testSubjects.click('saved-query-management-clear-button');
}
public async assertHitCount(expectedHitCount: string) {
await this.retry.tryForTime(2 * 1000, async () => {
// Close side bar to ensure Discover hit count shows
// edge case for when browser width is small
await this.closeSidebar();
const hitCount = await this.getHitCount();
expect(hitCount).to.eql(
expectedHitCount,
`Expected Discover hit count to be ${expectedHitCount} but got ${hitCount}.`
);
});
}
public async assertViewModeToggleNotExists() {
await this.testSubjects.missingOrFail('dscViewModeToggle', { timeout: 2 * 1000 });
}
public async assertViewModeToggleExists() {
await this.testSubjects.existOrFail('dscViewModeToggle', { timeout: 2 * 1000 });
}
public async assertFieldStatsTableNotExists() {
await this.testSubjects.missingOrFail('dscFieldStatsEmbeddedContent', { timeout: 2 * 1000 });
}
public async clickViewModeFieldStatsButton() {
await this.retry.tryForTime(2 * 1000, async () => {
await this.testSubjects.existOrFail('dscViewModeFieldStatsButton');
await this.testSubjects.clickWhenNotDisabled('dscViewModeFieldStatsButton');
await this.testSubjects.existOrFail('dscFieldStatsEmbeddedContent');
});
}
}

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" },

View file

@ -15,7 +15,7 @@ import {
import { estypes } from '@elastic/elasticsearch';
import { useMlContext } from '../../../../../contexts/ml';
import { SEARCH_QUERY_LANGUAGE } from '../../../../../../../common/constants/search';
import { getQueryFromSavedSearch } from '../../../../../util/index_utils';
import { getQueryFromSavedSearchObject } from '../../../../../util/index_utils';
// `undefined` is used for a non-initialized state
// `null` is set if no saved search is used
@ -40,7 +40,7 @@ export function useSavedSearch() {
let qryString;
if (currentSavedSearch !== null) {
const { query } = getQueryFromSavedSearch(currentSavedSearch);
const { query } = getQueryFromSavedSearchObject(currentSavedSearch);
const queryLanguage = query.language;
qryString = query.query;

View file

@ -18,7 +18,7 @@ import { IUiSettingsClient } from 'kibana/public';
import { getEsQueryConfig } from '../../../../../../../../src/plugins/data/public';
import { SEARCH_QUERY_LANGUAGE } from '../../../../../common/constants/search';
import { SavedSearchSavedObject } from '../../../../../common/types/kibana';
import { getQueryFromSavedSearch } from '../../../util/index_utils';
import { getQueryFromSavedSearchObject } from '../../../util/index_utils';
// Provider for creating the items used for searching and job creation.
@ -52,7 +52,7 @@ export function createSearchItems(
let combinedQuery: any = getDefaultDatafeedQuery();
if (savedSearch !== null) {
const data = getQueryFromSavedSearch(savedSearch);
const data = getQueryFromSavedSearchObject(savedSearch);
query = data.query;
const filter = data.filter;

View file

@ -80,7 +80,7 @@ export async function getIndexPatternAndSavedSearch(savedSearchId: string) {
return resp;
}
export function getQueryFromSavedSearch(savedSearch: SavedSearchSavedObject) {
export function getQueryFromSavedSearchObject(savedSearch: SavedSearchSavedObject) {
const search = savedSearch.attributes.kibanaSavedObjectMeta as { searchSourceJSON: string };
return JSON.parse(search.searchSourceJSON) as {
query: Query;

View file

@ -9,9 +9,10 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('data visualizer', function () {
this.tags(['skipFirefox']);
this.tags(['skipFirefox', 'mlqa']);
loadTestFile(require.resolve('./index_data_visualizer'));
loadTestFile(require.resolve('./index_data_visualizer_grid_in_discover'));
loadTestFile(require.resolve('./index_data_visualizer_actions_panel'));
loadTestFile(require.resolve('./index_data_visualizer_index_pattern_management'));
loadTestFile(require.resolve('./file_data_visualizer'));

View file

@ -6,374 +6,18 @@
*/
import { FtrProviderContext } from '../../../ftr_provider_context';
import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types';
import { FieldVisConfig } from '../../../../../plugins/data_visualizer/public/application/common/components/stats_table/types';
interface MetricFieldVisConfig extends FieldVisConfig {
statsMaxDecimalPlaces: number;
docCountFormatted: string;
topValuesCount: number;
viewableInLens: boolean;
}
interface NonMetricFieldVisConfig extends FieldVisConfig {
docCountFormatted: string;
exampleCount: number;
viewableInLens: boolean;
}
interface TestData {
suiteTitle: string;
sourceIndexOrSavedSearch: string;
fieldNameFilters: string[];
fieldTypeFilters: string[];
rowsPerPage?: 10 | 25 | 50;
sampleSizeValidations: Array<{
size: number;
expected: { field: string; docCountFormatted: string };
}>;
expected: {
totalDocCountFormatted: string;
metricFields?: MetricFieldVisConfig[];
nonMetricFields?: NonMetricFieldVisConfig[];
emptyFields: string[];
visibleMetricFieldsCount: number;
totalMetricFieldsCount: number;
populatedFieldsCount: number;
totalFieldsCount: number;
fieldNameFiltersResultCount: number;
fieldTypeFiltersResultCount: number;
};
}
import { TestData, MetricFieldVisConfig } from './types';
import {
farequoteDataViewTestData,
farequoteKQLSearchTestData,
farequoteLuceneSearchTestData,
sampleLogTestData,
} from './index_test_data';
export default function ({ getService }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const ml = getService('ml');
const farequoteDataViewTestData: TestData = {
suiteTitle: 'data view',
sourceIndexOrSavedSearch: 'ft_farequote',
fieldNameFilters: ['airline', '@timestamp'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.KEYWORD],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '86,274',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 10,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 2,
fieldTypeFiltersResultCount: 3,
},
};
const farequoteKQLSearchTestData: TestData = {
suiteTitle: 'KQL saved search',
sourceIndexOrSavedSearch: 'ft_farequote_kuery',
fieldNameFilters: ['@version'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '34,415',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 5,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 1,
fieldTypeFiltersResultCount: 3,
},
};
const farequoteLuceneSearchTestData: TestData = {
suiteTitle: 'lucene saved search',
sourceIndexOrSavedSearch: 'ft_farequote_lucene',
fieldNameFilters: ['@version.keyword', 'type'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '34,416',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 5,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 2,
fieldTypeFiltersResultCount: 1,
},
};
const sampleLogTestData: TestData = {
suiteTitle: 'geo point field',
sourceIndexOrSavedSearch: 'ft_module_sample_logs',
fieldNameFilters: ['geo.coordinates'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT],
rowsPerPage: 50,
expected: {
totalDocCountFormatted: '408',
metricFields: [],
// only testing the geo_point fields
nonMetricFields: [
{
fieldName: 'geo.coordinates',
type: ML_JOB_FIELD_TYPES.GEO_POINT,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '408 (100%)',
exampleCount: 10,
viewableInLens: false,
},
],
emptyFields: [],
visibleMetricFieldsCount: 4,
totalMetricFieldsCount: 5,
populatedFieldsCount: 35,
totalFieldsCount: 36,
fieldNameFiltersResultCount: 1,
fieldTypeFiltersResultCount: 1,
},
sampleSizeValidations: [
{ size: 1000, expected: { field: 'geo.coordinates', docCountFormatted: '408 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '408 (100%)' } },
],
};
function runTests(testData: TestData) {
it(`${testData.suiteTitle} loads the source data in the data visualizer`, async () => {
await ml.testExecution.logTestStep(
@ -541,7 +185,7 @@ export default function ({ getService }: FtrProviderContext) {
});
describe('with module_sample_logs ', function () {
// Run tests on full farequote index.
// Run tests on full ft_module_sample_logs index.
it(`${sampleLogTestData.suiteTitle} loads the data visualizer selector page`, async () => {
// Start navigation from the base of the ML app.
await ml.navigation.navigateToMl();

View file

@ -0,0 +1,172 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../../ftr_provider_context';
import { TestData, MetricFieldVisConfig } from './types';
const SHOW_FIELD_STATISTICS = 'discover:showFieldStatistics';
import {
farequoteDataViewTestData,
farequoteKQLSearchTestData,
farequoteLuceneFiltersSearchTestData,
farequoteKQLFiltersSearchTestData,
farequoteLuceneSearchTestData,
sampleLogTestData,
} from './index_test_data';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings']);
const ml = getService('ml');
const testSubjects = getService('testSubjects');
const retry = getService('retry');
const toasts = getService('toasts');
const selectIndexPattern = async (indexPattern: string) => {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.discover.selectIndexPattern(indexPattern);
const indexPatternTitle = await testSubjects.getVisibleText('indexPattern-switch-link');
expect(indexPatternTitle).to.be(indexPattern);
});
};
const clearAdvancedSetting = async (propertyName: string) => {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.common.navigateToUrl('management', 'kibana/settings', {
shouldUseHashForSubUrl: false,
});
if ((await PageObjects.settings.getAdvancedSettingCheckbox(propertyName)) === 'true') {
await PageObjects.settings.clearAdvancedSettings(propertyName);
}
});
};
const setAdvancedSettingCheckbox = async (propertyName: string, checkedState: boolean) => {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.common.navigateToUrl('management', 'kibana/settings', {
shouldUseHashForSubUrl: false,
});
await testSubjects.click('settings');
await toasts.dismissAllToasts();
await PageObjects.settings.toggleAdvancedSettingCheckbox(propertyName, checkedState);
});
};
function runTestsWhenDisabled(testData: TestData) {
it('should not show view mode toggle or Field stats table', async function () {
await PageObjects.common.navigateToApp('discover');
if (testData.isSavedSearch) {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch);
});
} else {
await selectIndexPattern(testData.sourceIndexOrSavedSearch);
}
await PageObjects.timePicker.setAbsoluteRange(
'Jan 1, 2016 @ 00:00:00.000',
'Nov 1, 2020 @ 00:00:00.000'
);
await PageObjects.discover.assertViewModeToggleNotExists();
await PageObjects.discover.assertFieldStatsTableNotExists();
});
}
function runTests(testData: TestData) {
describe(`with ${testData.suiteTitle}`, function () {
it(`displays the 'Field statistics' table content correctly`, async function () {
await PageObjects.common.navigateToApp('discover');
if (testData.isSavedSearch) {
await retry.tryForTime(2 * 1000, async () => {
await PageObjects.discover.loadSavedSearch(testData.sourceIndexOrSavedSearch);
});
} else {
await selectIndexPattern(testData.sourceIndexOrSavedSearch);
}
await PageObjects.timePicker.setAbsoluteRange(
'Jan 1, 2016 @ 00:00:00.000',
'Nov 1, 2020 @ 00:00:00.000'
);
await PageObjects.discover.assertHitCount(testData.expected.totalDocCountFormatted);
await PageObjects.discover.assertViewModeToggleExists();
await PageObjects.discover.clickViewModeFieldStatsButton();
await ml.testExecution.logTestStep(
'displays details for metric fields and non-metric fields correctly'
);
for (const fieldRow of testData.expected.metricFields as Array<
Required<MetricFieldVisConfig>
>) {
await ml.dataVisualizerTable.assertNumberFieldContents(
fieldRow.fieldName,
fieldRow.docCountFormatted,
fieldRow.topValuesCount,
fieldRow.viewableInLens
);
}
for (const fieldRow of testData.expected.nonMetricFields!) {
await ml.dataVisualizerTable.assertNonMetricFieldContents(
fieldRow.type,
fieldRow.fieldName!,
fieldRow.docCountFormatted,
fieldRow.exampleCount,
fieldRow.viewableInLens,
false,
fieldRow.exampleContent
);
}
});
});
}
describe('field statistics in Discover', function () {
before(async function () {
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/farequote');
await esArchiver.loadIfNeeded('x-pack/test/functional/es_archives/ml/module_sample_logs');
await ml.testResources.createIndexPatternIfNeeded('ft_farequote', '@timestamp');
await ml.testResources.createIndexPatternIfNeeded('ft_module_sample_logs', '@timestamp');
await ml.testResources.createSavedSearchFarequoteKueryIfNeeded();
await ml.testResources.createSavedSearchFarequoteLuceneIfNeeded();
await ml.testResources.createSavedSearchFarequoteFilterAndLuceneIfNeeded();
await ml.testResources.createSavedSearchFarequoteFilterAndKueryIfNeeded();
await ml.securityUI.loginAsMlPowerUser();
});
after(async function () {
await clearAdvancedSetting(SHOW_FIELD_STATISTICS);
});
describe('when enabled', function () {
before(async function () {
await setAdvancedSettingCheckbox(SHOW_FIELD_STATISTICS, true);
});
after(async function () {
await clearAdvancedSetting(SHOW_FIELD_STATISTICS);
});
runTests(farequoteDataViewTestData);
runTests(farequoteKQLSearchTestData);
runTests(farequoteLuceneSearchTestData);
runTests(farequoteKQLFiltersSearchTestData);
runTests(farequoteLuceneFiltersSearchTestData);
runTests(sampleLogTestData);
});
describe('when disabled', function () {
before(async function () {
// Ensure that the setting is set to default state which is false
await setAdvancedSettingCheckbox(SHOW_FIELD_STATISTICS, false);
});
runTestsWhenDisabled(farequoteDataViewTestData);
});
});
}

View file

@ -0,0 +1,533 @@
/*
* 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 { TestData } from './types';
import { ML_JOB_FIELD_TYPES } from '../../../../../plugins/ml/common/constants/field_types';
export const farequoteDataViewTestData: TestData = {
suiteTitle: 'farequote index pattern',
isSavedSearch: false,
sourceIndexOrSavedSearch: 'ft_farequote',
fieldNameFilters: ['airline', '@timestamp'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.KEYWORD],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '86,274',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 10,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 2,
fieldTypeFiltersResultCount: 3,
},
};
export const farequoteKQLSearchTestData: TestData = {
suiteTitle: 'KQL saved search',
isSavedSearch: true,
sourceIndexOrSavedSearch: 'ft_farequote_kuery',
fieldNameFilters: ['@version'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '34,415',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 5,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 1,
fieldTypeFiltersResultCount: 3,
},
};
export const farequoteKQLFiltersSearchTestData: TestData = {
suiteTitle: 'KQL saved search and filters',
isSavedSearch: true,
sourceIndexOrSavedSearch: 'ft_farequote_filter_and_kuery',
fieldNameFilters: ['@version'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.DATE, ML_JOB_FIELD_TYPES.TEXT],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '5,674',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
exampleContent: ['ASA'],
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 1,
fieldTypeFiltersResultCount: 3,
},
};
export const farequoteLuceneSearchTestData: TestData = {
suiteTitle: 'lucene saved search',
isSavedSearch: true,
sourceIndexOrSavedSearch: 'ft_farequote_lucene',
fieldNameFilters: ['@version.keyword', 'type'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '34,416',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 5,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 2,
fieldTypeFiltersResultCount: 1,
},
};
export const farequoteLuceneFiltersSearchTestData: TestData = {
suiteTitle: 'lucene saved search and filter',
isSavedSearch: true,
sourceIndexOrSavedSearch: 'ft_farequote_filter_and_lucene',
fieldNameFilters: ['@version.keyword', 'type'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.NUMBER],
sampleSizeValidations: [
{ size: 1000, expected: { field: 'airline', docCountFormatted: '1000 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '5000 (100%)' } },
],
expected: {
totalDocCountFormatted: '5,673',
metricFields: [
{
fieldName: 'responsetime',
type: ML_JOB_FIELD_TYPES.NUMBER,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
statsMaxDecimalPlaces: 3,
topValuesCount: 10,
viewableInLens: true,
},
],
nonMetricFields: [
{
fieldName: '@timestamp',
type: ML_JOB_FIELD_TYPES.DATE,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '5000 (100%)',
exampleCount: 2,
viewableInLens: true,
},
{
fieldName: '@version',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: '@version.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'airline',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
exampleContent: ['ASA'],
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
{
fieldName: 'type',
type: ML_JOB_FIELD_TYPES.TEXT,
existsInDocs: true,
aggregatable: false,
loading: false,
exampleCount: 1,
docCountFormatted: '',
viewableInLens: false,
},
{
fieldName: 'type.keyword',
type: ML_JOB_FIELD_TYPES.KEYWORD,
existsInDocs: true,
aggregatable: true,
loading: false,
exampleCount: 1,
docCountFormatted: '5000 (100%)',
viewableInLens: true,
},
],
emptyFields: ['sourcetype'],
visibleMetricFieldsCount: 1,
totalMetricFieldsCount: 1,
populatedFieldsCount: 7,
totalFieldsCount: 8,
fieldNameFiltersResultCount: 2,
fieldTypeFiltersResultCount: 1,
},
};
export const sampleLogTestData: TestData = {
suiteTitle: 'geo point field',
isSavedSearch: false,
sourceIndexOrSavedSearch: 'ft_module_sample_logs',
fieldNameFilters: ['geo.coordinates'],
fieldTypeFilters: [ML_JOB_FIELD_TYPES.GEO_POINT],
rowsPerPage: 50,
expected: {
totalDocCountFormatted: '408',
metricFields: [],
// only testing the geo_point fields
nonMetricFields: [
{
fieldName: 'geo.coordinates',
type: ML_JOB_FIELD_TYPES.GEO_POINT,
existsInDocs: true,
aggregatable: true,
loading: false,
docCountFormatted: '408 (100%)',
exampleCount: 10,
viewableInLens: false,
},
],
emptyFields: [],
visibleMetricFieldsCount: 4,
totalMetricFieldsCount: 5,
populatedFieldsCount: 35,
totalFieldsCount: 36,
fieldNameFiltersResultCount: 1,
fieldTypeFiltersResultCount: 1,
},
sampleSizeValidations: [
{ size: 1000, expected: { field: 'geo.coordinates', docCountFormatted: '408 (100%)' } },
{ size: 5000, expected: { field: '@timestamp', docCountFormatted: '408 (100%)' } },
],
};

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { FieldVisConfig } from '../../../../../plugins/data_visualizer/public/application/common/components/stats_table/types';
export interface MetricFieldVisConfig extends FieldVisConfig {
statsMaxDecimalPlaces: number;
docCountFormatted: string;
topValuesCount: number;
viewableInLens: boolean;
}
export interface NonMetricFieldVisConfig extends FieldVisConfig {
docCountFormatted: string;
exampleCount: number;
exampleContent?: string[];
viewableInLens: boolean;
}
export interface TestData {
suiteTitle: string;
isSavedSearch?: boolean;
sourceIndexOrSavedSearch: string;
fieldNameFilters: string[];
fieldTypeFilters: string[];
rowsPerPage?: 10 | 25 | 50;
sampleSizeValidations: Array<{
size: number;
expected: { field: string; docCountFormatted: string };
}>;
expected: {
totalDocCountFormatted: string;
metricFields?: MetricFieldVisConfig[];
nonMetricFields?: NonMetricFieldVisConfig[];
emptyFields: string[];
visibleMetricFieldsCount: number;
totalMetricFieldsCount: number;
populatedFieldsCount: number;
totalFieldsCount: number;
fieldNameFiltersResultCount: number;
fieldTypeFiltersResultCount: number;
};
}

View file

@ -169,7 +169,10 @@ export function MachineLearningCustomUrlsProvider({
async assertDiscoverCustomUrlAction(expectedHitCountFormatted: string) {
await PageObjects.discover.waitForDiscoverAppOnScreen();
await retry.tryForTime(5000, async () => {
// During cloud tests, the small browser width might cause hit count to be invisible
// so temporarily collapsing the sidebar ensures the count shows
await PageObjects.discover.closeSidebar();
await retry.tryForTime(10 * 1000, async () => {
const hitCount = await PageObjects.discover.getHitCount();
expect(hitCount).to.eql(
expectedHitCountFormatted,

View file

@ -361,7 +361,27 @@ export function MachineLearningDataVisualizerTableProvider(
});
}
public async assertTopValuesContents(fieldName: string, expectedTopValuesCount: number) {
public async assertTopValuesContent(fieldName: string, expectedTopValues: string[]) {
const selector = this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValuesContent');
const topValuesElement = await testSubjects.find(selector);
const topValuesBars = await topValuesElement.findAllByTestSubject(
'dataVisualizerFieldDataTopValueBar'
);
const topValuesBarsValues = await Promise.all(
topValuesBars.map(async (bar) => {
const visibleText = await bar.getVisibleText();
return visibleText ? visibleText.split('\n')[0] : undefined;
})
);
expect(topValuesBarsValues).to.eql(
expectedTopValues,
`Expected top values for field '${fieldName}' to equal '${expectedTopValues}' (got '${topValuesBarsValues}')`
);
}
public async assertTopValuesCount(fieldName: string, expectedTopValuesCount: number) {
const selector = this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValuesContent');
const topValuesElement = await testSubjects.find(selector);
const topValuesBars = await topValuesElement.findAllByTestSubject(
@ -401,7 +421,7 @@ export function MachineLearningDataVisualizerTableProvider(
await testSubjects.existOrFail(
this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValues')
);
await this.assertTopValuesContents(fieldName, topValuesCount);
await this.assertTopValuesCount(fieldName, topValuesCount);
if (checkDistributionPreviewExist) {
await this.assertDistributionPreviewExist(fieldName);
@ -433,7 +453,8 @@ export function MachineLearningDataVisualizerTableProvider(
public async assertKeywordFieldContents(
fieldName: string,
docCountFormatted: string,
topValuesCount: number
topValuesCount: number,
exampleContent?: string[]
) {
await this.assertRowExists(fieldName);
await this.assertFieldDocCount(fieldName, docCountFormatted);
@ -442,7 +463,11 @@ export function MachineLearningDataVisualizerTableProvider(
await testSubjects.existOrFail(
this.detailsSelector(fieldName, 'dataVisualizerFieldDataTopValuesContent')
);
await this.assertTopValuesContents(fieldName, topValuesCount);
await this.assertTopValuesCount(fieldName, topValuesCount);
if (exampleContent) {
await this.assertTopValuesContent(fieldName, exampleContent);
}
await this.ensureDetailsClosed(fieldName);
}
@ -508,13 +533,19 @@ export function MachineLearningDataVisualizerTableProvider(
docCountFormatted: string,
exampleCount: number,
viewableInLens: boolean,
hasActionMenu?: boolean
hasActionMenu?: boolean,
exampleContent?: string[]
) {
// Currently the data used in the data visualizer tests only contains these field types.
if (fieldType === ML_JOB_FIELD_TYPES.DATE) {
await this.assertDateFieldContents(fieldName, docCountFormatted);
} else if (fieldType === ML_JOB_FIELD_TYPES.KEYWORD) {
await this.assertKeywordFieldContents(fieldName, docCountFormatted, exampleCount);
await this.assertKeywordFieldContents(
fieldName,
docCountFormatted,
exampleCount,
exampleContent
);
} else if (fieldType === ML_JOB_FIELD_TYPES.TEXT) {
await this.assertTextFieldContents(fieldName, docCountFormatted, exampleCount);
} else if (fieldType === ML_JOB_FIELD_TYPES.GEO_POINT) {

View file

@ -19,6 +19,11 @@ export default function ({ loadTestFile }: FtrProviderContext) {
loadTestFile(
require.resolve('../../../../functional/apps/ml/data_visualizer/index_data_visualizer')
);
loadTestFile(
require.resolve(
'../../../../functional/apps/ml/data_visualizer/index_data_visualizer_grid_in_discover'
)
);
loadTestFile(require.resolve('./index_data_visualizer_actions_panel'));
});
}