[ML] Add support for ES|QL in Data visualizer (#174188)

## Summary

This PR adds support for ES|QL queries in Data visualizer.

<img width="1695" alt="Screenshot 2024-01-26 at 17 07 59"
src="8a54b859-60d6-4c47-b3dd-e5f3ed43b6b0">

<img width="1695" alt="Screenshot 2024-01-26 at 17 12 39"
src="32fd08e4-7f3b-43e6-81a7-7ec4e777bac0">


a3f540e9-461d-4ebc-bd69-de4ffa2bc554



### Changes:

- Add a new card from the Data visualizer main page

- Add a link from the ML navigation

<img width="1717" alt="Screenshot 2024-01-08 at 18 03 50"
src="832f7890-4ce6-44c1-ab87-cde01f4bf1c0">

- Added a new button to Use ES|QL

<img width="1714" alt="Screenshot 2024-01-09 at 11 23 09"
src="a38a9360-6691-4f3b-a824-8481ab543250">


- Support for **keyword**, **text**, **numeric**, **boolean**, **date**,
and **ip** fields

<img width="1714" alt="Screenshot 2024-01-09 at 11 24 38"
src="b122ee5c-1500-4e2b-9434-e64b0b6ea3be">

<img width="1441" alt="Screenshot 2024-01-09 at 11 25 25"
src="eb35ee78-8a34-467e-84da-2026b01fcda1">

<img width="969" alt="Screenshot 2024-01-09 at 11 44 02"
src="d0f9947d-2b2c-4c14-89ba-9fc5d0a2bf64">

<img width="981" alt="Screenshot 2024-01-10 at 12 01 42"
src="aa5a8d44-7447-41fc-a544-d1b626bf8bce">

- Default to user's fieldFormats for fields that are dynamic generated
by ES|QL, else use Data view's format

- Default to Data view's setting (e.g. type `bytes` in this case for
field `bytes_normal_counter`)
<img width="1037" alt="Screenshot 2024-01-10 at 12 10 38"
src="9fb7e31c-f397-4209-a463-e1a43fe27ffd">

- Default to user's fieldFormats formatting for dynamically generated
fields (e.g. type `number` in this case for field `avg_price`)
<img width="1283" alt="Screenshot 2024-01-10 at 12 01 03"
src="acc25358-50bb-4237-9476-86067ef0badf">

- Add a new UI control to allow users to limit analysis to 5,000 -
10,000 - 100,000 - 1,000,000, rows. This speeds up fetching of the stats
for big data sets and avoid potential circuit breaking exceptions.
- Break overall stats request into smaller parallel requests (which
prevent time out or payload too big due by too many fields), at 10
requests at a time
- Break field stats for individual fields into more efficient batches
(which prevent time out or payload too big due by too many fields), at
10 requests at at ime
- Improve error handling by propagating up the error AND the ES|QL
request in both the UI and the developer's console (for better
debugging)
- Improve error handling in field stats rows: If one field, or a group
of fields, say 'keyword' fields fail to fetch for some reasons, it will
show error for that field but not affect all other fields.


<img width="1690" alt="Screenshot 2024-01-26 at 16 04 28"
src="6e240e12-76b4-42d6-b3be-c05342d76df9">

- Add deep linking in the top search bar

<img width="1185" alt="Screenshot 2024-01-26 at 16 56 49"
src="4f24df68-edc5-41c5-b2ed-d6150ba1e20b">

- More robust support for keyword fields with geo data

<img width="1438" alt="Screenshot 2024-01-26 at 16 55 01"
src="3b97925b-ca28-4952-8082-8d3242e3cb3f">




### Todos:
- [x] Add earliest/latest for date time fields -> Current blocker:
escape special characters in esql variable names
- [x] Fix formatting of numbers for dynamic query, where we don't know
the formatting based on the data view
- [x] Fix date time 'Update' not updating until Refresh is clicked
- [x] Better optimization to not fetch distribution & expanded row
content for pages that are not visible


### Good to have:
- [ ] Investigate bringing back the +/- filter buttons (either by
modifying the ES|QL query directly or by adding separate DSL filters?)

------------

### Checklist

Delete any items that are not applicable to this PR.

- [ ] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Quynh Nguyen (Quinn) 2024-01-31 12:52:31 -06:00 committed by GitHub
parent 2ba824b889
commit 53c3907529
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
65 changed files with 2847 additions and 135 deletions

1
.github/CODEOWNERS vendored
View file

@ -530,6 +530,7 @@ x-pack/packages/maps/vector_tile_utils @elastic/kibana-gis
x-pack/plugins/metrics_data_access @elastic/obs-knowledge-team
x-pack/packages/ml/agg_utils @elastic/ml-ui
x-pack/packages/ml/anomaly_utils @elastic/ml-ui
x-pack/packages/ml/cancellable_search @elastic/ml-ui
x-pack/packages/ml/category_validator @elastic/ml-ui
x-pack/packages/ml/chi2test @elastic/ml-ui
x-pack/packages/ml/creation_wizard_utils @elastic/ml-ui

View file

@ -550,6 +550,7 @@
"@kbn/metrics-data-access-plugin": "link:x-pack/plugins/metrics_data_access",
"@kbn/ml-agg-utils": "link:x-pack/packages/ml/agg_utils",
"@kbn/ml-anomaly-utils": "link:x-pack/packages/ml/anomaly_utils",
"@kbn/ml-cancellable-search": "link:x-pack/packages/ml/cancellable_search",
"@kbn/ml-category-validator": "link:x-pack/packages/ml/category_validator",
"@kbn/ml-chi2test": "link:x-pack/packages/ml/chi2test",
"@kbn/ml-creation-wizard-utils": "link:x-pack/packages/ml/creation_wizard_utils",

View file

@ -27,6 +27,7 @@ export type LinkId =
| 'nodesOverview'
| 'nodes'
| 'memoryUsage'
| 'esqlDataVisualizer'
| 'dataVisualizer'
| 'fileUpload'
| 'indexDataVisualizer'

View file

