+
+ {veil}
{showLegend !== undefined && uiState && (
}
onRenderChange={onRenderChange}
+ onResize={onResize}
onPointerUpdate={syncCursor ? handleCursorUpdate : undefined}
externalPointerEvents={{
tooltip: { visible: syncTooltips, placement: Placement.Right },
diff --git a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx
index c2561191deb9a..2d88ed53ac3f0 100644
--- a/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx
+++ b/src/plugins/chart_expressions/expression_xy/public/expression_renderers/xy_chart_renderer.tsx
@@ -26,7 +26,12 @@ import { FormatFactory } from '@kbn/field-formats-plugin/common';
import { KibanaThemeProvider } from '@kbn/kibana-react-plugin/public';
import { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
import { getColumnByAccessor } from '@kbn/visualizations-plugin/common/utils';
-import { extractContainerType, extractVisualizationType } from '@kbn/chart-expressions-common';
+import {
+ type ChartSizeEvent,
+ type ChartSizeSpec,
+ extractContainerType,
+ extractVisualizationType,
+} from '@kbn/chart-expressions-common';
import type { getDataLayers } from '../helpers';
import { LayerTypes, SeriesTypes } from '../../common/constants';
@@ -215,6 +220,10 @@ export const getXyChartRenderer = ({
const onClickMultiValue = (data: MultiFilterEvent['data']) => {
handlers.event({ name: 'multiFilter', data });
};
+ const setChartSize = (data: ChartSizeSpec) => {
+ const event: ChartSizeEvent = { name: 'chartSize', data };
+ handlers.event(event);
+ };
const layerCellValueActions = await getLayerCellValueActions(
getDataLayers(config.args.layers),
@@ -275,8 +284,10 @@ export const getXyChartRenderer = ({
syncColors={config.syncColors}
syncTooltips={config.syncTooltips}
syncCursor={config.syncCursor}
+ shouldUseVeil={handlers.shouldUseSizeTransitionVeil()}
uiState={handlers.uiState as PersistedState}
renderComplete={renderComplete}
+ setChartSize={setChartSize}
/>
diff --git a/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts
index 33c76be0bcb8b..54406cdefb095 100644
--- a/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts
+++ b/src/plugins/console/server/lib/spec_definitions/js/aggregations.ts
@@ -224,6 +224,25 @@ const rules = {
// populated by a global rule
},
},
+ ip_prefix: {
+ __template: {
+ field: '',
+ },
+ ipPrefix: {
+ __template: {
+ prefixLength: 1,
+ isIpv6: false,
+ },
+ prefixLength: 1,
+ isIpv6: false,
+ },
+ field: '{field}',
+ format: '',
+ keyed: { __one_of: [true, false] },
+ script: {
+ // populated by a global rule
+ },
+ },
ip_range: {
__template: {
field: '',
diff --git a/src/plugins/data/common/search/aggs/agg_types.ts b/src/plugins/data/common/search/aggs/agg_types.ts
index 33485b2fda629..659a9b4a67cea 100644
--- a/src/plugins/data/common/search/aggs/agg_types.ts
+++ b/src/plugins/data/common/search/aggs/agg_types.ts
@@ -58,6 +58,7 @@ export const getAggTypes = () => ({
{ name: BUCKET_TYPES.HISTOGRAM, fn: buckets.getHistogramBucketAgg },
{ name: BUCKET_TYPES.RANGE, fn: buckets.getRangeBucketAgg },
{ name: BUCKET_TYPES.DATE_RANGE, fn: buckets.getDateRangeBucketAgg },
+ { name: BUCKET_TYPES.IP_PREFIX, fn: buckets.getIpPrefixBucketAgg },
{ name: BUCKET_TYPES.IP_RANGE, fn: buckets.getIpRangeBucketAgg },
{ name: BUCKET_TYPES.TERMS, fn: buckets.getTermsBucketAgg },
{ name: BUCKET_TYPES.MULTI_TERMS, fn: buckets.getMultiTermsBucketAgg },
@@ -79,6 +80,7 @@ export const getAggTypesFunctions = () => [
buckets.aggFilters,
buckets.aggSignificantTerms,
buckets.aggSignificantText,
+ buckets.aggIpPrefix,
buckets.aggIpRange,
buckets.aggDateRange,
buckets.aggRange,
diff --git a/src/plugins/data/common/search/aggs/aggs_service.test.ts b/src/plugins/data/common/search/aggs/aggs_service.test.ts
index ebc357a20571a..1699d9815edc6 100644
--- a/src/plugins/data/common/search/aggs/aggs_service.test.ts
+++ b/src/plugins/data/common/search/aggs/aggs_service.test.ts
@@ -57,6 +57,7 @@ describe('Aggs service', () => {
"histogram",
"range",
"date_range",
+ "ip_prefix",
"ip_range",
"terms",
"multi_terms",
@@ -112,6 +113,7 @@ describe('Aggs service', () => {
"histogram",
"range",
"date_range",
+ "ip_prefix",
"ip_range",
"terms",
"multi_terms",
diff --git a/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts b/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts
index 91b0bc1b56fc3..2d6702d5ca721 100644
--- a/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts
+++ b/src/plugins/data/common/search/aggs/buckets/bucket_agg_types.ts
@@ -10,6 +10,7 @@ export enum BUCKET_TYPES {
FILTER = 'filter',
FILTERS = 'filters',
HISTOGRAM = 'histogram',
+ IP_PREFIX = 'ip_prefix',
IP_RANGE = 'ip_range',
DATE_RANGE = 'date_range',
RANGE = 'range',
diff --git a/src/plugins/data/common/search/aggs/buckets/create_filter/ip_prefix.test.ts b/src/plugins/data/common/search/aggs/buckets/create_filter/ip_prefix.test.ts
new file mode 100644
index 0000000000000..8ee2376a47416
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/buckets/create_filter/ip_prefix.test.ts
@@ -0,0 +1,122 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { createFilterIpPrefix } from './ip_prefix';
+import { AggConfigs, CreateAggConfigParams } from '../../agg_configs';
+import { mockAggTypesRegistry } from '../../test_helpers';
+import { IpFormat } from '@kbn/field-formats-plugin/common';
+import { BUCKET_TYPES } from '../bucket_agg_types';
+import { IBucketAggConfig } from '../bucket_agg_type';
+import { RangeFilter } from '@kbn/es-query';
+
+describe('AggConfig Filters', () => {
+ describe('IP prefix', () => {
+ const typesRegistry = mockAggTypesRegistry();
+ const getAggConfigs = (aggs: CreateAggConfigParams[]) => {
+ const field = {
+ name: 'ip',
+ format: IpFormat,
+ };
+
+ const indexPattern = {
+ id: '1234',
+ title: 'logstash-*',
+ fields: {
+ getByName: () => field,
+ filter: () => [field],
+ },
+ } as any;
+
+ return new AggConfigs(indexPattern, aggs, { typesRegistry }, jest.fn());
+ };
+
+ test('should return a range filter for ip_prefix agg - ipv4', () => {
+ const aggConfigs = getAggConfigs([
+ {
+ type: BUCKET_TYPES.IP_PREFIX,
+ schema: 'segment',
+ params: {
+ field: 'ip',
+ address: '10.0.0.0',
+ prefix_length: 8,
+ },
+ },
+ ]);
+
+ const filter = createFilterIpPrefix(aggConfigs.aggs[0] as IBucketAggConfig, {
+ type: 'ip_prefix',
+ address: '10.0.0.0',
+ prefix_length: 8,
+ }) as RangeFilter;
+
+ expect(filter.query).toHaveProperty('range');
+ expect(filter).toHaveProperty('meta');
+ expect(filter.meta).toHaveProperty('index', '1234');
+ expect(filter.query.range).toHaveProperty('ip');
+ expect(filter.query.range.ip).toHaveProperty('gte', '10.0.0.0');
+ expect(filter.query.range.ip).toHaveProperty('lte', '10.255.255.255');
+ });
+
+ test('should return a range filter for ip_prefix agg - ipv4 mapped to ipv6', () => {
+ const aggConfigs = getAggConfigs([
+ {
+ type: BUCKET_TYPES.IP_PREFIX,
+ schema: 'segment',
+ params: {
+ field: 'ip',
+ address: '0.0.0.0',
+ prefix_length: 96,
+ },
+ },
+ ]);
+
+ const filter = createFilterIpPrefix(aggConfigs.aggs[0] as IBucketAggConfig, {
+ type: 'ip_prefix',
+ address: '0.0.0.0',
+ prefix_length: 96,
+ }) as RangeFilter;
+
+ expect(filter.query).toHaveProperty('range');
+ expect(filter).toHaveProperty('meta');
+ expect(filter.meta).toHaveProperty('index', '1234');
+ expect(filter.query.range).toHaveProperty('ip');
+ expect(filter.query.range.ip).toHaveProperty('gte', '::ffff:0:0');
+ expect(filter.query.range.ip).toHaveProperty('lte', '::ffff:ffff:ffff');
+ });
+
+ test('should return a range filter for ip_prefix agg - ipv6', () => {
+ const aggConfigs = getAggConfigs([
+ {
+ type: BUCKET_TYPES.IP_PREFIX,
+ schema: 'segment',
+ params: {
+ field: 'ip',
+ address: '1989:1337:c0de:7e57::',
+ prefix_length: 56,
+ },
+ },
+ ]);
+
+ const filter = createFilterIpPrefix(aggConfigs.aggs[0] as IBucketAggConfig, {
+ type: 'ip_prefix',
+ address: '1989:1337:c0de:7e57::',
+ prefix_length: 56,
+ }) as RangeFilter;
+
+ expect(filter.query).toHaveProperty('range');
+ expect(filter).toHaveProperty('meta');
+ expect(filter.meta).toHaveProperty('index', '1234');
+ expect(filter.query.range).toHaveProperty('ip');
+ expect(filter.query.range.ip).toHaveProperty('gte', '1989:1337:c0de:7e00::');
+ expect(filter.query.range.ip).toHaveProperty(
+ 'lte',
+ '1989:1337:c0de:7eff:ffff:ffff:ffff:ffff'
+ );
+ });
+ });
+});
diff --git a/src/plugins/data/common/search/aggs/buckets/create_filter/ip_prefix.ts b/src/plugins/data/common/search/aggs/buckets/create_filter/ip_prefix.ts
new file mode 100644
index 0000000000000..fb979ca2b7967
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/buckets/create_filter/ip_prefix.ts
@@ -0,0 +1,38 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { buildRangeFilter } from '@kbn/es-query';
+import { CidrMask } from '../lib/cidr_mask';
+import { IBucketAggConfig } from '../bucket_agg_type';
+import { IpPrefixKey } from '../lib/ip_prefix';
+
+export const createFilterIpPrefix = (aggConfig: IBucketAggConfig, key: IpPrefixKey) => {
+ let ipAddress = key.address;
+
+ /*
+ * Can occur when both IPv4 and IPv6 addresses are in the field being
+ * aggregated. When prefix_length is < 96, ES will group all IPv4 addresses
+ * into an IPv6 address and display that as the key, thus no mapping is required.
+ * Per RFC 4038 section 4.2, the IPv6 address ::FFFF:x.y.z.w represents the IPv4
+ * address x.y.z.w. Therefore, if they key is an IPv4 address (e.g. it contains
+ * a dot) and the requested prefix is >= 96, then prepending ::ffff: will properly
+ * map the IPv4 address to IPv6 according to the RFC mentioned above which will
+ * enable proper filtering on the visualization.
+ */
+ if (ipAddress.includes('.') && key.prefix_length >= 96) {
+ ipAddress = '::ffff:' + key.address;
+ }
+
+ const range = new CidrMask(ipAddress + '/' + key.prefix_length).getRange();
+
+ return buildRangeFilter(
+ aggConfig.params.field,
+ { gte: range.from, lte: range.to },
+ aggConfig.getIndexPattern()
+ );
+};
diff --git a/src/plugins/data/common/search/aggs/buckets/index.ts b/src/plugins/data/common/search/aggs/buckets/index.ts
index 369e56caf1859..6a4ad38560cc3 100644
--- a/src/plugins/data/common/search/aggs/buckets/index.ts
+++ b/src/plugins/data/common/search/aggs/buckets/index.ts
@@ -21,6 +21,8 @@ export * from './geo_tile_fn';
export * from './geo_tile';
export * from './histogram_fn';
export * from './histogram';
+export * from './ip_prefix_fn';
+export * from './ip_prefix';
export * from './ip_range_fn';
export * from './ip_range';
export * from './lib/cidr_mask';
diff --git a/src/plugins/data/common/search/aggs/buckets/ip_prefix.ts b/src/plugins/data/common/search/aggs/buckets/ip_prefix.ts
new file mode 100644
index 0000000000000..6b3cc8e28ac9c
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/buckets/ip_prefix.ts
@@ -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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { IpPrefix, ipPrefixToAst } from '../../expressions';
+
+import { BucketAggType } from './bucket_agg_type';
+import { BUCKET_TYPES } from './bucket_agg_types';
+import { createFilterIpPrefix } from './create_filter/ip_prefix';
+import { IpPrefixKey } from './lib/ip_prefix';
+import { aggIpPrefixFnName } from './ip_prefix_fn';
+import { KBN_FIELD_TYPES } from '../../..';
+import { BaseAggParams } from '../types';
+
+const ipPrefixTitle = i18n.translate('data.search.aggs.buckets.ipPrefixTitle', {
+ defaultMessage: 'IP Prefix',
+});
+
+export interface AggParamsIpPrefix extends BaseAggParams {
+ field: string;
+ ipPrefix?: IpPrefix;
+}
+
+export const getIpPrefixBucketAgg = () =>
+ new BucketAggType({
+ name: BUCKET_TYPES.IP_PREFIX,
+ expressionName: aggIpPrefixFnName,
+ title: ipPrefixTitle,
+ createFilter: createFilterIpPrefix,
+ getKey(bucket, key, agg): IpPrefixKey {
+ return { type: 'ip_prefix', address: key, prefix_length: bucket.prefix_length };
+ },
+ getSerializedFormat(agg) {
+ return {
+ id: 'ip_prefix',
+ params: agg.params.field
+ ? agg.aggConfigs.indexPattern.getFormatterForField(agg.params.field).toJSON()
+ : {},
+ };
+ },
+ makeLabel(aggConfig) {
+ return i18n.translate('data.search.aggs.buckets.ipPrefixLabel', {
+ defaultMessage: '{fieldName} IP prefixes',
+ values: {
+ fieldName: aggConfig.getFieldDisplayName(),
+ },
+ });
+ },
+ params: [
+ {
+ name: 'field',
+ type: 'field',
+ filterFieldTypes: KBN_FIELD_TYPES.IP,
+ },
+ {
+ name: 'ipPrefix',
+ default: {
+ prefixLength: 0,
+ isIpv6: false,
+ },
+ write: (aggConfig, output) => {
+ output.params.prefix_length = aggConfig.params.ipPrefix.prefixLength;
+ output.params.is_ipv6 = aggConfig.params.ipPrefix.isIpv6;
+ },
+ toExpressionAst: ipPrefixToAst,
+ },
+ ],
+ });
diff --git a/src/plugins/data/common/search/aggs/buckets/ip_prefix_fn.test.ts b/src/plugins/data/common/search/aggs/buckets/ip_prefix_fn.test.ts
new file mode 100644
index 0000000000000..fb0c6e378a2b4
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/buckets/ip_prefix_fn.test.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { functionWrapper } from '../test_helpers';
+import { aggIpPrefix } from './ip_prefix_fn';
+
+describe('agg_expression_functions', () => {
+ describe('aggIpPrefix', () => {
+ const fn = functionWrapper(aggIpPrefix());
+
+ test('fills in defaults when only required args are provided', () => {
+ const actual = fn({
+ field: 'ip_field',
+ });
+ expect(actual).toMatchInlineSnapshot(`
+ Object {
+ "type": "agg_type",
+ "value": Object {
+ "enabled": true,
+ "id": undefined,
+ "params": Object {
+ "customLabel": undefined,
+ "field": "ip_field",
+ "ipPrefix": undefined,
+ "json": undefined,
+ },
+ "schema": undefined,
+ "type": "ip_prefix",
+ },
+ }
+ `);
+ });
+
+ test('includes optional params when they are provided', () => {
+ const actual = fn({
+ field: 'ip_field',
+ ipPrefix: { prefixLength: 1, isIpv6: false, type: 'ip_prefix' },
+ });
+
+ expect(actual.value).toMatchInlineSnapshot(`
+ Object {
+ "enabled": true,
+ "id": undefined,
+ "params": Object {
+ "customLabel": undefined,
+ "field": "ip_field",
+ "ipPrefix": Object {
+ "isIpv6": false,
+ "prefixLength": 1,
+ "type": "ip_prefix",
+ },
+ "json": undefined,
+ },
+ "schema": undefined,
+ "type": "ip_prefix",
+ }
+ `);
+ });
+
+ test('correctly parses json string argument', () => {
+ const actual = fn({
+ field: 'ip_field',
+ json: '{ "foo": true }',
+ });
+
+ expect(actual.value.params.json).toEqual('{ "foo": true }');
+ });
+ });
+});
diff --git a/src/plugins/data/common/search/aggs/buckets/ip_prefix_fn.ts b/src/plugins/data/common/search/aggs/buckets/ip_prefix_fn.ts
new file mode 100644
index 0000000000000..68d7fc40544d3
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/buckets/ip_prefix_fn.ts
@@ -0,0 +1,96 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { Assign } from '@kbn/utility-types';
+import { ExpressionFunctionDefinition } from '@kbn/expressions-plugin/common';
+import { IpPrefixOutput } from '../../expressions';
+import { AggExpressionType, AggExpressionFunctionArgs, BUCKET_TYPES } from '..';
+
+export const aggIpPrefixFnName = 'aggIpPrefix';
+
+type Input = any;
+type AggArgs = AggExpressionFunctionArgs
;
+
+type Arguments = Assign;
+
+type Output = AggExpressionType;
+type FunctionDefinition = ExpressionFunctionDefinition<
+ typeof aggIpPrefixFnName,
+ Input,
+ Arguments,
+ Output
+>;
+
+export const aggIpPrefix = (): FunctionDefinition => ({
+ name: aggIpPrefixFnName,
+ help: i18n.translate('data.search.aggs.function.buckets.ipPrefix.help', {
+ defaultMessage: 'Generates a serialized agg config for a Ip Prefix agg',
+ }),
+ type: 'agg_type',
+ args: {
+ id: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.id.help', {
+ defaultMessage: 'ID for this aggregation',
+ }),
+ },
+ enabled: {
+ types: ['boolean'],
+ default: true,
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.enabled.help', {
+ defaultMessage: 'Specifies whether this aggregation should be enabled',
+ }),
+ },
+ schema: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.schema.help', {
+ defaultMessage: 'Schema to use for this aggregation',
+ }),
+ },
+ field: {
+ types: ['string'],
+ required: true,
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.field.help', {
+ defaultMessage: 'Field to use for this aggregation',
+ }),
+ },
+ ipPrefix: {
+ types: ['ip_prefix'],
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.help', {
+ defaultMessage: 'Length of the network prefix and whether it is for IPv4 or IPv6',
+ }),
+ },
+ json: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.json.help', {
+ defaultMessage: 'Advanced json to include when the agg is sent to Elasticsearch',
+ }),
+ },
+ customLabel: {
+ types: ['string'],
+ help: i18n.translate('data.search.aggs.buckets.ipPrefix.customLabel.help', {
+ defaultMessage: 'Represents a custom label for this aggregation',
+ }),
+ },
+ },
+ fn: (input, { id, enabled, schema, ...params }) => {
+ return {
+ type: 'agg_type',
+ value: {
+ id,
+ enabled,
+ schema,
+ type: BUCKET_TYPES.IP_PREFIX,
+ params: {
+ ...params,
+ },
+ },
+ };
+ },
+});
diff --git a/src/plugins/data/common/search/aggs/buckets/lib/ip_prefix.ts b/src/plugins/data/common/search/aggs/buckets/lib/ip_prefix.ts
new file mode 100644
index 0000000000000..5b64c95290fe9
--- /dev/null
+++ b/src/plugins/data/common/search/aggs/buckets/lib/ip_prefix.ts
@@ -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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+export interface IpPrefixAggKey {
+ type: 'ip_prefix';
+ address: string;
+ prefix_length: number;
+}
+
+export type IpPrefixKey = IpPrefixAggKey;
+
+export const convertIPPrefixToString = (cidr: IpPrefixKey, format: (val: any) => string) => {
+ return format(cidr.address);
+};
diff --git a/src/plugins/data/common/search/aggs/types.ts b/src/plugins/data/common/search/aggs/types.ts
index 06320728105d2..bf8375724fe7f 100644
--- a/src/plugins/data/common/search/aggs/types.ts
+++ b/src/plugins/data/common/search/aggs/types.ts
@@ -30,6 +30,7 @@ import {
aggGeoCentroid,
aggGeoTile,
aggHistogram,
+ aggIpPrefix,
aggIpRange,
aggMax,
aggMedian,
@@ -59,6 +60,7 @@ import {
AggParamsGeoCentroid,
AggParamsGeoTile,
AggParamsHistogram,
+ AggParamsIpPrefix,
AggParamsIpRange,
AggParamsMax,
AggParamsMedian,
@@ -123,6 +125,7 @@ export type { IAggType } from './agg_type';
export type { AggParam, AggParamOption } from './agg_params';
export type { IFieldParamType } from './param_types';
export type { IMetricAggType } from './metrics/metric_agg_type';
+export type { IpPrefixKey } from './buckets/lib/ip_prefix';
export type { IpRangeKey } from './buckets/lib/ip_range';
export type { OptionedValueProp } from './param_types/optioned';
@@ -173,6 +176,7 @@ export type AggExpressionFunctionArgs;
aggFilters: ReturnType;
aggSignificantTerms: ReturnType;
+ aggIpPrefix: ReturnType;
aggIpRange: ReturnType;
aggDateRange: ReturnType;
aggRange: ReturnType;
diff --git a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts
index 24312060147cb..cfc5d96351128 100644
--- a/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts
+++ b/src/plugins/data/common/search/aggs/utils/get_aggs_formats.ts
@@ -19,6 +19,7 @@ import {
import { SerializableRecord } from '@kbn/utility-types';
import { DateRange } from '../../expressions';
import { convertDateRangeToString } from '../buckets/lib/date_range';
+import { convertIPPrefixToString, IpPrefixKey } from '../buckets/lib/ip_prefix';
import { convertIPRangeToString, IpRangeKey } from '../buckets/lib/ip_range';
import { MultiFieldKey } from '../buckets/multi_field_key';
@@ -113,6 +114,20 @@ export function getAggsFormats(getFieldFormat: GetFieldFormat): FieldFormatInsta
return convertDateRangeToString(range, format.convert.bind(format));
};
},
+ class AggsIpPrefixFieldFormat extends FieldFormatWithCache {
+ static id = 'ip_prefix';
+ static hidden = true;
+
+ textConvert = (cidr: IpPrefixKey) => {
+ if (cidr == null) {
+ return '';
+ }
+
+ const nestedFormatter = this._params as SerializedFieldFormat;
+ const format = this.getCachedFormat(nestedFormatter);
+ return convertIPPrefixToString(cidr, format.convert.bind(format));
+ };
+ },
class AggsIpRangeFieldFormat extends FieldFormatWithCache {
static id = 'ip_range';
static hidden = true;
diff --git a/src/plugins/data/common/search/expressions/index.ts b/src/plugins/data/common/search/expressions/index.ts
index 8c37836e30dea..01e1f62193ad5 100644
--- a/src/plugins/data/common/search/expressions/index.ts
+++ b/src/plugins/data/common/search/expressions/index.ts
@@ -16,6 +16,8 @@ export * from './geo_bounding_box';
export * from './geo_bounding_box_to_ast';
export * from './geo_point';
export * from './geo_point_to_ast';
+export * from './ip_prefix';
+export * from './ip_prefix_to_ast';
export * from './ip_range';
export * from './ip_range_to_ast';
export * from './kibana';
diff --git a/src/plugins/data/common/search/expressions/ip_prefix.test.ts b/src/plugins/data/common/search/expressions/ip_prefix.test.ts
new file mode 100644
index 0000000000000..953e3ae3d5619
--- /dev/null
+++ b/src/plugins/data/common/search/expressions/ip_prefix.test.ts
@@ -0,0 +1,24 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { functionWrapper } from './utils';
+import { ipPrefixFunction } from './ip_prefix';
+
+describe('interpreter/functions#ipPrefix', () => {
+ const fn = functionWrapper(ipPrefixFunction);
+
+ it('should return an IP prefix structure', () => {
+ expect(fn(null, { prefixLength: 2, isIpv6: true })).toEqual(
+ expect.objectContaining({
+ prefixLength: 2,
+ isIpv6: true,
+ type: 'ip_prefix',
+ })
+ );
+ });
+});
diff --git a/src/plugins/data/common/search/expressions/ip_prefix.ts b/src/plugins/data/common/search/expressions/ip_prefix.ts
new file mode 100644
index 0000000000000..bcdbb67e4f23b
--- /dev/null
+++ b/src/plugins/data/common/search/expressions/ip_prefix.ts
@@ -0,0 +1,55 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { i18n } from '@kbn/i18n';
+import { ExpressionFunctionDefinition, ExpressionValueBoxed } from '@kbn/expressions-plugin/common';
+
+export interface IpPrefix {
+ prefixLength?: number;
+ isIpv6?: boolean;
+}
+
+export type IpPrefixOutput = ExpressionValueBoxed<'ip_prefix', IpPrefix>;
+
+export type ExpressionFunctionIpPrefix = ExpressionFunctionDefinition<
+ 'ipPrefix',
+ null,
+ IpPrefix,
+ IpPrefixOutput
+>;
+
+export const ipPrefixFunction: ExpressionFunctionIpPrefix = {
+ name: 'ipPrefix',
+ type: 'ip_prefix',
+ inputTypes: ['null'],
+ help: i18n.translate('data.search.functions.ipPrefix.help', {
+ defaultMessage: 'Create an IP prefix',
+ }),
+ args: {
+ prefixLength: {
+ types: ['number'],
+ help: i18n.translate('data.search.functions.ipPrefix.prefixLength.help', {
+ defaultMessage: 'Specify the length of the network prefix',
+ }),
+ },
+ isIpv6: {
+ types: ['boolean'],
+ help: i18n.translate('data.search.functions.ipPrefix.isIpv6.help', {
+ defaultMessage: 'Specify whether the prefix applies to IPv6 addresses',
+ }),
+ },
+ },
+
+ fn(input, { prefixLength, isIpv6 }) {
+ return {
+ type: 'ip_prefix',
+ prefixLength,
+ isIpv6,
+ };
+ },
+};
diff --git a/src/plugins/data/common/search/expressions/ip_prefix_to_ast.test.ts b/src/plugins/data/common/search/expressions/ip_prefix_to_ast.test.ts
new file mode 100644
index 0000000000000..5dc5642fb96e1
--- /dev/null
+++ b/src/plugins/data/common/search/expressions/ip_prefix_to_ast.test.ts
@@ -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 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { ipPrefixToAst } from './ip_prefix_to_ast';
+
+describe('ipPrefixToAst', () => {
+ it('should return an expression', () => {
+ expect(ipPrefixToAst({ prefixLength: 2, isIpv6: false })).toHaveProperty('type', 'expression');
+ });
+
+ it('should forward arguments', () => {
+ expect(ipPrefixToAst({ prefixLength: 2, isIpv6: false })).toHaveProperty(
+ 'chain.0.arguments',
+ expect.objectContaining({
+ prefixLength: [2],
+ isIpv6: [false],
+ })
+ );
+ });
+});
diff --git a/src/plugins/data/common/search/expressions/ip_prefix_to_ast.ts b/src/plugins/data/common/search/expressions/ip_prefix_to_ast.ts
new file mode 100644
index 0000000000000..82f2e03a4d813
--- /dev/null
+++ b/src/plugins/data/common/search/expressions/ip_prefix_to_ast.ts
@@ -0,0 +1,16 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import { buildExpression, buildExpressionFunction } from '@kbn/expressions-plugin/common';
+import { ExpressionFunctionIpPrefix, IpPrefix } from './ip_prefix';
+
+export const ipPrefixToAst = (ipPrefix: IpPrefix) => {
+ return buildExpression([
+ buildExpressionFunction('ipPrefix', ipPrefix),
+ ]).toAst();
+};
diff --git a/src/plugins/data/public/search/aggs/aggs_service.test.ts b/src/plugins/data/public/search/aggs/aggs_service.test.ts
index cbd0cbc40eb03..8f3a54f2de86f 100644
--- a/src/plugins/data/public/search/aggs/aggs_service.test.ts
+++ b/src/plugins/data/public/search/aggs/aggs_service.test.ts
@@ -52,7 +52,7 @@ describe('AggsService - public', () => {
test('registers default agg types', () => {
service.setup(setupDeps);
const start = service.start(startDeps);
- expect(start.types.getAll().buckets.length).toBe(16);
+ expect(start.types.getAll().buckets.length).toBe(17);
expect(start.types.getAll().metrics.length).toBe(27);
});
@@ -68,7 +68,7 @@ describe('AggsService - public', () => {
);
const start = service.start(startDeps);
- expect(start.types.getAll().buckets.length).toBe(17);
+ expect(start.types.getAll().buckets.length).toBe(18);
expect(start.types.getAll().buckets.some(({ name }) => name === 'foo')).toBe(true);
expect(start.types.getAll().metrics.length).toBe(28);
expect(start.types.getAll().metrics.some(({ name }) => name === 'bar')).toBe(true);
diff --git a/src/plugins/data/public/search/search_service.ts b/src/plugins/data/public/search/search_service.ts
index f336096468606..eded37dd3dd6f 100644
--- a/src/plugins/data/public/search/search_service.ts
+++ b/src/plugins/data/public/search/search_service.ts
@@ -39,6 +39,7 @@ import {
fieldFunction,
geoBoundingBoxFunction,
geoPointFunction,
+ ipPrefixFunction,
ipRangeFunction,
ISearchGeneric,
kibana,
@@ -149,6 +150,7 @@ export class SearchService implements Plugin {
expressions.registerFunction(cidrFunction);
expressions.registerFunction(dateRangeFunction);
expressions.registerFunction(extendedBoundsFunction);
+ expressions.registerFunction(ipPrefixFunction);
expressions.registerFunction(ipRangeFunction);
expressions.registerFunction(luceneFunction);
expressions.registerFunction(kqlFunction);
diff --git a/src/plugins/data/server/search/search_service.ts b/src/plugins/data/server/search/search_service.ts
index 188f853e6a2ce..ea7ec05d74ca9 100644
--- a/src/plugins/data/server/search/search_service.ts
+++ b/src/plugins/data/server/search/search_service.ts
@@ -60,6 +60,7 @@ import {
IEsSearchResponse,
IKibanaSearchRequest,
IKibanaSearchResponse,
+ ipPrefixFunction,
ipRangeFunction,
ISearchOptions,
kibana,
@@ -225,6 +226,7 @@ export class SearchService implements Plugin {
expressions.registerFunction(extendedBoundsFunction);
expressions.registerFunction(geoBoundingBoxFunction);
expressions.registerFunction(geoPointFunction);
+ expressions.registerFunction(ipPrefixFunction);
expressions.registerFunction(ipRangeFunction);
expressions.registerFunction(kibana);
expressions.registerFunction(luceneFunction);
diff --git a/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts b/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts
index 422fa787da849..ef88aba74d7db 100644
--- a/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts
+++ b/src/plugins/discover/public/application/main/services/discover_saved_search_container.ts
@@ -318,7 +318,9 @@ function getSearchSourceFieldValueForComparison(
searchSourceFieldName: keyof SearchSourceFields
) {
if (searchSourceFieldName === 'index') {
- return searchSource.getField('index')?.id;
+ const query = searchSource.getField('query');
+ // ad-hoc data view id can change, so we rather compare the ES|QL query itself here
+ return query && 'esql' in query ? query.esql : searchSource.getField('index')?.id;
}
if (searchSourceFieldName === 'filter') {
diff --git a/src/plugins/expressions/common/execution/execution.ts b/src/plugins/expressions/common/execution/execution.ts
index 593e437fd9dc7..03a0923b4b313 100644
--- a/src/plugins/expressions/common/execution/execution.ts
+++ b/src/plugins/expressions/common/execution/execution.ts
@@ -287,6 +287,7 @@ export class Execution<
isSyncColorsEnabled: () => execution.params.syncColors!,
isSyncCursorEnabled: () => execution.params.syncCursor!,
isSyncTooltipsEnabled: () => execution.params.syncTooltips!,
+ shouldUseSizeTransitionVeil: () => execution.params.shouldUseSizeTransitionVeil!,
...execution.executor.context,
getExecutionContext: () => execution.params.executionContext,
};
diff --git a/src/plugins/expressions/common/execution/types.ts b/src/plugins/expressions/common/execution/types.ts
index 03dbcc8a6ff13..ac216515a3f1b 100644
--- a/src/plugins/expressions/common/execution/types.ts
+++ b/src/plugins/expressions/common/execution/types.ts
@@ -72,6 +72,11 @@ export interface ExecutionContext
*/
isSyncTooltipsEnabled?: () => boolean;
+ /**
+ * Returns whether or not to use the size transition veil when resizing visualizations.
+ */
+ shouldUseSizeTransitionVeil?: () => boolean;
+
/**
* Contains the meta-data about the source of the expression.
*/
diff --git a/src/plugins/expressions/common/expression_renderers/types.ts b/src/plugins/expressions/common/expression_renderers/types.ts
index 7dae307aa6c01..46908e8b38e6e 100644
--- a/src/plugins/expressions/common/expression_renderers/types.ts
+++ b/src/plugins/expressions/common/expression_renderers/types.ts
@@ -97,6 +97,9 @@ export interface IInterpreterRenderHandlers {
isSyncCursorEnabled(): boolean;
isSyncTooltipsEnabled(): boolean;
+
+ shouldUseSizeTransitionVeil(): boolean;
+
/**
* This uiState interface is actually `PersistedState` from the visualizations plugin,
* but expressions cannot know about vis or it creates a mess of circular dependencies.
diff --git a/src/plugins/expressions/common/service/expressions_services.ts b/src/plugins/expressions/common/service/expressions_services.ts
index e73e07a387c46..2683921bc038b 100644
--- a/src/plugins/expressions/common/service/expressions_services.ts
+++ b/src/plugins/expressions/common/service/expressions_services.ts
@@ -156,6 +156,11 @@ export interface ExpressionExecutionParams {
syncTooltips?: boolean;
+ // if this is set to true, a veil will be shown when resizing visualizations in response
+ // to a chart resize event (see src/plugins/chart_expressions/common/chart_size_transition_veil.tsx).
+ // This should be only set to true if the client will be responding to the resize events
+ shouldUseSizeTransitionVeil?: boolean;
+
inspectorAdapters?: Adapters;
executionContext?: KibanaExecutionContext;
diff --git a/src/plugins/expressions/public/loader.ts b/src/plugins/expressions/public/loader.ts
index f10b8db1f1287..0a3c0e0990645 100644
--- a/src/plugins/expressions/public/loader.ts
+++ b/src/plugins/expressions/public/loader.ts
@@ -60,6 +60,7 @@ export class ExpressionLoader {
syncColors: params?.syncColors,
syncTooltips: params?.syncTooltips,
syncCursor: params?.syncCursor,
+ shouldUseSizeTransitionVeil: params?.shouldUseSizeTransitionVeil,
hasCompatibleActions: params?.hasCompatibleActions,
getCompatibleCellValueActions: params?.getCompatibleCellValueActions,
executionContext: params?.executionContext,
@@ -148,6 +149,7 @@ export class ExpressionLoader {
syncColors: params.syncColors,
syncCursor: params?.syncCursor,
syncTooltips: params.syncTooltips,
+ shouldUseSizeTransitionVeil: params.shouldUseSizeTransitionVeil,
executionContext: params.executionContext,
partial: params.partial,
throttle: params.throttle,
diff --git a/src/plugins/expressions/public/render.ts b/src/plugins/expressions/public/render.ts
index a7b919625b8d6..0b494f30b2e69 100644
--- a/src/plugins/expressions/public/render.ts
+++ b/src/plugins/expressions/public/render.ts
@@ -33,6 +33,7 @@ export interface ExpressionRenderHandlerParams {
syncCursor?: boolean;
syncTooltips?: boolean;
interactive?: boolean;
+ shouldUseSizeTransitionVeil?: boolean;
hasCompatibleActions?: (event: ExpressionRendererEvent) => Promise;
getCompatibleCellValueActions?: (data: object[]) => Promise;
executionContext?: KibanaExecutionContext;
@@ -62,6 +63,7 @@ export class ExpressionRenderHandler {
syncColors,
syncTooltips,
syncCursor,
+ shouldUseSizeTransitionVeil,
interactive,
hasCompatibleActions = async () => false,
getCompatibleCellValueActions = async () => [],
@@ -113,6 +115,9 @@ export class ExpressionRenderHandler {
isSyncCursorEnabled: () => {
return syncCursor || true;
},
+ shouldUseSizeTransitionVeil: () => {
+ return Boolean(shouldUseSizeTransitionVeil);
+ },
isInteractive: () => {
return interactive ?? true;
},
diff --git a/src/plugins/expressions/public/types/index.ts b/src/plugins/expressions/public/types/index.ts
index 7bbb486fde390..27090f36fdc7c 100644
--- a/src/plugins/expressions/public/types/index.ts
+++ b/src/plugins/expressions/public/types/index.ts
@@ -52,6 +52,10 @@ export interface IExpressionLoaderParams {
syncColors?: boolean;
syncCursor?: boolean;
syncTooltips?: boolean;
+ // if this is set to true, a veil will be shown when resizing visualizations in response
+ // to a chart resize event (see src/plugins/chart_expressions/common/chart_size_transition_veil.tsx).
+ // This should be only set to true if the client will be responding to the resize events
+ shouldUseSizeTransitionVeil?: boolean;
hasCompatibleActions?: ExpressionRenderHandlerParams['hasCompatibleActions'];
getCompatibleCellValueActions?: ExpressionRenderHandlerParams['getCompatibleCellValueActions'];
executionContext?: KibanaExecutionContext;
diff --git a/src/plugins/presentation_util/public/__stories__/render.tsx b/src/plugins/presentation_util/public/__stories__/render.tsx
index ca9f968842270..e02f1c803d332 100644
--- a/src/plugins/presentation_util/public/__stories__/render.tsx
+++ b/src/plugins/presentation_util/public/__stories__/render.tsx
@@ -18,6 +18,7 @@ export const defaultHandlers: IInterpreterRenderHandlers = {
isSyncColorsEnabled: () => false,
isSyncCursorEnabled: () => true,
isSyncTooltipsEnabled: () => false,
+ shouldUseSizeTransitionVeil: () => false,
isInteractive: () => true,
getExecutionContext: () => undefined,
done: action('done'),
diff --git a/src/plugins/unified_histogram/public/chart/histogram.tsx b/src/plugins/unified_histogram/public/chart/histogram.tsx
index a4071b4ac8cfa..29940af44193c 100644
--- a/src/plugins/unified_histogram/public/chart/histogram.tsx
+++ b/src/plugins/unified_histogram/public/chart/histogram.tsx
@@ -181,6 +181,8 @@ export function Histogram({
});
const { euiTheme } = useEuiTheme();
+ const boxShadow = `0 2px 2px -1px ${euiTheme.colors.mediumShade},
+ 0 1px 5px -2px ${euiTheme.colors.mediumShade}`;
const chartCss = css`
position: relative;
flex-grow: 1;
@@ -195,6 +197,7 @@ export function Histogram({
& .lnsExpressionRenderer {
width: ${chartSize};
margin: auto;
+ box-shadow: ${attributes.visualizationType === 'lnsMetric' ? boxShadow : 'none'};
}
& .echLegend .echLegendList {
diff --git a/src/plugins/vis_default_editor/public/components/agg_params_map.ts b/src/plugins/vis_default_editor/public/components/agg_params_map.ts
index 7802da7bf9e2f..253d4e581a4f6 100644
--- a/src/plugins/vis_default_editor/public/components/agg_params_map.ts
+++ b/src/plugins/vis_default_editor/public/components/agg_params_map.ts
@@ -31,6 +31,9 @@ const buckets = {
has_extended_bounds: controls.HasExtendedBoundsParamEditor,
extended_bounds: controls.ExtendedBoundsParamEditor,
},
+ [BUCKET_TYPES.IP_PREFIX]: {
+ ipPrefix: controls.IpPrefixParamEditor,
+ },
[BUCKET_TYPES.IP_RANGE]: {
ipRangeType: controls.IpRangeTypeParamEditor,
ranges: controls.IpRangesParamEditor,
diff --git a/src/plugins/vis_default_editor/public/components/controls/index.ts b/src/plugins/vis_default_editor/public/components/controls/index.ts
index 3d040130b2acd..b1c2672328fc5 100644
--- a/src/plugins/vis_default_editor/public/components/controls/index.ts
+++ b/src/plugins/vis_default_editor/public/components/controls/index.ts
@@ -13,6 +13,7 @@ export { FieldParamEditor } from './field';
export { FiltersParamEditor } from './filters';
export { HasExtendedBoundsParamEditor } from './has_extended_bounds';
export { IncludeExcludeParamEditor } from './include_exclude';
+export { IpPrefixParamEditor } from './ip_prefix';
export { IpRangesParamEditor } from './ip_ranges';
export { IpRangeTypeParamEditor } from './ip_range_type';
export { MetricAggParamEditor } from './metric_agg';
diff --git a/src/plugins/vis_default_editor/public/components/controls/ip_prefix.tsx b/src/plugins/vis_default_editor/public/components/controls/ip_prefix.tsx
new file mode 100644
index 0000000000000..02bd8111fa9af
--- /dev/null
+++ b/src/plugins/vis_default_editor/public/components/controls/ip_prefix.tsx
@@ -0,0 +1,125 @@
+/*
+ * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
+ * or more contributor license agreements. Licensed under the Elastic License
+ * 2.0 and the Server Side Public License, v 1; you may not use this file except
+ * in compliance with, at your election, the Elastic License 2.0 or the Server
+ * Side Public License, v 1.
+ */
+
+import React, { ChangeEvent, useCallback } from 'react';
+
+import {
+ EuiFormRow,
+ EuiFieldNumber,
+ EuiFieldNumberProps,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiSwitch,
+ EuiSwitchEvent,
+ EuiSwitchProps,
+} from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+
+import { AggParamEditorProps } from '../agg_param_props';
+import { useValidation } from './utils';
+
+export interface IpPrefix {
+ prefixLength: number;
+ isIpv6: boolean;
+}
+
+function isPrefixValid({ prefixLength, isIpv6 }: IpPrefix): boolean {
+ if (prefixLength < 0) {
+ return false;
+ } else if (prefixLength > 32 && !isIpv6) {
+ return false;
+ } else if (prefixLength > 128 && isIpv6) {
+ return false;
+ }
+
+ return true;
+}
+
+const prefixLengthLabel = i18n.translate('visDefaultEditor.controls.IpPrefix.prefixLength', {
+ defaultMessage: 'Prefix length',
+});
+
+const isIpv6Label = i18n.translate('visDefaultEditor.controls.IpPrefix.isIpv6', {
+ defaultMessage: 'Prefix applies to IPv6 addresses',
+});
+
+function IpPrefixParamEditor({
+ agg,
+ value = {} as IpPrefix,
+ setTouched,
+ setValue,
+ setValidity,
+ showValidation,
+}: AggParamEditorProps) {
+ const isValid = isPrefixValid(value);
+ let error;
+
+ if (!isValid) {
+ if (!value.isIpv6) {
+ error = i18n.translate('visDefaultEditor.controls.ipPrefix.errorMessageIpv4', {
+ defaultMessage: 'Prefix length must be between 0 and 32 for IPv4 addresses.',
+ });
+ } else {
+ error = i18n.translate('visDefaultEditor.controls.ipPrefix.errorMessageIpv6', {
+ defaultMessage: 'Prefix length must be between 0 and 128 for IPv6 addresses.',
+ });
+ }
+ }
+
+ useValidation(setValidity, isValid);
+
+ const onPrefixLengthChange: EuiFieldNumberProps['onChange'] = useCallback(
+ (ev: ChangeEvent) => {
+ setValue({ ...value, prefixLength: ev.target.valueAsNumber });
+ },
+ [setValue, value]
+ );
+
+ const onIsIpv6Change: EuiSwitchProps['onChange'] = useCallback(
+ (ev: EuiSwitchEvent) => {
+ setValue({ ...value, isIpv6: ev.target.checked });
+ },
+ [setValue, value]
+ );
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export { IpPrefixParamEditor };
diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx
index 7743ca46f95ba..285612700863c 100644
--- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx
+++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.tsx
@@ -40,6 +40,7 @@ import {
import type { RenderMode } from '@kbn/expressions-plugin/common';
import { DATA_VIEW_SAVED_OBJECT_TYPE } from '@kbn/data-views-plugin/public';
import { mapAndFlattenFilters } from '@kbn/data-plugin/public';
+import { isChartSizeEvent } from '@kbn/chart-expressions-common';
import { isFallbackDataView } from '../visualize_app/utils';
import { VisualizationMissedSavedObjectError } from '../components/visualization_missed_saved_object_error';
import VisualizationError from '../components/visualization_error';
@@ -477,6 +478,10 @@ export class VisualizeEmbeddable
this.handler.events$
.pipe(
mergeMap(async (event) => {
+ // Visualize doesn't respond to sizing events, so ignore.
+ if (isChartSizeEvent(event)) {
+ return;
+ }
if (!this.input.disableTriggers) {
const triggerId = get(VIS_EVENT_TO_TRIGGER, event.name, VIS_EVENT_TO_TRIGGER.filter);
let context;
diff --git a/src/plugins/visualizations/tsconfig.json b/src/plugins/visualizations/tsconfig.json
index 813c47ca83872..296367543271a 100644
--- a/src/plugins/visualizations/tsconfig.json
+++ b/src/plugins/visualizations/tsconfig.json
@@ -66,6 +66,7 @@
"@kbn/search-response-warnings",
"@kbn/logging",
"@kbn/content-management-table-list-view-common",
+ "@kbn/chart-expressions-common",
"@kbn/shared-ux-utility"
],
"exclude": [
diff --git a/test/functional/apps/discover/group3/_unsaved_changes_badge.ts b/test/functional/apps/discover/group3/_unsaved_changes_badge.ts
index c931a11f4f5f4..305298ff2ccc6 100644
--- a/test/functional/apps/discover/group3/_unsaved_changes_badge.ts
+++ b/test/functional/apps/discover/group3/_unsaved_changes_badge.ts
@@ -11,6 +11,7 @@ import { FtrProviderContext } from '../ftr_provider_context';
const SAVED_SEARCH_NAME = 'test saved search';
const SAVED_SEARCH_WITH_FILTERS_NAME = 'test saved search with filters';
+const SAVED_SEARCH_ESQL = 'test saved search ES|QL';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const esArchiver = getService('esArchiver');
@@ -18,6 +19,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const dataGrid = getService('dataGrid');
const filterBar = getService('filterBar');
+ const monacoEditor = getService('monacoEditor');
+ const browser = getService('browser');
const PageObjects = getPageObjects([
'settings',
'common',
@@ -194,5 +197,32 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
expect(await filterBar.isFilterNegated('bytes')).to.be(false);
expect(await PageObjects.discover.getHitCount()).to.be('1,373');
});
+
+ it('should not show a badge after loading an ES|QL saved search, only after changes', async () => {
+ await PageObjects.discover.selectTextBaseLang();
+
+ await monacoEditor.setCodeEditorValue('from logstash-* | limit 10');
+ await testSubjects.click('querySubmitButton');
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.discover.waitUntilSearchingHasFinished();
+
+ await PageObjects.discover.saveSearch(SAVED_SEARCH_ESQL);
+ await PageObjects.discover.waitUntilSearchingHasFinished();
+
+ await testSubjects.missingOrFail('unsavedChangesBadge');
+
+ await browser.refresh();
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.discover.waitUntilSearchingHasFinished();
+
+ await testSubjects.missingOrFail('unsavedChangesBadge');
+
+ await monacoEditor.setCodeEditorValue('from logstash-* | limit 100');
+ await testSubjects.click('querySubmitButton');
+ await PageObjects.header.waitUntilLoadingHasFinished();
+ await PageObjects.discover.waitUntilSearchingHasFinished();
+
+ await testSubjects.existOrFail('unsavedChangesBadge');
+ });
});
}
diff --git a/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts b/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts
index 726c3eb0dd268..74c8a54b69c57 100644
--- a/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts
+++ b/x-pack/packages/ml/in_memory_table/hooks/use_table_state.ts
@@ -39,10 +39,11 @@ export interface UseTableState {
export function useTableState(
items: T[],
initialSortField: string,
- initialSortDirection: 'asc' | 'desc' = 'asc'
+ initialSortDirection: 'asc' | 'desc' = 'asc',
+ initialPagionation?: Partial
) {
- const [pageIndex, setPageIndex] = useState(0);
- const [pageSize, setPageSize] = useState(10);
+ const [pageIndex, setPageIndex] = useState(initialPagionation?.pageIndex ?? 0);
+ const [pageSize, setPageSize] = useState(initialPagionation?.pageSize ?? 10);
const [sortField, setSortField] = useState(initialSortField);
const [sortDirection, setSortDirection] = useState(initialSortDirection);
@@ -63,7 +64,7 @@ export function useTableState(
pageIndex,
pageSize,
totalItemCount: (items ?? []).length,
- pageSizeOptions: [10, 20, 50],
+ pageSizeOptions: initialPagionation?.pageSizeOptions ?? [10, 20, 50],
showPerPageOptions: true,
};
diff --git a/x-pack/performance/journeys/infra_hosts_view.ts b/x-pack/performance/journeys/infra_hosts_view.ts
new file mode 100644
index 0000000000000..b936cc7c1719c
--- /dev/null
+++ b/x-pack/performance/journeys/infra_hosts_view.ts
@@ -0,0 +1,86 @@
+/*
+ * 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 { Journey } from '@kbn/journeys';
+import {
+ createLogger,
+ InfraSynthtraceEsClient,
+ LogLevel,
+ InfraSynthtraceKibanaClient,
+} from '@kbn/apm-synthtrace';
+import { infra, timerange } from '@kbn/apm-synthtrace-client';
+import { subj } from '@kbn/test-subj-selector';
+
+export const journey = new Journey({
+ beforeSteps: async ({ kbnUrl, auth, es }) => {
+ const logger = createLogger(LogLevel.debug);
+ const synthKibanaClient = new InfraSynthtraceKibanaClient({
+ logger,
+ target: kbnUrl.get(),
+ username: auth.getUsername(),
+ password: auth.getPassword(),
+ });
+
+ const pkgVersion = await synthKibanaClient.fetchLatestSystemPackageVersion();
+ await synthKibanaClient.installSystemPackage(pkgVersion);
+
+ const synthEsClient = new InfraSynthtraceEsClient({
+ logger,
+ client: es,
+ refreshAfterIndex: true,
+ });
+
+ const start = Date.now() - 1000 * 60 * 10;
+ await synthEsClient.index(
+ generateHostsData({
+ from: new Date(start).toISOString(),
+ to: new Date().toISOString(),
+ count: 1000,
+ })
+ );
+ },
+}).step('Navigate to Hosts view and load 500 hosts', async ({ page, kbnUrl, kibanaPage }) => {
+ await page.goto(
+ kbnUrl.get(
+ `app/metrics/hosts?_a=(dateRange:(from:now-15m,to:now),filters:!(),limit:500,panelFilters:!(),query:(language:kuery,query:''))`
+ )
+ );
+ // wait for table to be loaded
+ await page.waitForSelector(subj('hostsView-table-loaded'));
+ // wait for metric charts to be loaded
+ await kibanaPage.waitForCharts({ count: 5, timeout: 60000 });
+});
+
+export function generateHostsData({
+ from,
+ to,
+ count = 1,
+}: {
+ from: string;
+ to: string;
+ count: number;
+}) {
+ const range = timerange(from, to);
+
+ const hosts = Array(count)
+ .fill(0)
+ .map((_, idx) => infra.host(`my-host-${idx}`));
+
+ return range
+ .interval('30s')
+ .rate(1)
+ .generator((timestamp, index) =>
+ hosts.flatMap((host) => [
+ host.cpu().timestamp(timestamp),
+ host.memory().timestamp(timestamp),
+ host.network().timestamp(timestamp),
+ host.load().timestamp(timestamp),
+ host.filesystem().timestamp(timestamp),
+ host.diskio().timestamp(timestamp),
+ ])
+ );
+}
diff --git a/x-pack/plugins/aiops/common/constants.ts b/x-pack/plugins/aiops/common/constants.ts
index 5916464e90980..334bb64dd2484 100644
--- a/x-pack/plugins/aiops/common/constants.ts
+++ b/x-pack/plugins/aiops/common/constants.ts
@@ -26,9 +26,19 @@ export const CASES_ATTACHMENT_CHANGE_POINT_CHART = 'aiopsChangePointChart';
export const EMBEDDABLE_CHANGE_POINT_CHART_TYPE = 'aiopsChangePointChart' as const;
+export type EmbeddableChangePointType = typeof EMBEDDABLE_CHANGE_POINT_CHART_TYPE;
+
export const AIOPS_TELEMETRY_ID = {
AIOPS_DEFAULT_SOURCE: 'ml_aiops_labs',
AIOPS_ANALYSIS_RUN_ORIGIN: 'aiops-analysis-run-origin',
} as const;
export const EMBEDDABLE_ORIGIN = 'embeddable';
+
+export const CHANGE_POINT_DETECTION_VIEW_TYPE = {
+ CHARTS: 'charts',
+ TABLE: 'table',
+} as const;
+
+export type ChangePointDetectionViewType =
+ typeof CHANGE_POINT_DETECTION_VIEW_TYPE[keyof typeof CHANGE_POINT_DETECTION_VIEW_TYPE];
diff --git a/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx b/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx
index 4aa830328e805..c5b88bc7e92cd 100644
--- a/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx
+++ b/x-pack/plugins/aiops/public/cases/change_point_charts_attachment.tsx
@@ -45,7 +45,7 @@ export const initComponent = memoize(
return (
<>
-
+
>
);
},
diff --git a/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx b/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx
index cc70d7ff98a3b..d91a50ab13d36 100644
--- a/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx
+++ b/x-pack/plugins/aiops/public/cases/register_change_point_charts_attachment.tsx
@@ -10,7 +10,10 @@ import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import type { CasesUiSetup } from '@kbn/cases-plugin/public';
import type { CoreStart } from '@kbn/core/public';
-import { CASES_ATTACHMENT_CHANGE_POINT_CHART } from '../../common/constants';
+import {
+ CASES_ATTACHMENT_CHANGE_POINT_CHART,
+ EMBEDDABLE_CHANGE_POINT_CHART_TYPE,
+} from '../../common/constants';
import { getEmbeddableChangePointChart } from '../embeddable/embeddable_change_point_chart_component';
import { AiopsPluginStartDeps } from '../types';
@@ -19,7 +22,11 @@ export function registerChangePointChartsAttachment(
coreStart: CoreStart,
pluginStart: AiopsPluginStartDeps
) {
- const EmbeddableComponent = getEmbeddableChangePointChart(coreStart, pluginStart);
+ const EmbeddableComponent = getEmbeddableChangePointChart(
+ EMBEDDABLE_CHANGE_POINT_CHART_TYPE,
+ coreStart,
+ pluginStart
+ );
cases.attachmentFramework.registerPersistableState({
id: CASES_ATTACHMENT_CHANGE_POINT_CHART,
diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx
index 84c5723548ee2..6abf5102a37ca 100644
--- a/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx
+++ b/x-pack/plugins/aiops/public/components/change_point_detection/change_points_table.tsx
@@ -7,35 +7,37 @@
import {
EuiBadge,
- type EuiBasicTableColumn,
EuiEmptyPrompt,
EuiIcon,
EuiInMemoryTable,
EuiToolTip,
type DefaultItemAction,
+ type EuiBasicTableColumn,
} from '@elastic/eui';
-import React, { type FC, useMemo } from 'react';
+import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types';
+import { FilterStateStore, type Filter } from '@kbn/es-query';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
-import { EuiTableSelectionType } from '@elastic/eui/src/components/basic_table/table_types';
-import { type Filter, FilterStateStore } from '@kbn/es-query';
-import { NoChangePointsWarning } from './no_change_points_warning';
+import { useTableState } from '@kbn/ml-in-memory-table';
+import React, { useCallback, useEffect, useMemo, useRef, type FC } from 'react';
+import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
import { useDataSource } from '../../hooks/use_data_source';
-import { useCommonChartProps } from './use_common_chart_props';
import {
- type ChangePointAnnotation,
FieldConfig,
SelectedChangePoint,
useChangePointDetectionContext,
+ type ChangePointAnnotation,
} from './change_point_detection_context';
import { type ChartComponentProps } from './chart_component';
-import { useAiopsAppContext } from '../../hooks/use_aiops_app_context';
+import { NoChangePointsWarning } from './no_change_points_warning';
+import { useCommonChartProps } from './use_common_chart_props';
export interface ChangePointsTableProps {
annotations: ChangePointAnnotation[];
fieldConfig: FieldConfig;
isLoading: boolean;
- onSelectionChange: (update: SelectedChangePoint[]) => void;
+ onSelectionChange?: (update: SelectedChangePoint[]) => void;
+ onRenderComplete?: () => void;
}
function getFilterConfig(
@@ -68,31 +70,62 @@ function getFilterConfig(
};
}
+const pageSizeOptions = [5, 10, 15];
+
export const ChangePointsTable: FC = ({
isLoading,
annotations,
fieldConfig,
onSelectionChange,
+ onRenderComplete,
}) => {
const {
fieldFormats,
data: {
query: { filterManager },
},
+ embeddingOrigin,
} = useAiopsAppContext();
const { dataView } = useDataSource();
+ const chartLoadingCount = useRef(0);
+
+ const { onTableChange, pagination, sorting } = useTableState(
+ annotations ?? [],
+ 'p_value',
+ 'asc',
+ {
+ pageIndex: 0,
+ pageSize: 10,
+ pageSizeOptions,
+ }
+ );
+
const dateFormatter = useMemo(() => fieldFormats.deserialize({ id: 'date' }), [fieldFormats]);
- const defaultSorting = {
- sort: {
- field: 'p_value',
- // Lower p_value indicates a bigger change point, hence the asc sorting
- direction: 'asc' as const,
+ useEffect(() => {
+ // Reset loading counter on pagination or sort change
+ chartLoadingCount.current = 0;
+ }, [pagination.pageIndex, pagination.pageSize, sorting.sort]);
+
+ /**
+ * Callback to track render of each chart component
+ * to report when all charts on the current page are ready.
+ */
+ const onChartRenderCompleteCallback = useCallback(
+ (isLoadingChart: boolean) => {
+ if (!onRenderComplete) return;
+ if (!isLoadingChart) {
+ chartLoadingCount.current++;
+ }
+ if (chartLoadingCount.current === pagination.pageSize) {
+ onRenderComplete();
+ }
},
- };
+ [onRenderComplete, pagination.pageSize]
+ );
- const hasActions = fieldConfig.splitField !== undefined;
+ const hasActions = fieldConfig.splitField !== undefined && embeddingOrigin !== 'cases';
const { bucketInterval } = useChangePointDetectionContext();
@@ -131,6 +164,7 @@ export const ChangePointsTable: FC = ({
annotation={annotation}
fieldConfig={fieldConfig}
interval={bucketInterval.expression}
+ onRenderComplete={onChartRenderCompleteCallback.bind(null, false)}
/>
);
},
@@ -190,70 +224,83 @@ export const ChangePointsTable: FC = ({
truncateText: false,
sortable: true,
},
- {
- name: i18n.translate('xpack.aiops.changePointDetection.actionsColumn', {
- defaultMessage: 'Actions',
- }),
- actions: [
- {
- name: i18n.translate(
- 'xpack.aiops.changePointDetection.actions.filterForValueAction',
- {
- defaultMessage: 'Filter for value',
- }
- ),
- description: i18n.translate(
- 'xpack.aiops.changePointDetection.actions.filterForValueAction',
- {
- defaultMessage: 'Filter for value',
- }
- ),
- icon: 'plusInCircle',
- color: 'primary',
- type: 'icon',
- onClick: (item) => {
- filterManager.addFilters(
- getFilterConfig(dataView.id!, item as Required, false)!
- );
- },
- isPrimary: true,
- 'data-test-subj': 'aiopsChangePointFilterForValue',
- },
- {
- name: i18n.translate(
- 'xpack.aiops.changePointDetection.actions.filterOutValueAction',
- {
- defaultMessage: 'Filter out value',
- }
- ),
- description: i18n.translate(
- 'xpack.aiops.changePointDetection.actions.filterOutValueAction',
- {
- defaultMessage: 'Filter out value',
- }
- ),
- icon: 'minusInCircle',
- color: 'primary',
- type: 'icon',
- onClick: (item) => {
- filterManager.addFilters(
- getFilterConfig(dataView.id!, item as Required, true)!
- );
+ ...(hasActions
+ ? [
+ {
+ name: i18n.translate('xpack.aiops.changePointDetection.actionsColumn', {
+ defaultMessage: 'Actions',
+ }),
+ actions: [
+ {
+ name: i18n.translate(
+ 'xpack.aiops.changePointDetection.actions.filterForValueAction',
+ {
+ defaultMessage: 'Filter for value',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.aiops.changePointDetection.actions.filterForValueAction',
+ {
+ defaultMessage: 'Filter for value',
+ }
+ ),
+ icon: 'plusInCircle',
+ color: 'primary',
+ type: 'icon',
+ onClick: (item) => {
+ filterManager.addFilters(
+ getFilterConfig(
+ dataView.id!,
+ item as Required,
+ false
+ )!
+ );
+ },
+ isPrimary: true,
+ 'data-test-subj': 'aiopsChangePointFilterForValue',
+ },
+ {
+ name: i18n.translate(
+ 'xpack.aiops.changePointDetection.actions.filterOutValueAction',
+ {
+ defaultMessage: 'Filter out value',
+ }
+ ),
+ description: i18n.translate(
+ 'xpack.aiops.changePointDetection.actions.filterOutValueAction',
+ {
+ defaultMessage: 'Filter out value',
+ }
+ ),
+ icon: 'minusInCircle',
+ color: 'primary',
+ type: 'icon',
+ onClick: (item) => {
+ filterManager.addFilters(
+ getFilterConfig(
+ dataView.id!,
+ item as Required,
+ true
+ )!
+ );
+ },
+ isPrimary: true,
+ 'data-test-subj': 'aiopsChangePointFilterOutValue',
+ },
+ ] as Array>,
},
- isPrimary: true,
- 'data-test-subj': 'aiopsChangePointFilterOutValue',
- },
- ] as Array>,
- },
+ ]
+ : []),
]
: []),
];
- const selectionValue = useMemo>(() => {
+ const selectionValue = useMemo | undefined>(() => {
+ if (!onSelectionChange) return;
return {
selectable: (item) => true,
onSelectionChange: (selection) => {
- onSelectionChange(
+ onSelectionChange!(
selection.map((s) => {
return {
...s,
@@ -273,8 +320,11 @@ export const ChangePointsTable: FC = ({
data-test-subj={`aiopsChangePointResultsTable ${isLoading ? 'loading' : 'loaded'}`}
items={annotations}
columns={columns}
- pagination={{ pageSizeOptions: [5, 10, 15] }}
- sorting={defaultSorting}
+ pagination={
+ pagination.pageSizeOptions![0] > pagination!.totalItemCount ? undefined : pagination
+ }
+ sorting={sorting}
+ onTableChange={onTableChange}
hasActions={hasActions}
rowProps={(item) => ({
'data-test-subj': `aiopsChangePointResultsTableRow row-${item.id}`,
@@ -300,7 +350,12 @@ export const ChangePointsTable: FC = ({
);
};
-export const MiniChartPreview: FC = ({ fieldConfig, annotation }) => {
+export const MiniChartPreview: FC = ({
+ fieldConfig,
+ annotation,
+ onRenderComplete,
+ onLoading,
+}) => {
const {
lens: { EmbeddableComponent },
} = useAiopsAppContext();
@@ -314,8 +369,31 @@ export const MiniChartPreview: FC = ({ fieldConfig, annotat
bucketInterval: bucketInterval.expression,
});
+ const chartWrapperRef = useRef(null);
+
+ const renderCompleteListener = useCallback(
+ (event: Event) => {
+ if (event.target === chartWrapperRef.current) return;
+ if (onRenderComplete) {
+ onRenderComplete();
+ }
+ },
+ [onRenderComplete]
+ );
+
+ useEffect(() => {
+ if (!chartWrapperRef.current) {
+ throw new Error('Reference to the chart wrapper is not set');
+ }
+ const chartWrapper = chartWrapperRef.current;
+ chartWrapper.addEventListener('renderComplete', renderCompleteListener);
+ return () => {
+ chartWrapper.removeEventListener('renderComplete', renderCompleteListener);
+ };
+ }, [renderCompleteListener]);
+
return (
-
+
= ({ fieldConfig, annotat
type: 'aiops_change_point_detection_chart',
name: 'Change point detection',
}}
+ onLoad={onLoading}
/>
);
diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx
index 7429ad7ba9f0a..3a6a00624b719 100644
--- a/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx
+++ b/x-pack/plugins/aiops/public/components/change_point_detection/fields_config.tsx
@@ -33,7 +33,11 @@ import {
import { EuiContextMenuProps } from '@elastic/eui/src/components/context_menu/context_menu';
import { isDefined } from '@kbn/ml-is-defined';
import { MaxSeriesControl } from './max_series_control';
-import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE } from '../../../common/constants';
+import {
+ ChangePointDetectionViewType,
+ CHANGE_POINT_DETECTION_VIEW_TYPE,
+ EMBEDDABLE_CHANGE_POINT_CHART_TYPE,
+} from '../../../common/constants';
import { useCasesModal } from '../../hooks/use_cases_modal';
import { type EmbeddableChangePointChartInput } from '../../embeddable/embeddable_change_point_chart';
import { useDataSource } from '../../hooks/use_data_source';
@@ -51,6 +55,7 @@ import {
} from './change_point_detection_context';
import { useChangePointResults } from './use_change_point_agg_request';
import { useSplitFieldCardinality } from './use_split_field_cardinality';
+import { ViewTypeSelector } from './view_type_selector';
const selectControlCss = { width: '350px' };
@@ -191,10 +196,17 @@ const FieldPanel: FC
= ({
const [dashboardAttachment, setDashboardAttachment] = useState<{
applyTimeRange: boolean;
maxSeriesToPlot: number;
+ viewType: ChangePointDetectionViewType;
}>({
applyTimeRange: false,
maxSeriesToPlot: 6,
+ viewType: CHANGE_POINT_DETECTION_VIEW_TYPE.CHARTS,
});
+
+ const [caseAttachment, setCaseAttachment] = useState<{
+ viewType: ChangePointDetectionViewType;
+ }>({ viewType: CHANGE_POINT_DETECTION_VIEW_TYPE.CHARTS });
+
const [dashboardAttachmentReady, setDashboardAttachmentReady] = useState(false);
const {
@@ -294,20 +306,7 @@ const FieldPanel: FC = ({
}
: {}),
'data-test-subj': 'aiopsChangePointDetectionAttachToCaseButton',
- onClick: () => {
- openCasesModalCallback({
- timeRange,
- fn: fieldConfig.fn,
- metricField: fieldConfig.metricField,
- dataViewId: dataView.id,
- ...(fieldConfig.splitField
- ? {
- splitField: fieldConfig.splitField,
- partitions: selectedPartitions,
- }
- : {}),
- });
- },
+ panel: 'attachToCasePanel',
},
]
: []),
@@ -324,6 +323,17 @@ const FieldPanel: FC = ({
+ {
+ setDashboardAttachment((prevState) => {
+ return {
+ ...prevState,
+ viewType: v,
+ };
+ });
+ }}
+ />
= ({
fill
type={'submit'}
fullWidth
- onClick={setDashboardAttachmentReady.bind(null, true)}
+ onClick={() => {
+ setIsActionMenuOpen(false);
+ setDashboardAttachmentReady(true);
+ }}
+ disabled={!isDashboardFormValid}
+ >
+
+
+
+
+ ),
+ },
+ {
+ id: 'attachToCasePanel',
+ title: i18n.translate('xpack.aiops.changePointDetection.attachToCaseTitle', {
+ defaultMessage: 'Attach to case',
+ }),
+ size: 's',
+ content: (
+
+
+
+ {
+ setCaseAttachment((prevState) => {
+ return {
+ ...prevState,
+ viewType: v,
+ };
+ });
+ }}
+ />
+ {
+ setIsActionMenuOpen(false);
+ openCasesModalCallback({
+ timeRange,
+ viewType: caseAttachment.viewType,
+ fn: fieldConfig.fn,
+ metricField: fieldConfig.metricField,
+ dataViewId: dataView.id,
+ ...(fieldConfig.splitField
+ ? {
+ splitField: fieldConfig.splitField,
+ partitions: selectedPartitions,
+ }
+ : {}),
+ });
+ }}
disabled={!isDashboardFormValid}
>
= ({
canCreateCase,
canEditDashboards,
canUpdateCase,
+ caseAttachment.viewType,
caseAttachmentButtonDisabled,
dashboardAttachment.applyTimeRange,
dashboardAttachment.maxSeriesToPlot,
+ dashboardAttachment.viewType,
dataView.id,
fieldConfig.fn,
fieldConfig.metricField,
@@ -405,6 +473,7 @@ const FieldPanel: FC = ({
const embeddableInput: Partial = {
title: newTitle,
description: newDescription,
+ viewType: dashboardAttachment.viewType,
dataViewId: dataView.id,
metricField: fieldConfig.metricField,
splitField: fieldConfig.splitField,
@@ -428,12 +497,13 @@ const FieldPanel: FC = ({
},
[
embeddable,
+ dashboardAttachment.viewType,
+ dashboardAttachment.applyTimeRange,
+ dashboardAttachment.maxSeriesToPlot,
dataView.id,
fieldConfig.metricField,
fieldConfig.splitField,
fieldConfig.fn,
- dashboardAttachment.applyTimeRange,
- dashboardAttachment.maxSeriesToPlot,
timeRange,
selectedChangePoints,
panelIndex,
diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx
index 4363de30ce162..79b6930e7e50a 100644
--- a/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx
+++ b/x-pack/plugins/aiops/public/components/change_point_detection/partitions_selector.tsx
@@ -6,7 +6,14 @@
*/
import React, { type FC, useState, useCallback, useMemo, useEffect } from 'react';
-import { EuiComboBox, EuiFormRow } from '@elastic/eui';
+import {
+ EuiComboBox,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiIcon,
+ EuiToolTip,
+} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { type SearchRequest } from '@elastic/elasticsearch/lib/api/types';
import { EuiComboBoxOptionOption } from '@elastic/eui/src/components/combo_box/types';
@@ -171,9 +178,26 @@ export const PartitionsSelector: FC = ({
return (
+
+ {i18n.translate('xpack.aiops.changePointDetection.partitionsLabel', {
+ defaultMessage: 'Partitions',
+ })}
+
+
+
+
+
+
+
+ }
>
isLoading={isLoading}
diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts
index 0393ab5e5a6fc..b8e43511c8c58 100644
--- a/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts
+++ b/x-pack/plugins/aiops/public/components/change_point_detection/use_change_point_agg_request.ts
@@ -136,7 +136,7 @@ export function useChangePointResults(
/**
* null also means the fetching has been complete
*/
- const [progress, setProgress] = useState(null);
+ const [progress, setProgress] = useState(0);
const isSingleMetric = !isDefined(fieldConfig.splitField);
diff --git a/x-pack/plugins/aiops/public/components/change_point_detection/view_type_selector.tsx b/x-pack/plugins/aiops/public/components/change_point_detection/view_type_selector.tsx
new file mode 100644
index 0000000000000..1182a56fbe9d4
--- /dev/null
+++ b/x-pack/plugins/aiops/public/components/change_point_detection/view_type_selector.tsx
@@ -0,0 +1,59 @@
+/*
+ * 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, { FC } from 'react';
+import { FormattedMessage } from '@kbn/i18n-react';
+import { EuiButtonGroup, EuiFormRow, type EuiButtonGroupOptionProps } from '@elastic/eui';
+import { i18n } from '@kbn/i18n';
+import { ChangePointDetectionViewType } from '../../../common/constants';
+
+const viewTypeOptions: EuiButtonGroupOptionProps[] = [
+ {
+ id: `charts`,
+ label: (
+
+ ),
+ iconType: 'visLine',
+ },
+ {
+ id: `table`,
+ label: (
+
+ ),
+ iconType: 'visTable',
+ },
+];
+
+export interface ViewTypeSelectorProps {
+ value: ChangePointDetectionViewType;
+ onChange: (update: ChangePointDetectionViewType) => void;
+}
+
+export const ViewTypeSelector: FC = ({ value, onChange }) => {
+ return (
+
+ void}
+ />
+
+ );
+};
diff --git a/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx b/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx
index 71780f26a4fcb..83ee5c65b082d 100644
--- a/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx
+++ b/x-pack/plugins/aiops/public/embeddable/change_point_chart_initializer.tsx
@@ -5,7 +5,6 @@
* 2.0.
*/
-import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
import {
EuiButton,
EuiButtonEmpty,
@@ -18,28 +17,30 @@ import {
EuiModalHeader,
EuiModalHeaderTitle,
} from '@elastic/eui';
-import { FormattedMessage } from '@kbn/i18n-react';
+import { ES_FIELD_TYPES } from '@kbn/field-types';
import { i18n } from '@kbn/i18n';
-import usePrevious from 'react-use/lib/usePrevious';
-import { pick } from 'lodash';
+import { FormattedMessage } from '@kbn/i18n-react';
import { isPopulatedObject } from '@kbn/ml-is-populated-object';
-import { ES_FIELD_TYPES } from '@kbn/field-types';
-import { PartitionsSelector } from '../components/change_point_detection/partitions_selector';
-import { DEFAULT_SERIES } from './const';
-import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component';
-import { type EmbeddableChangePointChartExplicitInput } from './types';
-import { MaxSeriesControl } from '../components/change_point_detection/max_series_control';
-import { SplitFieldSelector } from '../components/change_point_detection/split_field_selector';
-import { MetricFieldSelector } from '../components/change_point_detection/metric_field_selector';
+import { pick } from 'lodash';
+import React, { FC, useCallback, useEffect, useMemo, useState } from 'react';
+import usePrevious from 'react-use/lib/usePrevious';
import {
ChangePointDetectionControlsContextProvider,
useChangePointDetectionControlsContext,
} from '../components/change_point_detection/change_point_detection_context';
-import { useAiopsAppContext } from '../hooks/use_aiops_app_context';
-import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart';
+import { DEFAULT_AGG_FUNCTION } from '../components/change_point_detection/constants';
import { FunctionPicker } from '../components/change_point_detection/function_picker';
+import { MaxSeriesControl } from '../components/change_point_detection/max_series_control';
+import { MetricFieldSelector } from '../components/change_point_detection/metric_field_selector';
+import { PartitionsSelector } from '../components/change_point_detection/partitions_selector';
+import { SplitFieldSelector } from '../components/change_point_detection/split_field_selector';
+import { ViewTypeSelector } from '../components/change_point_detection/view_type_selector';
+import { useAiopsAppContext } from '../hooks/use_aiops_app_context';
import { DataSourceContextProvider } from '../hooks/use_data_source';
-import { DEFAULT_AGG_FUNCTION } from '../components/change_point_detection/constants';
+import { DEFAULT_SERIES } from './const';
+import { EmbeddableChangePointChartInput } from './embeddable_change_point_chart';
+import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component';
+import { type EmbeddableChangePointChartExplicitInput } from './types';
export interface AnomalyChartsInitializerProps {
initialInput?: Partial;
@@ -59,6 +60,7 @@ export const ChangePointChartInitializer: FC = ({
} = useAiopsAppContext();
const [dataViewId, setDataViewId] = useState(initialInput?.dataViewId ?? '');
+ const [viewType, setViewType] = useState(initialInput?.viewType ?? 'charts');
const [formInput, setFormInput] = useState(
pick(initialInput ?? {}, [
@@ -75,6 +77,7 @@ export const ChangePointChartInitializer: FC = ({
const updatedProps = useMemo(() => {
return {
...formInput,
+ viewType,
title: isPopulatedObject(formInput)
? i18n.translate('xpack.aiops.changePointDetection.attachmentTitle', {
defaultMessage: 'Change point: {function}({metric}){splitBy}',
@@ -92,7 +95,7 @@ export const ChangePointChartInitializer: FC = ({
: '',
dataViewId,
};
- }, [formInput, dataViewId]);
+ }, [formInput, dataViewId, viewType]);
return (
@@ -100,13 +103,14 @@ export const ChangePointChartInitializer: FC = ({
+
= ({
}}
/>
-
diff --git a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx
index 3422d980f5fd8..42fffe5edaac1 100644
--- a/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx
+++ b/x-pack/plugins/aiops/public/embeddable/embeddable_change_point_chart.tsx
@@ -23,8 +23,9 @@ import { LensPublicStart } from '@kbn/lens-plugin/public';
import { Subject } from 'rxjs';
import type { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import type { DataView } from '@kbn/data-views-plugin/common';
+import type { FieldFormatsStart } from '@kbn/field-formats-plugin/public';
import { EmbeddableInputTracker } from './embeddable_chart_component_wrapper';
-import { EMBEDDABLE_CHANGE_POINT_CHART_TYPE, EMBEDDABLE_ORIGIN } from '../../common/constants';
+import { EMBEDDABLE_ORIGIN, EmbeddableChangePointType } from '../../common/constants';
import { AiopsAppContext, type AiopsAppDependencies } from '../hooks/use_aiops_app_context';
import { EmbeddableChangePointChartProps } from './embeddable_change_point_chart_component';
@@ -42,6 +43,7 @@ export interface EmbeddableChangePointChartDeps {
i18n: CoreStart['i18n'];
lens: LensPublicStart;
usageCollection: UsageCollectionSetup;
+ fieldFormats: FieldFormatsStart;
}
export type IEmbeddableChangePointChart = typeof EmbeddableChangePointChart;
@@ -50,8 +52,6 @@ export class EmbeddableChangePointChart extends AbstractEmbeddable<
EmbeddableChangePointChartInput,
EmbeddableChangePointChartOutput
> {
- public readonly type = EMBEDDABLE_CHANGE_POINT_CHART_TYPE;
-
private reload$ = new Subject