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

<img width="524" alt="Screenshot 2025-06-24 at 12 27 18"
src="https://github.com/user-attachments/assets/4015ed6a-5977-486d-93e6-d8b5714af9fd"
/>

#### 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

<img width="624" alt="Screenshot 2025-06-24 at 12 19 48"
src="https://github.com/user-attachments/assets/634b4ef0-0934-4721-9217-334286b6464a"
/>

<img width="624" alt="Screenshot 2025-06-24 at 12 20 07"
src="https://github.com/user-attachments/assets/bdc6de9c-784f-4c78-bf18-1f37b645429d"
/>

#### Filtering controls use full field name


https://github.com/user-attachments/assets/7858d803-271e-4913-9aae-385dd7bc9e25

#### Add explanatory tooltip for attribute namespaces

<img width="525" alt="Screenshot 2025-06-24 at 12 24 33"
src="https://github.com/user-attachments/assets/a76b1419-c1d9-4e46-a289-a819b7533b18"
/>

<img width="525" alt="Screenshot 2025-06-24 at 12 24 51"
src="https://github.com/user-attachments/assets/e48b19a3-85a8-4a13-b527-3a4494aef2af"
/>

<img width="525" alt="Screenshot 2025-06-24 at 12 24 57"
src="https://github.com/user-attachments/assets/50501672-4d75-43ce-b61b-646108b4b14a"
/>


