From f27099f51c5f4d0e5fdc8e78cd118013d65a96b1 Mon Sep 17 00:00:00 2001 From: Matthew Kime Date: Tue, 6 Feb 2024 15:33:27 -0600 Subject: [PATCH] [data views] Default field formatters based on field meta values (#174973) ## Summary Default field formatters based on field meta units data. Note: the smallest unit our formatter will show is milliseconds which means micro and nanoseconds may round down to zero for smaller values. https://github.com/elastic/kibana/issues/176112 Closes: https://github.com/elastic/kibana/issues/82318 Mapping and doc setup for testing - ``` PUT my-index-000001 PUT my-index-000001/_mapping { "properties": { "nanos": { "type": "long", "meta": { "unit": "nanos" } }, "micros": { "type": "long", "meta": { "unit": "micros" } }, "ms": { "type": "long", "meta": { "unit": "ms" } }, "second": { "type": "long", "meta": { "unit": "s" } }, "minute": { "type": "long", "meta": { "unit": "m" } }, "hour": { "type": "long", "meta": { "unit": "h" } }, "day": { "type": "long", "meta": { "unit": "d" } }, "percent": { "type": "long", "meta": { "unit": "percent" } }, "bytes": { "type": "long", "meta": { "unit": "byte" } } } } POST my-index-000001/_doc { "nanos" : 1234.5, "micros" : 1234.5, "ms" : 1234.5, "second" : 1234.5, "minute" : 1234.5, "hour" : 1234.5, "day" : 1234.5, "percent" : 1234.5, "bytes" : 1234.5 } ``` --- .../__snapshots__/data_views.test.ts.snap | 1 + .../common/data_views/abstract_data_views.ts | 6 +++ .../data_views/meta_units_to_formatter.ts | 34 ++++++++++++++ .../common/fields/data_view_field.ts | 6 +++ src/plugins/data_views/common/types.ts | 2 + .../server/fetcher/index_patterns_fetcher.ts | 1 + .../field_caps_response.test.js | 17 +++++++ .../field_capabilities/field_caps_response.ts | 16 ++++++- .../rest_api_routes/internal/fields_for.ts | 1 + .../management/data_views/_field_formatter.ts | 45 +++++++++++++++++++ 10 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 src/plugins/data_views/common/data_views/meta_units_to_formatter.ts diff --git a/src/plugins/data_views/common/data_views/__snapshots__/data_views.test.ts.snap b/src/plugins/data_views/common/data_views/__snapshots__/data_views.test.ts.snap index 53b77e28a416c3..cb9bb0517f5071 100644 --- a/src/plugins/data_views/common/data_views/__snapshots__/data_views.test.ts.snap +++ b/src/plugins/data_views/common/data_views/__snapshots__/data_views.test.ts.snap @@ -7,6 +7,7 @@ FldList [ "conflictDescriptions": undefined, "count": 5, "customLabel": "A Runtime Field", + "defaultFormatter": undefined, "esTypes": Array [ "keyword", ], diff --git a/src/plugins/data_views/common/data_views/abstract_data_views.ts b/src/plugins/data_views/common/data_views/abstract_data_views.ts index 15ec8342cfd5b9..cc69ed7e7cedfb 100644 --- a/src/plugins/data_views/common/data_views/abstract_data_views.ts +++ b/src/plugins/data_views/common/data_views/abstract_data_views.ts @@ -24,6 +24,7 @@ import type { RuntimeField, } from '../types'; import { removeFieldAttrs } from './utils'; +import { metaUnitsToFormatter } from './meta_units_to_formatter'; import type { DataViewAttributes, FieldAttrs, FieldAttrSet } from '..'; @@ -251,6 +252,11 @@ export abstract class AbstractDataView { return fieldFormat; } + const fmt = field.defaultFormatter ? metaUnitsToFormatter[field.defaultFormatter] : undefined; + if (fmt) { + return this.fieldFormats.getInstance(fmt.id, fmt.params); + } + return this.fieldFormats.getDefaultInstance( field.type as KBN_FIELD_TYPES, field.esTypes as ES_FIELD_TYPES[] diff --git a/src/plugins/data_views/common/data_views/meta_units_to_formatter.ts b/src/plugins/data_views/common/data_views/meta_units_to_formatter.ts new file mode 100644 index 00000000000000..2989bf5828c378 --- /dev/null +++ b/src/plugins/data_views/common/data_views/meta_units_to_formatter.ts @@ -0,0 +1,34 @@ +/* + * 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 { FieldFormatParams } from '@kbn/field-formats-plugin/common'; + +const timeUnitToDurationFmt = (inputFormat = 'milliseconds') => { + return { + id: 'duration', + params: { + inputFormat, + outputFormat: 'humanizePrecise', + outputPrecision: 2, + includeSpaceWithSuffix: true, + useShortSuffix: true, + }, + }; +}; + +export const metaUnitsToFormatter: Record = { + percent: { id: 'percent' }, + byte: { id: 'bytes' }, + nanos: timeUnitToDurationFmt('nanoseconds'), + micros: timeUnitToDurationFmt('microseconds'), + ms: timeUnitToDurationFmt('milliseconds'), + s: timeUnitToDurationFmt('seconds'), + m: timeUnitToDurationFmt('minutes'), + h: timeUnitToDurationFmt('hours'), + d: timeUnitToDurationFmt('days'), +}; diff --git a/src/plugins/data_views/common/fields/data_view_field.ts b/src/plugins/data_views/common/fields/data_view_field.ts index 36cd78682aa974..52c304dbf27f1a 100644 --- a/src/plugins/data_views/common/fields/data_view_field.ts +++ b/src/plugins/data_views/common/fields/data_view_field.ts @@ -69,6 +69,10 @@ export class DataViewField implements DataViewFieldBase { this.spec.count = count; } + public get defaultFormatter() { + return this.spec.defaultFormatter; + } + /** * Returns runtime field definition or undefined if field is not runtime field. */ @@ -370,6 +374,7 @@ export class DataViewField implements DataViewFieldBase { readFromDocValues: this.readFromDocValues, subType: this.subType, customLabel: this.customLabel, + defaultFormatter: this.defaultFormatter, }; } @@ -403,6 +408,7 @@ export class DataViewField implements DataViewFieldBase { timeSeriesMetric: this.spec.timeSeriesMetric, timeZone: this.spec.timeZone, fixedInterval: this.spec.fixedInterval, + defaultFormatter: this.defaultFormatter, }; // Filter undefined values from the spec diff --git a/src/plugins/data_views/common/types.ts b/src/plugins/data_views/common/types.ts index 2177f51621feca..caf1fc80dc5b6f 100644 --- a/src/plugins/data_views/common/types.ts +++ b/src/plugins/data_views/common/types.ts @@ -462,6 +462,8 @@ export type FieldSpec = DataViewFieldBase & { * Name of parent field for composite runtime field subfields. */ parentName?: string; + + defaultFormatter?: string; }; export type DataViewFieldMap = Record; diff --git a/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts b/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts index aca312c8ea64dc..15ee00e5118cbb 100644 --- a/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts +++ b/src/plugins/data_views/server/fetcher/index_patterns_fetcher.ts @@ -32,6 +32,7 @@ export interface FieldDescriptor { timeZone?: string[]; timeSeriesMetric?: estypes.MappingTimeSeriesMetricType; timeSeriesDimension?: boolean; + defaultFormatter?: string; } interface FieldSubType { diff --git a/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.test.js b/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.test.js index e0722125cf7d6c..61f345876ac8d9 100644 --- a/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.test.js +++ b/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.test.js @@ -165,5 +165,22 @@ describe('index_patterns/field_capabilities/field_caps_response', () => { expect(child).not.toHaveProperty('subType'); }); }); + + it('sets default field formatter', () => { + const fields = readFieldCapsResponse({ + fields: { + seconds: { + long: { + searchable: true, + aggregatable: true, + meta: { + unit: ['s'], + }, + }, + }, + }, + }); + expect(fields[0].defaultFormatter).toEqual('s'); + }); }); }); diff --git a/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.ts b/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.ts index 715fea9beef3b8..805dbb9deb5fd1 100644 --- a/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.ts +++ b/src/plugins/data_views/server/fetcher/lib/field_capabilities/field_caps_response.ts @@ -12,6 +12,11 @@ import { castEsToKbnFieldTypeName } from '@kbn/field-types'; import { shouldReadFieldFromDocValues } from './should_read_field_from_doc_values'; import { FieldDescriptor } from '../..'; +// The array will have different values if values vary across indices +const unitsArrayToFormatter = (unitArr: string[]) => { + return unitArr.find((unit) => unitArr[0] !== unit) ? undefined : unitArr[0]; +}; + /** * Read the response from the _field_caps API to determine the type and * "aggregatable"/"searchable" status of each field. @@ -134,7 +139,11 @@ export function readFieldCapsResponse( timeSeriesMetricType = 'position'; } const esType = types[0]; - const field = { + + const defaultFormatter = + capsByType[types[0]].meta?.unit && unitsArrayToFormatter(capsByType[types[0]].meta?.unit); + + const field: FieldDescriptor = { name: fieldName, type: castEsToKbnFieldTypeName(esType), esTypes: types, @@ -147,6 +156,11 @@ export function readFieldCapsResponse( timeSeriesMetric: timeSeriesMetricType, timeSeriesDimension: capsByType[types[0]].time_series_dimension, }; + + if (defaultFormatter) { + field.defaultFormatter = defaultFormatter; + } + // This is intentionally using a "hash" and a "push" to be highly optimized with very large indexes agg.array.push(field); agg.hash[fieldName] = field; diff --git a/src/plugins/data_views/server/rest_api_routes/internal/fields_for.ts b/src/plugins/data_views/server/rest_api_routes/internal/fields_for.ts index 98b4086bf161ef..1d5f8e636315a3 100644 --- a/src/plugins/data_views/server/rest_api_routes/internal/fields_for.ts +++ b/src/plugins/data_views/server/rest_api_routes/internal/fields_for.ts @@ -99,6 +99,7 @@ const FieldDescriptorSchema = schema.object({ conflictDescriptions: schema.maybe( schema.recordOf(schema.string(), schema.arrayOf(schema.string())) ), + defaultFormatter: schema.maybe(schema.string()), }); export const validate: FullValidationConfig = { diff --git a/test/functional/apps/management/data_views/_field_formatter.ts b/test/functional/apps/management/data_views/_field_formatter.ts index dfcdc0b8775818..cddbe8ccf5ce4f 100644 --- a/test/functional/apps/management/data_views/_field_formatter.ts +++ b/test/functional/apps/management/data_views/_field_formatter.ts @@ -413,6 +413,51 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { ]); }); }); + + describe('default formatter by field meta value', () => { + const indexTitle = 'field_formats_management_functional_tests'; + + before(async () => { + if (await es.indices.exists({ index: indexTitle })) { + await es.indices.delete({ index: indexTitle }); + } + }); + + it('should apply default formatter by field meta value', async () => { + await es.indices.create({ + index: indexTitle, + body: { + mappings: { + properties: { + seconds: { type: 'long', meta: { unit: 's' } }, + }, + }, + }, + }); + + const docResult = await es.index({ + index: indexTitle, + body: { seconds: 1234 }, + refresh: 'wait_for', + }); + + const testDocumentId = docResult._id; + + const indexPatternResult = await indexPatterns.create( + { title: `${indexTitle}*` }, // sidesteps field caching when index pattern is reused + { override: true } + ); + + await PageObjects.common.navigateToApp('discover', { + hash: `/doc/${indexPatternResult.id}/${indexTitle}?id=${testDocumentId}`, + }); + await testSubjects.exists('doc-hit'); + + const renderedValue = await testSubjects.find(`tableDocViewRow-seconds-value`); + const text = await renderedValue.getVisibleText(); + expect(text).to.be('20.57 min'); + }); + }); }); /**