mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
[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—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—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:
parent
2ba824b889
commit
53c3907529
65 changed files with 2847 additions and 135 deletions
1
.github/CODEOWNERS
vendored
1
.github/CODEOWNERS
vendored
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -27,6 +27,7 @@ export type LinkId =
|
|||
| 'nodesOverview'
|
||||
| 'nodes'
|
||||
| 'memoryUsage'
|
||||
| 'esqlDataVisualizer'
|
||||
| 'dataVisualizer'
|
||||
| 'fileUpload'
|
||||
| 'indexDataVisualizer'
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"],
|
||||
|
|
3
x-pack/packages/ml/cancellable_search/README.md
Normal file
3
x-pack/packages/ml/cancellable_search/README.md
Normal file
|
@ -0,0 +1,3 @@
|
|||
# @kbn/ml-cancellable-search
|
||||
|
||||
React hook for cancellable data searching
|
8
x-pack/packages/ml/cancellable_search/index.ts
Normal file
8
x-pack/packages/ml/cancellable_search/index.ts
Normal 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';
|
12
x-pack/packages/ml/cancellable_search/jest.config.js
Normal file
12
x-pack/packages/ml/cancellable_search/jest.config.js
Normal 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'],
|
||||
};
|
5
x-pack/packages/ml/cancellable_search/kibana.jsonc
Normal file
5
x-pack/packages/ml/cancellable_search/kibana.jsonc
Normal file
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"type": "shared-common",
|
||||
"id": "@kbn/ml-cancellable-search",
|
||||
"owner": "@elastic/ml-ui"
|
||||
}
|
8
x-pack/packages/ml/cancellable_search/package.json
Normal file
8
x-pack/packages/ml/cancellable_search/package.json
Normal 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"
|
||||
}
|
|
@ -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 };
|
||||
}
|
21
x-pack/packages/ml/cancellable_search/tsconfig.json
Normal file
21
x-pack/packages/ml/cancellable_search/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
|
@ -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 ? (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -36,7 +36,8 @@
|
|||
"esUiShared",
|
||||
"fieldFormats",
|
||||
"uiActions",
|
||||
"lens"
|
||||
"lens",
|
||||
"textBasedLanguages",
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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)' }
|
||||
);
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 ? (
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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',
|
||||
{
|
||||
|
|
|
@ -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';
|
|
@ -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']
|
||||
>;
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
|
@ -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 };
|
||||
};
|
|
@ -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]
|
||||
);
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -66,6 +66,7 @@ export function rateLimitingForkJoin<T>(
|
|||
}
|
||||
|
||||
export function useOverallStats<TParams extends OverallStatsSearchStrategyParams>(
|
||||
esql = false,
|
||||
searchStrategyParams: TParams | undefined,
|
||||
lastRefresh: number,
|
||||
probability?: number | null
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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 [];
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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 [];
|
||||
};
|
|
@ -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 [];
|
||||
};
|
|
@ -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);
|
||||
};
|
|
@ -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 [];
|
||||
};
|
|
@ -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:',
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
|
@ -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)',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -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}`;
|
||||
};
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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], {});
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -31,7 +31,6 @@ export interface DataVisualizerIndexBasedAppState extends Omit<ListingPageUrlSta
|
|||
searchQueryLanguage?: SearchQueryLanguage;
|
||||
visibleFieldTypes?: string[];
|
||||
visibleFieldNames?: string[];
|
||||
samplerShardSize?: number;
|
||||
showDistributions?: boolean;
|
||||
showAllFields?: boolean;
|
||||
showEmptyFields?: boolean;
|
||||
|
|
|
@ -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 = [
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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": [
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>;
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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 ""
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue