Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mockMetric: MetricField = {
name: 'cpu.usage',
type: ES_FIELD_TYPES.DOUBLE,
index: 'metrics-*',
uniqueKey: 'metrics-*::cpu.usage',
dimensions: [
{ name: 'host.name', type: ES_FIELD_TYPES.KEYWORD },
{ name: 'container.id', type: ES_FIELD_TYPES.KEYWORD },
Expand Down Expand Up @@ -312,6 +313,7 @@ TS metrics-*
name: 'cpu.usage',
type: ES_FIELD_TYPES.LONG,
index: 'metrics-*',
uniqueKey: 'metrics-*::cpu.usage',
dimensions: [
{ name: 'service-name', type: ES_FIELD_TYPES.KEYWORD },
{ name: 'container-id', type: ES_FIELD_TYPES.KEYWORD },
Expand Down Expand Up @@ -363,6 +365,7 @@ TS metrics-*
name: 'cpu.usage',
type: ES_FIELD_TYPES.DOUBLE,
index: 'metrics-*',
uniqueKey: 'metrics-*::cpu.usage',
dimensions: [{ name: 'field`with`ticks', type: ES_FIELD_TYPES.KEYWORD }],
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import { hasValue } from '.';
import { hasValue, getMetricKey } from '.';

describe('hasValue', () => {
describe('null and undefined', () => {
Expand Down Expand Up @@ -80,3 +80,24 @@ describe('hasValue', () => {
});
});
});

describe('getMetricKey', () => {
it('should generate key in format index::metricName', () => {
expect(getMetricKey('metrics-*', 'system.cpu.user.pct')).toBe('metrics-*::system.cpu.user.pct');
});

it('should handle special characters in names', () => {
expect(getMetricKey('logs-kubernetes-*', 'kubernetes.pod.cpu.usage.node.pct')).toBe(
'logs-kubernetes-*::kubernetes.pod.cpu.usage.node.pct'
);
});

it('should generate different keys for same metric name in different data views', () => {
const key1 = getMetricKey('metrics-system-*', 'cpu.usage');
const key2 = getMetricKey('metrics-kubernetes-*', 'cpu.usage');

expect(key1).not.toBe(key2);
expect(key1).toBe('metrics-system-*::cpu.usage');
expect(key2).toBe('metrics-kubernetes-*::cpu.usage');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,19 @@ export const hasValue = (value: unknown): boolean => {
}
return true;
};

/**
* Generates a unique key for a MetricField.
* Format: index::metricName
*
* This combination is unique because:
* - Field names are unique within a data view (Elasticsearch guarantees this)
* - The index differentiates metrics with the same name from different data views
*
* @param index - The data view index pattern (e.g., "metrics-*")
* @param metricName - The field name (e.g., "system.cpu.user.pct")
* @returns A unique string key in the format "index::metricName"
*/
export const getMetricKey = (index: string, metricName: string): string => {
return `${index}::${metricName}`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For now, using the index is fine, but we should be aware that user-defined strings could become quite long.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the suggestion Kate!
QQ, what would be the valid unique combinations? I know index + metricName is one, but do we have another/s one? Because once metrics_info is ready, if we get more info we could improve it

};
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe('useChartLayers', () => {
instrument: 'gauge',
unit: 'percent',
index: 'metrics-*',
uniqueKey: 'metrics-*::system.cpu.total.norm.pct',
dimensions: [],
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
/*
* 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 '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ES_FIELD_TYPES } from '@kbn/field-types';
import type { MetricField } from '../../types';
import { MetricFlyoutBody } from './metrics_flyout_body';
import { useMetricsExperienceState } from '../observability/metrics/context/metrics_experience_state_provider';

jest.mock('../observability/metrics/context/metrics_experience_state_provider', () => ({
useMetricsExperienceState: jest.fn(),
}));

const mockUseMetricsExperienceState = useMetricsExperienceState as jest.Mock;

describe('MetricFlyoutBody', () => {
const createMockMetric = (overrides: Partial<MetricField> = {}): MetricField => ({
name: 'system.cpu.user.pct',
index: 'metrics-*',
type: ES_FIELD_TYPES.FLOAT,
uniqueKey: 'metrics-*::system.cpu.user.pct',
dimensions: [],
...overrides,
});

const mockOnFlyoutTabChange = jest.fn();

const createDefaultFlyoutState = (overrides = {}) => ({
gridPosition: 0,
metricUniqueKey: 'metrics-*::system.cpu.user.pct',
esqlQuery: 'FROM metrics-*',
selectedTabId: undefined,
...overrides,
});

beforeEach(() => {
jest.clearAllMocks();
mockUseMetricsExperienceState.mockReturnValue({
flyoutState: createDefaultFlyoutState(),
onFlyoutTabChange: mockOnFlyoutTabChange,
});
});

it('renders both tabs', () => {
const metric = createMockMetric();
render(<MetricFlyoutBody metric={metric} esqlQuery="FROM metrics-*" />);

expect(screen.getByRole('tab', { name: 'Overview' })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: 'ES|QL Query' })).toBeInTheDocument();
});

it('defaults to Overview tab when selectedTabId is undefined', () => {
const metric = createMockMetric();
render(<MetricFlyoutBody metric={metric} esqlQuery="FROM metrics-*" />);

const overviewTab = screen.getByRole('tab', { name: 'Overview' });
const esqlTab = screen.getByRole('tab', { name: 'ES|QL Query' });

expect(overviewTab).toHaveAttribute('aria-selected', 'true');
expect(esqlTab).toHaveAttribute('aria-selected', 'false');
});

it('selects the tab from flyoutState.selectedTabId', () => {
mockUseMetricsExperienceState.mockReturnValue({
flyoutState: createDefaultFlyoutState({ selectedTabId: 'esql-query' }),
onFlyoutTabChange: mockOnFlyoutTabChange,
});

const metric = createMockMetric();
render(<MetricFlyoutBody metric={metric} esqlQuery="FROM metrics-*" />);

const overviewTab = screen.getByRole('tab', { name: 'Overview' });
const esqlTab = screen.getByRole('tab', { name: 'ES|QL Query' });

expect(overviewTab).toHaveAttribute('aria-selected', 'false');
expect(esqlTab).toHaveAttribute('aria-selected', 'true');
});

it('calls onFlyoutTabChange when clicking a tab', async () => {
const user = userEvent.setup();
const metric = createMockMetric();

render(<MetricFlyoutBody metric={metric} esqlQuery="FROM metrics-*" />);

const esqlTab = screen.getByRole('tab', { name: 'ES|QL Query' });
await user.click(esqlTab);

expect(mockOnFlyoutTabChange).toHaveBeenCalledWith('esql-query');
});

it('calls onFlyoutTabChange with overview when clicking Overview tab', async () => {
const user = userEvent.setup();

mockUseMetricsExperienceState.mockReturnValue({
flyoutState: createDefaultFlyoutState({ selectedTabId: 'esql-query' }),
onFlyoutTabChange: mockOnFlyoutTabChange,
});

const metric = createMockMetric();
render(<MetricFlyoutBody metric={metric} esqlQuery="FROM metrics-*" />);

const overviewTab = screen.getByRole('tab', { name: 'Overview' });
await user.click(overviewTab);

expect(mockOnFlyoutTabChange).toHaveBeenCalledWith('overview');
});

it('handles undefined flyoutState gracefully', () => {
mockUseMetricsExperienceState.mockReturnValue({
flyoutState: undefined,
onFlyoutTabChange: mockOnFlyoutTabChange,
});

const metric = createMockMetric();
render(<MetricFlyoutBody metric={metric} esqlQuery="FROM metrics-*" />);

const overviewTab = screen.getByRole('tab', { name: 'Overview' });
expect(overviewTab).toHaveAttribute('aria-selected', 'true');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/

import React, { useState } from 'react';
import React from 'react';
import { EuiTabs, EuiTab } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import type { MetricField } from '../../types';
import { OverviewTab } from './overview_tab';
import { EsqlQueryTab } from './esql_query_tab';
import { useMetricsExperienceState } from '../observability/metrics/context/metrics_experience_state_provider';

const tabIds = {
OVERVIEW: 'overview',
Expand Down Expand Up @@ -49,10 +50,11 @@ interface MetricFlyoutBodyProps {
}

export const MetricFlyoutBody = ({ metric, esqlQuery, description }: MetricFlyoutBodyProps) => {
const [selectedTabId, setSelectedTabId] = useState<TabId>(tabIds.OVERVIEW);
const { flyoutState, onFlyoutTabChange } = useMetricsExperienceState();
const selectedTabId = flyoutState?.selectedTabId ?? tabIds.OVERVIEW;

const onSelectedTabChanged = (id: TabId) => {
setSelectedTabId(id);
onFlyoutTabChange(id);
};

const renderTabs = () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe('MetricInsightsFlyout', () => {
type: ES_FIELD_TYPES.DOUBLE,
dimensions: [],
...overrides,
uniqueKey: overrides.uniqueKey ?? 'test-index::test.metric',
});

const defaultProps = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiPortal,
EuiTitle,
isDOMNode,
keys,
Expand Down Expand Up @@ -78,42 +79,44 @@ export const MetricInsightsFlyout = ({ metric, esqlQuery, onClose }: MetricInsig
const maxWidth = euiTheme.breakpoint.xl;

return (
<EuiFlyout
session="start"
flyoutMenuProps={{
title: metricFlyoutTitle,
}}
onClose={onClose}
type="push"
pushMinBreakpoint="xl"
size={flyoutWidthRef.current}
data-test-subj="metricsExperienceFlyout"
aria-labelledby={metricFlyoutTitleId}
onKeyDown={onKeyDown}
ownFocus
minWidth={minWidth}
maxWidth={maxWidth}
resizable={true}
onResize={setFlyoutWidth}
css={{
maxWidth: `${isXlScreen ? `calc(100vw - ${defaultWidth}px)` : '90vw'} !important`,
}}
paddingSize="m"
{...a11yProps}
>
{screenReaderDescription}
<EuiFlyoutHeader>
<EuiTitle size="xs" data-test-subj="metricsExperienceFlyoutRowDetailsTitle">
<h2 id={metricFlyoutTitleId}>{metricFlyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<MetricFlyoutBody
metric={metric}
esqlQuery={esqlQuery}
description={fieldsMetadata[metric.name]?.description}
/>
</EuiFlyoutBody>
</EuiFlyout>
<EuiPortal>
<EuiFlyout
session="start"
flyoutMenuProps={{
title: metricFlyoutTitle,
}}
onClose={onClose}
type="push"
pushMinBreakpoint="xl"
size={flyoutWidthRef.current}
data-test-subj="metricsExperienceFlyout"
aria-labelledby={metricFlyoutTitleId}
onKeyDown={onKeyDown}
ownFocus={false} // Workaround: avoids Emotion insertBefore error when flyout unmounts inside react-reverse-portal
minWidth={minWidth}
maxWidth={maxWidth}
resizable={true}
onResize={setFlyoutWidth}
css={{
maxWidth: `${isXlScreen ? `calc(100vw - ${defaultWidth}px)` : '90vw'} !important`,
}}
paddingSize="m"
{...a11yProps}
>
{screenReaderDescription}
<EuiFlyoutHeader>
<EuiTitle size="xs" data-test-subj="metricsExperienceFlyoutRowDetailsTitle">
<h2 id={metricFlyoutTitleId}>{metricFlyoutTitle}</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<MetricFlyoutBody
metric={metric}
esqlQuery={esqlQuery}
description={fieldsMetadata[metric.name]?.description}
/>
</EuiFlyoutBody>
</EuiFlyout>
</EuiPortal>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe('Metric Flyout Overview Tab', () => {
index: 'test-index',
type: ES_FIELD_TYPES.DOUBLE,
unit: 'ms',
uniqueKey: 'test-index::test.metric',
dimensions: [],
...overrides,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ describe('fields_parser', () => {
type: 'double',
instrument: 'gauge',
dimensions: [],
uniqueKey: 'metrics-*::system.cpu.utilization',
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { DatatableColumn, DatatableRow } from '@kbn/expressions-plugin/comm
import type { ES_FIELD_TYPES } from '@kbn/field-types';
import type { DataViewFieldMap } from '@kbn/data-views-plugin/common';
import type { MetricField, Dimension } from '../../../../../../types';
import { hasValue } from '../../../../../../common/utils/fields';
import { hasValue, getMetricKey } from '../../../../../../common/utils/fields';
import { ALLOWED_METRIC_TYPES, DIMENSION_TYPES } from '../../../../../../common/constants';

const ALLOWED_METRIC_TYPES_SET = new Set(ALLOWED_METRIC_TYPES);
Expand Down Expand Up @@ -76,12 +76,14 @@ export const categorizeFields = ({
dataViewField.timeSeriesMetric &&
ALLOWED_METRIC_TYPES_SET.has(dataViewField.timeSeriesMetric)
) {
const metricIndex = column.meta?.index ?? index;
metricFields.push({
index: column.meta?.index ?? index,
index: metricIndex,
name: columnName,
type: fieldType,
instrument: dataViewField.timeSeriesMetric,
dimensions: [],
uniqueKey: getMetricKey(metricIndex, columnName),
});
} else if (dataViewField.timeSeriesDimension || DIMENSION_TYPES.includes(fieldType)) {
dimensions.push({
Expand Down
Loading