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/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 /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/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 ## 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 /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 { interface Props {
fieldName: string; fieldName: string;
displayNameOverride?: string;
fieldType?: string; fieldType?: string;
fieldMapping?: DataViewField; fieldMapping?: DataViewField;
fieldIconProps?: Omit<FieldIconProps, 'type'>; fieldIconProps?: Omit<FieldIconProps, 'type'>;
@ -31,13 +32,15 @@ export function FieldName({
fieldMapping, fieldMapping,
fieldType, fieldType,
fieldIconProps, fieldIconProps,
displayNameOverride,
scripted = false, scripted = false,
highlight = '', highlight = '',
}: Props) { }: Props) {
const typeName = getFieldTypeName(fieldType); const typeName = getFieldTypeName(fieldType);
const displayName = const fieldMappingDisplayName = fieldMapping?.displayName ? fieldMapping.displayName : fieldName;
fieldMapping && fieldMapping.displayName ? fieldMapping.displayName : fieldName; const fieldDisplayName = displayNameOverride ?? fieldMappingDisplayName;
const tooltip = displayName !== fieldName ? `${displayName} (${fieldName})` : fieldName;
const tooltip = fieldDisplayName !== fieldName ? `${fieldDisplayName} (${fieldName})` : fieldName;
const subTypeMulti = fieldMapping && getDataViewFieldSubtypeMulti(fieldMapping.spec); const subTypeMulti = fieldMapping && getDataViewFieldSubtypeMulti(fieldMapping.spec);
const isMultiField = !!subTypeMulti?.multi; const isMultiField = !!subTypeMulti?.multi;
@ -71,7 +74,7 @@ export function FieldName({
delay="long" delay="long"
anchorClassName="eui-textBreakAll" anchorClassName="eui-textBreakAll"
> >
<EuiHighlight search={highlight}>{displayName}</EuiHighlight> <EuiHighlight search={highlight}>{fieldDisplayName}</EuiHighlight>
</EuiToolTip> </EuiToolTip>
</EuiFlexItem> </EuiFlexItem>

View file

@ -17,6 +17,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",
@ -45,6 +46,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",
@ -66,6 +68,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",
@ -90,6 +93,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",
@ -128,6 +132,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",
@ -149,6 +154,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",
@ -168,6 +174,7 @@ Array [
"scripted": false, "scripted": false,
"type": "string", "type": "string",
}, },
"displayNameOverride": undefined,
"flattenedValue": "test", "flattenedValue": "test",
"isPinned": false, "isPinned": false,
"name": "message", "name": "message",

View file

@ -22,6 +22,7 @@ import { getDataViewFieldOrCreateFromColumnMeta } from '@kbn/data-view-utils';
export class FieldRow { export class FieldRow {
readonly name: string; readonly name: string;
readonly displayNameOverride: string | undefined;
readonly flattenedValue: unknown; readonly flattenedValue: unknown;
readonly dataViewField: DataViewField | undefined; readonly dataViewField: DataViewField | undefined;
readonly isPinned: boolean; readonly isPinned: boolean;
@ -41,6 +42,7 @@ export class FieldRow {
constructor({ constructor({
name, name,
displayNameOverride,
flattenedValue, flattenedValue,
hit, hit,
dataView, dataView,
@ -49,6 +51,7 @@ export class FieldRow {
columnsMeta, columnsMeta,
}: { }: {
name: string; name: string;
displayNameOverride?: string;
flattenedValue: unknown; flattenedValue: unknown;
hit: DataTableRecord; hit: DataTableRecord;
dataView: DataView; dataView: DataView;
@ -63,6 +66,7 @@ export class FieldRow {
this.#isFormattedAsText = false; this.#isFormattedAsText = false;
this.name = name; this.name = name;
this.displayNameOverride = displayNameOverride;
this.flattenedValue = flattenedValue; this.flattenedValue = flattenedValue;
this.dataViewField = getDataViewFieldOrCreateFromColumnMeta({ this.dataViewField = getDataViewFieldOrCreateFromColumnMeta({
dataView, dataView,

View file

@ -73,7 +73,7 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100, 250, 500];
const DEFAULT_PAGE_SIZE = 25; const DEFAULT_PAGE_SIZE = 25;
const PINNED_FIELDS_KEY = 'discover:pinnedFields'; const PINNED_FIELDS_KEY = 'discover:pinnedFields';
const PAGE_SIZE = 'discover:pageSize'; 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'; export const SHOW_ONLY_SELECTED_FIELDS = 'unifiedDocViewer:showOnlySelectedFields';
const GRID_COLUMN_FIELD_NAME = 'name'; const GRID_COLUMN_FIELD_NAME = 'name';

View file

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

View file

@ -7,17 +7,20 @@
* License v3.0 only", or the "Server Side Public License, v 1". * License v3.0 only", or the "Server Side Public License, v 1".
*/ */
import React from 'react'; 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 { DataTableColumnsMeta, DataTableRecord } from '@kbn/discover-utils';
import { DataView } from '@kbn/data-views-plugin/common'; import { DataView } from '@kbn/data-views-plugin/common';
import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types'; import { DocViewFilterFn } from '@kbn/unified-doc-viewer/types';
import { AttributesTable } from './attributes_table'; import { AttributesTable } from './attributes_table';
import { AttributesEmptyPrompt } from './attributes_empty_prompt';
import { AttributeField } from './attributes_overview';
interface AttributesAccordionProps { interface AttributesAccordionProps {
id: string; id: string;
title: string; title: string;
ariaLabel: string; ariaLabel: string;
fields: string[]; tooltipMessage: string;
fields: AttributeField[];
hit: DataTableRecord; hit: DataTableRecord;
dataView: DataView; dataView: DataView;
columns?: string[]; columns?: string[];
@ -26,12 +29,13 @@ interface AttributesAccordionProps {
onAddColumn?: (col: string) => void; onAddColumn?: (col: string) => void;
onRemoveColumn?: (col: string) => void; onRemoveColumn?: (col: string) => void;
filter?: DocViewFilterFn; filter?: DocViewFilterFn;
isEsqlMode: boolean;
} }
export const AttributesAccordion = ({ export const AttributesAccordion = ({
id, id,
title, title,
ariaLabel, tooltipMessage,
fields, fields,
hit, hit,
dataView, dataView,
@ -41,34 +45,51 @@ export const AttributesAccordion = ({
onAddColumn, onAddColumn,
onRemoveColumn, onRemoveColumn,
filter, filter,
}: AttributesAccordionProps) => ( isEsqlMode = false,
<EuiAccordion }: AttributesAccordionProps) => {
id={id} const { euiTheme } = useEuiTheme();
buttonContent={ return (
<EuiText size="s"> <EuiAccordion
<strong aria-label={ariaLabel}>{title}</strong> id={id}
</EuiText> buttonContent={
} <EuiText size="s">
initialIsOpen={fields.length > 0} <strong css={{ marginRight: euiTheme.size.xs }}>{title}</strong>
forceState={fields.length === 0 ? 'closed' : undefined} <EuiIconTip
isDisabled={fields.length === 0} aria-label={tooltipMessage}
extraAction={ type="questionInCircle"
<EuiNotificationBadge size="m" color="subdued"> color="subdued"
{fields.length} size="s"
</EuiNotificationBadge> content={tooltipMessage}
} iconProps={{
paddingSize="m" className: 'eui-alignTop',
> }}
<AttributesTable />
hit={hit} </EuiText>
dataView={dataView} }
columns={columns} initialIsOpen={fields.length > 0}
columnsMeta={columnsMeta} extraAction={
fields={fields} <EuiNotificationBadge size="m" color="subdued">
searchTerm={searchTerm} {fields.length}
onAddColumn={onAddColumn} </EuiNotificationBadge>
onRemoveColumn={onRemoveColumn} }
filter={filter} paddingSize="m"
/> >
</EuiAccordion> {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 * your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1". * 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 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 { i18n } from '@kbn/i18n';
import { css } from '@emotion/react'; import { css } from '@emotion/react';
import { SHOW_MULTIFIELDS, getShouldShowFieldHandler } from '@kbn/discover-utils'; import { SHOW_MULTIFIELDS, getShouldShowFieldHandler } from '@kbn/discover-utils';
import useLocalStorage from 'react-use/lib/useLocalStorage';
import { import {
LOCAL_STORAGE_KEY_SEARCH_TERM, LOCAL_STORAGE_KEY_SEARCH_TERM,
useTableFilters, useTableFilters,
@ -23,12 +31,21 @@ import {
} from '../../../doc_viewer_source/get_height'; } from '../../../doc_viewer_source/get_height';
import { AttributesAccordion } from './attributes_accordion'; import { AttributesAccordion } from './attributes_accordion';
import { getAttributesTitle } from './get_attributes_title'; 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({ export function AttributesOverview({
columns, columns,
columnsMeta, columnsMeta,
hit, hit,
dataView, dataView,
textBasedHits,
filter, filter,
decreaseAvailableHeightBy, decreaseAvailableHeightBy,
onAddColumn, onAddColumn,
@ -36,11 +53,13 @@ export function AttributesOverview({
}: DocViewRenderProps) { }: DocViewRenderProps) {
const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null); const [containerRef, setContainerRef] = useState<HTMLDivElement | null>(null);
const { storage, uiSettings } = getUnifiedDocViewerServices(); const { storage, uiSettings } = getUnifiedDocViewerServices();
const isEsqlMode = Array.isArray(textBasedHits);
const showMultiFields = uiSettings.get(SHOW_MULTIFIELDS); const showMultiFields = uiSettings.get(SHOW_MULTIFIELDS);
const { searchTerm, onChangeSearchTerm } = useTableFilters({ const { searchTerm, onChangeSearchTerm } = useTableFilters({
storage, storage,
storageKey: LOCAL_STORAGE_KEY_SEARCH_TERM, storageKey: LOCAL_STORAGE_KEY_SEARCH_TERM,
}); });
const [areNullValuesHidden, setAreNullValuesHidden] = useLocalStorage(HIDE_NULL_VALUES, false);
const flattened = hit.flattened; const flattened = hit.flattened;
@ -53,43 +72,45 @@ export function AttributesOverview({
const allFields = Object.keys(flattened); 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 groupedFields = useMemo(
const filteredFields = useMemo( () =>
() => allFields.filter(shouldShowFieldHandler), groupAttributesFields({
[allFields, shouldShowFieldHandler] allFields,
flattened,
searchTerm,
shouldShowFieldHandler,
isEsqlMode,
areNullValuesHidden,
}),
[allFields, flattened, searchTerm, shouldShowFieldHandler, isEsqlMode, areNullValuesHidden]
); );
const groupedFields = useMemo(() => { const { attributesFields, resourceAttributesFields, scopeAttributesFields } = groupedFields;
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 containerHeight = containerRef const containerHeight = containerRef
? getTabContentAvailableHeight(containerRef, decreaseAvailableHeightBy ?? DEFAULT_MARGIN_BOTTOM) ? getTabContentAvailableHeight(containerRef, decreaseAvailableHeightBy ?? DEFAULT_MARGIN_BOTTOM)
: 0; : 0;
const filterFieldsBySearchTerm = (fields: AttributeField[]) =>
fields.filter(
(field) =>
field.displayName.toLowerCase().includes(searchTerm.toLowerCase()) ||
field.name.toLowerCase().includes(searchTerm.toLowerCase())
);
const accordionConfigs = [ const accordionConfigs = [
{ {
id: 'signal_attributes', id: 'signal_attributes',
title: attributesTitle, title: attributesTitle,
ariaLabel: 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', id: 'resource_attributes',
@ -100,7 +121,14 @@ export function AttributesOverview({
'unifiedDocViewer.docView.attributes.resourceAttributesTitle.ariaLabel', 'unifiedDocViewer.docView.attributes.resourceAttributesTitle.ariaLabel',
{ defaultMessage: 'Resource attributes' } { 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', id: 'scope_attributes',
@ -111,10 +139,26 @@ export function AttributesOverview({
'unifiedDocViewer.docView.attributes.scopeAttributesTitle.ariaLabel', 'unifiedDocViewer.docView.attributes.scopeAttributesTitle.ariaLabel',
{ defaultMessage: 'Scope attributes' } { 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 ( return (
<EuiFlexGroup <EuiFlexGroup
ref={setContainerRef} ref={setContainerRef}
@ -149,37 +193,71 @@ export function AttributesOverview({
<EuiFlexItem grow={false}> <EuiFlexItem grow={false}>
<EuiSpacer size="s" /> <EuiSpacer size="s" />
</EuiFlexItem> </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 <EuiFlexItem
grow={true} grow={true}
css={css` css={css`
overflow: auto; overflow: auto;
`} `}
> >
<EuiFlexGroup direction="column" gutterSize="none" responsive={false}> {noFields ? (
{accordionConfigs.map(({ id, title, ariaLabel, fields }) => ( <AttributesEmptyPrompt />
<React.Fragment key={id}> ) : (
<EuiFlexItem grow={false}> <EuiFlexGroup direction="column" gutterSize="none" responsive={false}>
<AttributesAccordion {accordionConfigs.map(({ id, title, ariaLabel, fields, tooltipMessage }) => (
id={id} <React.Fragment key={id}>
title={title} <EuiFlexItem grow={false}>
ariaLabel={ariaLabel} <AttributesAccordion
fields={fields} id={id}
hit={hit} title={title}
dataView={dataView} ariaLabel={ariaLabel}
columns={columns} tooltipMessage={tooltipMessage}
columnsMeta={columnsMeta} fields={fields}
searchTerm={searchTerm} hit={hit}
onAddColumn={onAddColumn} dataView={dataView}
onRemoveColumn={onRemoveColumn} columns={columns}
filter={filter} columnsMeta={columnsMeta}
/> searchTerm={searchTerm}
</EuiFlexItem> onAddColumn={onAddColumn}
<EuiFlexItem grow={false}> onRemoveColumn={onRemoveColumn}
<EuiSpacer size="s" /> filter={filter}
</EuiFlexItem> isEsqlMode={isEsqlMode}
</React.Fragment> />
))} </EuiFlexItem>
</EuiFlexGroup> <EuiFlexItem grow={false}>
<EuiSpacer size="s" />
</EuiFlexItem>
</React.Fragment>
))}
</EuiFlexGroup>
)}
</EuiFlexItem> </EuiFlexItem>
</EuiFlexGroup> </EuiFlexGroup>
); );

View file

@ -18,14 +18,16 @@ import {
} from '../../../doc_viewer_table/table_cell_actions'; } from '../../../doc_viewer_table/table_cell_actions';
import { FieldRow } from '../../../doc_viewer_table/field_row'; import { FieldRow } from '../../../doc_viewer_table/field_row';
import { getUnifiedDocViewerServices } from '../../../../plugin'; import { getUnifiedDocViewerServices } from '../../../../plugin';
import { AttributeField } from './attributes_overview';
interface AttributesTableProps interface AttributesTableProps
extends Pick< extends Pick<
DocViewRenderProps, DocViewRenderProps,
'hit' | 'dataView' | 'columnsMeta' | 'filter' | 'onAddColumn' | 'onRemoveColumn' | 'columns' 'hit' | 'dataView' | 'columnsMeta' | 'filter' | 'onAddColumn' | 'onRemoveColumn' | 'columns'
> { > {
fields: string[]; fields: AttributeField[];
searchTerm: string; searchTerm: string;
isEsqlMode: boolean;
} }
export const AttributesTable = ({ export const AttributesTable = ({
@ -38,6 +40,7 @@ export const AttributesTable = ({
filter, filter,
onAddColumn, onAddColumn,
onRemoveColumn, onRemoveColumn,
isEsqlMode,
}: AttributesTableProps) => { }: AttributesTableProps) => {
const flattened = hit.flattened; const flattened = hit.flattened;
const { fieldFormats, toasts } = getUnifiedDocViewerServices(); const { fieldFormats, toasts } = getUnifiedDocViewerServices();
@ -55,18 +58,14 @@ export const AttributesTable = ({
}; };
}, [onRemoveColumn, onAddColumn, columns]); }, [onRemoveColumn, onAddColumn, columns]);
const displayedFields = useMemo(
() => fields.filter((field) => field.toLowerCase().includes(searchTerm.toLowerCase())),
[fields, searchTerm]
);
const rows: FieldRow[] = useMemo( const rows: FieldRow[] = useMemo(
() => () =>
displayedFields.map( fields.map(
(field) => (field) =>
new FieldRow({ new FieldRow({
name: field, name: field.name,
flattenedValue: flattened[field], displayNameOverride: field.displayName,
flattenedValue: flattened[field.name],
hit, hit,
dataView, dataView,
fieldFormats, fieldFormats,
@ -74,16 +73,16 @@ export const AttributesTable = ({
columnsMeta, columnsMeta,
}) })
), ),
[displayedFields, flattened, hit, dataView, fieldFormats, columnsMeta] [fields, flattened, hit, dataView, fieldFormats, columnsMeta]
); );
const fieldCellActions = useMemo( const fieldCellActions = useMemo(
() => getFieldCellActions({ rows, isEsqlMode: false, onFilter: filter, onToggleColumn }), () => getFieldCellActions({ rows, isEsqlMode, onFilter: filter, onToggleColumn }),
[rows, filter, onToggleColumn] [rows, filter, onToggleColumn, isEsqlMode]
); );
const fieldValueCellActions = useMemo( const fieldValueCellActions = useMemo(
() => getFieldValueCellActions({ rows, isEsqlMode: false, toasts, onFilter: filter }), () => getFieldValueCellActions({ rows, isEsqlMode, toasts, onFilter: filter }),
[rows, toasts, filter] [rows, toasts, filter, isEsqlMode]
); );
const gridColumns: EuiDataGridProps['columns'] = [ 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 };
}