From 75ba373fbdd6a2ee52da6a1e9604c6ff7036b704 Mon Sep 17 00:00:00 2001 From: Miriam <31922082+MiriamAparicio@users.noreply.github.com> Date: Thu, 26 Jun 2025 13:13:27 +0100 Subject: [PATCH] Remaining work attributes table (#224723) Closes https://github.com/elastic/kibana/issues/221928 #### Add ES|QL logic https://github.com/user-attachments/assets/d29f939a-7b82-4873-92d4-8210c2202339 #### Empty message for accordion - Empty message when there are no attributes fields at all - For now we kept the accordion closed when fields count is zero, with an empty message inside, waiting for UI/UX team to review this implementation Screenshot 2025-06-24 at 12 27 18 #### Simplify attribute display names - the field name should not show the full field name. The tooltip will show both, simplify and full name, this is part of the implementation `FieldName` component from platform Screenshot 2025-06-24 at 12 19 48 Screenshot 2025-06-24 at 12 20 07 #### Filtering controls use full field name https://github.com/user-attachments/assets/7858d803-271e-4913-9aae-385dd7bc9e25 #### Add explanatory tooltip for attribute namespaces Screenshot 2025-06-24 at 12 24 33 Screenshot 2025-06-24 at 12 24 51 Screenshot 2025-06-24 at 12 24 57 ### Test: #### How to generate OTel data - Follow https://github.com/smith/elastic-stack-docker-compose?tab=readme-ov-file#elastic-stack-docker-compose #### How to test - Make sure your solution view is Observability - update your `kibana.yml` ``` discover.experimental.enabledProfiles: - observability-root-profile-with-attributes-tab # if you want to test it with the additional profiles add the following to your `kibana.yaml` - observability-traces-data-source-profile - observability-traces-transaction-document-profile - observability-traces-span-document-profile ``` --- .github/CODEOWNERS | 1 + .../src/components/field_name/field_name.tsx | 11 +- .../table_cell_actions.test.tsx.snap | 7 + .../components/doc_viewer_table/field_row.ts | 4 + .../components/doc_viewer_table/table.tsx | 2 +- .../doc_viewer_table/table_cell.tsx | 4 +- .../attributes_accordion.tsx | 89 +++++---- .../attributes_empty_prompt.tsx | 51 +++++ .../attributes_overview.tsx | 186 +++++++++++++----- .../attributes_table.tsx | 27 ++- .../get_attribute_display_name.test.ts | 29 +++ .../get_attribute_display_name.ts | 15 ++ .../group_attributes_fields.test.ts | 75 +++++++ .../group_attributes_fields.ts | 61 ++++++ 14 files changed, 454 insertions(+), 108 deletions(-) create mode 100644 src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_empty_prompt.tsx create mode 100644 src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.test.ts create mode 100644 src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.ts create mode 100644 src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.test.ts create mode 100644 src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 81b8d0d5e0bc..7030a6f085d0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1391,6 +1391,7 @@ src/platform/plugins/shared/discover/public/context_awareness/profile_providers/ /x-pack/test/api_integration/services/infraops_source_configuration.ts @elastic/obs-ux-infra_services-team @elastic/obs-ux-logs-team # Assigned per https://github.com/elastic/kibana/pull/34916 /x-pack/solutions/observability/plugins/observability/public/pages/overview @elastic/obs-ux-infra_services-team # Assigned to this team since it mostly uses infra/APM components /src/platform/plugins/shared/unified_doc_viewer/public/components/observability/traces @elastic/obs-ux-infra_services-team +/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes @elastic/obs-ux-infra_services-team ## Logs UI code exceptions -> @elastic/obs-ux-logs-team /x-pack/test/api_integration/deployment_agnostic/apis/observability/data_quality/ @elastic/obs-ux-logs-team diff --git a/src/platform/packages/shared/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx b/src/platform/packages/shared/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx index d03f3f06d38b..e0b2f7efa139 100644 --- a/src/platform/packages/shared/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx +++ b/src/platform/packages/shared/kbn-unified-doc-viewer/src/components/field_name/field_name.tsx @@ -19,6 +19,7 @@ import { getFieldTypeName } from '@kbn/field-utils'; interface Props { fieldName: string; + displayNameOverride?: string; fieldType?: string; fieldMapping?: DataViewField; fieldIconProps?: Omit; @@ -31,13 +32,15 @@ export function FieldName({ fieldMapping, fieldType, fieldIconProps, + displayNameOverride, scripted = false, highlight = '', }: Props) { const typeName = getFieldTypeName(fieldType); - const displayName = - fieldMapping && fieldMapping.displayName ? fieldMapping.displayName : fieldName; - const tooltip = displayName !== fieldName ? `${displayName} (${fieldName})` : fieldName; + const fieldMappingDisplayName = fieldMapping?.displayName ? fieldMapping.displayName : fieldName; + const fieldDisplayName = displayNameOverride ?? fieldMappingDisplayName; + + const tooltip = fieldDisplayName !== fieldName ? `${fieldDisplayName} (${fieldName})` : fieldName; const subTypeMulti = fieldMapping && getDataViewFieldSubtypeMulti(fieldMapping.spec); const isMultiField = !!subTypeMulti?.multi; @@ -71,7 +74,7 @@ export function FieldName({ delay="long" anchorClassName="eui-textBreakAll" > - {displayName} + {fieldDisplayName} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap index a1021b74093b..0390578c05dd 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/__snapshots__/table_cell_actions.test.tsx.snap @@ -17,6 +17,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", @@ -45,6 +46,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", @@ -66,6 +68,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", @@ -90,6 +93,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", @@ -128,6 +132,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", @@ -149,6 +154,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", @@ -168,6 +174,7 @@ Array [ "scripted": false, "type": "string", }, + "displayNameOverride": undefined, "flattenedValue": "test", "isPinned": false, "name": "message", diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/field_row.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/field_row.ts index 26c2d270f028..1f9bd2967b62 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/field_row.ts +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/field_row.ts @@ -22,6 +22,7 @@ import { getDataViewFieldOrCreateFromColumnMeta } from '@kbn/data-view-utils'; export class FieldRow { readonly name: string; + readonly displayNameOverride: string | undefined; readonly flattenedValue: unknown; readonly dataViewField: DataViewField | undefined; readonly isPinned: boolean; @@ -41,6 +42,7 @@ export class FieldRow { constructor({ name, + displayNameOverride, flattenedValue, hit, dataView, @@ -49,6 +51,7 @@ export class FieldRow { columnsMeta, }: { name: string; + displayNameOverride?: string; flattenedValue: unknown; hit: DataTableRecord; dataView: DataView; @@ -63,6 +66,7 @@ export class FieldRow { this.#isFormattedAsText = false; this.name = name; + this.displayNameOverride = displayNameOverride; this.flattenedValue = flattenedValue; this.dataViewField = getDataViewFieldOrCreateFromColumnMeta({ dataView, diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.tsx index c81a14255022..6584b437a0dc 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table.tsx @@ -73,7 +73,7 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100, 250, 500]; const DEFAULT_PAGE_SIZE = 25; const PINNED_FIELDS_KEY = 'discover:pinnedFields'; const PAGE_SIZE = 'discover:pageSize'; -const HIDE_NULL_VALUES = 'unifiedDocViewer:hideNullValues'; +export const HIDE_NULL_VALUES = 'unifiedDocViewer:hideNullValues'; export const SHOW_ONLY_SELECTED_FIELDS = 'unifiedDocViewer:showOnlySelectedFields'; const GRID_COLUMN_FIELD_NAME = 'name'; diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx index 9e8f748bfdf8..71735912c6f9 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/doc_viewer_table/table_cell.tsx @@ -49,13 +49,15 @@ export const TableCell: React.FC = React.memo( return null; } - const { flattenedValue, name, dataViewField, ignoredReason, fieldType } = row; + const { flattenedValue, name, displayNameOverride, dataViewField, ignoredReason, fieldType } = + row; if (columnId === 'name') { return (
void; onRemoveColumn?: (col: string) => void; filter?: DocViewFilterFn; + isEsqlMode: boolean; } export const AttributesAccordion = ({ id, title, - ariaLabel, + tooltipMessage, fields, hit, dataView, @@ -41,34 +45,51 @@ export const AttributesAccordion = ({ onAddColumn, onRemoveColumn, filter, -}: AttributesAccordionProps) => ( - - {title} - - } - initialIsOpen={fields.length > 0} - forceState={fields.length === 0 ? 'closed' : undefined} - isDisabled={fields.length === 0} - extraAction={ - - {fields.length} - - } - paddingSize="m" - > - - -); + isEsqlMode = false, +}: AttributesAccordionProps) => { + const { euiTheme } = useEuiTheme(); + return ( + + {title} + + + } + initialIsOpen={fields.length > 0} + extraAction={ + + {fields.length} + + } + paddingSize="m" + > + {fields.length === 0 ? ( + + ) : ( + + )} + + ); +}; diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_empty_prompt.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_empty_prompt.tsx new file mode 100644 index 000000000000..d71c26b0f667 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_empty_prompt.tsx @@ -0,0 +1,51 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import React from 'react'; +import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; + +export const AttributesEmptyPrompt = () => ( + +

+ {i18n.translate( + 'unifiedDocViewer.docView.attributes.accordion.noFieldsMessage.noFieldsLabel', + { + defaultMessage: 'No attributes fields found.', + } + )} +

+ <> + + {i18n.translate( + 'unifiedDocViewer.docView.attributes.accordion.noFieldsMessage.tryText', + { + defaultMessage: 'Try:', + } + )} + +
    +
  • + {i18n.translate( + 'unifiedDocViewer.docView.attributes.accordion.noFieldsMessage.fieldTypeFilterBullet', + { + defaultMessage: 'Using different field filters if applicable.', + } + )} +
  • +
+ + + } + data-test-subj="attributesAccordionEmptyPrompt" + /> +); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_overview.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_overview.tsx index 3205e6aadf25..e00130092f0a 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_overview.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_overview.tsx @@ -6,12 +6,20 @@ * your election, the "Elastic License 2.0", the "GNU Affero General Public * License v3.0 only", or the "Server Side Public License, v 1". */ -import React, { useMemo, useState } from 'react'; +import React, { useCallback, useMemo, useState } from 'react'; import type { DocViewRenderProps } from '@kbn/unified-doc-viewer/types'; -import { EuiSpacer, EuiFieldSearch, EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { + EuiSpacer, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiSwitch, + EuiSwitchEvent, +} from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { css } from '@emotion/react'; import { SHOW_MULTIFIELDS, getShouldShowFieldHandler } from '@kbn/discover-utils'; +import useLocalStorage from 'react-use/lib/useLocalStorage'; import { LOCAL_STORAGE_KEY_SEARCH_TERM, useTableFilters, @@ -23,12 +31,21 @@ import { } from '../../../doc_viewer_source/get_height'; import { AttributesAccordion } from './attributes_accordion'; import { getAttributesTitle } from './get_attributes_title'; +import { HIDE_NULL_VALUES } from '../../../doc_viewer_table/table'; +import { AttributesEmptyPrompt } from './attributes_empty_prompt'; +import { groupAttributesFields } from './group_attributes_fields'; + +export interface AttributeField { + name: string; // full field name for filtering/actions + displayName: string; // stripped prefix for UI display +} export function AttributesOverview({ columns, columnsMeta, hit, dataView, + textBasedHits, filter, decreaseAvailableHeightBy, onAddColumn, @@ -36,11 +53,13 @@ export function AttributesOverview({ }: DocViewRenderProps) { const [containerRef, setContainerRef] = useState(null); const { storage, uiSettings } = getUnifiedDocViewerServices(); + const isEsqlMode = Array.isArray(textBasedHits); const showMultiFields = uiSettings.get(SHOW_MULTIFIELDS); const { searchTerm, onChangeSearchTerm } = useTableFilters({ storage, storageKey: LOCAL_STORAGE_KEY_SEARCH_TERM, }); + const [areNullValuesHidden, setAreNullValuesHidden] = useLocalStorage(HIDE_NULL_VALUES, false); const flattened = hit.flattened; @@ -53,43 +72,45 @@ export function AttributesOverview({ const allFields = Object.keys(flattened); - // it filters out multifields that have a parent, to prevent entries for multifields like this: field, field.keyword, field.whatever - const filteredFields = useMemo( - () => allFields.filter(shouldShowFieldHandler), - [allFields, shouldShowFieldHandler] + const groupedFields = useMemo( + () => + groupAttributesFields({ + allFields, + flattened, + searchTerm, + shouldShowFieldHandler, + isEsqlMode, + areNullValuesHidden, + }), + [allFields, flattened, searchTerm, shouldShowFieldHandler, isEsqlMode, areNullValuesHidden] ); - const groupedFields = useMemo(() => { - const attributesFields: string[] = []; - const resourceAttributesFields: string[] = []; - const scopeAttributesFields: string[] = []; - const lowerSearchTerm = searchTerm.toLowerCase(); - for (const fieldName of filteredFields) { - const lowerFieldName = fieldName.toLowerCase(); - if (!lowerFieldName.includes(lowerSearchTerm)) { - continue; - } - if (lowerFieldName.startsWith('resource.attributes.')) { - resourceAttributesFields.push(fieldName); - } else if (lowerFieldName.startsWith('scope.attributes.')) { - scopeAttributesFields.push(fieldName); - } else if (lowerFieldName.startsWith('attributes.')) { - attributesFields.push(fieldName); - } - } - return { attributesFields, resourceAttributesFields, scopeAttributesFields }; - }, [filteredFields, searchTerm]); + const { attributesFields, resourceAttributesFields, scopeAttributesFields } = groupedFields; const containerHeight = containerRef ? getTabContentAvailableHeight(containerRef, decreaseAvailableHeightBy ?? DEFAULT_MARGIN_BOTTOM) : 0; + const filterFieldsBySearchTerm = (fields: AttributeField[]) => + fields.filter( + (field) => + field.displayName.toLowerCase().includes(searchTerm.toLowerCase()) || + field.name.toLowerCase().includes(searchTerm.toLowerCase()) + ); + const accordionConfigs = [ { id: 'signal_attributes', title: attributesTitle, ariaLabel: attributesTitle, - fields: groupedFields.attributesFields, + fields: filterFieldsBySearchTerm(attributesFields), + tooltipMessage: i18n.translate( + 'unifiedDocViewer.docView.attributes.signalAttributesTooltip', + { + defaultMessage: + 'Metadata added by the instrumentation library to describe the telemetry data (e.g., HTTP method, user agent).', + } + ), }, { id: 'resource_attributes', @@ -100,7 +121,14 @@ export function AttributesOverview({ 'unifiedDocViewer.docView.attributes.resourceAttributesTitle.ariaLabel', { defaultMessage: 'Resource attributes' } ), - fields: groupedFields.resourceAttributesFields, + fields: filterFieldsBySearchTerm(resourceAttributesFields), + tooltipMessage: i18n.translate( + 'unifiedDocViewer.docView.attributes.resourceAttributesTooltip', + { + defaultMessage: + 'Metadata originally set at the source of the telemetry, such as in the SDK or agent that generated the data.', + } + ), }, { id: 'scope_attributes', @@ -111,10 +139,26 @@ export function AttributesOverview({ 'unifiedDocViewer.docView.attributes.scopeAttributesTitle.ariaLabel', { defaultMessage: 'Scope attributes' } ), - fields: groupedFields.scopeAttributesFields, + fields: filterFieldsBySearchTerm(scopeAttributesFields), + tooltipMessage: i18n.translate('unifiedDocViewer.docView.attributes.scopeAttributesTooltip', { + defaultMessage: + 'Metadata associated with the instrumentation scope (i.e., the library/module that produced the telemetry), helping identify its origin and version.', + }), }, ]; + const onHideNullValuesChange = useCallback( + (e: EuiSwitchEvent) => { + setAreNullValuesHidden(e.target.checked); + }, + [setAreNullValuesHidden] + ); + + const noFields = + attributesFields.length === 0 && + resourceAttributesFields.length === 0 && + scopeAttributesFields.length === 0; + return ( + + + {isEsqlMode && ( + + + + )} + + + + + - - {accordionConfigs.map(({ id, title, ariaLabel, fields }) => ( - - - - - - - - - ))} - + {noFields ? ( + + ) : ( + + {accordionConfigs.map(({ id, title, ariaLabel, fields, tooltipMessage }) => ( + + + + + + + + + ))} + + )} ); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_table.tsx b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_table.tsx index fecb29c80f7d..f4a8440166a3 100644 --- a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_table.tsx +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/attributes_table.tsx @@ -18,14 +18,16 @@ import { } from '../../../doc_viewer_table/table_cell_actions'; import { FieldRow } from '../../../doc_viewer_table/field_row'; import { getUnifiedDocViewerServices } from '../../../../plugin'; +import { AttributeField } from './attributes_overview'; interface AttributesTableProps extends Pick< DocViewRenderProps, 'hit' | 'dataView' | 'columnsMeta' | 'filter' | 'onAddColumn' | 'onRemoveColumn' | 'columns' > { - fields: string[]; + fields: AttributeField[]; searchTerm: string; + isEsqlMode: boolean; } export const AttributesTable = ({ @@ -38,6 +40,7 @@ export const AttributesTable = ({ filter, onAddColumn, onRemoveColumn, + isEsqlMode, }: AttributesTableProps) => { const flattened = hit.flattened; const { fieldFormats, toasts } = getUnifiedDocViewerServices(); @@ -55,18 +58,14 @@ export const AttributesTable = ({ }; }, [onRemoveColumn, onAddColumn, columns]); - const displayedFields = useMemo( - () => fields.filter((field) => field.toLowerCase().includes(searchTerm.toLowerCase())), - [fields, searchTerm] - ); - const rows: FieldRow[] = useMemo( () => - displayedFields.map( + fields.map( (field) => new FieldRow({ - name: field, - flattenedValue: flattened[field], + name: field.name, + displayNameOverride: field.displayName, + flattenedValue: flattened[field.name], hit, dataView, fieldFormats, @@ -74,16 +73,16 @@ export const AttributesTable = ({ columnsMeta, }) ), - [displayedFields, flattened, hit, dataView, fieldFormats, columnsMeta] + [fields, flattened, hit, dataView, fieldFormats, columnsMeta] ); const fieldCellActions = useMemo( - () => getFieldCellActions({ rows, isEsqlMode: false, onFilter: filter, onToggleColumn }), - [rows, filter, onToggleColumn] + () => getFieldCellActions({ rows, isEsqlMode, onFilter: filter, onToggleColumn }), + [rows, filter, onToggleColumn, isEsqlMode] ); const fieldValueCellActions = useMemo( - () => getFieldValueCellActions({ rows, isEsqlMode: false, toasts, onFilter: filter }), - [rows, toasts, filter] + () => getFieldValueCellActions({ rows, isEsqlMode, toasts, onFilter: filter }), + [rows, toasts, filter, isEsqlMode] ); const gridColumns: EuiDataGridProps['columns'] = [ diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.test.ts new file mode 100644 index 000000000000..213350360575 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.test.ts @@ -0,0 +1,29 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { getAttributeDisplayName } from './get_attribute_display_name'; + +describe('getAttributeDisplayName', () => { + it('removes "attributes." prefix', () => { + expect(getAttributeDisplayName('attributes.foo')).toBe('foo'); + }); + + it('removes "resource.attributes." prefix', () => { + expect(getAttributeDisplayName('resource.attributes.env')).toBe('env'); + }); + + it('removes "scope.attributes." prefix', () => { + expect(getAttributeDisplayName('scope.attributes.lib')).toBe('lib'); + }); + + it('returns the original name if no prefix matches', () => { + expect(getAttributeDisplayName('other')).toBe('other'); + expect(getAttributeDisplayName('attributesfoo')).toBe('attributesfoo'); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.ts new file mode 100644 index 000000000000..7f05c9b09346 --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/get_attribute_display_name.ts @@ -0,0 +1,15 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +export function getAttributeDisplayName(fieldName: string): string { + return fieldName + .replace(/^resource\.attributes\./, '') + .replace(/^scope\.attributes\./, '') + .replace(/^attributes\./, ''); +} diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.test.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.test.ts new file mode 100644 index 000000000000..5933a01ddd8d --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.test.ts @@ -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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { groupAttributesFields } from './group_attributes_fields'; + +describe('groupAttributesFields', () => { + const flattened = { + 'attributes.foo': 'bar', + 'resource.attributes.env': 'prod', + 'scope.attributes.lib': 'lib1', + other: 'value', + }; + + const allFields = Object.keys(flattened); + + const alwaysShow = () => true; + + it('groups fields correctly', () => { + const result = groupAttributesFields({ + allFields, + flattened, + searchTerm: '', + shouldShowFieldHandler: alwaysShow, + isEsqlMode: false, + areNullValuesHidden: false, + }); + + expect(result.attributesFields).toEqual([{ name: 'attributes.foo', displayName: 'foo' }]); + expect(result.resourceAttributesFields).toEqual([ + { name: 'resource.attributes.env', displayName: 'env' }, + ]); + expect(result.scopeAttributesFields).toEqual([ + { name: 'scope.attributes.lib', displayName: 'lib' }, + ]); + }); + + it('filters by searchTerm', () => { + const result = groupAttributesFields({ + allFields, + flattened, + searchTerm: 'env', + shouldShowFieldHandler: alwaysShow, + isEsqlMode: false, + areNullValuesHidden: false, + }); + + expect(result.resourceAttributesFields).toEqual([ + { name: 'resource.attributes.env', displayName: 'env' }, + ]); + expect(result.attributesFields).toEqual([]); + expect(result.scopeAttributesFields).toEqual([]); + }); + + it('filters out null values in ES|QL mode', () => { + const flattenedWithNull = { ...flattened, 'attributes.null': null }; + const allFieldsWithNull = Object.keys(flattenedWithNull); + + const result = groupAttributesFields({ + allFields: allFieldsWithNull, + flattened: flattenedWithNull, + searchTerm: '', + shouldShowFieldHandler: alwaysShow, + isEsqlMode: true, + areNullValuesHidden: true, + }); + + expect(result.attributesFields).toEqual([{ name: 'attributes.foo', displayName: 'foo' }]); + }); +}); diff --git a/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.ts b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.ts new file mode 100644 index 000000000000..1cdb54c2c8cc --- /dev/null +++ b/src/platform/plugins/shared/unified_doc_viewer/public/components/observability/attributes/doc_viewer_attributes_overview/group_attributes_fields.ts @@ -0,0 +1,61 @@ +/* + * 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", the "GNU Affero General Public License v3.0 only", 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", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { AttributeField } from './attributes_overview'; +import { getAttributeDisplayName } from './get_attribute_display_name'; + +interface GroupAttributesFieldsParams { + allFields: string[]; + flattened: Record; + searchTerm: string; + shouldShowFieldHandler: (fieldName: string) => boolean; + isEsqlMode: boolean; + areNullValuesHidden?: boolean; +} + +export function groupAttributesFields({ + allFields, + flattened, + searchTerm, + shouldShowFieldHandler, + isEsqlMode, + areNullValuesHidden, +}: GroupAttributesFieldsParams): { + attributesFields: AttributeField[]; + resourceAttributesFields: AttributeField[]; + scopeAttributesFields: AttributeField[]; +} { + const attributesFields: AttributeField[] = []; + const resourceAttributesFields: AttributeField[] = []; + const scopeAttributesFields: AttributeField[] = []; + const lowerSearchTerm = searchTerm.toLowerCase(); + + allFields.forEach((fieldName) => { + const lowerFieldName = fieldName.toLowerCase(); + + if (!shouldShowFieldHandler(fieldName)) return; + if (!lowerFieldName.includes(lowerSearchTerm)) return; + if (isEsqlMode && areNullValuesHidden && flattened[fieldName] == null) return; + + const field: AttributeField = { + name: fieldName, + displayName: getAttributeDisplayName(fieldName), + }; + + if (lowerFieldName.startsWith('resource.attributes.')) { + resourceAttributesFields.push(field); + } else if (lowerFieldName.startsWith('scope.attributes.')) { + scopeAttributesFields.push(field); + } else if (lowerFieldName.startsWith('attributes.')) { + attributesFields.push(field); + } + }); + + return { attributesFields, resourceAttributesFields, scopeAttributesFields }; +}