### 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
```
This commit is contained in:
Miriam 2025-06-26 13:13:27 +01:00 committed by GitHub
parent d532ff490a
commit 75ba373fbd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 454 additions and 108 deletions

1
.github/CODEOWNERS vendored
View file

@ -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

View file

@ -19,6 +19,7 @@ import { getFieldTypeName } from '@kbn/field-utils';
interface Props {
fieldName: string;
displayNameOverride?: string;
fieldType?: string;
fieldMapping?: DataViewField;
fieldIconProps?: Omit<FieldIconProps, 'type'>;
@ -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"
>
<EuiHighlight search={highlight}>{displayName}</EuiHighlight>
<EuiHighlight search={highlight}>{fieldDisplayName}</EuiHighlight>
</EuiToolTip>
</EuiFlexItem>

View file

@ -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",

View file

@ -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,

View file

@ -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';

View file

@ -49,13 +49,15 @@ export const TableCell: React.FC<TableCellProps> = React.memo(
return null;
}
const { flattenedValue, name, dataViewField, ignoredReason, fieldType } = row;
const { flattenedValue, name, displayNameOverride, dataViewField, ignoredReason, fieldType } =
row;
if (columnId === 'name') {
return (
<div>
<FieldName
fieldName={name}
displayNameOverride={displayNameOverride}
fieldType={fieldType}
fieldMapping={dataViewField}
scripted={dataViewField?.scripted}

View file

@ -7,17 +7,20 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import React from 'react';
import { EuiAccordion, EuiText, EuiNotificationBadge } from '@elastic/eui';
import { EuiAccordion, EuiText, EuiNotificationBadge, EuiIconTip, useEuiTheme } from '@elastic/eui';
import { DataTableColumnsMeta, DataTableRecord } from '@kbn/discover-utils';
import { DataView } from '@kbn/data-views-plugin/common';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { AttributesTable } from './attributes_table';
import { AttributesEmptyPrompt } from './attributes_empty_prompt';
import { AttributeField } from './attributes_overview';
interface AttributesAccordionProps {
id: string;
title: string;
ariaLabel: string;
fields: string[];
tooltipMessage: string;
fields: AttributeField[];
hit: DataTableRecord;
dataView: DataView;
columns?: string[];
@ -26,12 +29,13 @@ interface AttributesAccordionProps {
onAddColumn?: (col: string) => 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) => (
<EuiAccordion
id={id}
buttonContent={
<EuiText size="s">
<strong aria-label={ariaLabel}>{title}</strong>
</EuiText>
}
initialIsOpen={fields.length > 0}
forceState={fields.length === 0 ? 'closed' : undefined}
isDisabled={fields.length === 0}
extraAction={
<EuiNotificationBadge size="m" color="subdued">
{fields.length}
</EuiNotificationBadge>
}
paddingSize="m"
>
<AttributesTable
hit={hit}
dataView={dataView}
columns={columns}
columnsMeta={columnsMeta}
fields={fields}
searchTerm={searchTerm}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
filter={filter}
/>
</EuiAccordion>
);
isEsqlMode = false,
}: AttributesAccordionProps) => {
const { euiTheme } = useEuiTheme();
return (
<EuiAccordion
id={id}
buttonContent={
<EuiText size="s">
<strong css={{ marginRight: euiTheme.size.xs }}>{title}</strong>
<EuiIconTip
aria-label={tooltipMessage}
type="questionInCircle"
color="subdued"
size="s"
content={tooltipMessage}
iconProps={{
className: 'eui-alignTop',
}}
/>
</EuiText>
}
initialIsOpen={fields.length > 0}
extraAction={
<EuiNotificationBadge size="m" color="subdued">
{fields.length}
</EuiNotificationBadge>
}
paddingSize="m"
>
{fields.length === 0 ? (
<AttributesEmptyPrompt />
) : (
<AttributesTable
hit={hit}
dataView={dataView}
columns={columns}
columnsMeta={columnsMeta}
fields={fields}
searchTerm={searchTerm}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
filter={filter}
isEsqlMode={isEsqlMode}
/>
)}
</EuiAccordion>
);
};

View file

@ -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 = () => (
<EuiEmptyPrompt
layout="horizontal"
body={
<EuiText size="s" color="subdued">
<p>
{i18n.translate(
'unifiedDocViewer.docView.attributes.accordion.noFieldsMessage.noFieldsLabel',
{
defaultMessage: 'No attributes fields found.',
}
)}
</p>
<>
<strong>
{i18n.translate(
'unifiedDocViewer.docView.attributes.accordion.noFieldsMessage.tryText',
{
defaultMessage: 'Try:',
}
)}
</strong>
<ul>
<li>
{i18n.translate(
'unifiedDocViewer.docView.attributes.accordion.noFieldsMessage.fieldTypeFilterBullet',
{
defaultMessage: 'Using different field filters if applicable.',
}
)}
</li>
</ul>
</>
</EuiText>
}
data-test-subj="attributesAccordionEmptyPrompt"
/>
);

View file

@ -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<HTMLDivElement | null>(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 (
<EuiFlexGroup
ref={setContainerRef}
@ -149,37 +193,71 @@ export function AttributesOverview({
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiFlexGroup
responsive={false}
wrap
direction="row"
justifyContent="flexEnd"
alignItems="center"
gutterSize="m"
>
{isEsqlMode && (
<EuiFlexItem grow={false}>
<EuiSwitch
label={i18n.translate('unifiedDocViewer.hideNullValues.switchLabel', {
defaultMessage: 'Hide null fields',
description: 'Switch label to hide fields with null values in the table',
})}
checked={areNullValuesHidden ?? false}
onChange={onHideNullValuesChange}
compressed
data-test-subj="unifiedDocViewerHideNullValuesSwitch"
/>
</EuiFlexItem>
)}
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
<EuiFlexItem
grow={true}
css={css`
overflow: auto;
`}
>
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{accordionConfigs.map(({ id, title, ariaLabel, fields }) => (
<React.Fragment key={id}>
<EuiFlexItem grow={false}>
<AttributesAccordion
id={id}
title={title}
ariaLabel={ariaLabel}
fields={fields}
hit={hit}
dataView={dataView}
columns={columns}
columnsMeta={columnsMeta}
searchTerm={searchTerm}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
filter={filter}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
</React.Fragment>
))}
</EuiFlexGroup>
{noFields ? (
<AttributesEmptyPrompt />
) : (
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
{accordionConfigs.map(({ id, title, ariaLabel, fields, tooltipMessage }) => (
<React.Fragment key={id}>
<EuiFlexItem grow={false}>
<AttributesAccordion
id={id}
title={title}
ariaLabel={ariaLabel}
tooltipMessage={tooltipMessage}
fields={fields}
hit={hit}
dataView={dataView}
columns={columns}
columnsMeta={columnsMeta}
searchTerm={searchTerm}
onAddColumn={onAddColumn}
onRemoveColumn={onRemoveColumn}
filter={filter}
isEsqlMode={isEsqlMode}
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
</React.Fragment>
))}
</EuiFlexGroup>
)}
</EuiFlexItem>
</EuiFlexGroup>
);

View file

@ -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'] = [

View file

@ -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');
});
});

View file

@ -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\./, '');
}

View file

@ -0,0 +1,75 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", 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' }]);
});
});

View file

@ -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<string, unknown>;
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 };
}