@ -121,6 +121,15 @@ export const defaultNavigation: MlNodeDefinition = {
);
},
},
{
title: i18n.translate('defaultNavigation.ml.esqlDataVisualizer', {
defaultMessage: 'ES|QL',
}),
link: 'ml:esqlDataVisualizer',
getIsActive: ({ pathNameSerialized, prepend }) => {
return pathNameSerialized.includes(prepend('/app/ml/datavisualizer/esql'));
},
},
{
title: i18n.translate('defaultNavigation.ml.dataComparison', {
defaultMessage: 'Data drift',

View file

@ -1054,6 +1054,8 @@
"@kbn/ml-agg-utils/*": ["x-pack/packages/ml/agg_utils/*"],
"@kbn/ml-anomaly-utils": ["x-pack/packages/ml/anomaly_utils"],
"@kbn/ml-anomaly-utils/*": ["x-pack/packages/ml/anomaly_utils/*"],
"@kbn/ml-cancellable-search": ["x-pack/packages/ml/cancellable_search"],
"@kbn/ml-cancellable-search/*": ["x-pack/packages/ml/cancellable_search/*"],
"@kbn/ml-category-validator": ["x-pack/packages/ml/category_validator"],
"@kbn/ml-category-validator/*": ["x-pack/packages/ml/category_validator/*"],
"@kbn/ml-chi2test": ["x-pack/packages/ml/chi2test"],

View file

@ -0,0 +1,3 @@
# @kbn/ml-cancellable-search
React hook for cancellable data searching

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 { useCancellableSearch, type UseCancellableSearch } from './src/use_cancellable_search';

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../../..',
roots: ['<rootDir>/x-pack/packages/ml/cancellable_search'],
};

View file

@ -0,0 +1,5 @@
{
"type": "shared-common",
"id": "@kbn/ml-cancellable-search",
"owner": "@elastic/ml-ui"
}

View file

@ -0,0 +1,8 @@
{
"name": "@kbn/ml-cancellable-search",
"description": "React hook for cancellable data searching",
"author": "Machine Learning UI",
"private": true,
"version": "1.0.0",
"license": "Elastic License 2.0"
}

View file

@ -0,0 +1,73 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import { useCallback, useRef, useState } from 'react';
import { type IKibanaSearchResponse, isRunningResponse } from '@kbn/data-plugin/common';
import { tap } from 'rxjs/operators';
import type { DataPublicPluginStart } from '@kbn/data-plugin/public';
export interface UseCancellableSearch {
runRequest: <RequestBody, ResponseType extends IKibanaSearchResponse>(
requestBody: RequestBody,
options?: object
) => Promise<ResponseType | null>;
cancelRequest: () => void;
isLoading: boolean;
}
// Similar to aiops/hooks/use_cancellable_search.ts
export function useCancellableSearch(data: DataPublicPluginStart) {
const abortController = useRef(new AbortController());
const [isLoading, setIsFetching] = useState<boolean>(false);
const runRequest = useCallback(
<RequestBody, ResponseType extends IKibanaSearchResponse>(
requestBody: RequestBody,
options = {}
): Promise<ResponseType | null> => {
return new Promise((resolve, reject) => {
data.search
.search<RequestBody, ResponseType>(requestBody, {
abortSignal: abortController.current.signal,
...options,
})
.pipe(
tap(() => {
setIsFetching(true);
})
)
.subscribe({
next: (result) => {
if (!isRunningResponse(result)) {
setIsFetching(false);
resolve(result);
} else {
// partial results
// Ignore partial results for now.
// An issue with the search function means partial results are not being returned correctly.
}
},
error: (error) => {
if (error.name === 'AbortError') {
return resolve(null);
}
setIsFetching(false);
reject(error);
},
});
});
},
[data.search]
);
const cancelRequest = useCallback(() => {
abortController.current.abort();
abortController.current = new AbortController();
}, []);
return { runRequest, cancelRequest, isLoading };
}

View file

@ -0,0 +1,21 @@
{
"extends": "../../../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": [
"jest",
"node",
"react"
]
},
"include": [
"**/*.ts",
"**/*.tsx",
],
"exclude": [
"target/**/*",
],
"kbn_references": [
"@kbn/data-plugin"
]
}

View file

@ -90,6 +90,10 @@ interface DatePickerWrapperProps {
* Boolean flag to set use of flex group wrapper
*/
flexGroup?: boolean;
/**
* Boolean flag to disable the date picker
*/
isDisabled?: boolean;
}
/**
@ -100,7 +104,14 @@ interface DatePickerWrapperProps {
* @returns {React.ReactElement} The DatePickerWrapper component.
*/
export const DatePickerWrapper: FC<DatePickerWrapperProps> = (props) => {
const { isAutoRefreshOnly, isLoading = false, showRefresh, width, flexGroup = true } = props;
const {
isAutoRefreshOnly,
isLoading = false,
showRefresh,
width,
flexGroup = true,
isDisabled = false,
} = props;
const {
data,
notifications: { toasts },
@ -292,6 +303,7 @@ export const DatePickerWrapper: FC<DatePickerWrapperProps> = (props) => {
commonlyUsedRanges={commonlyUsedRanges}
updateButtonProps={{ iconOnly: isWithinLBreakpoint, fill: false }}
width={width}
isDisabled={isDisabled}
/>
</EuiFlexItem>
{showRefresh === true || !isTimeRangeSelectorEnabled ? (

View file

@ -72,18 +72,21 @@ export const isIKibanaSearchResponse = (arg: unknown): arg is IKibanaSearchRespo
return isPopulatedObject(arg, ['rawResponse']);
};
export interface NumericFieldStats {
export interface NonSampledNumericFieldStats {
fieldName: string;
count?: number;
min?: number;
max?: number;
avg?: number;
median?: number;
distribution?: Distribution;
}
export interface NumericFieldStats extends NonSampledNumericFieldStats {
isTopValuesSampled: boolean;
topValues: Bucket[];
topValuesSampleSize: number;
topValuesSamplerShardSize: number;
median?: number;
distribution?: Distribution;
}
export interface StringFieldStats {
@ -178,6 +181,7 @@ export type ChartRequestAgg = AggHistogram | AggCardinality | AggTerms;
export type ChartData = NumericChartData | OrdinalChartData | UnsupportedChartData;
export type BatchStats =
| NonSampledNumericFieldStats
| NumericFieldStats
| StringFieldStats
| BooleanFieldStats
@ -186,6 +190,7 @@ export type BatchStats =
| FieldExamples;
export type FieldStats =
| NonSampledNumericFieldStats
| NumericFieldStats
| StringFieldStats
| BooleanFieldStats
@ -199,7 +204,6 @@ export function isValidFieldStats(arg: unknown): arg is FieldStats {
export interface FieldStatsCommonRequestParams {
index: string;
samplerShardSize: number;
timeFieldName?: string;
earliestMs?: number | undefined;
latestMs?: number | undefined;
@ -222,7 +226,6 @@ export interface OverallStatsSearchStrategyParams {
aggInterval: TimeBucketsInterval;
intervalMs?: number;
searchQuery: Query['query'];
samplerShardSize: number;
index: string;
timeFieldName?: string;
runtimeFieldMap?: estypes.MappingRuntimeFields;

View file

@ -36,7 +36,8 @@
"esUiShared",
"fieldFormats",
"uiActions",
"lens"
"lens",
"textBasedLanguages",
]
}
}

View file

@ -38,8 +38,9 @@ export interface Props {
samplingProbability?: number | null;
setSamplingProbability?: (value: number | null) => void;
randomSamplerPreference?: RandomSamplerOption;
setRandomSamplerPreference: (value: RandomSamplerOption) => void;
setRandomSamplerPreference?: (value: RandomSamplerOption) => void;
loading: boolean;
showSettings?: boolean;
}
const CalculatingProbabilityMessage = (
@ -61,6 +62,7 @@ export const DocumentCountContent: FC<Props> = ({
loading,
randomSamplerPreference,
setRandomSamplerPreference,
showSettings = true,
}) => {
const [showSamplingOptionsPopover, setShowSamplingOptionsPopover] = useState(false);
@ -120,75 +122,79 @@ export const DocumentCountContent: FC<Props> = ({
<>
<EuiFlexGroup alignItems="center" gutterSize="xs">
<TotalCountHeader totalCount={totalCount} approximate={approximate} loading={loading} />
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
<EuiPopover
data-test-subj="dvRandomSamplerOptionsPopover"
id="dataVisualizerSamplingOptions"
button={
<EuiToolTip
content={i18n.translate('xpack.dataVisualizer.samplingOptionsButton', {
defaultMessage: 'Sampling options',
})}
>
<EuiButtonIcon
size="xs"
iconType="gear"
onClick={onShowSamplingOptions}
data-test-subj="dvRandomSamplerOptionsButton"
aria-label={i18n.translate('xpack.dataVisualizer.samplingOptionsButton', {
{showSettings ? (
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
<EuiPopover
data-test-subj="dvRandomSamplerOptionsPopover"
id="dataVisualizerSamplingOptions"
button={
<EuiToolTip
content={i18n.translate('xpack.dataVisualizer.samplingOptionsButton', {
defaultMessage: 'Sampling options',
})}
/>
</EuiToolTip>
}
isOpen={showSamplingOptionsPopover}
closePopover={closeSamplingOptions}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiPanel style={{ maxWidth: 400 }}>
<EuiFlexItem grow={true}>
<EuiCallOut size="s" color={'primary'} title={calloutInfoMessage} />
</EuiFlexItem>
<EuiSpacer size="m" />
>
<EuiButtonIcon
size="xs"
iconType="gear"
onClick={onShowSamplingOptions}
data-test-subj="dvRandomSamplerOptionsButton"
aria-label={i18n.translate('xpack.dataVisualizer.samplingOptionsButton', {
defaultMessage: 'Sampling options',
})}
/>
</EuiToolTip>
}
isOpen={showSamplingOptionsPopover}
closePopover={closeSamplingOptions}
panelPaddingSize="none"
anchorPosition="downLeft"
>
<EuiPanel style={{ maxWidth: 400 }}>
<EuiFlexItem grow={true}>
<EuiCallOut size="s" color={'primary'} title={calloutInfoMessage} />
</EuiFlexItem>
<EuiSpacer size="m" />
<EuiFormRow
data-test-subj="dvRandomSamplerOptionsFormRow"
label={i18n.translate(
'xpack.dataVisualizer.randomSamplerSettingsPopUp.randomSamplerRowLabel',
{
defaultMessage: 'Random sampling',
}
)}
>
<EuiSelect
data-test-subj="dvRandomSamplerOptionsSelect"
options={RANDOM_SAMPLER_SELECT_OPTIONS}
value={randomSamplerPreference}
onChange={(e) =>
setRandomSamplerPreference(e.target.value as RandomSamplerOption)
}
/>
</EuiFormRow>
{setRandomSamplerPreference ? (
<EuiFormRow
data-test-subj="dvRandomSamplerOptionsFormRow"
label={i18n.translate(
'xpack.dataVisualizer.randomSamplerSettingsPopUp.randomSamplerRowLabel',
{
defaultMessage: 'Random sampling',
}
)}
>
<EuiSelect
data-test-subj="dvRandomSamplerOptionsSelect"
options={RANDOM_SAMPLER_SELECT_OPTIONS}
value={randomSamplerPreference}
onChange={(e) =>
setRandomSamplerPreference(e.target.value as RandomSamplerOption)
}
/>
</EuiFormRow>
) : null}
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_MANUAL ? (
<RandomSamplerRangeSlider
samplingProbability={samplingProbability}
setSamplingProbability={setSamplingProbability}
/>
) : null}
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_MANUAL ? (
<RandomSamplerRangeSlider
samplingProbability={samplingProbability}
setSamplingProbability={setSamplingProbability}
/>
) : null}
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_AUTOMATIC ? (
loading ? (
CalculatingProbabilityMessage
) : (
<ProbabilityUsedMessage samplingProbability={samplingProbability} />
)
) : null}
</EuiPanel>
</EuiPopover>
<EuiFlexItem />
</EuiFlexItem>
{randomSamplerPreference === RANDOM_SAMPLER_OPTION.ON_AUTOMATIC ? (
loading ? (
CalculatingProbabilityMessage
) : (
<ProbabilityUsedMessage samplingProbability={samplingProbability} />
)
) : null}
</EuiPanel>
</EuiPopover>
<EuiFlexItem />
</EuiFlexItem>
) : null}
</EuiFlexGroup>
<DocumentCountChart
chartPoints={chartPoints}

View file

@ -19,7 +19,7 @@ interface Props {
examples: Array<string | GeoPointExample | object>;
}
const EMPTY_EXAMPLE = i18n.translate(
export const EMPTY_EXAMPLE = i18n.translate(
'xpack.dataVisualizer.dataGrid.field.examplesList.emptyExampleMessage',
{ defaultMessage: '(empty)' }
);

View file

@ -31,18 +31,21 @@ export const IndexBasedDataVisualizerExpandedRow = ({
combinedQuery,
onAddFilter,
totalDocuments,
typeAccessor = 'type',
}: {
item: FieldVisConfig;
dataView: DataView | undefined;
combinedQuery: CombinedQuery;
totalDocuments?: number;
typeAccessor?: 'type' | 'secondaryType';
/**
* Callback to add a filter to filter bar
*/
onAddFilter?: (field: DataViewField | string, value: string, type: '+' | '-') => void;
}) => {
const config = { ...item, stats: { ...item.stats, totalDocuments } };
const { loading, type, existsInDocs, fieldName } = config;
const { loading, existsInDocs, fieldName } = config;
const type = config[typeAccessor];
const dvExpandedRow = useExpandedRowCss();
function getCardContent() {

View file

@ -31,6 +31,7 @@ export const DocumentStat = ({ config, showIcon, totalCount }: Props) => {
if (stats === undefined) return null;
const { count, sampleCount } = stats;
const total = sampleCount ?? totalCount;
// If field exists is docs but we don't have count stats then don't show
@ -39,7 +40,7 @@ export const DocumentStat = ({ config, showIcon, totalCount }: Props) => {
count ?? (isIndexBasedFieldVisConfig(config) && config.existsInDocs === true ? undefined : 0);
const docsPercent =
valueCount !== undefined && total !== undefined
? `(${roundToDecimalPlace((valueCount / total) * 100)}%)`
? `(${total === 0 ? 0 : roundToDecimalPlace((valueCount / total) * 100)}%)`
: null;
const content = (

View file

@ -24,7 +24,7 @@ export const TopValuesPreview: FC<TopValuesPreviewProps> = ({ config, isNumeric
const data: OrdinalDataItem[] = topValues.map((d) => ({
...d,
key: d.key.toString(),
key: d.key?.toString(),
}));
const chartData: ChartData = {
cardinality,

View file

@ -341,7 +341,10 @@ export const DataVisualizerTable = <T extends DataVisualizerTableItem>({
return <TopValuesPreview config={item} />;
}
if (item.type === SUPPORTED_FIELD_TYPES.NUMBER) {
if (
item.type === SUPPORTED_FIELD_TYPES.NUMBER ||
item.secondaryType === SUPPORTED_FIELD_TYPES.NUMBER
) {
if (isIndexBasedFieldVisConfig(item) && item.stats?.distribution !== undefined) {
// If the cardinality is only low, show the top values instead of a distribution chart
return item.stats?.distribution?.percentiles.length <= 2 ? (

View file

@ -28,6 +28,7 @@ import { kibanaFieldFormat } from '../utils';
import { ExpandedRowFieldHeader } from '../stats_table/components/expanded_row_field_header';
import { FieldVisStats } from '../../../../../common/types';
import { ExpandedRowPanel } from '../stats_table/components/field_data_expanded_row/expanded_row_panel';
import { EMPTY_EXAMPLE } from '../examples_list/examples_list';
interface Props {
stats: FieldVisStats | undefined;
@ -115,7 +116,8 @@ export const TopValues: FC<Props> = ({ stats, fieldFormat, barColor, compressed,
>
{Array.isArray(topValues)
? topValues.map((value) => {
const fieldValue = value.key_as_string ?? value.key.toString();
const fieldValue =
value.key_as_string ?? (value.key ? value.key.toString() : EMPTY_EXAMPLE);
return (
<EuiFlexGroup gutterSize="xs" alignItems="center" key={fieldValue}>
<EuiFlexItem data-test-subj="dataVisualizerFieldDataTopValueBar">

View file

@ -7,6 +7,8 @@
import { i18n } from '@kbn/i18n';
export const DEFAULT_BAR_TARGET = 75;
export const INDEX_DATA_VISUALIZER_NAME = i18n.translate(
'xpack.dataVisualizer.chrome.help.appName',
{

View file

@ -0,0 +1,13 @@
/*
* 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 isFulfilled = <T>(
input: PromiseSettledResult<Awaited<T>>
): input is PromiseFulfilledResult<Awaited<T>> => input.status === 'fulfilled';
export const isRejected = <T>(
input: PromiseSettledResult<Awaited<T>>
): input is PromiseRejectedResult => input.status === 'rejected';

View file

@ -54,6 +54,7 @@ import {
TimeRange,
ComparisonHistogram,
} from './types';
import { isFulfilled, isRejected } from '../common/util/promise_all_settled_utils';
export const getDataComparisonType = (kibanaType: string): DataDriftField['type'] => {
switch (kibanaType) {
@ -588,12 +589,6 @@ const fetchHistogramData = async ({
}
};
const isFulfilled = <T>(
input: PromiseSettledResult<Awaited<T>>
): input is PromiseFulfilledResult<Awaited<T>> => input.status === 'fulfilled';
const isRejected = <T>(input: PromiseSettledResult<Awaited<T>>): input is PromiseRejectedResult =>
input.status === 'rejected';
type EsRequestParams = NonNullable<
IKibanaSearchRequest<NonNullable<estypes.SearchRequest>>['params']
>;

View file

@ -0,0 +1,815 @@
/*
* 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.
*/
/* eslint-disable react-hooks/exhaustive-deps */
import { css } from '@emotion/react';
import React, { FC, useEffect, useMemo, useState, useCallback, useRef } from 'react';
import type { Required } from 'utility-types';
import {
FullTimeRangeSelector,
mlTimefilterRefresh$,
useTimefilter,
DatePickerWrapper,
} from '@kbn/ml-date-picker';
import { TextBasedLangEditor } from '@kbn/text-based-languages/public';
import type { AggregateQuery } from '@kbn/es-query';
import { merge } from 'rxjs';
import { Comparators } from '@elastic/eui';
import {
useEuiBreakpoint,
useIsWithinMaxBreakpoint,
EuiFlexGroup,
EuiFlexItem,
EuiPageTemplate,
EuiPanel,
EuiProgress,
EuiSpacer,
} from '@elastic/eui';
import { usePageUrlState, useUrlState } from '@kbn/ml-url-state';
import { SEARCH_QUERY_LANGUAGE } from '@kbn/ml-query-utils';
import { getIndexPatternFromSQLQuery, getIndexPatternFromESQLQuery } from '@kbn/es-query';
import type { DataView } from '@kbn/data-views-plugin/common';
import { KBN_FIELD_TYPES } from '@kbn/field-types';
import type { QueryDslQueryContainer } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { getFieldType } from '@kbn/field-utils';
import { UI_SETTINGS } from '@kbn/data-service';
import type { SupportedFieldType } from '../../../../../common/types';
import { useCurrentEuiTheme } from '../../../common/hooks/use_current_eui_theme';
import type { FieldVisConfig } from '../../../common/components/stats_table/types';
import { DATA_VISUALIZER_INDEX_VIEWER } from '../../constants/index_data_visualizer_viewer';
import type { DataVisualizerIndexBasedAppState } from '../../types/index_data_visualizer_state';
import { useDataVisualizerKibana } from '../../../kibana_context';
import { GetAdditionalLinks } from '../../../common/components/results_links';
import { DocumentCountContent } from '../../../common/components/document_count_content';
import { useTimeBuckets } from '../../../common/hooks/use_time_buckets';
import {
DataVisualizerTable,
ItemIdToExpandedRowMap,
} from '../../../common/components/stats_table';
import type {
MetricFieldsStats,
TotalFieldsStats,
} from '../../../common/components/stats_table/components/field_count_stats';
import { filterFields } from '../../../common/components/fields_stats_grid/filter_fields';
import { IndexBasedDataVisualizerExpandedRow } from '../../../common/components/expanded_row/index_based_expanded_row';
import { getOrCreateDataViewByIndexPattern } from '../../search_strategy/requests/get_data_view_by_index_pattern';
import { FieldCountPanel } from '../../../common/components/field_count_panel';
import { useESQLFieldStatsData } from '../../hooks/esql/use_esql_field_stats_data';
import type { NonAggregatableField, OverallStats } from '../../types/overall_stats';
import { isESQLQuery } from '../../search_strategy/requests/esql_utils';
import { DEFAULT_BAR_TARGET } from '../../../common/constants';
import {
type ESQLDefaultLimitSizeOption,
ESQLDefaultLimitSizeSelect,
} from '../search_panel/esql/limit_size';
import { type Column, useESQLOverallStatsData } from '../../hooks/esql/use_esql_overall_stats_data';
import { type AggregatableField } from '../../types/esql_data_visualizer';
const defaults = getDefaultPageState();
interface DataVisualizerPageState {
overallStats: OverallStats;
metricConfigs: FieldVisConfig[];
totalMetricFieldCount: number;
populatedMetricFieldCount: number;
metricsLoaded: boolean;
nonMetricConfigs: FieldVisConfig[];
nonMetricsLoaded: boolean;
documentCountStats?: FieldVisConfig;
}
const defaultSearchQuery = {
match_all: {},
};
export function getDefaultPageState(): DataVisualizerPageState {
return {
overallStats: {
totalCount: 0,
aggregatableExistsFields: [],
aggregatableNotExistsFields: [],
nonAggregatableExistsFields: [],
nonAggregatableNotExistsFields: [],
},
metricConfigs: [],
totalMetricFieldCount: 0,
populatedMetricFieldCount: 0,
metricsLoaded: false,
nonMetricConfigs: [],
nonMetricsLoaded: false,
documentCountStats: undefined,
};
}
interface ESQLDataVisualizerIndexBasedAppState extends DataVisualizerIndexBasedAppState {
limitSize: ESQLDefaultLimitSizeOption;
}
export interface ESQLDataVisualizerIndexBasedPageUrlState {
pageKey: typeof DATA_VISUALIZER_INDEX_VIEWER;
pageUrlState: Required<ESQLDataVisualizerIndexBasedAppState>;
}
export const getDefaultDataVisualizerListState = (
overrides?: Partial<ESQLDataVisualizerIndexBasedAppState>
): Required<ESQLDataVisualizerIndexBasedAppState> => ({
pageIndex: 0,
pageSize: 25,
sortField: 'fieldName',
sortDirection: 'asc',
visibleFieldTypes: [],
visibleFieldNames: [],
limitSize: '10000',
searchString: '',
searchQuery: defaultSearchQuery,
searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY,
filters: [],
showDistributions: true,
showAllFields: false,
showEmptyFields: false,
probability: null,
rndSamplerPref: 'off',
...overrides,
});
export interface IndexDataVisualizerESQLProps {
getAdditionalLinks?: GetAdditionalLinks;
}
export const IndexDataVisualizerESQL: FC<IndexDataVisualizerESQLProps> = (dataVisualizerProps) => {
const { services } = useDataVisualizerKibana();
const { data, fieldFormats, uiSettings } = services;
const euiTheme = useCurrentEuiTheme();
const [query, setQuery] = useState<AggregateQuery>({ esql: '' });
const [currentDataView, setCurrentDataView] = useState<DataView | undefined>();
const updateDataView = (dv: DataView) => {
if (dv.id !== currentDataView?.id) {
setCurrentDataView(dv);
}
};
const [lastRefresh, setLastRefresh] = useState(0);
const _timeBuckets = useTimeBuckets();
const timefilter = useTimefilter({
timeRangeSelector: true,
autoRefreshSelector: true,
});
const indexPattern = useMemo(() => {
let indexPatternFromQuery = '';
if ('sql' in query) {
indexPatternFromQuery = getIndexPatternFromSQLQuery(query.sql);
}
if ('esql' in query) {
indexPatternFromQuery = getIndexPatternFromESQLQuery(query.esql);
}
// we should find a better way to work with ESQL queries which dont need a dataview
if (indexPatternFromQuery === '') {
return undefined;
}
return indexPatternFromQuery;
}, [query]);
const restorableDefaults = useMemo(
() => getDefaultDataVisualizerListState({}),
// We just need to load the saved preference when the page is first loaded
[]
);
const [dataVisualizerListState, setDataVisualizerListState] =
usePageUrlState<ESQLDataVisualizerIndexBasedPageUrlState>(
DATA_VISUALIZER_INDEX_VIEWER,
restorableDefaults
);
const [globalState, setGlobalState] = useUrlState('_g');
const showEmptyFields =
dataVisualizerListState.showEmptyFields ?? restorableDefaults.showEmptyFields;
const toggleShowEmptyFields = () => {
setDataVisualizerListState({
...dataVisualizerListState,
showEmptyFields: !dataVisualizerListState.showEmptyFields,
});
};
const limitSize = dataVisualizerListState.limitSize ?? restorableDefaults.limitSize;
const updateLimitSize = (newLimitSize: ESQLDefaultLimitSizeOption) => {
setDataVisualizerListState({
...dataVisualizerListState,
limitSize: newLimitSize,
});
};
useEffect(
function updateAdhocDataViewFromQuery() {
let unmounted = false;
const update = async () => {
if (!indexPattern) return;
const dv = await getOrCreateDataViewByIndexPattern(
data.dataViews,
indexPattern,
currentDataView
);
if (dv) {
updateDataView(dv);
}
};
if (!unmounted) {
update();
}
return () => {
unmounted = true;
};
},
[indexPattern, data.dataViews, currentDataView]
);
/** Search strategy **/
const fieldStatsRequest = useMemo(() => {
// Obtain the interval to use for date histogram aggregations
// (such as the document count chart). Aim for 75 bars.
const buckets = _timeBuckets;
const tf = timefilter;
if (!buckets || !tf || (isESQLQuery(query) && query.esql === '')) return;
const activeBounds = tf.getActiveBounds();
let earliest: number | undefined;
let latest: number | undefined;
if (activeBounds !== undefined && currentDataView?.timeFieldName !== undefined) {
earliest = activeBounds.min?.valueOf();
latest = activeBounds.max?.valueOf();
}
const bounds = tf.getActiveBounds();
const barTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ?? DEFAULT_BAR_TARGET;
buckets.setInterval('auto');
if (bounds) {
buckets.setBounds(bounds);
buckets.setBarTarget(barTarget);
}
const aggInterval = buckets.getInterval();
const filter = currentDataView?.timeFieldName
? ({
bool: {
must: [],
filter: [
{
range: {
[currentDataView.timeFieldName]: {
format: 'strict_date_optional_time',
gte: timefilter.getTime().from,
lte: timefilter.getTime().to,
},
},
},
],
should: [],
must_not: [],
},
} as QueryDslQueryContainer)
: undefined;
return {
earliest,
latest,
aggInterval,
intervalMs: aggInterval?.asMilliseconds(),
searchQuery: query,
limitSize,
sessionId: undefined,
indexPattern,
timeFieldName: currentDataView?.timeFieldName,
runtimeFieldMap: currentDataView?.getRuntimeMappings(),
lastRefresh,
filter,
};
}, [
_timeBuckets,
timefilter,
currentDataView?.id,
JSON.stringify(query),
indexPattern,
lastRefresh,
limitSize,
]);
useEffect(() => {
// Force refresh on index pattern change
setLastRefresh(Date.now());
}, [setLastRefresh]);
useEffect(() => {
if (globalState?.time !== undefined) {
timefilter.setTime({
from: globalState.time.from,
to: globalState.time.to,
});
}
}, [JSON.stringify(globalState?.time), timefilter]);
useEffect(() => {
const timeUpdateSubscription = merge(
timefilter.getTimeUpdate$(),
timefilter.getAutoRefreshFetch$(),
mlTimefilterRefresh$
).subscribe(() => {
setGlobalState({
time: timefilter.getTime(),
refreshInterval: timefilter.getRefreshInterval(),
});
setLastRefresh(Date.now());
});
return () => {
timeUpdateSubscription.unsubscribe();
};
}, []);
useEffect(() => {
if (globalState?.refreshInterval !== undefined) {
timefilter.setRefreshInterval(globalState.refreshInterval);
}
}, [JSON.stringify(globalState?.refreshInterval), timefilter]);
const {
documentCountStats,
totalCount,
overallStats,
overallStatsProgress,
columns,
cancelOverallStatsRequest,
} = useESQLOverallStatsData(fieldStatsRequest);
const [metricConfigs, setMetricConfigs] = useState(defaults.metricConfigs);
const [metricsLoaded] = useState(defaults.metricsLoaded);
const [metricsStats, setMetricsStats] = useState<undefined | MetricFieldsStats>();
const [nonMetricConfigs, setNonMetricConfigs] = useState(defaults.nonMetricConfigs);
const [nonMetricsLoaded] = useState(defaults.nonMetricsLoaded);
const [fieldStatFieldsToFetch, setFieldStatFieldsToFetch] = useState<Column[] | undefined>();
const visibleFieldTypes =
dataVisualizerListState.visibleFieldTypes ?? restorableDefaults.visibleFieldTypes;
const visibleFieldNames =
dataVisualizerListState.visibleFieldNames ?? restorableDefaults.visibleFieldNames;
useEffect(
function updateFieldStatFieldsToFetch() {
const { sortField, sortDirection } = dataVisualizerListState;
// Otherwise, sort the list of fields by the initial sort field and sort direction
// Then divide into chunks by the initial page size
const itemsSorter = Comparators.property(
sortField as string,
Comparators.default(sortDirection as 'asc' | 'desc' | undefined)
);
const preslicedSortedConfigs = [...nonMetricConfigs, ...metricConfigs]
.map((c) => ({
...c,
name: c.fieldName,
docCount: c.stats?.count,
cardinality: c.stats?.cardinality,
}))
.sort(itemsSorter);
const filteredItems = filterFields(
preslicedSortedConfigs,
dataVisualizerListState.visibleFieldNames,
dataVisualizerListState.visibleFieldTypes
);
const { pageIndex, pageSize } = dataVisualizerListState;
const pageOfConfigs = filteredItems.filteredFields
?.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize)
.filter((d) => d.existsInDocs === true);
setFieldStatFieldsToFetch(pageOfConfigs);
},
[
dataVisualizerListState.pageIndex,
dataVisualizerListState.pageSize,
dataVisualizerListState.sortField,
dataVisualizerListState.sortDirection,
nonMetricConfigs,
metricConfigs,
]
);
const { fieldStats, fieldStatsProgress, cancelFieldStatsRequest } = useESQLFieldStatsData({
searchQuery: fieldStatsRequest?.searchQuery,
columns: fieldStatFieldsToFetch,
filter: fieldStatsRequest?.filter,
limitSize: fieldStatsRequest?.limitSize,
});
const createMetricCards = useCallback(() => {
if (!columns || !overallStats) return;
const configs: FieldVisConfig[] = [];
const aggregatableExistsFields: AggregatableField[] =
overallStats.aggregatableExistsFields || [];
const allMetricFields = columns.filter((f) => {
return f.secondaryType === KBN_FIELD_TYPES.NUMBER;
});
const metricExistsFields = allMetricFields.filter((f) => {
return aggregatableExistsFields.find((existsF) => {
return existsF.fieldName === f.name;
});
});
let _aggregatableFields: AggregatableField[] = 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.name;
});
if (!fieldData) return;
const metricConfig: FieldVisConfig = {
...field,
...fieldData,
loading: fieldData?.existsInDocs ?? true,
fieldFormat:
currentDataView?.getFormatterForFieldNoDefault(field.name) ??
fieldFormats.deserialize({ id: field.secondaryType }),
aggregatable: true,
deletable: false,
type: getFieldType(field) as SupportedFieldType,
};
configs.push(metricConfig);
});
setMetricsStats({
totalMetricFieldsCount: allMetricFields.length,
visibleMetricsCount: metricFieldsToShow.length,
});
setMetricConfigs(configs);
}, [metricsLoaded, overallStats, showEmptyFields, columns, currentDataView?.id]);
const createNonMetricCards = useCallback(() => {
if (!columns || !overallStats) return;
const allNonMetricFields = columns.filter((f) => {
return f.secondaryType !== KBN_FIELD_TYPES.NUMBER;
});
// Obtain the list of all non-metric fields which appear in documents
// (aggregatable or not aggregatable).
const populatedNonMetricFields: Column[] = []; // Kibana index pattern non metric fields.
let nonMetricFieldData: Array<AggregatableField | NonAggregatableField> = []; // Basic non metric field data loaded from requesting overall stats.
const aggregatableExistsFields: AggregatableField[] =
overallStats.aggregatableExistsFields || [];
const nonAggregatableExistsFields: NonAggregatableField[] =
overallStats.nonAggregatableExistsFields || [];
allNonMetricFields.forEach((f) => {
const checkAggregatableField = aggregatableExistsFields.find(
(existsField) => existsField.fieldName === f.name
);
if (checkAggregatableField !== undefined) {
populatedNonMetricFields.push(f);
nonMetricFieldData.push(checkAggregatableField);
} else {
const checkNonAggregatableField = nonAggregatableExistsFields.find(
(existsField) => existsField.fieldName === f.name
);
if (checkNonAggregatableField !== undefined) {
populatedNonMetricFields.push(f);
nonMetricFieldData.push(checkNonAggregatableField);
}
}
});
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.name);
const nonMetricConfig: Partial<FieldVisConfig> = {
...(fieldData ? fieldData : {}),
secondaryType: getFieldType(field) as SupportedFieldType,
loading: fieldData?.existsInDocs ?? true,
deletable: false,
fieldFormat:
currentDataView?.getFormatterForFieldNoDefault(field.name) ??
fieldFormats.deserialize({ id: field.secondaryType }),
};
// Map the field type from the Kibana index pattern to the field type
// used in the data visualizer.
const dataVisualizerType = getFieldType(field) as SupportedFieldType;
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 as SupportedFieldType;
nonMetricConfig.isUnsupportedType = true;
}
if (field.name !== nonMetricConfig.fieldName) {
nonMetricConfig.displayName = field.name;
}
configs.push(nonMetricConfig as FieldVisConfig);
});
setNonMetricConfigs(configs);
}, [columns, nonMetricsLoaded, overallStats, showEmptyFields, currentDataView?.id]);
const fieldsCountStats: TotalFieldsStats | undefined = useMemo(() => {
if (!overallStats) return;
let _visibleFieldsCount = 0;
let _totalFieldsCount = 0;
Object.keys(overallStats).forEach((key) => {
const fieldsGroup = overallStats[key as keyof typeof overallStats];
if (Array.isArray(fieldsGroup) && fieldsGroup.length > 0) {
_totalFieldsCount += fieldsGroup.length;
}
});
if (showEmptyFields === true) {
_visibleFieldsCount = _totalFieldsCount;
} else {
_visibleFieldsCount =
overallStats.aggregatableExistsFields.length +
overallStats.nonAggregatableExistsFields.length;
}
return { visibleFieldsCount: _visibleFieldsCount, totalFieldsCount: _totalFieldsCount };
}, [overallStats, showEmptyFields]);
useEffect(() => {
createMetricCards();
createNonMetricCards();
}, [overallStats, showEmptyFields]);
const configs = useMemo(() => {
let combinedConfigs = [...nonMetricConfigs, ...metricConfigs];
combinedConfigs = filterFields(
combinedConfigs,
visibleFieldNames,
visibleFieldTypes
).filteredFields;
if (fieldStatsProgress.loaded === 100 && fieldStats) {
combinedConfigs = combinedConfigs.map((c) => {
const loadedFullStats = fieldStats.get(c.fieldName) ?? {};
return loadedFullStats
? {
...c,
loading: false,
stats: { ...c.stats, ...loadedFullStats },
}
: c;
});
}
return combinedConfigs;
}, [
nonMetricConfigs,
metricConfigs,
visibleFieldTypes,
visibleFieldNames,
fieldStatsProgress.loaded,
dataVisualizerListState.pageIndex,
dataVisualizerListState.pageSize,
]);
// 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();
}
};
}, []);
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}
dataView={currentDataView}
combinedQuery={{ searchQueryLanguage: 'kuery', searchString: '' }}
totalDocuments={totalCount}
typeAccessor="secondaryType"
/>
);
}
return m;
}, {} as ItemIdToExpandedRowMap);
},
[currentDataView, totalCount]
);
const hasValidTimeField = useMemo(
() =>
currentDataView &&
currentDataView.timeFieldName !== undefined &&
currentDataView.timeFieldName !== '',
[currentDataView]
);
const isWithinLargeBreakpoint = useIsWithinMaxBreakpoint('l');
const dvPageHeader = css({
[useEuiBreakpoint(['xs', 's', 'm', 'l'])]: {
flexDirection: 'column',
alignItems: 'flex-start',
},
});
const combinedProgress = useMemo(
() => overallStatsProgress.loaded * 0.3 + fieldStatsProgress.loaded * 0.7,
[overallStatsProgress.loaded, fieldStatsProgress.loaded]
);
// Query that has been typed, but has not submitted with cmd + enter
const [localQuery, setLocalQuery] = useState<AggregateQuery>({ esql: '' });
const onQueryUpdate = (q?: AggregateQuery) => {
// When user submits a new query
// resets all current requests and other data
if (cancelOverallStatsRequest) {
cancelOverallStatsRequest();
}
if (cancelFieldStatsRequest) {
cancelFieldStatsRequest();
}
// Reset field stats to fetch state
setFieldStatFieldsToFetch(undefined);
setMetricConfigs(defaults.metricConfigs);
setNonMetricConfigs(defaults.nonMetricConfigs);
if (q) {
setQuery(q);
}
};
useEffect(
function resetFieldStatsFieldToFetch() {
// If query returns 0 document, no need to do more work here
if (totalCount === undefined || totalCount === 0) {
setFieldStatFieldsToFetch(undefined);
return;
}
},
[totalCount]
);
return (
<EuiPageTemplate
offset={0}
restrictWidth={false}
bottomBorder={false}
grow={false}
data-test-subj="dataVisualizerIndexPage"
paddingSize="none"
>
<EuiPageTemplate.Section>
<EuiPageTemplate.Header data-test-subj="dataVisualizerPageHeader" css={dvPageHeader}>
<EuiFlexGroup
data-test-subj="dataViewTitleHeader"
direction="row"
alignItems="center"
css={{ padding: `${euiTheme.euiSizeS} 0`, marginRight: `${euiTheme.euiSize}` }}
/>
{isWithinLargeBreakpoint ? <EuiSpacer size="m" /> : null}
<EuiFlexGroup
alignItems="center"
justifyContent="flexEnd"
gutterSize="s"
data-test-subj="dataVisualizerTimeRangeSelectorSection"
>
{hasValidTimeField && currentDataView ? (
<EuiFlexItem grow={false}>
<FullTimeRangeSelector
frozenDataPreference={'exclude-frozen'}
setFrozenDataPreference={() => {}}
dataView={currentDataView}
query={undefined}
disabled={false}
timefilter={timefilter}
/>
</EuiFlexItem>
) : null}
<EuiFlexItem grow={false}>
<DatePickerWrapper
isAutoRefreshOnly={false}
showRefresh={false}
width="full"
isDisabled={!hasValidTimeField}
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageTemplate.Header>
<EuiSpacer size="m" />
<TextBasedLangEditor
query={localQuery}
onTextLangQueryChange={setLocalQuery}
onTextLangQuerySubmit={onQueryUpdate}
expandCodeEditor={() => false}
isCodeEditorExpanded={true}
detectTimestamp={true}
hideMinimizeButton={true}
hideRunQueryText={false}
/>
<EuiFlexGroup gutterSize="m" direction={isWithinLargeBreakpoint ? 'column' : 'row'}>
<EuiFlexItem>
<EuiPanel hasShadow={false} hasBorder grow={false}>
{totalCount !== undefined && (
<>
<EuiFlexGroup gutterSize="s" direction="column">
<DocumentCountContent
documentCountStats={documentCountStats}
totalCount={totalCount}
samplingProbability={1}
loading={false}
showSettings={false}
/>
</EuiFlexGroup>
</>
)}
<EuiSpacer size="m" />
<EuiFlexGroup direction="row">
<FieldCountPanel
showEmptyFields={showEmptyFields}
toggleShowEmptyFields={toggleShowEmptyFields}
fieldsCountStats={fieldsCountStats}
metricsStats={metricsStats}
/>
<EuiFlexItem />
<ESQLDefaultLimitSizeSelect
limitSize={limitSize}
onChangeLimitSize={updateLimitSize}
/>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiProgress value={combinedProgress} max={100} size="xs" />
<DataVisualizerTable<FieldVisConfig>
items={configs}
pageState={dataVisualizerListState}
updatePageState={setDataVisualizerListState}
getItemIdToExpandedRowMap={getItemIdToExpandedRowMap}
loading={overallStatsProgress.isRunning}
overallStatsRunning={overallStatsProgress.isRunning}
showPreviewByDefault={dataVisualizerListState.showDistributions ?? true}
onChange={setDataVisualizerListState}
totalCount={totalCount}
/>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageTemplate.Section>
</EuiPageTemplate>
);
};

View file

@ -64,9 +64,9 @@ import { SearchPanel } from '../search_panel';
import { ActionsPanel } from '../actions_panel';
import { createMergedEsQuery } from '../../utils/saved_search_utils';
import { DataVisualizerDataViewManagement } from '../data_view_management';
import { GetAdditionalLinks } from '../../../common/components/results_links';
import type { GetAdditionalLinks } from '../../../common/components/results_links';
import { useDataVisualizerGridData } from '../../hooks/use_data_visualizer_grid_data';
import { DataVisualizerGridInput } from '../../embeddables/grid_embeddable/grid_embeddable';
import type { DataVisualizerGridInput } from '../../embeddables/grid_embeddable/grid_embeddable';
import {
MIN_SAMPLER_PROBABILITY,
RANDOM_SAMPLER_OPTION,
@ -115,7 +115,6 @@ export const getDefaultDataVisualizerListState = (
sortDirection: 'asc',
visibleFieldTypes: [],
visibleFieldNames: [],
samplerShardSize: 5000,
searchString: '',
searchQuery: defaultSearchQuery,
searchQueryLanguage: SEARCH_QUERY_LANGUAGE.KUERY,

View file

@ -0,0 +1,83 @@
/*
* 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, { type ChangeEvent } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSelect, EuiText, useGeneratedHtmlId } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
const options = [
{
value: '5000',
text: i18n.translate('xpack.dataVisualizer.searchPanel.esql.limitSizeOptionLabel', {
defaultMessage: '{limit} rows',
values: { limit: '5,000' },
}),
},
{
value: '10000',
text: i18n.translate('xpack.dataVisualizer.searchPanel.esql.limitSizeOptionLabel', {
defaultMessage: '{limit} rows',
values: { limit: '10,000' },
}),
},
{
value: '100000',
text: i18n.translate('xpack.dataVisualizer.searchPanel.esql.limitSizeOptionLabel', {
defaultMessage: '{limit} rows',
values: { limit: '100,000' },
}),
},
{
value: '1000000',
text: i18n.translate('xpack.dataVisualizer.searchPanel.esql.limitSizeOptionLabel', {
defaultMessage: '{limit} rows',
values: { limit: '1,000,000' },
}),
},
{
value: 'none',
text: i18n.translate('xpack.dataVisualizer.searchPanel.esql.analyzeAll', {
defaultMessage: 'Analyze all',
}),
},
];
export type ESQLDefaultLimitSizeOption = '5000' | '10000' | '100000' | '1000000' | 'none';
export const ESQLDefaultLimitSizeSelect = ({
limitSize,
onChangeLimitSize,
}: {
limitSize: string;
onChangeLimitSize: (newLimit: ESQLDefaultLimitSizeOption) => void;
}) => {
const basicSelectId = useGeneratedHtmlId({ prefix: 'dvESQLLimit' });
const onChange = (e: ChangeEvent<HTMLSelectElement>) => {
onChangeLimitSize(e.target.value as ESQLDefaultLimitSizeOption);
};
return (
<EuiSelect
id={basicSelectId}
options={options}
value={limitSize}
onChange={onChange}
aria-label={i18n.translate('xpack.dataVisualizer.searchPanel.esql.limitSizeAriaLabel', {
defaultMessage: 'Limit size',
})}
prepend={
<EuiText textAlign="center" size="s">
<FormattedMessage
id="xpack.dataVisualizer.searchPanel.esql.limitSizeLabel"
defaultMessage="Limit analysis to"
/>
</EuiText>
}
/>
);
};

View file

@ -0,0 +1,170 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import type { AggregateQuery } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useEffect, useReducer, useState } from 'react';
import { chunk } from 'lodash';
import { useCancellableSearch } from '@kbn/ml-cancellable-search';
import type { DataStatsFetchProgress, FieldStats } from '../../../../../common/types/field_stats';
import { useDataVisualizerKibana } from '../../../kibana_context';
import { getInitialProgress, getReducer } from '../../progress_utils';
import { isESQLQuery, getSafeESQLLimitSize } from '../../search_strategy/requests/esql_utils';
import type { Column } from './use_esql_overall_stats_data';
import { getESQLNumericFieldStats } from '../../search_strategy/esql_requests/get_numeric_field_stats';
import { getESQLKeywordFieldStats } from '../../search_strategy/esql_requests/get_keyword_fields';
import { getESQLDateFieldStats } from '../../search_strategy/esql_requests/get_date_field_stats';
import { getESQLBooleanFieldStats } from '../../search_strategy/esql_requests/get_boolean_field_stats';
import { getESQLTextFieldStats } from '../../search_strategy/esql_requests/get_text_field_stats';
export const useESQLFieldStatsData = <T extends Column>({
searchQuery,
columns: allColumns,
filter,
limitSize,
}: {
searchQuery?: AggregateQuery;
columns?: T[];
filter?: QueryDslQueryContainer;
limitSize?: string;
}) => {
const [fieldStats, setFieldStats] = useState<Map<string, FieldStats>>();
const [fetchState, setFetchState] = useReducer(
getReducer<DataStatsFetchProgress>(),
getInitialProgress()
);
const {
services: {
data,
notifications: { toasts },
},
} = useDataVisualizerKibana();
const { runRequest, cancelRequest } = useCancellableSearch(data);
useEffect(
function updateFieldStats() {
let unmounted = false;
const fetchFieldStats = async () => {
cancelRequest();
if (!isESQLQuery(searchQuery) || !allColumns) return;
setFetchState({
...getInitialProgress(),
isRunning: true,
error: undefined,
});
try {
// By default, limit the source data to 100,000 rows
const esqlBaseQuery = searchQuery.esql + getSafeESQLLimitSize(limitSize);
const totalFieldsCnt = allColumns.length;
const processedFieldStats = new Map<string, FieldStats>();
function addToProcessedFieldStats(stats: Array<FieldStats | undefined>) {
if (!unmounted) {
stats.forEach((field) => {
if (field) {
processedFieldStats.set(field.fieldName!, field);
}
});
setFetchState({
loaded: (processedFieldStats.size / totalFieldsCnt) * 100,
});
}
}
setFieldStats(processedFieldStats);
const aggregatableFieldsChunks = chunk(allColumns, 25);
for (const columns of aggregatableFieldsChunks) {
// GETTING STATS FOR NUMERIC FIELDS
await getESQLNumericFieldStats({
columns: columns.filter((f) => f.secondaryType === 'number'),
filter,
runRequest,
esqlBaseQuery,
}).then(addToProcessedFieldStats);
// GETTING STATS FOR KEYWORD FIELDS
await getESQLKeywordFieldStats({
columns: columns.filter(
(f) => f.secondaryType === 'keyword' || f.secondaryType === 'ip'
),
filter,
runRequest,
esqlBaseQuery,
}).then(addToProcessedFieldStats);
// GETTING STATS FOR BOOLEAN FIELDS
await getESQLBooleanFieldStats({
columns: columns.filter((f) => f.secondaryType === 'boolean'),
filter,
runRequest,
esqlBaseQuery,
}).then(addToProcessedFieldStats);
// GETTING STATS FOR TEXT FIELDS
await getESQLTextFieldStats({
columns: columns.filter((f) => f.secondaryType === 'text'),
filter,
runRequest,
esqlBaseQuery,
}).then(addToProcessedFieldStats);
// GETTING STATS FOR DATE FIELDS
await getESQLDateFieldStats({
columns: columns.filter((f) => f.secondaryType === 'date'),
filter,
runRequest,
esqlBaseQuery,
}).then(addToProcessedFieldStats);
}
setFetchState({
loaded: 100,
isRunning: false,
});
} catch (e) {
if (e.name !== 'AbortError') {
const title = i18n.translate(
'xpack.dataVisualizer.index.errorFetchingESQLFieldStatisticsMessage',
{
defaultMessage: 'Error fetching field statistics for ES|QL query',
}
);
toasts.addError(e, {
title,
});
// Log error to console for better debugging
// eslint-disable-next-line no-console
console.error(`${title}: fetchFieldStats`, e);
setFetchState({
loaded: 100,
isRunning: false,
error: e,
});
}
}
};
fetchFieldStats();
return () => {
unmounted = true;
};
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[allColumns, JSON.stringify({ filter }), limitSize]
);
return { fieldStats, fieldStatsProgress: fetchState, cancelFieldStatsRequest: cancelRequest };
};

View file

@ -0,0 +1,395 @@
/*
* 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 { ESQL_SEARCH_STRATEGY, KBN_FIELD_TYPES } from '@kbn/data-plugin/common';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import type { AggregateQuery } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { useCallback, useEffect, useMemo, useReducer } from 'react';
import { type UseCancellableSearch, useCancellableSearch } from '@kbn/ml-cancellable-search';
import type { estypes } from '@elastic/elasticsearch';
import type { ISearchOptions } from '@kbn/data-plugin/common';
import { OMIT_FIELDS } from '../../../../../common/constants';
import type { TimeBucketsInterval } from '../../../../../common/services/time_buckets';
import type {
DataStatsFetchProgress,
DocumentCountStats,
} from '../../../../../common/types/field_stats';
import { getSupportedFieldType } from '../../../common/components/fields_stats_grid/get_field_names';
import { useDataVisualizerKibana } from '../../../kibana_context';
import { getInitialProgress, getReducer } from '../../progress_utils';
import {
getSafeESQLLimitSize,
getSafeESQLName,
isESQLQuery,
} from '../../search_strategy/requests/esql_utils';
import type { NonAggregatableField } from '../../types/overall_stats';
import { getESQLSupportedAggs } from '../../utils/get_supported_aggs';
import type { ESQLDefaultLimitSizeOption } from '../../components/search_panel/esql/limit_size';
import { getESQLOverallStats } from '../../search_strategy/esql_requests/get_count_and_cardinality';
import type { AggregatableField } from '../../types/esql_data_visualizer';
import {
handleError,
type HandleErrorCallback,
} from '../../search_strategy/esql_requests/handle_error';
export interface Column {
type: string;
name: string;
secondaryType: string;
}
interface Data {
timeFieldName?: string;
columns?: Column[];
totalCount?: number;
nonAggregatableFields?: Array<{ name: string; type: string }>;
aggregatableFields?: Array<{ name: string; type: string; supportedAggs: Set<string> }>;
documentCountStats?: DocumentCountStats;
overallStats?: {
aggregatableExistsFields: AggregatableField[];
aggregatableNotExistsFields: AggregatableField[];
nonAggregatableExistsFields: NonAggregatableField[];
nonAggregatableNotExistsFields: NonAggregatableField[];
};
}
const getESQLDocumentCountStats = async (
runRequest: UseCancellableSearch['runRequest'],
query: AggregateQuery,
filter?: estypes.QueryDslQueryContainer,
timeFieldName?: string,
intervalMs?: number,
searchOptions?: ISearchOptions,
onError?: HandleErrorCallback
): Promise<{ documentCountStats?: DocumentCountStats; totalCount: number }> => {
if (!isESQLQuery(query)) {
throw Error(
i18n.translate('xpack.dataVisualizer.esql.noQueryProvided', {
defaultMessage: 'No ES|QL query provided',
})
);
}
const esqlBaseQuery = query.esql;
let earliestMs = Infinity;
let latestMs = -Infinity;
if (timeFieldName) {
const aggQuery = ` | EVAL _timestamp_= TO_DOUBLE(DATE_TRUNC(${intervalMs} millisecond, ${getSafeESQLName(
timeFieldName
)}))
| stats rows = count(*) by _timestamp_
| LIMIT 10000`;
const request = {
params: {
query: esqlBaseQuery + aggQuery,
...(filter ? { filter } : {}),
},
};
try {
const esqlResults = await runRequest(request, { ...(searchOptions ?? {}), strategy: 'esql' });
let totalCount = 0;
const _buckets: Record<string, number> = {};
// @ts-expect-error ES types needs to be updated with columns and values as part of esql response
esqlResults?.rawResponse.values.forEach((val) => {
const [count, bucket] = val;
_buckets[bucket] = count;
totalCount += count;
if (bucket < earliestMs) {
earliestMs = bucket;
}
if (bucket >= latestMs) {
latestMs = bucket;
}
});
const result: DocumentCountStats = {
interval: intervalMs,
probability: 1,
randomlySampled: false,
timeRangeEarliest: earliestMs,
timeRangeLatest: latestMs,
buckets: _buckets,
totalCount,
};
return { documentCountStats: result, totalCount };
} catch (error) {
handleError({
request,
error,
onError,
title: i18n.translate('xpack.dataVisualizer.esql.docCountError', {
defaultMessage: `Error getting total count & doc count chart for ES|QL time-series data for request:`,
}),
});
return Promise.reject(error);
}
} else {
// If not time field, get the total count
const request = {
params: {
query: esqlBaseQuery + ' | STATS _count_ = COUNT(*) | LIMIT 1',
...(filter ? { filter } : {}),
},
};
try {
const esqlResults = await runRequest(request, { ...(searchOptions ?? {}), strategy: 'esql' });
return {
documentCountStats: undefined,
totalCount: esqlResults?.rawResponse.values[0][0],
};
} catch (error) {
handleError({
request,
error,
onError,
title: i18n.translate('xpack.dataVisualizer.esql.docCountNoneTimeseriesError', {
defaultMessage: `Error getting total count for ES|QL data:`,
}),
});
return Promise.reject(error);
}
}
};
export const getInitialData = (): Data => ({
timeFieldName: undefined,
columns: undefined,
totalCount: undefined,
});
const NON_AGGREGATABLE_FIELD_TYPES = new Set<string>([
KBN_FIELD_TYPES.GEO_SHAPE,
KBN_FIELD_TYPES.GEO_POINT,
KBN_FIELD_TYPES.HISTOGRAM,
]);
const fieldStatsErrorTitle = i18n.translate(
'xpack.dataVisualizer.index.errorFetchingESQLFieldStatisticsMessage',
{
defaultMessage: 'Error fetching field statistics for ES|QL query',
}
);
export const useESQLOverallStatsData = (
fieldStatsRequest:
| {
earliest: number | undefined;
latest: number | undefined;
aggInterval: TimeBucketsInterval;
intervalMs: number;
searchQuery: AggregateQuery;
indexPattern: string | undefined;
timeFieldName: string | undefined;
lastRefresh: number;
filter?: QueryDslQueryContainer;
limitSize?: ESQLDefaultLimitSizeOption;
}
| undefined
) => {
const {
services: {
data,
notifications: { toasts },
},
} = useDataVisualizerKibana();
const { runRequest, cancelRequest } = useCancellableSearch(data);
const [tableData, setTableData] = useReducer(getReducer<Data>(), getInitialData());
const [overallStatsProgress, setOverallStatsProgress] = useReducer(
getReducer<DataStatsFetchProgress>(),
getInitialProgress()
);
const onError = useCallback(
(error, title?: string) =>
toasts.addError(error, {
title: title ?? fieldStatsErrorTitle,
}),
[toasts]
);
const startFetch = useCallback(
async function fetchOverallStats() {
try {
cancelRequest();
if (!fieldStatsRequest) {
return;
}
setOverallStatsProgress({
...getInitialProgress(),
isRunning: true,
error: undefined,
});
setTableData({ totalCount: undefined, documentCountStats: undefined });
const { searchQuery, intervalMs, filter, limitSize } = fieldStatsRequest;
if (!isESQLQuery(searchQuery)) {
return;
}
const intervalInMs = intervalMs === 0 ? 60 * 60 * 60 * 10 : intervalMs;
// For doc count chart, we want the full base query without any limit
const esqlBaseQuery = searchQuery.esql;
const columnsResp = await runRequest(
{
params: {
query: esqlBaseQuery + '| LIMIT 0',
...(filter ? { filter } : {}),
},
},
{ strategy: ESQL_SEARCH_STRATEGY }
);
const columns = columnsResp?.rawResponse
? // @ts-expect-error ES types need to be updated with columns for ESQL queries
(columnsResp.rawResponse.columns.map((c) => ({
...c,
secondaryType: getSupportedFieldType(c.type),
})) as Column[])
: [];
const timeFields = columns.filter((d) => d.type === 'date');
const dataViewTimeField = timeFields.find(
(f) => f.name === fieldStatsRequest?.timeFieldName
)
? fieldStatsRequest?.timeFieldName
: undefined;
// If a date field named '@timestamp' exists, set that as default time field
// Else, use the default time view defined by data view
// Else, use first available date field as default
const timeFieldName =
timeFields.length > 0
? timeFields.find((f) => f.name === '@timestamp')
? '@timestamp'
: dataViewTimeField ?? timeFields[0].name
: undefined;
setTableData({ columns, timeFieldName });
const { totalCount, documentCountStats } = await getESQLDocumentCountStats(
runRequest,
searchQuery,
filter,
timeFieldName,
intervalInMs,
undefined,
onError
);
setTableData({ totalCount, documentCountStats });
setOverallStatsProgress({
loaded: 50,
});
const aggregatableFields: Array<{
fieldName: string;
name: string;
type: string;
supportedAggs: Set<string>;
secondaryType: string;
aggregatable: boolean;
}> = [];
const nonAggregatableFields: Array<{
fieldName: string;
name: string;
type: string;
secondaryType: string;
}> = [];
const fields = columns
// Some field types are not supported by ESQL yet
// Also, temporarily removing null columns because it causes problems with some aggs
// See https://github.com/elastic/elasticsearch/issues/104430
.filter((c) => c.type !== 'unsupported' && c.type !== 'null')
.map((field) => {
return { ...field, aggregatable: !NON_AGGREGATABLE_FIELD_TYPES.has(field.type) };
});
fields?.forEach((field) => {
const fieldName = field.name;
if (!OMIT_FIELDS.includes(fieldName)) {
if (!field.aggregatable) {
nonAggregatableFields.push({
...field,
fieldName: field.name,
secondaryType: getSupportedFieldType(field.type),
});
} else {
aggregatableFields.push({
...field,
fieldName: field.name,
secondaryType: getSupportedFieldType(field.type),
supportedAggs: getESQLSupportedAggs(field, true),
aggregatable: true,
});
}
}
});
setTableData({ aggregatableFields, nonAggregatableFields });
// COUNT + CARDINALITY
// For % count & cardinality, we want the full base query WITH specified limit
// to safeguard against huge datasets
const esqlBaseQueryWithLimit = searchQuery.esql + getSafeESQLLimitSize(limitSize);
if (totalCount === 0) {
setOverallStatsProgress({
loaded: 100,
isRunning: false,
error: undefined,
});
return;
}
if (totalCount > 0 && fields.length > 0) {
const stats = await getESQLOverallStats({
runRequest,
fields,
esqlBaseQueryWithLimit,
filter,
limitSize,
totalCount,
onError,
});
setTableData({ overallStats: stats });
setOverallStatsProgress({
loaded: 100,
isRunning: false,
error: undefined,
});
}
} catch (error) {
// If error already handled in sub functions, no need to propogate
if (error.name !== 'AbortError' && error.handled !== true) {
toasts.addError(error, {
title: fieldStatsErrorTitle,
});
// Log error to console for better debugging
// eslint-disable-next-line no-console
console.error(`${fieldStatsErrorTitle}: fetchOverallStats`, error);
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[runRequest, toasts, JSON.stringify({ fieldStatsRequest }), onError]
);
// auto-update
useEffect(() => {
startFetch();
}, [startFetch]);
return useMemo(
() => ({ ...tableData, overallStatsProgress, cancelOverallStatsRequest: cancelRequest }),
[tableData, overallStatsProgress, cancelRequest]
);
};

View file

@ -10,7 +10,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { merge } from 'rxjs';
import type { EuiTableActionsColumnType } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { type DataViewField } from '@kbn/data-plugin/common';
import { UI_SETTINGS, type DataViewField } from '@kbn/data-plugin/common';
import { ES_FIELD_TYPES, KBN_FIELD_TYPES } from '@kbn/field-types';
import seedrandom from 'seedrandom';
import type { SamplingOption } from '@kbn/discover-plugin/public/application/main/components/field_stats_table/field_stats_table';
@ -44,6 +44,7 @@ import { useOverallStats } from './use_overall_stats';
import type { OverallStatsSearchStrategyParams } from '../../../../common/types/field_stats';
import type { AggregatableField, NonAggregatableField } from '../types/overall_stats';
import { getSupportedAggs } from '../utils/get_supported_aggs';
import { DEFAULT_BAR_TARGET } from '../../common/constants';
const defaults = getDefaultPageState();
@ -83,7 +84,7 @@ export const useDataVisualizerGridData = (
useExecutionContext(executionContext, embeddableExecutionContext);
const { samplerShardSize, visibleFieldTypes, showEmptyFields } = dataVisualizerListState;
const { visibleFieldTypes, showEmptyFields } = dataVisualizerListState;
const [lastRefresh, setLastRefresh] = useState(0);
const searchSessionId = input.sessionId;
@ -205,12 +206,12 @@ export const useDataVisualizerGridData = (
}
const bounds = tf.getActiveBounds();
const BAR_TARGET = 75;
const barTarget = uiSettings.get(UI_SETTINGS.HISTOGRAM_BAR_TARGET) ?? DEFAULT_BAR_TARGET;
buckets.setInterval('auto');
if (bounds) {
buckets.setBounds(bounds);
buckets.setBarTarget(BAR_TARGET);
buckets.setBarTarget(barTarget);
}
const aggInterval = buckets.getInterval();
@ -243,7 +244,6 @@ export const useDataVisualizerGridData = (
aggInterval,
intervalMs: aggInterval?.asMilliseconds(),
searchQuery,
samplerShardSize,
sessionId: searchSessionId,
index: currentDataView.title,
timeFieldName: currentDataView.timeFieldName,
@ -265,7 +265,6 @@ export const useDataVisualizerGridData = (
JSON.stringify(searchQuery),
// eslint-disable-next-line react-hooks/exhaustive-deps
JSON.stringify(samplingOption),
samplerShardSize,
searchSessionId,
lastRefresh,
fieldsToFetch,
@ -275,6 +274,7 @@ export const useDataVisualizerGridData = (
);
const { overallStats, progress: overallStatsProgress } = useOverallStats(
false,
fieldStatsRequest,
lastRefresh,
dataVisualizerListState.probability

View file

@ -73,6 +73,7 @@ export function useFieldStatsSearchStrategy(
} = useDataVisualizerKibana();
const [fieldStats, setFieldStats] = useState<Map<string, FieldStats>>();
const [fetchState, setFetchState] = useReducer(
getReducer<DataStatsFetchProgress>(),
getInitialProgress()
@ -154,7 +155,6 @@ export function useFieldStatsSearchStrategy(
const params: FieldStatsCommonRequestParams = {
index: searchStrategyParams.index,
samplerShardSize: searchStrategyParams.samplerShardSize,
timeFieldName: searchStrategyParams.timeFieldName,
earliestMs: searchStrategyParams.earliest,
latestMs: searchStrategyParams.latest,

View file

@ -66,6 +66,7 @@ export function rateLimitingForkJoin<T>(
}
export function useOverallStats<TParams extends OverallStatsSearchStrategyParams>(
esql = false,
searchStrategyParams: TParams | undefined,
lastRefresh: number,
probability?: number | null

View file

@ -26,6 +26,7 @@ import {
type Accessor,
type Dictionary,
type SetUrlState,
UrlStateProvider,
} from '@kbn/ml-url-state';
import type { SavedSearch } from '@kbn/saved-search-plugin/public';
import { getCoreStart, getPluginsStart } from '../../kibana_services';
@ -33,6 +34,8 @@ import {
type IndexDataVisualizerViewProps,
IndexDataVisualizerView,
} from './components/index_data_visualizer_view';
import { IndexDataVisualizerESQL } from './components/index_data_visualizer_view/index_data_visualizer_esql';
import { useDataVisualizerKibana } from '../kibana_context';
import type { GetAdditionalLinks } from '../common/components/results_links';
import { DATA_VISUALIZER_APP_LOCATOR, type IndexDataVisualizerLocatorParams } from './locator';
@ -80,7 +83,15 @@ export const getLocatorParams = (params: {
return locatorParams;
};
export const DataVisualizerStateContextProvider: FC<DataVisualizerStateContextProviderProps> = ({
const DataVisualizerESQLStateContextProvider = () => {
return (
<UrlStateProvider>
<IndexDataVisualizerESQL />
</UrlStateProvider>
);
};
const DataVisualizerStateContextProvider: FC<DataVisualizerStateContextProviderProps> = ({
IndexDataVisualizerComponent,
getAdditionalLinks,
}) => {
@ -256,9 +267,7 @@ export const DataVisualizerStateContextProvider: FC<DataVisualizerStateContextPr
currentSessionId={currentSessionId}
getAdditionalLinks={getAdditionalLinks}
/>
) : (
<div />
)}
) : null}
</UrlStateContextProvider>
);
};
@ -266,11 +275,13 @@ export const DataVisualizerStateContextProvider: FC<DataVisualizerStateContextPr
interface Props {
getAdditionalLinks?: GetAdditionalLinks;
showFrozenDataTierChoice?: boolean;
esql?: boolean;
}
export const IndexDataVisualizer: FC<Props> = ({
getAdditionalLinks,
showFrozenDataTierChoice = true,
esql,
}) => {
const coreStart = getCoreStart();
const {
@ -320,10 +331,14 @@ export const IndexDataVisualizer: FC<Props> = ({
<KibanaContextProvider services={{ ...services }}>
<StorageContextProvider storage={localStorage} storageKeys={DV_STORAGE_KEYS}>
<DatePickerContextProvider {...datePickerDeps}>
<DataVisualizerStateContextProvider
IndexDataVisualizerComponent={IndexDataVisualizerView}
getAdditionalLinks={getAdditionalLinks}
/>
{!esql ? (
<DataVisualizerStateContextProvider
IndexDataVisualizerComponent={IndexDataVisualizerView}
getAdditionalLinks={getAdditionalLinks}
/>
) : (
<DataVisualizerESQLStateContextProvider />
)}
</DatePickerContextProvider>
</StorageContextProvider>
</KibanaContextProvider>

View file

@ -0,0 +1,111 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseCancellableSearch } from '@kbn/ml-cancellable-search';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import pLimit from 'p-limit';
import type { Column } from '../../hooks/esql/use_esql_overall_stats_data';
import { getSafeESQLName } from '../requests/esql_utils';
import { isFulfilled, isRejected } from '../../../common/util/promise_all_settled_utils';
import { MAX_CONCURRENT_REQUESTS } from '../../constants/index_data_visualizer_viewer';
import type { BucketCount } from '../../types/esql_data_visualizer';
import type { BooleanFieldStats, FieldStatsError } from '../../../../../common/types/field_stats';
interface Params {
runRequest: UseCancellableSearch['runRequest'];
columns: Column[];
esqlBaseQuery: string;
filter?: QueryDslQueryContainer;
}
export const getESQLBooleanFieldStats = async ({
runRequest,
columns,
esqlBaseQuery,
filter,
}: Params): Promise<Array<BooleanFieldStats | FieldStatsError | undefined>> => {
const limiter = pLimit(MAX_CONCURRENT_REQUESTS);
const booleanFields = columns
.filter((f) => f.secondaryType === 'boolean')
.map((field) => {
const query = `| STATS ${getSafeESQLName(`${field.name}_terms`)} = count(${getSafeESQLName(
field.name
)}) BY ${getSafeESQLName(field.name)}
| LIMIT 3`;
return {
field,
request: {
params: {
query: esqlBaseQuery + query,
...(filter ? { filter } : {}),
},
},
};
});
if (booleanFields.length > 0) {
const booleanTopTermsResp = await Promise.allSettled(
booleanFields.map(({ request }) =>
limiter(() => runRequest(request, { strategy: ESQL_SEARCH_STRATEGY }))
)
);
if (booleanTopTermsResp) {
return booleanFields.map(({ field, request }, idx) => {
const resp = booleanTopTermsResp[idx];
if (!resp) return;
if (isFulfilled(resp) && resp.value) {
const results = resp.value.rawResponse.values as Array<[BucketCount, boolean]>;
const topValuesSampleSize = results.reduce((acc, row) => acc + row[0], 0);
let falseCount = 0;
let trueCount = 0;
const terms = results.map((row) => {
if (row[1] === false) {
falseCount = row[0];
}
if (row[1] === true) {
trueCount = row[0];
}
return {
key_as_string: row[1]?.toString(),
doc_count: row[0],
percent: row[0] / topValuesSampleSize,
};
});
return {
fieldName: field.name,
topValues: terms,
topValuesSampleSize,
topValuesSamplerShardSize: topValuesSampleSize,
isTopValuesSampled: false,
trueCount,
falseCount,
count: trueCount + falseCount,
} as BooleanFieldStats;
}
if (isRejected(resp)) {
// Log for debugging purposes
// eslint-disable-next-line no-console
console.error(resp, request);
return {
fieldName: field.name,
error: resp.reason,
} as FieldStatsError;
}
});
}
}
return [];
};

View file

@ -0,0 +1,200 @@
/*
* 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 { ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import pLimit from 'p-limit';
import { chunk } from 'lodash';
import { isDefined } from '@kbn/ml-is-defined';
import type { ESQLSearchReponse } from '@kbn/es-types';
import type { UseCancellableSearch } from '@kbn/ml-cancellable-search';
import * as estypes from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { i18n } from '@kbn/i18n';
import { getSafeESQLName } from '../requests/esql_utils';
import { MAX_CONCURRENT_REQUESTS } from '../../constants/index_data_visualizer_viewer';
import type { NonAggregatableField } from '../../types/overall_stats';
import { isFulfilled } from '../../../common/util/promise_all_settled_utils';
import type { ESQLDefaultLimitSizeOption } from '../../components/search_panel/esql/limit_size';
import type { Column } from '../../hooks/esql/use_esql_overall_stats_data';
import { AggregatableField } from '../../types/esql_data_visualizer';
import { handleError, HandleErrorCallback } from './handle_error';
interface Field extends Column {
aggregatable?: boolean;
}
const getESQLOverallStatsInChunk = async ({
runRequest,
fields,
esqlBaseQueryWithLimit,
filter,
limitSize,
totalCount,
onError,
}: {
runRequest: UseCancellableSearch['runRequest'];
fields: Field[];
esqlBaseQueryWithLimit: string;
filter?: estypes.QueryDslQueryContainer;
limitSize?: ESQLDefaultLimitSizeOption;
totalCount: number;
onError?: HandleErrorCallback;
}) => {
if (fields.length > 0) {
const aggregatableFieldsToQuery = fields.filter((f) => f.aggregatable);
let countQuery = aggregatableFieldsToQuery.length > 0 ? '| STATS ' : '';
countQuery += aggregatableFieldsToQuery
.map((field) => {
// count idx = 0, cardinality idx = 1
return `${getSafeESQLName(`${field.name}_count`)} = COUNT(${getSafeESQLName(field.name)}),
${getSafeESQLName(`${field.name}_cardinality`)} = COUNT_DISTINCT(${getSafeESQLName(
field.name
)})`;
})
.join(',');
const request = {
params: {
query: esqlBaseQueryWithLimit + countQuery,
...(filter ? { filter } : {}),
},
};
try {
const esqlResults = await runRequest(request, { strategy: ESQL_SEARCH_STRATEGY });
const stats = {
aggregatableExistsFields: [] as AggregatableField[],
aggregatableNotExistsFields: [] as AggregatableField[],
nonAggregatableExistsFields: [] as NonAggregatableField[],
nonAggregatableNotExistsFields: [] as NonAggregatableField[],
};
if (!esqlResults) {
return;
}
const esqlResultsResp = esqlResults.rawResponse as unknown as ESQLSearchReponse;
const sampleCount =
limitSize === 'none' || !isDefined(limitSize) ? totalCount : parseInt(limitSize, 10);
aggregatableFieldsToQuery.forEach((field, idx) => {
const count = esqlResultsResp.values[0][idx * 2] as number;
const cardinality = esqlResultsResp.values[0][idx * 2 + 1] as number;
if (field.aggregatable === true) {
if (count > 0) {
stats.aggregatableExistsFields.push({
...field,
fieldName: field.name,
existsInDocs: true,
stats: {
sampleCount,
count,
cardinality,
},
});
} else {
stats.aggregatableNotExistsFields.push({
...field,
fieldName: field.name,
existsInDocs: false,
stats: undefined,
});
}
} else {
const fieldData = {
fieldName: field.name,
existsInDocs: true,
};
if (count > 0) {
stats.nonAggregatableExistsFields.push(fieldData);
} else {
stats.nonAggregatableNotExistsFields.push(fieldData);
}
}
});
return stats;
} catch (error) {
handleError({
error,
request,
onError,
title: i18n.translate('xpack.dataVisualizer.esql.countAndCardinalityError', {
defaultMessage:
'Unable to fetch count & cardinality for {count} {count, plural, one {field} other {fields}}: {fieldNames}',
values: {
count: aggregatableFieldsToQuery.length,
fieldNames: aggregatableFieldsToQuery.map((r) => r.name).join(),
},
}),
});
return Promise.reject(error);
}
}
};
/**
* Fetching count and cardinality in chunks of 30 fields per request in parallel
* limiting at 10 requests maximum at a time
* @param runRequest
* @param fields
* @param esqlBaseQueryWithLimit
*/
export const getESQLOverallStats = async ({
runRequest,
fields,
esqlBaseQueryWithLimit,
filter,
limitSize,
totalCount,
onError,
}: {
runRequest: UseCancellableSearch['runRequest'];
fields: Column[];
esqlBaseQueryWithLimit: string;
filter?: estypes.QueryDslQueryContainer;
limitSize?: ESQLDefaultLimitSizeOption;
totalCount: number;
onError?: HandleErrorCallback;
}) => {
const limiter = pLimit(MAX_CONCURRENT_REQUESTS);
const chunkedFields = chunk(fields, 30);
const resp = await Promise.allSettled(
chunkedFields.map((groupedFields, idx) =>
limiter(() =>
getESQLOverallStatsInChunk({
runRequest,
fields: groupedFields,
esqlBaseQueryWithLimit,
limitSize,
filter,
totalCount,
onError,
})
)
)
);
const results = resp.filter(isFulfilled).map((r) => r.value);
const stats = results.reduce(
(acc, result) => {
if (acc && result) {
acc.aggregatableExistsFields.push(...result.aggregatableExistsFields);
acc.aggregatableNotExistsFields.push(...result.aggregatableNotExistsFields);
acc.nonAggregatableExistsFields.push(...result.nonAggregatableExistsFields);
acc.nonAggregatableNotExistsFields.push(...result.nonAggregatableNotExistsFields);
}
return acc;
},
{
aggregatableExistsFields: [] as AggregatableField[],
aggregatableNotExistsFields: [] as AggregatableField[],
nonAggregatableExistsFields: [] as NonAggregatableField[],
nonAggregatableNotExistsFields: [] as NonAggregatableField[],
}
);
return stats;
};

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseCancellableSearch } from '@kbn/ml-cancellable-search';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import type { Column } from '../../hooks/esql/use_esql_overall_stats_data';
import { getSafeESQLName } from '../requests/esql_utils';
import type { DateFieldStats, FieldStatsError } from '../../../../../common/types/field_stats';
interface Params {
runRequest: UseCancellableSearch['runRequest'];
columns: Column[];
esqlBaseQuery: string;
filter?: QueryDslQueryContainer;
}
export const getESQLDateFieldStats = async ({
runRequest,
columns,
esqlBaseQuery,
filter,
}: Params) => {
const dateFields = columns.map((field) => {
return {
field,
query: `${getSafeESQLName(`${field.name}_earliest`)} = MIN(${getSafeESQLName(
field.name
)}), ${getSafeESQLName(`${field.name}_latest`)} = MAX(${getSafeESQLName(field.name)})`,
};
});
if (dateFields.length > 0) {
const dateStatsQuery = ' | STATS ' + dateFields.map(({ query }) => query).join(',');
const request = {
params: {
query: esqlBaseQuery + dateStatsQuery,
...(filter ? { filter } : {}),
},
};
try {
const dateFieldsResp = await runRequest(request, { strategy: ESQL_SEARCH_STRATEGY });
if (dateFieldsResp) {
return dateFields.map(({ field: dateField }, idx) => {
const row = dateFieldsResp.rawResponse.values[0] as Array<null | string | number>;
const earliest = row[idx * 2];
const latest = row[idx * 2 + 1];
return {
fieldName: dateField.name,
earliest,
latest,
} as DateFieldStats;
});
}
} catch (error) {
// Log for debugging purposes
// eslint-disable-next-line no-console
console.error(error, request);
return dateFields.map(({ field }, idx) => {
return {
fieldName: field.name,
error,
} as FieldStatsError;
});
}
}
return [];
};

View file

@ -0,0 +1,99 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseCancellableSearch } from '@kbn/ml-cancellable-search';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import pLimit from 'p-limit';
import type { Column } from '../../hooks/esql/use_esql_overall_stats_data';
import { getSafeESQLName } from '../requests/esql_utils';
import { isFulfilled, isRejected } from '../../../common/util/promise_all_settled_utils';
import { MAX_CONCURRENT_REQUESTS } from '../../constants/index_data_visualizer_viewer';
import type { BucketCount, BucketTerm } from '../../types/esql_data_visualizer';
import type { FieldStatsError, StringFieldStats } from '../../../../../common/types/field_stats';
interface Params {
runRequest: UseCancellableSearch['runRequest'];
columns: Column[];
esqlBaseQuery: string;
filter?: QueryDslQueryContainer;
}
export const getESQLKeywordFieldStats = async ({
runRequest,
columns,
esqlBaseQuery,
filter,
}: Params) => {
const limiter = pLimit(MAX_CONCURRENT_REQUESTS);
const keywordFields = columns.map((field) => {
const query =
esqlBaseQuery +
`| STATS ${getSafeESQLName(`${field.name}_terms`)} = count(${getSafeESQLName(
field.name
)}) BY ${getSafeESQLName(field.name)}
| LIMIT 10
| SORT ${getSafeESQLName(`${field.name}_terms`)} DESC`;
return {
field,
request: {
params: {
query,
...(filter ? { filter } : {}),
},
},
};
});
if (keywordFields.length > 0) {
const keywordTopTermsResp = await Promise.allSettled(
keywordFields.map(({ request }) =>
limiter(() => runRequest(request, { strategy: ESQL_SEARCH_STRATEGY }))
)
);
if (keywordTopTermsResp) {
return keywordFields.map(({ field, request }, idx) => {
const resp = keywordTopTermsResp[idx];
if (!resp) return;
if (isFulfilled(resp)) {
const results = resp.value?.rawResponse.values as Array<[BucketCount, BucketTerm]>;
if (results) {
const topValuesSampleSize = results?.reduce((acc: number, row) => acc + row[0], 0);
const terms = results.map((row) => ({
key: row[1],
doc_count: row[0],
percent: row[0] / topValuesSampleSize,
}));
return {
fieldName: field.name,
topValues: terms,
topValuesSampleSize,
topValuesSamplerShardSize: topValuesSampleSize,
isTopValuesSampled: false,
} as StringFieldStats;
}
return;
}
if (isRejected(resp)) {
// Log for debugging purposes
// eslint-disable-next-line no-console
console.error(resp, request);
return {
fieldName: field.name,
error: resp.reason,
} as FieldStatsError;
}
});
}
}
return [];
};

View file

@ -0,0 +1,149 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseCancellableSearch } from '@kbn/ml-cancellable-search';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import { chunk } from 'lodash';
import pLimit from 'p-limit';
import type { Column } from '../../hooks/esql/use_esql_overall_stats_data';
import { processDistributionData } from '../../utils/process_distribution_data';
import { PERCENTILE_SPACING } from '../requests/constants';
import { getESQLPercentileQueryArray, getSafeESQLName, PERCENTS } from '../requests/esql_utils';
import { isFulfilled } from '../../../common/util/promise_all_settled_utils';
import { MAX_CONCURRENT_REQUESTS } from '../../constants/index_data_visualizer_viewer';
import { handleError } from './handle_error';
import type {
FieldStatsError,
NonSampledNumericFieldStats,
} from '../../../../../common/types/field_stats';
interface Params {
runRequest: UseCancellableSearch['runRequest'];
columns: Column[];
esqlBaseQuery: string;
filter?: QueryDslQueryContainer;
}
const getESQLNumericFieldStatsInChunk = async ({
runRequest,
columns,
esqlBaseQuery,
filter,
}: Params): Promise<Array<NonSampledNumericFieldStats | FieldStatsError>> => {
// Hashmap of agg to index/order of which is made in the ES|QL query
// {min: 0, max: 1, p0: 2, p5: 3, ..., p100: 22}
const numericAccessorMap = PERCENTS.reduce<{ [key: string]: number }>(
(acc, curr, idx) => {
// +2 for the min and max aggs
acc[`p${curr}`] = idx + 2;
return acc;
},
{
// First two are min and max aggs
min: 0,
max: 1,
// and percentiles p0, p5, ..., p100 are the rest
}
);
const numericFields = columns.map((field, idx) => {
const percentiles = getESQLPercentileQueryArray(field.name, PERCENTS);
return {
field,
query: `${getSafeESQLName(`${field.name}_min`)} = MIN(${getSafeESQLName(field.name)}),
${getSafeESQLName(`${field.name}_max`)} = MAX(${getSafeESQLName(field.name)}),
${percentiles.join(',')}
`,
// Start index of field in the response, so we know to slice & access the values
startIndex: idx * Object.keys(numericAccessorMap).length,
};
});
if (numericFields.length > 0) {
const numericStatsQuery = '| STATS ' + numericFields.map(({ query }) => query).join(',');
const request = {
params: {
query: esqlBaseQuery + numericStatsQuery,
...(filter ? { filter } : {}),
},
};
try {
const fieldStatsResp = await runRequest(request, { strategy: ESQL_SEARCH_STRATEGY });
if (fieldStatsResp) {
const values = fieldStatsResp.rawResponse.values[0];
return numericFields.map(({ field, startIndex }, idx) => {
/** Order of aggs we are expecting back from query
* 0 = min; 23 = startIndex + 0 for 2nd field
* 1 = max; 24 = startIndex + 1
* 2 p0; 25; 24 = startIndex + 2
* 3 p5; 26
* 4 p10; 27
* ...
* 22 p100;
*/
const min = values[startIndex + numericAccessorMap.min];
const max = values[startIndex + numericAccessorMap.max];
const median = values[startIndex + numericAccessorMap.p50];
const percentiles = values
.slice(startIndex + numericAccessorMap.p0, startIndex + numericAccessorMap.p100)
.map((value: number) => ({ value }));
const distribution = processDistributionData(percentiles, PERCENTILE_SPACING, min);
return {
fieldName: field.name,
...field,
min,
max,
median,
distribution,
} as NonSampledNumericFieldStats;
});
}
} catch (error) {
handleError({ error, request });
return numericFields.map(({ field }) => {
return {
fieldName: field.name,
error,
} as FieldStatsError;
});
}
}
return [];
};
export const getESQLNumericFieldStats = async ({
runRequest,
columns,
esqlBaseQuery,
filter,
}: Params): Promise<Array<NonSampledNumericFieldStats | FieldStatsError>> => {
const limiter = pLimit(MAX_CONCURRENT_REQUESTS);
// Breakdown so that each requests only contains 10 numeric fields
// to prevent potential circuit breaking exception
// or too big of a payload
const numericColumnChunks = chunk(columns, 10);
const numericStats = await Promise.allSettled(
numericColumnChunks.map((numericColumns) =>
limiter(() =>
getESQLNumericFieldStatsInChunk({
columns: numericColumns,
filter,
runRequest,
esqlBaseQuery,
})
)
)
);
return numericStats.filter(isFulfilled).flatMap((stat) => stat.value);
};

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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import type { UseCancellableSearch } from '@kbn/ml-cancellable-search';
import type { QueryDslQueryContainer } from '@kbn/data-views-plugin/common/types';
import { ESQL_SEARCH_STRATEGY } from '@kbn/data-plugin/common';
import type { Column } from '../../hooks/esql/use_esql_overall_stats_data';
import type { FieldExamples, FieldStatsError } from '../../../../../common/types/field_stats';
interface Params {
runRequest: UseCancellableSearch['runRequest'];
columns: Column[];
esqlBaseQuery: string;
filter?: QueryDslQueryContainer;
}
/**
* Make one query that gets the top 10 rows for each text field requested
* then process the values to showcase examples for each field
* @param
* @returns
*/
export const getESQLTextFieldStats = async ({
runRequest,
columns: textFields,
esqlBaseQuery,
filter,
}: Params): Promise<Array<FieldExamples | FieldStatsError>> => {
try {
if (textFields.length > 0) {
const request = {
params: {
query:
esqlBaseQuery +
`| KEEP ${textFields.map((f) => f.name).join(',')}
| LIMIT 10`,
...(filter ? { filter } : {}),
},
};
const textFieldsResp = await runRequest(request, { strategy: ESQL_SEARCH_STRATEGY });
if (textFieldsResp) {
return textFields.map((textField, idx) => {
const examples = (textFieldsResp.rawResponse.values as unknown[][]).map(
(row) => row[idx]
);
return {
fieldName: textField.name,
examples,
} as FieldExamples;
});
}
}
} catch (error) {
return textFields.map((textField, idx) => ({
fieldName: textField.name,
error,
})) as FieldStatsError[];
}
return [];
};

View file

@ -0,0 +1,40 @@
/*
* 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';
interface DataVizError extends Error {
handled?: boolean;
}
export type HandleErrorCallback = (e: DataVizError, title?: string) => void;
export const handleError = ({
onError,
request,
error,
title,
}: {
error: DataVizError;
request: object;
onError?: HandleErrorCallback;
title?: string;
}) => {
// Log error and request for debugging purposes
// eslint-disable-next-line no-console
console.error(error, request);
if (onError) {
error.handled = true;
error.message = JSON.stringify(request);
onError(
error,
title ??
i18n.translate('xpack.dataVisualizer.esql.errorMessage', {
defaultMessage: 'Error excecuting ES|QL request:',
})
);
}
};

View file

@ -0,0 +1,19 @@
/*
* 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 { getESQLPercentileQueryArray } from './esql_utils';
describe('getESQLPercentileQueryArray', () => {
test('should return correct ESQL query', () => {
const query = getESQLPercentileQueryArray('@odd_field', [0, 50, 100]);
expect(query).toEqual([
'`@odd_field_p0` = PERCENTILE(`@odd_field`, 0)',
'`@odd_field_p50` = PERCENTILE(`@odd_field`, 50)',
'`@odd_field_p100` = PERCENTILE(`@odd_field`, 100)',
]);
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { MAX_PERCENT, PERCENTILE_SPACING } from './constants';
export interface ESQLQuery {
esql: string;
}
/**
* Helper function to escape special characters for field names used in ES|QL queries.
* https://www.elastic.co/guide/en/elasticsearch/reference/current/esql-syntax.html#esql-identifiers
* @param str
* @returns "`str`"
**/
export const getSafeESQLName = (str: string) => {
return `\`${str}\``;
};
export function isESQLQuery(arg: unknown): arg is ESQLQuery {
return isPopulatedObject(arg, ['esql']);
}
export const PERCENTS = Array.from(
Array(MAX_PERCENT / PERCENTILE_SPACING + 1),
(_, i) => i * PERCENTILE_SPACING
);
export const getESQLPercentileQueryArray = (fieldName: string, percents = PERCENTS) =>
percents.map(
(p) =>
`${getSafeESQLName(`${fieldName}_p${p}`)} = PERCENTILE(${getSafeESQLName(fieldName)}, ${p})`
);
export const getSafeESQLLimitSize = (str?: string) => {
if (str === 'none' || !str) return '';
return ` | LIMIT ${str}`;
};

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 type { DataView, DataViewsContract } from '@kbn/data-views-plugin/public';
/**
* Get a saved data view that matches the index pattern (as close as possible)
* or create a new adhoc data view if no matches found
* @param dataViews
* @param indexPatternFromQuery
* @param currentDataView
* @returns
*/
export async function getOrCreateDataViewByIndexPattern(
dataViews: DataViewsContract,
indexPatternFromQuery: string | undefined,
currentDataView: DataView | undefined
) {
if (indexPatternFromQuery) {
const matched = await dataViews.find(indexPatternFromQuery);
// Only returns persisted data view if it matches index pattern exactly
// Because * in pattern can result in misleading matches (i.e. "kibana*" will return data view with pattern "kibana_1")
// which is not neccessarily the one we want to use
if (matched.length > 0 && matched[0].getIndexPattern() === indexPatternFromQuery)
return matched[0];
}
if (
indexPatternFromQuery &&
(currentDataView?.isPersisted() || indexPatternFromQuery !== currentDataView?.getIndexPattern())
) {
const dataViewObj = await dataViews.create({
title: indexPatternFromQuery,
});
if (dataViewObj.fields.getByName('@timestamp')?.type === 'date') {
dataViewObj.timeFieldName = '@timestamp';
}
return dataViewObj;
}
return currentDataView;
}

View file

@ -25,7 +25,7 @@ export const getDocumentCountStats = async (
search: DataPublicPluginStart['search'],
params: OverallStatsSearchStrategyParams,
searchOptions: ISearchOptions,
browserSessionSeed: string,
browserSessionSeed?: string,
probability?: number | null,
minimumRandomSamplerDocCount?: number
): Promise<DocumentCountStats> => {
@ -193,6 +193,7 @@ export const getDocumentCountStats = async (
}
}
}
return result;
};

View file

@ -21,7 +21,7 @@ import { isDefined } from '@kbn/ml-is-defined';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { processTopValues } from './utils';
import { buildAggregationWithSamplingOption } from './build_random_sampler_agg';
import { MAX_PERCENT, PERCENTILE_SPACING, SAMPLER_TOP_TERMS_THRESHOLD } from './constants';
import { MAX_PERCENT, PERCENTILE_SPACING } from './constants';
import type {
Aggs,
Bucket,
@ -154,7 +154,6 @@ export const fetchNumericFieldsStats = (
fields: Field[],
options: ISearchOptions
): Observable<NumericFieldStats[] | FieldStatsError> => {
const { samplerShardSize } = params;
const request: estypes.SearchRequest = getNumericFieldsStatsRequest(params, fields);
return dataSearch
@ -183,9 +182,6 @@ export const fetchNumericFieldsStats = (
);
const topAggsPath = [...aggsPath, `${safeFieldName}_top`];
if (samplerShardSize < 1 && field.cardinality >= SAMPLER_TOP_TERMS_THRESHOLD) {
topAggsPath.push('top');
}
const fieldAgg = get(aggregations, [...topAggsPath], {}) as { buckets: Bucket[] };
const { topValuesSampleSize, topValues } = processTopValues(fieldAgg);

View file

@ -19,7 +19,6 @@ import { isPopulatedObject } from '@kbn/ml-is-populated-object';
import { extractErrorProperties } from '@kbn/ml-error-utils';
import { processTopValues } from './utils';
import { buildAggregationWithSamplingOption } from './build_random_sampler_agg';
import { SAMPLER_TOP_TERMS_THRESHOLD } from './constants';
import type {
Aggs,
Field,
@ -71,7 +70,6 @@ export const fetchStringFieldsStats = (
fields: Field[],
options: ISearchOptions
): Observable<StringFieldStats[] | FieldStatsError> => {
const { samplerShardSize } = params;
const request: estypes.SearchRequest = getStringFieldStatsRequest(params, fields);
return dataSearch
@ -94,9 +92,6 @@ export const fetchStringFieldsStats = (
const safeFieldName = field.safeFieldName;
const topAggsPath = [...aggsPath, `${safeFieldName}_top`];
if (samplerShardSize < 1 && field.cardinality >= SAMPLER_TOP_TERMS_THRESHOLD) {
topAggsPath.push('top');
}
const fieldAgg = get(aggregations, [...topAggsPath], {});

View file

@ -0,0 +1,19 @@
/*
* 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 type BucketCount = number;
export type BucketTerm = string;
export interface AggregatableField {
fieldName: string;
existsInDocs: boolean;
stats?: {
sampleCount: number;
count: number;
cardinality: number;
};
aggregatable?: boolean;
}

View file

@ -31,7 +31,6 @@ export interface DataVisualizerIndexBasedAppState extends Omit<ListingPageUrlSta
searchQueryLanguage?: SearchQueryLanguage;
visibleFieldTypes?: string[];
visibleFieldNames?: string[];
samplerShardSize?: number;
showDistributions?: boolean;
showAllFields?: boolean;
showEmptyFields?: boolean;

View file

@ -6,6 +6,7 @@
*/
import { type FrozenTierPreference } from '@kbn/ml-date-picker';
import type { ESQLDefaultLimitSizeOption } from '../components/search_panel/esql/limit_size';
import { type RandomSamplerOption } from '../constants/random_sampler';
import { DATA_DRIFT_COMPARISON_CHART_TYPE } from './data_drift';
@ -14,12 +15,14 @@ export const DV_FROZEN_TIER_PREFERENCE = 'dataVisualizer.frozenDataTierPreferenc
export const DV_RANDOM_SAMPLER_PREFERENCE = 'dataVisualizer.randomSamplerPreference';
export const DV_RANDOM_SAMPLER_P_VALUE = 'dataVisualizer.randomSamplerPValue';
export const DV_DATA_DRIFT_DISTRIBUTION_CHART_TYPE = 'dataVisualizer.dataDriftChartType';
export const DV_ESQL_LIMIT_SIZE = 'dataVisualizer.esql.limitSize';
export type DV = Partial<{
[DV_FROZEN_TIER_PREFERENCE]: FrozenTierPreference;
[DV_RANDOM_SAMPLER_PREFERENCE]: RandomSamplerOption;
[DV_RANDOM_SAMPLER_P_VALUE]: null | number;
[DV_DATA_DRIFT_DISTRIBUTION_CHART_TYPE]: DATA_DRIFT_COMPARISON_CHART_TYPE;
[DV_ESQL_LIMIT_SIZE]: ESQLDefaultLimitSizeOption;
}> | null;
export type DVKey = keyof Exclude<DV, null>;
@ -32,6 +35,8 @@ export type DVStorageMapped<T extends DVKey> = T extends typeof DV_FROZEN_TIER_P
? number | null
: T extends typeof DV_DATA_DRIFT_DISTRIBUTION_CHART_TYPE
? DATA_DRIFT_COMPARISON_CHART_TYPE
: T extends typeof DV_ESQL_LIMIT_SIZE
? ESQLDefaultLimitSizeOption
: null;
export const DV_STORAGE_KEYS = [

View file

@ -74,3 +74,11 @@ export const getSupportedAggs = (field: DataViewField) => {
if (field.aggregatable) return SUPPORTED_AGGS.AGGREGATABLE;
return SUPPORTED_AGGS.DEFAULT;
};
export const getESQLSupportedAggs = (
field: { name: string; type: string },
aggregatable = true
) => {
if (aggregatable) return SUPPORTED_AGGS.AGGREGATABLE;
return SUPPORTED_AGGS.DEFAULT;
};

View file

@ -5,6 +5,7 @@
* 2.0.
*/
import { isDefined } from '@kbn/ml-is-defined';
import { last } from 'lodash';
import type { Distribution } from '../../../../common/types/field_stats';
@ -70,6 +71,8 @@ export const processDistributionData = (
for (let i = 0; i < totalBuckets; i++) {
const bucket = percentileBuckets[i];
if (!isDefined(bucket.value)) continue;
// Results from the percentiles aggregation can have precision rounding
// artifacts e.g returning 200 and 200.000000000123, so check for equality
// around double floating point precision i.e. 15 sig figs.

View file

@ -40,6 +40,7 @@
"@kbn/lens-plugin",
"@kbn/maps-plugin",
"@kbn/ml-agg-utils",
"@kbn/ml-cancellable-search",
"@kbn/ml-date-picker",
"@kbn/ml-is-defined",
"@kbn/ml-is-populated-object",
@ -70,7 +71,9 @@
"@kbn/ml-chi2test",
"@kbn/field-utils",
"@kbn/visualization-utils",
"@kbn/text-based-languages",
"@kbn/code-editor",
"@kbn/es-types",
"@kbn/ui-theme"
],
"exclude": [

View file

@ -40,6 +40,7 @@ export const ML_PAGES = {
* Page: Data Visualizer
* Open index data visualizer viewer page
*/
DATA_VISUALIZER_ESQL: 'datavisualizer/esql',
DATA_VISUALIZER_INDEX_VIEWER: 'jobs/new_job/datavisualizer',
ANOMALY_DETECTION_CREATE_JOB: 'jobs/new_job',
ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER: 'jobs/new_job/recognize',

View file

@ -44,6 +44,7 @@ export interface MlGenericUrlPageState extends MlIndexBasedSearchState {
export type MlGenericUrlState = MLPageState<
| typeof ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER
| typeof ML_PAGES.DATA_VISUALIZER_ESQL
| typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB
| typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_RECOGNIZER
| typeof ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_ADVANCED

View file

@ -234,6 +234,16 @@ export function useSideNavItems(activeRoute: MlRoute | undefined) {
disabled: false,
testSubj: 'mlMainTab indexDataVisualizer',
},
{
id: 'esql_datavisualizer',
pathId: ML_PAGES.DATA_VISUALIZER_ESQL,
name: i18n.translate('xpack.ml.navMenu.esqlDataVisualizerLinkText', {
defaultMessage: 'ES|QL',
}),
disabled: false,
testSubj: 'mlMainTab esqlDataVisualizer',
},
{
id: 'data_drift',
pathId: ML_PAGES.DATA_DRIFT_INDEX_SELECT,

View file

@ -17,6 +17,8 @@ import {
EuiLink,
EuiSpacer,
EuiText,
EuiBetaBadge,
EuiTextAlign,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
@ -25,6 +27,7 @@ import { isFullLicense } from '../license';
import { useMlKibana, useNavigateToPath } from '../contexts/kibana';
import { HelpMenu } from '../components/help_menu';
import { MlPageHeader } from '../components/page_header';
import { ML_PAGES } from '../../locator';
function startTrialDescription() {
return (
@ -49,6 +52,7 @@ function startTrialDescription() {
export const DatavisualizerSelector: FC = () => {
useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false });
const {
services: {
licenseManagement,
@ -154,6 +158,54 @@ export const DatavisualizerSelector: FC = () => {
data-test-subj="mlDataVisualizerCardIndexData"
/>
</EuiFlexItem>
<EuiFlexItem>
<EuiCard
hasBorder
icon={<EuiIcon size="xxl" type="dataVisualizer" />}
title={
<EuiTextAlign textAlign="center">
<>
<FormattedMessage
id="xpack.ml.datavisualizer.selector.selectESQLTitle"
defaultMessage="Visualize data using ES|QL"
/>{' '}
<EuiBetaBadge
label=""
iconType="beaker"
size="m"
color="hollow"
tooltipContent={
<FormattedMessage
id="xpack.ml.datavisualizer.selector.technicalPreviewBadge.titleMsg"
defaultMessage="ES|QL is in technical preview."
/>
}
tooltipPosition={'right'}
/>
</>
</EuiTextAlign>
}
description={
<FormattedMessage
id="xpack.ml.datavisualizer.selector.technicalPreviewBadge.contentMsg"
defaultMessage="Use ES|QL queries to visualize information about any data set."
/>
}
footer={
<EuiButton
target="_self"
onClick={() => navigateToPath(ML_PAGES.DATA_VISUALIZER_ESQL)}
data-test-subj="mlDataVisualizerSelectESQLButton"
>
<FormattedMessage
id="xpack.ml.datavisualizer.selector.useESQLButtonLabel"
defaultMessage="Use ES|QL"
/>
</EuiButton>
}
data-test-subj="mlDataVisualizerCardESQLData"
/>
</EuiFlexItem>
</EuiFlexGrid>
{startTrialVisible === true && (
<Fragment>

View file

@ -15,6 +15,7 @@ import type {
GetAdditionalLinksParams,
} from '@kbn/data-visualizer-plugin/public';
import { useTimefilter } from '@kbn/ml-date-picker';
import { EuiFlexGroup, EuiFlexItem, useEuiTheme } from '@elastic/eui';
import { useMlKibana, useMlLocator } from '../../contexts/kibana';
import { HelpMenu } from '../../components/help_menu';
import { ML_PAGES } from '../../../../common/constants/locator';
@ -23,8 +24,9 @@ import { mlNodesAvailable, getMlNodeCount } from '../../ml_nodes_check/check_ml_
import { checkPermission } from '../../capabilities/check_capabilities';
import { MlPageHeader } from '../../components/page_header';
import { useEnabledFeatures } from '../../contexts/ml';
import { TechnicalPreviewBadge } from '../../components/technical_preview_badge/technical_preview_badge';
export const IndexDataVisualizerPage: FC = () => {
export const IndexDataVisualizerPage: FC<{ esql: boolean }> = ({ esql = false }) => {
useTimefilter({ timeRangeSelector: false, autoRefreshSelector: false });
const {
services: {
@ -180,19 +182,34 @@ export const IndexDataVisualizerPage: FC = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
[mlLocator, mlFeaturesDisabled]
);
const { euiTheme } = useEuiTheme();
return IndexDataVisualizer ? (
<Fragment>
{IndexDataVisualizer !== null ? (
<>
<MlPageHeader>
<FormattedMessage
id="xpack.ml.dataVisualizer.pageHeader"
defaultMessage="Data Visualizer"
/>
<EuiFlexGroup gutterSize="s" alignItems="center" direction="row">
<FormattedMessage
id="xpack.ml.dataVisualizer.pageHeader"
defaultMessage="Data Visualizer"
/>
{esql ? (
<>
<EuiFlexItem grow={false}>
<FormattedMessage id="xpack.ml.datavisualizer" defaultMessage="(ES|QL)" />
</EuiFlexItem>
<EuiFlexItem grow={false} css={{ marginTop: euiTheme.size.xs }}>
<TechnicalPreviewBadge />
</EuiFlexItem>
</>
) : null}
</EuiFlexGroup>
</MlPageHeader>
<IndexDataVisualizer
getAdditionalLinks={getAdditionalLinks}
showFrozenDataTierChoice={showNodeInfo}
esql={esql}
/>
</>
) : null}

View file

@ -6,7 +6,7 @@
*/
import React, { FC, useCallback } from 'react';
import { EuiPageBody, EuiPanel } from '@elastic/eui';
import { EuiFlexGroup, EuiPageBody, EuiPanel } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { SavedObjectFinder } from '@kbn/saved-objects-finder-plugin/public';
@ -20,7 +20,13 @@ export interface PageProps {
const RESULTS_PER_PAGE = 20;
export const Page: FC<PageProps> = ({ nextStepPath }) => {
export const Page: FC<PageProps> = ({
nextStepPath,
extraButtons,
}: {
nextStepPath: string;
extraButtons?: React.ReactNode;
}) => {
const { contentManagement, uiSettings } = useMlKibana().services;
const navigateToPath = useNavigateToPath();
@ -80,7 +86,13 @@ export const Page: FC<PageProps> = ({ nextStepPath }) => {
uiSettings,
}}
>
<CreateDataViewButton onDataViewCreated={onObjectSelection} allowAdHocDataView={true} />
<EuiFlexGroup direction="row" gutterSize="s">
<CreateDataViewButton
onDataViewCreated={onObjectSelection}
allowAdHocDataView={true}
/>
{extraButtons ? extraButtons : null}
</EuiFlexGroup>
</SavedObjectFinder>
</EuiPanel>
</EuiPageBody>

View file

@ -0,0 +1,25 @@
/*
* 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, { useCallback } from 'react';
import { EuiButton } from '@elastic/eui';
import { useNavigateToPath } from '../../contexts/kibana';
export const NavigateToPageButton = ({
nextStepPath,
title,
}: {
nextStepPath: string;
title: string | React.ReactNode;
}) => {
const navigateToPath = useNavigateToPath();
const onClick = useCallback(() => {
navigateToPath(nextStepPath);
}, [navigateToPath, nextStepPath]);
return <EuiButton onClick={onClick}>{title}</EuiButton>;
};

View file

@ -24,7 +24,7 @@ export const indexBasedRouteFactory = (
title: i18n.translate('xpack.ml.dataVisualizer.dataView.docTitle', {
defaultMessage: 'Index Data Visualizer',
}),
render: () => <PageWrapper />,
render: () => <PageWrapper esql={false} />,
breadcrumbs: [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath),
@ -36,13 +36,34 @@ export const indexBasedRouteFactory = (
],
});
const PageWrapper: FC = () => {
export const indexESQLBasedRouteFactory = (
navigateToPath: NavigateToPath,
basePath: string
): MlRoute => ({
id: 'data_view_datavisualizer_esql',
path: createPath(ML_PAGES.DATA_VISUALIZER_ESQL),
title: i18n.translate('xpack.ml.dataVisualizer.esql.docTitle', {
defaultMessage: 'Index Data Visualizer (ES|QL)',
}),
render: () => <PageWrapper esql={true} />,
breadcrumbs: [
getBreadcrumbWithUrlForApp('ML_BREADCRUMB', navigateToPath, basePath),
getBreadcrumbWithUrlForApp('DATA_VISUALIZER_BREADCRUMB', navigateToPath, basePath),
{
text: i18n.translate('xpack.ml.dataFrameAnalyticsBreadcrumbs.esqlLabel', {
defaultMessage: 'Index Data Visualizer (ES|QL)',
}),
},
],
});
const PageWrapper: FC<{ esql: boolean }> = ({ esql }) => {
const { context } = useRouteResolver('basic', []);
return (
<PageLoader context={context}>
<DataSourceContextProvider>
<Page />
<Page esql={esql} />
</DataSourceContextProvider>
</PageLoader>
);

View file

@ -8,6 +8,7 @@
import React, { FC } from 'react';
import { Redirect } from 'react-router-dom';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { ML_PAGES } from '../../../../locator';
import { NavigateToPath, useMlKibana } from '../../../contexts/kibana';
import { createPath, MlRoute, PageLoader, PageProps } from '../../router';
@ -15,6 +16,7 @@ import { useRouteResolver } from '../../use_resolver';
import { basicResolvers } from '../../resolvers';
import { Page, preConfiguredJobRedirect } from '../../../jobs/new_job/pages/index_or_search';
import { getBreadcrumbWithUrlForApp } from '../../breadcrumbs';
import { NavigateToPageButton } from '../../components/navigate_to_page_button';
enum MODE {
NEW_JOB,
@ -24,6 +26,7 @@ enum MODE {
interface IndexOrSearchPageProps extends PageProps {
nextStepPath: string;
mode: MODE;
extraButtons?: React.ReactNode;
}
const getBreadcrumbs = (navigateToPath: NavigateToPath, basePath: string) => [
@ -104,14 +107,28 @@ export const dataVizIndexOrSearchRouteFactory = (
title: i18n.translate('xpack.ml.selectDataViewLabel', {
defaultMessage: 'Select Data View',
}),
render: (props, deps) => (
<PageWrapper
{...props}
nextStepPath={createPath(ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER)}
deps={deps}
mode={MODE.DATAVISUALIZER}
/>
),
render: (props, deps) => {
const button = (
<NavigateToPageButton
nextStepPath={createPath(ML_PAGES.DATA_VISUALIZER_ESQL)}
title={
<FormattedMessage
id="xpack.ml.datavisualizer.selector.useESQLButtonLabel"
defaultMessage="Use ES|QL"
/>
}
/>
);
return (
<PageWrapper
{...props}
nextStepPath={createPath(ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER)}
deps={deps}
mode={MODE.DATAVISUALIZER}
extraButtons={button}
/>
);
},
breadcrumbs: getDataVisBreadcrumbs(navigateToPath, basePath),
});
@ -185,7 +202,7 @@ export const changePointDetectionIndexOrSearchRouteFactory = (
breadcrumbs: getChangePointDetectionBreadcrumbs(navigateToPath, basePath),
});
const PageWrapper: FC<IndexOrSearchPageProps> = ({ nextStepPath, mode }) => {
const PageWrapper: FC<IndexOrSearchPageProps> = ({ nextStepPath, mode, extraButtons }) => {
const {
services: {
http: { basePath },
@ -207,7 +224,7 @@ const PageWrapper: FC<IndexOrSearchPageProps> = ({ nextStepPath, mode }) => {
);
return (
<PageLoader context={context}>
<Page {...{ nextStepPath }} />
<Page {...{ nextStepPath, extraButtons }} />
</PageLoader>
);
};

View file

@ -88,6 +88,7 @@ export class MlLocatorDefinition implements LocatorDefinition<MlLocatorParams> {
case ML_PAGES.ANOMALY_DETECTION_CREATE_JOB_FROM_PATTERN_ANALYSIS:
case ML_PAGES.DATA_VISUALIZER:
case ML_PAGES.DATA_VISUALIZER_FILE:
case ML_PAGES.DATA_VISUALIZER_ESQL:
case ML_PAGES.DATA_VISUALIZER_INDEX_VIEWER:
case ML_PAGES.DATA_VISUALIZER_INDEX_SELECT:
case ML_PAGES.AIOPS:

View file

@ -263,6 +263,17 @@ function createDeepLinks(
};
},
getESQLDataVisualizerDeepLink: (): AppDeepLink<LinkId> => {
return {
id: 'indexDataVisualizer',
title: i18n.translate('xpack.ml.deepLink.esqlDataVisualizer', {
defaultMessage: 'ES|QL Data Visualizer',
}),
path: `/${ML_PAGES.DATA_VISUALIZER_ESQL}`,
navLinkStatus: getNavStatus(true),
};
},
getDataDriftDeepLink: (): AppDeepLink<LinkId> => {
return {
id: 'dataDrift',

View file

@ -5172,6 +5172,10 @@
version "0.0.0"
uid ""
"@kbn/ml-cancellable-search@link:x-pack/packages/ml/cancellable_search":
version "0.0.0"
uid ""
"@kbn/ml-category-validator@link:x-pack/packages/ml/category_validator":
version "0.0.0"
uid ""