From be6a64dda4db6768710f7cf941d011dd17d08ec9 Mon Sep 17 00:00:00 2001 From: Sumukh Swamy Date: Mon, 10 Jun 2024 22:43:58 -0700 Subject: [PATCH] [Backport 2.x] manual backport of otel-metrics pr (#1892) * resolved conflicts and added the manual cherry pick for #1314 Signed-off-by: sumukhswamy * removed duplicates Signed-off-by: sumukhswamy * updated snapshots Signed-off-by: sumukhswamy --------- Signed-off-by: sumukhswamy Co-authored-by: Kavitha Conjeevaram Mohan --- .../metrics_analytics.spec.js | 67 ++--- .../integration/panels_test/panels.spec.ts | 2 - .cypress/utils/metrics_constants.js | 6 + common/constants/metrics.ts | 19 ++ common/constants/shared.ts | 4 +- common/types/explorer.ts | 5 +- common/types/metrics.ts | 8 +- .../application_analytics/helpers/utils.tsx | 2 +- .../__tests__/query_utils.test.tsx | 9 +- public/components/common/query_utils/index.ts | 49 +++- .../custom_panels/custom_panel_view.tsx | 1 - .../custom_panels/helpers/utils.tsx | 219 ++++++++++++++- .../panel_modules/panel_grid/panel_grid.tsx | 44 ++- .../panel_grid/panel_grid_so.tsx | 48 ++-- .../visualization_container.tsx | 46 ++- .../event_analytics/explorer/explorer.tsx | 2 +- .../saved_query_table.test.tsx.snap | 4 + .../home/saved_objects_table.tsx | 3 +- .../event_analytics/utils/utils.tsx | 68 +++++ public/components/metrics/helpers/utils.tsx | 48 +++- public/components/metrics/index.tsx | 15 +- .../slices/__tests__/metric_slice.test.tsx | 54 ++-- .../metrics/redux/slices/metrics_slice.ts | 73 ++++- .../__snapshots__/sidebar.test.tsx.snap | 265 +++++++++++++++++- .../sidebar/__tests__/sidebar.test.tsx | 20 +- .../metrics/sidebar/data_source_picker.tsx | 39 +++ .../metrics/sidebar/index_picker.tsx | 36 +++ .../metrics/sidebar/metric_name.tsx | 25 +- .../metrics/sidebar/metrics_accordion.tsx | 2 +- public/components/metrics/sidebar/sidebar.tsx | 109 ++++++- .../metrics/top_menu/metrics_export.tsx | 75 +++-- .../components/metrics/view/metrics_grid.tsx | 54 ++-- .../__snapshots__/histogram.test.tsx.snap | 4 +- .../visualizations/charts/lines/line.tsx | 6 +- .../saved_object_visualization.tsx | 85 ++++-- .../visualizations/visualization.tsx | 5 +- .../visualizations/visualization_chart.tsx | 1 - .../embeddable/observability_embeddable.tsx | 11 +- .../event_analytics/saved_objects.ts | 10 +- .../osd_saved_object_client.ts | 5 + .../osd_saved_objects/saved_searches.ts | 1 + .../osd_saved_objects/saved_visualization.ts | 4 + .../saved_object_client/ppl/ppl_client.ts | 5 + .../ppl/saved_visualization.ts | 3 + .../saved_objects_actions.ts | 24 +- .../custom_panels/custom_panel_adaptor.ts | 3 + .../metrics/metrics_analytics_adaptor.ts | 150 ++++++++++ .../event_analytics/event_analytics_router.ts | 2 + server/routes/metrics/metrics_rounter.ts | 158 ++++++++++- 49 files changed, 1583 insertions(+), 315 deletions(-) create mode 100644 public/components/metrics/sidebar/data_source_picker.tsx create mode 100644 public/components/metrics/sidebar/index_picker.tsx create mode 100644 server/adaptors/metrics/metrics_analytics_adaptor.ts diff --git a/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js b/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js index c00e2b7045..611753bf84 100644 --- a/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js +++ b/.cypress/integration/metrics_analytics_test/metrics_analytics.spec.js @@ -55,8 +55,22 @@ describe('Metrics Analytics', () => { suppressResizeObserverIssue(); }); + describe('Check data source picker', () => { + it('Index picker should be only available under Otel metric datasource', () => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); + cy.get('[data-test-subj="metricsIndexPicker"]').should('not.exist'); + + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="openTelemetryOption"]').click(); + cy.get('[data-test-subj="metricsIndexPicker"]').should('exist'); + }); + }); + describe('Search for metrics in search bar', () => { it('Search for metrics in search bar from available metrics', () => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); cy.get('[data-test-subj="metricsSearch"]').type('metric', { wait: 50 }); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') @@ -76,6 +90,8 @@ describe('Metrics Analytics', () => { describe('Select and unselect metrics in sidebar', () => { it('Select and unselect metrics in sidebar', () => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[0]) .trigger('mouseover') @@ -84,7 +100,7 @@ describe('Metrics Analytics', () => { .contains(PPL_METRICS_NAMES[1]) .trigger('mouseover') .click(); - cy.wait(50); + cy.wait(delay/2); cy.get('[data-test-subj="metricsListItems_selectedMetrics"]') .contains(PPL_METRICS_NAMES[0]) .should('exist'); @@ -99,7 +115,7 @@ describe('Metrics Analytics', () => { .contains(PPL_METRICS_NAMES[1]) .trigger('mouseover') .click(); - cy.wait(50); + cy.wait(delay/2); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[0]) .trigger('mouseover') @@ -113,57 +129,29 @@ describe('Metrics Analytics', () => { describe('Test Metric Visualizations', () => { beforeEach(() => { + cy.get('[data-test-subj="metricsDataSourcePicker"]').click(); + cy.get('[data-test-subj="prometheusOption"]').click(); cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[0]) .trigger('mouseover') .click(); }); - it.only('Resize a Metric visualization in edit mode', () => { - cy.get('[data-test-subj="metrics__editView"]') - .contains('Edit view') - .trigger('mouseover') - .click(); - cy.wait(delay); - cy.get('.react-resizable-handle-se') - // .eq(1) - .trigger('mousedown', { which: 1 }) - .trigger('mousemove', { clientX: 2000, clientY: 800 }) - .trigger('mouseup', { force: true }); - cy.wait(delay); - cy.get('[data-test-subj="metrics__saveView"]').trigger('mouseover').click(); - cy.wait(delay * 3); - cy.get('div.react-grid-layout>div').invoke('height').should('match', new RegExp('790')); - cy.wait(delay); - }); - it('Drag and drop a Metric visualization in edit mode', () => { cy.get('[data-test-subj="metricsListItems_availableMetrics"]') .contains(PPL_METRICS_NAMES[1]) .trigger('mouseover') .click(); - cy.get('[data-test-subj="metrics__editView"]') - .contains('Edit view') - .trigger('mouseover') - .click(); - cy.wait(delay); cy.get('h5') .contains(PPL_METRICS_NAMES[0]) .trigger('mousedown', { which: 1, force: true }) .trigger('mousemove', { clientX: 415, clientY: 500 }) .trigger('mouseup', { force: true }); - cy.wait(delay); - cy.get('[data-test-subj="metrics__saveView"]') - .trigger('mouseover') - .click({ force: true }) - .then(() => { - cy.wait(delay * 3); - cy.get('div.react-grid-layout>div') - .eq(1) - .invoke('attr', 'style') - .should('match', new RegExp('(.*)transform: translate((.*)10px)(.*)')); - cy.wait(delay); - }); + cy.wait(delay * 3); + cy.get('div.react-grid-layout>div') + .eq(1) + .invoke('attr', 'style') + .should('match', new RegExp('(.*)transform: translate((.*)10px)(.*)')); }); it('Change date filter of the Metrics home page', () => { @@ -175,7 +163,6 @@ describe('Metrics Analytics', () => { cy.get('.euiSuperDatePicker__prettyFormat[data-test-subj="superDatePickerShowDatesButton"]') .contains('This year') .should('exist'); - cy.wait(delay); }); it('Saves metrics to an existing panel', () => { @@ -228,7 +215,6 @@ const createCustomMetric = ({ testMetricIndex }) => { delay: 50, }); cy.get('.euiButton__text').contains('Refresh').trigger('mouseover').click(); - cy.wait(delay); suppressResizeObserverIssue(); cy.get('button[id="main-content-vis"]').contains('Visualizations').trigger('mouseover').click(); cy.wait(delay * 2); @@ -239,11 +225,10 @@ const createCustomMetric = ({ testMetricIndex }) => { .focus() .type(PPL_METRICS_NAMES[metricIndex], { force: true }); cy.get('[data-test-subj="eventExplorer__metricSaveName"]').click({ force: true }); - cy.wait(1000); + cy.wait(delay * 10); cy.get('[data-test-subj="eventExplorer__querySaveConfirm"]', { timeout: COMMAND_TIMEOUT_LONG, }).click(); - cy.wait(delay); cy.get('.euiToastHeader__title').contains('successfully').should('exist'); }; diff --git a/.cypress/integration/panels_test/panels.spec.ts b/.cypress/integration/panels_test/panels.spec.ts index bd6e7a7f37..6bd6abc67d 100644 --- a/.cypress/integration/panels_test/panels.spec.ts +++ b/.cypress/integration/panels_test/panels.spec.ts @@ -776,7 +776,6 @@ const createSavedObjectPanel = (newName = TEST_PANEL) => { }; const addVisualizationsToPanel = (panel, additionalVisualizationIds: string[]) => { - console.log('addVisualizationsToPanel', additionalVisualizationIds); const additionalVisualizations = additionalVisualizationIds.map((id, idx) => { return { savedVisualizationId: `observability-visualization:${id}`, @@ -792,7 +791,6 @@ const addVisualizationsToPanel = (panel, additionalVisualizationIds: string[]) = ...panel.attributes.visualizations, ...additionalVisualizations, ]; - console.log(panel.attributes); cy.request({ method: 'PUT', failOnStatusCode: false, diff --git a/.cypress/utils/metrics_constants.js b/.cypress/utils/metrics_constants.js index 63c5c18955..1698f5da55 100644 --- a/.cypress/utils/metrics_constants.js +++ b/.cypress/utils/metrics_constants.js @@ -21,3 +21,9 @@ export const PPL_METRICS = [ export const VIS_TYPE_LINE = 'Time Series'; export const TESTING_PANEL = 'Mock Testing Panels for Metrics'; + +export const OTEL_METRICS_NAMES = [ + '[Otel Metric] ss4o_metrics-otel-dp.duration', + '[Otel Metric] ss4o_metrics-otel-dp.http.client.duration', + '[Otel Metric] ss4o_metrics-otel-dp.rpc.client.duration', +]; diff --git a/common/constants/metrics.ts b/common/constants/metrics.ts index 7e92e6663b..ffef2b8aa1 100644 --- a/common/constants/metrics.ts +++ b/common/constants/metrics.ts @@ -14,6 +14,7 @@ export const PPL_DATASOURCES_REQUEST = // redux export const OBSERVABILITY_CUSTOM_METRIC = 'CUSTOM_METRICS'; +export const OPEN_TELEMETRY = 'OpenTelemetry'; export const REDUX_SLICE_METRICS = 'metrics'; export const resolutionOptions = [ @@ -33,3 +34,21 @@ export const AGGREGATION_OPTIONS = [ { value: 'min', text: 'min()' }, { value: 'max', text: 'max()' }, ]; + +export const DATASOURCE_OPTIONS = [ + { + label: 'Prometheus', + 'data-test-subj': 'prometheusOption', + }, + { + label: 'OpenTelemetry', + 'data-test-subj': 'openTelemetryOption', + }, +]; +export const DATA_PREPPER_INDEX_NAME = 'ss4o_metrics-*-*'; +export const METRICS_ANALYTICS_DATA_PREPPER_INDICES_ROUTE = + '/api/observability/metrics_analytics/data_prepper_indices'; + +// Regex pattens +export const INDEX_DOCUMENT_NAME_PATTERN = /\[Otel Metric\]\s(\S+?-\S+?)\.(\S+)/; +export const SPAN_RESOLUTION_REGEX = /'(\d+)([smhdwMy])'/; diff --git a/common/constants/shared.ts b/common/constants/shared.ts index b30435eb3c..d9373b4f8b 100644 --- a/common/constants/shared.ts +++ b/common/constants/shared.ts @@ -2,7 +2,6 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -import CSS from 'csstype'; // Client route export const PPL_BASE = '/api/ppl'; @@ -81,9 +80,11 @@ export const PPL_PATTERNS_DOCUMENTATION_URL = 'https://github.com/opensearch-project/sql/blob/2.x/docs/user/ppl/cmd/patterns.rst#description'; export const UI_DATE_FORMAT = 'MM/DD/YYYY hh:mm A'; export const PPL_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss.SSSSSS'; +export const OTEL_DATE_FORMAT = 'YYYY-MM-DDTHH:mm:ss'; export const SPAN_REGEX = /span/; export const PROMQL_METRIC_SUBTYPE = 'promqlmetric'; +export const OTEL_METRIC_SUBTYPE = 'openTelemetryMetric'; export const PPL_METRIC_SUBTYPE = 'metric'; export const PPL_SPAN_REGEX = /by\s*span/i; @@ -136,6 +137,7 @@ export enum VIS_CHART_TYPES { Pie = 'pie', HeatMap = 'heatmap', Text = 'text', + Histogram = 'histogram', } export const NUMERICAL_FIELDS = ['short', 'integer', 'long', 'float', 'double']; diff --git a/common/types/explorer.ts b/common/types/explorer.ts index 7db9a64d40..706ca5883c 100644 --- a/common/types/explorer.ts +++ b/common/types/explorer.ts @@ -19,7 +19,7 @@ import { SavedObjectsStart, } from '../../../../src/core/public/saved_objects'; import { DataSourceType } from '../../../../src/plugins/data/public'; -import { VIS_CHART_TYPES } from '../../common/constants/shared'; +import { OTEL_METRIC_SUBTYPE, VIS_CHART_TYPES } from '../../common/constants/shared'; import DSLService from '../../public/services/requests/dsl'; import PPLService from '../../public/services/requests/ppl'; import SavedObjects from '../../public/services/saved_objects/event_analytics/saved_objects'; @@ -173,12 +173,13 @@ export interface SavedVisualization extends SavedObjectAttributes { selected_fields: { text: string; tokens: [] }; selected_timestamp: IField; type: string; - subType?: 'metric' | 'visualization' | typeof PROMQL_METRIC_SUBTYPE; // exists if sub type is metric + subType?: 'metric' | 'visualization'; // exists if sub type is metric user_configs?: string; units_of_measure?: string; application_id?: string; dataSources: string; // list of type SelectedDataSources that is stringified queryLang: string; + metricType?: typeof PROMQL_METRIC_SUBTYPE | typeof OTEL_METRIC_SUBTYPE; // exists if sub type is metric } export interface ExplorerDataType { diff --git a/common/types/metrics.ts b/common/types/metrics.ts index 7cefde98b8..55a9470cae 100644 --- a/common/types/metrics.ts +++ b/common/types/metrics.ts @@ -4,6 +4,7 @@ */ import { VisualizationType } from './custom_panels'; +type MetricTypes = 'savedCustomMetric' | 'prometheusMetric' | 'openTelemetryMetric'; export interface MetricType extends VisualizationType { id: string; @@ -13,10 +14,15 @@ export interface MetricType extends VisualizationType { w: number; h: number; query: { - type: 'savedCustomMetric' | 'prometheusMetric'; + type: MetricTypes; aggregation: string; attributesGroupBy: string[]; catalog: string; availableAttributes?: string[]; }; } + +export interface OptionType { + label: string; + 'data-test-subj': string; +} diff --git a/public/components/application_analytics/helpers/utils.tsx b/public/components/application_analytics/helpers/utils.tsx index 67ac27fb7d..12516e5a58 100644 --- a/public/components/application_analytics/helpers/utils.tsx +++ b/public/components/application_analytics/helpers/utils.tsx @@ -216,7 +216,7 @@ export const calculateAvailability = async ( for (let i = 0; i < savedVisualizationsIds.length; i++) { const visualizationId = savedVisualizationsIds[i]; // Fetches data for visualization - const visData = await fetchVisualizationById(http, visualizationId, (value: string) => + const visData = await fetchVisualizationById(visualizationId, (value: string) => console.error(value) ); diff --git a/public/components/common/query_utils/__tests__/query_utils.test.tsx b/public/components/common/query_utils/__tests__/query_utils.test.tsx index 3307a53807..f153ae8f7f 100644 --- a/public/components/common/query_utils/__tests__/query_utils.test.tsx +++ b/public/components/common/query_utils/__tests__/query_utils.test.tsx @@ -109,6 +109,7 @@ describe('Query Utils', () => { span: '1', resolution: 'h', }; + describe('updateCatalogVisualizationQuery', () => { it('should build plain promQL series query', () => { const query = updateCatalogVisualizationQuery(defaultQueryMetaData); @@ -126,12 +127,14 @@ describe('Query Utils', () => { it('should set timestamps and default resolution', () => { const [startDate, endDate] = ['2023-11-11', '2023-12-11']; const [start, end] = [1699660800, 1702252800]; // 2023-11-11 to 2023-12-11 - const query = preprocessMetricQuery({ - metaData: { queryMetaData: defaultQueryMetaData }, + const currentQuery = + "source = test_catalog.query_range('count by(one,two) (metric)', 1699660800, 1702252800, '1d')"; + const expectedQuery = preprocessMetricQuery({ + metaData: { query: currentQuery, queryMetaData: defaultQueryMetaData }, startTime: startDate, endTime: endDate, }); - expect(query).toMatch(new RegExp(`, ${start}, ${end}, '1d'`)); + expect(expectedQuery).toMatch(new RegExp(`, ${start}, ${end}, '1d'`)); }); }); }); diff --git a/public/components/common/query_utils/index.ts b/public/components/common/query_utils/index.ts index 4c1ea439f0..fa7ab638c0 100644 --- a/public/components/common/query_utils/index.ts +++ b/public/components/common/query_utils/index.ts @@ -4,23 +4,27 @@ */ import dateMath from '@elastic/datemath'; -import { Moment } from 'moment-timezone'; import { isEmpty } from 'lodash'; -import { SearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; +import { Moment } from 'moment-timezone'; import { PPL_DEFAULT_PATTERN_REGEX_FILETER, SELECTED_DATE_RANGE, SELECTED_FIELDS, SELECTED_TIMESTAMP, } from '../../../../common/constants/explorer'; +import { SPAN_RESOLUTION_REGEX } from '../../../../common/constants/metrics'; import { + OTEL_DATE_FORMAT, + OTEL_METRIC_SUBTYPE, PPL_DATE_FORMAT, + PPL_DESCRIBE_INDEX_REGEX, PPL_INDEX_INSERT_POINT_REGEX, PPL_INDEX_REGEX, PPL_NEWLINE_REGEX, - PPL_DESCRIBE_INDEX_REGEX, + PROMQL_METRIC_SUBTYPE, } from '../../../../common/constants/shared'; import { IExplorerFields, IQuery } from '../../../../common/types/explorer'; +import { SearchMetaData } from '../../event_analytics/redux/slices/search_meta_data_slice'; /* * "Query Utils" This file contains different reused functions in operational panels @@ -62,15 +66,24 @@ export const convertDateTime = ( datetime: string, isStart = true, formatted = true, - isMetrics: boolean = false + metricType: string = '' ) => { - let returnTime: undefined | Moment; + let returnTime: Moment = ''; + if (isStart) { returnTime = dateMath.parse(datetime); } else { returnTime = dateMath.parse(datetime, { roundUp: true }); } - if (isMetrics) { + + if (metricType === OTEL_METRIC_SUBTYPE) { + const formattedDate = returnTime!.utc().format(OTEL_DATE_FORMAT); + const milliseconds = returnTime!.millisecond(); + const formattedMilliseconds = String(milliseconds).padEnd(6, '0'); + return `${formattedDate}.${formattedMilliseconds}Z`; + } + + if (metricType === PROMQL_METRIC_SUBTYPE) { const myDate = new Date(returnTime._d); // Your timezone! const epochTime = myDate.getTime() / 1000.0; return Math.round(epochTime); @@ -101,8 +114,8 @@ export const updateCatalogVisualizationQuery = ({ resolution: string; }) => { const attributesGroupString = attributesGroupBy.join(','); - const startEpochTime = convertDateTime(start, true, false, true); - const endEpochTime = convertDateTime(end, false, false, true); + const startEpochTime = convertDateTime(start, true, false, PROMQL_METRIC_SUBTYPE); + const endEpochTime = convertDateTime(end, false, false, PROMQL_METRIC_SUBTYPE); const promQuery = attributesGroupBy.length === 0 ? `${aggregation} (${catalogTableName})` @@ -184,13 +197,14 @@ export const updatePromQLQueryFilters = ( const { connection, metric, aggregation, attributesGroupBy } = parsePromQLIntoKeywords( promQLQuery ); + const promQLPart = buildPromQLFromMetricQuery({ metric, attributesGroupBy: attributesGroupBy.split(','), aggregation, }); - const start = convertDateTime(startTime, true, false, true); - const end = convertDateTime(endTime, false, false, true); + const start = convertDateTime(startTime, true, false, PROMQL_METRIC_SUBTYPE); + const end = convertDateTime(endTime, false, false, PROMQL_METRIC_SUBTYPE); return `source = ${connection}.query_range('${promQLPart}', ${start}, ${end}, '1h')`; }; @@ -214,19 +228,25 @@ export const getDescribeQueryIndexFromRawQuery = (query: string): string | undef return undefined; }; +function extractSpanAndResolution(query: string) { + if (!query) return; + + const match = query.match(SPAN_RESOLUTION_REGEX); + return match ? { span: parseInt(match[1], 10), resolution: match[2] } : null; +} + export const preprocessMetricQuery = ({ metaData, startTime, endTime }) => { // convert to moment const start = convertDateTime(startTime, true); const end = convertDateTime(endTime, false); - - const resolution = findMinInterval(start, end); + const spanResolution = extractSpanAndResolution(metaData?.query); const visualizationQuery = updateCatalogVisualizationQuery({ ...metaData.queryMetaData, start, end, - span: '1', - resolution, + span: spanResolution?.span || 1, + resolution: spanResolution?.resolution || 'h', }); return visualizationQuery; @@ -255,7 +275,6 @@ export const preprocessQuery = ({ whereClause?: string; }) => { let finalQuery = ''; - if (isEmpty(rawQuery)) return finalQuery; // convert to moment diff --git a/public/components/custom_panels/custom_panel_view.tsx b/public/components/custom_panels/custom_panel_view.tsx index b43f15e937..9f85485f81 100644 --- a/public/components/custom_panels/custom_panel_view.tsx +++ b/public/components/custom_panels/custom_panel_view.tsx @@ -388,7 +388,6 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { const visualizationId = panelVisualizations[i].savedVisualizationId; // TODO: create route to get list of visualizations in one call const visData: SavedVisualizationType = await fetchVisualizationById( - http, visualizationId, (error: VizContainerError) => setToast(error.errorMessage, 'danger') ); diff --git a/public/components/custom_panels/helpers/utils.tsx b/public/components/custom_panels/helpers/utils.tsx index 1a0d2c2b6e..4989f52466 100644 --- a/public/components/custom_panels/helpers/utils.tsx +++ b/public/components/custom_panels/helpers/utils.tsx @@ -9,8 +9,15 @@ import _, { forEach, isEmpty, min } from 'lodash'; import { Moment } from 'moment-timezone'; import React from 'react'; import { Layout } from 'react-grid-layout'; -import { CoreStart, SavedObjectAttributes } from '../../../../../../src/core/public'; -import { PPL_INDEX_REGEX, PPL_WHERE_CLAUSE_REGEX } from '../../../../common/constants/shared'; +import { SavedObjectAttributes } from '../../../../../../src/core/public'; +import { INDEX_DOCUMENT_NAME_PATTERN } from '../../../../common/constants/metrics'; +import { + OBSERVABILITY_BASE, + OTEL_METRIC_SUBTYPE, + PPL_INDEX_REGEX, + PPL_METRIC_SUBTYPE, + PPL_WHERE_CLAUSE_REGEX, +} from '../../../../common/constants/shared'; import { QueryManager } from '../../../../common/query_manager'; import { SavedVisualizationType, @@ -18,15 +25,15 @@ import { VizContainerError, } from '../../../../common/types/custom_panels'; import { SavedVisualization } from '../../../../common/types/explorer'; -import { removeBacktick } from '../../../../common/utils'; +import { MetricType } from '../../../../common/types/metrics'; +import { getOSDHttp, removeBacktick } from '../../../../common/utils'; import { getVizContainerProps } from '../../../components/visualizations/charts/helpers'; import PPLService from '../../../services/requests/ppl'; import { SavedObjectsActions } from '../../../services/saved_objects/saved_object_client/saved_objects_actions'; import { ObservabilitySavedVisualization } from '../../../services/saved_objects/saved_object_client/types'; +import { convertDateTime, updateCatalogVisualizationQuery } from '../../common/query_utils'; import { getDefaultVisConfig } from '../../event_analytics/utils'; import { Visualization } from '../../visualizations/visualization'; -import { MetricType } from '../../../../common/types/metrics'; -import { convertDateTime, updateCatalogVisualizationQuery } from '../../common/query_utils'; /* * "Utils" This file contains different reused functions in operational panels @@ -130,7 +137,6 @@ const queryAccumulator = ( // Fetched Saved Visualization By Id export const fetchVisualizationById = async ( - http: CoreStart['http'], savedVisualizationId: string, setIsError: (error: VizContainerError) => void ) => { @@ -270,6 +276,7 @@ const createCatalogVisualizationMetaData = ({ query, type, subType, + metricType, timeField, queryData, }: { @@ -277,6 +284,7 @@ const createCatalogVisualizationMetaData = ({ query: string; type: string; subType: string; + metricType: string; timeField: string; queryData: object; }) => { @@ -286,6 +294,7 @@ const createCatalogVisualizationMetaData = ({ query, type, subType, + metricType, selected_date_range: { start: 'now/y', end: 'now', @@ -307,7 +316,6 @@ const createCatalogVisualizationMetaData = ({ // Creates a catalogVisualization for a runtime catalog based PPL query and runs getQueryResponse export const renderCatalogVisualization = async ({ - http, pplService, catalogSource, startTime, @@ -321,10 +329,8 @@ export const renderCatalogVisualization = async ({ setVisualizationMetaData, setIsLoading, setIsError, - spanResolution, visualization, }: { - http: CoreStart['http']; pplService: PPLService; catalogSource: string; startTime: string; @@ -338,7 +344,6 @@ export const renderCatalogVisualization = async ({ setVisualizationMetaData: React.Dispatch>; setIsLoading: React.Dispatch>; setIsError: React.Dispatch>; - spanResolution?: string; queryMetaData?: MetricType; visualization: SavedVisualizationType; }) => { @@ -347,7 +352,6 @@ export const renderCatalogVisualization = async ({ const visualizationType = 'line'; const visualizationTimeField = '@timestamp'; - const visualizationSubType = visualization.subType; const visualizationQuery = updateCatalogVisualizationQuery({ ...visualization.queryMetaData, @@ -378,6 +382,7 @@ export const renderCatalogVisualization = async ({ query: visualizationQuery, type: visualizationType, subType: visualization.subType, + metricType: visualization.metricType, timeField: visualizationTimeField, queryData, }); @@ -390,6 +395,182 @@ export const renderCatalogVisualization = async ({ setIsLoading(false); }; +const createOtelVisualizationMetaData = ( + documentName: string, + visualizationType: string, + startTime: string, + endTime: string, + queryData: object +) => { + return { + name: documentName, + description: '', + query: '', + type: visualizationType, + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, + selected_date_range: { + start: startTime, + end: endTime, + text: '', + }, + selected_fields: { + text: '', + tokens: [], + }, + userConfigs: { + layout: dynamicLayoutFromQueryData(queryData), + }, + }; +}; + +export const fetchAggregatedBinCount = async ( + minimumBound: string, + maximumBound: string, + startTime: string, + endTime: string, + documentName: string, + selectedOtelIndex: string, + setIsError: React.Dispatch>, + setIsLoading: React.Dispatch> +) => { + const http = getOSDHttp(); + try { + const response = await http.post(`${OBSERVABILITY_BASE}/metrics/otel/aggregatedBinCount`, { + body: JSON.stringify({ + min: minimumBound, + max: maximumBound, + startTime, + endTime, + documentName, + index: selectedOtelIndex, + }), + }); + return response; + } catch (error) { + const errorMessage = JSON.parse(error.body.message); + setIsError({ + errorMessage: errorMessage.error.reason || 'Issue in fetching bucket count', + errorDetails: errorMessage.error.details, + }); + console.error(error.body); + } finally { + setIsLoading(false); + } +}; + +export const fetchSampleOTDocument = async (selectedOtelIndex: string, documentName: string) => { + const http = getOSDHttp(); + try { + const response = await http.get( + `${OBSERVABILITY_BASE}/metrics/otel/${selectedOtelIndex}/${documentName}` + ); + return response; + } catch (error) { + console.error(error); + throw error; + } +}; + +export const extractIndexAndDocumentName = (metricString: string): [string, string] | null => { + const match = metricString.match(INDEX_DOCUMENT_NAME_PATTERN); + + if (match) { + const index = match[1]; + const documentName = match[2]; + return [index, documentName]; + } else { + return null; + } +}; + +export const renderOpenTelemetryVisualization = async ({ + startTime, + endTime, + setVisualizationTitle, + setVisualizationType, + setVisualizationData, + setVisualizationMetaData, + setIsLoading, + setIsError, + visualization, + setToast, +}: { + startTime: string; + endTime: string; + setVisualizationTitle: React.Dispatch>; + setVisualizationType: React.Dispatch>; + setVisualizationData: React.Dispatch>; + setVisualizationMetaData: React.Dispatch>; + setIsLoading: React.Dispatch>; + setIsError: React.Dispatch>; + visualization: any; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; +}) => { + setIsLoading(true); + setIsError({} as VizContainerError); + + const visualizationType = 'bar'; + let index = visualization?.index; + let documentName = visualization?.name; + + if (index === undefined) { + const indexAndDocumentName = extractIndexAndDocumentName(visualization.name); + index = indexAndDocumentName[0]; + documentName = indexAndDocumentName[1]; + if (documentName === undefined) + setToast('Document name is undefined', 'danger', undefined, 'right'); + } + + const fetchSampleDocument = await fetchSampleOTDocument(index, documentName); + const source = fetchSampleDocument.hits[0]._source; + + setVisualizationType(visualizationType); + setVisualizationTitle(source.name); + + const dataBinsPromises = source.buckets.map(async (bucket: any) => { + try { + const formattedStartTime = convertDateTime(startTime, false, false, OTEL_METRIC_SUBTYPE); + const formattedEndTime = convertDateTime(endTime, false, false, OTEL_METRIC_SUBTYPE); + const fetchingAggregatedBinCount = await fetchAggregatedBinCount( + bucket.min.toString(), + bucket.max.toString(), + formattedStartTime, + formattedEndTime, + documentName, + index, + setIsError, + setIsLoading + ); + + return { + xAxis: bucket.min + ' - ' + bucket.max, + 'count()': fetchingAggregatedBinCount?.nested_buckets?.bucket_range?.bucket_count?.value, + }; + } catch (error) { + console.error('Error processing bucket:', error); + return null; + } + }); + const jsonData = await Promise.all(dataBinsPromises); + const formatedJsonData = { jsonData }; + + const visualizationMetaData = createOtelVisualizationMetaData( + documentName, + visualizationType, + startTime, + endTime, + formatedJsonData + ); + setVisualizationData(formatedJsonData); + setVisualizationMetaData(visualizationMetaData); +}; + // Function to store recently used time filters and set start and end time. export const prependRecentlyUsedRange = ( start: ShortDate, @@ -423,6 +604,9 @@ export const parseSavedVisualizations = ( subType: visualization.savedVisualization.hasOwnProperty('subType') ? visualization.savedVisualization.subType : '', + metricType: visualization.savedVisualization.hasOwnProperty('metricType') + ? visualization.savedVisualization.metricType + : '', units_of_measure: visualization.savedVisualization.hasOwnProperty('units_of_measure') ? visualization.savedVisualization.units_of_measure : '', @@ -482,18 +666,18 @@ export const isPPLFilterValid = ( return true; }; -export const processMetricsData = (schema: any, dataConfig: any) => { +export const processMetricsData = (schema: any) => { if (isEmpty(schema)) return {}; if ( schema.length === 3 && schema.every((schemaField) => ['@labels', '@value', '@timestamp'].includes(schemaField.name)) ) { - return prepareMetricsData(schema, dataConfig); + return prepareMetricsData(schema); } return {}; }; -export const prepareMetricsData = (schema: any, dataConfig: any) => { +export const prepareMetricsData = (schema: any) => { const metricBreakdown: any[] = []; const metricSeries: any[] = []; const metricDimension: any[] = []; @@ -569,7 +753,12 @@ export const displayVisualization = (metaData: any, data: any, type: string) => }; // add metric specific overriding - finalDataConfig = { ...finalDataConfig, ...processMetricsData(data.schema, finalDataConfig) }; + finalDataConfig = { ...finalDataConfig, ...processMetricsData(data.schema) }; + + // add otel metric specific overriding + if (metaData?.metricType === OTEL_METRIC_SUBTYPE) { + finalDataConfig = { ...finalDataConfig, ...constructOtelMetricsMetaData() }; + } const mixedUserConfigs = { availabilityConfig: { diff --git a/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx index f7d7de6720..63eab7bcc0 100644 --- a/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx @@ -6,7 +6,7 @@ import _ from 'lodash'; import React, { useEffect, useState } from 'react'; -import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout'; +import { Layout, Responsive, WidthProvider } from 'react-grid-layout'; import useObservable from 'react-use/lib/useObservable'; import { CoreStart } from '../../../../../../../src/core/public'; import PPLService from '../../../../services/requests/ppl'; @@ -85,33 +85,31 @@ export const PanelGrid = (props: PanelGridProps) => { const isLocked = useObservable(chrome.getIsNavDrawerLocked$()); // Reset Size of Visualizations when layout is changed - const layoutChanged = (currLayouts: Layout[], allLayouts: Layouts) => { + const layoutChanged = (currLayouts: Layout[]) => { window.dispatchEvent(new Event('resize')); setPostEditLayout(currLayouts); }; const loadVizComponents = () => { - const gridDataComps = panelVisualizations.map( - (panelVisualization: VisualizationType, index) => ( - - ) - ); + const gridDataComps = panelVisualizations.map((panelVisualization: VisualizationType) => ( + + )); setGridData(gridDataComps); }; diff --git a/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx b/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx index f2a1cff644..08b2e0cccb 100644 --- a/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx @@ -5,9 +5,9 @@ /* eslint-disable react-hooks/exhaustive-deps */ import _, { forEach } from 'lodash'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { Layout, Layouts, Responsive, WidthProvider } from 'react-grid-layout'; +import { Layout, Responsive, WidthProvider } from 'react-grid-layout'; import useObservable from 'react-use/lib/useObservable'; import { CoreStart } from '../../../../../../../src/core/public'; import { VisualizationContainer } from '../visualization_container'; @@ -82,33 +82,33 @@ export const PanelGridSO = (props: PanelGridProps) => { const isLocked = useObservable(chrome.getIsNavDrawerLocked$()); // Reset Size of Visualizations when layout is changed - const layoutChanged = (currLayouts: Layout[], allLayouts: Layouts) => { + const layoutChanged = (currLayouts: Layout[]) => { window.dispatchEvent(new Event('resize')); setPostEditLayout(currLayouts); }; const loadVizComponents = () => { - const gridDataComps = panelVisualizations.map( - (panelVisualization: VisualizationType, index) => ( - - ) - ); + const gridDataComps = panelVisualizations.map((panelVisualization: VisualizationType) => ( + + )); setGridData(gridDataComps); }; diff --git a/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx b/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx index 268ae9a65a..1fe787a430 100644 --- a/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx +++ b/public/components/custom_panels/panel_modules/visualization_container/visualization_container.tsx @@ -24,23 +24,26 @@ import { EuiText, EuiToolTip, } from '@elastic/eui'; -import React, { useEffect, useMemo, useState } from 'react'; import { isEmpty } from 'lodash'; +import React, { useEffect, useMemo, useState } from 'react'; import { useSelector } from 'react-redux'; +import { + OTEL_METRIC_SUBTYPE, + PROMQL_METRIC_SUBTYPE, + observabilityMetricsID, +} from '../../../../../common/constants/shared'; +import { VizContainerError } from '../../../../../common/types/custom_panels'; +import { coreRefs } from '../../../../framework/core_refs'; +import { useToast } from '../../../common/toast'; +import { metricQuerySelector } from '../../../metrics/redux/slices/metrics_slice'; import { displayVisualization, fetchVisualizationById, renderCatalogVisualization, - renderSavedVisualization, + renderOpenTelemetryVisualization, + renderSavedVisualization } from '../../helpers/utils'; import './visualization_container.scss'; -import { VizContainerError } from '../../../../../common/types/custom_panels'; -import { metricQuerySelector } from '../../../metrics/redux/slices/metrics_slice'; -import { coreRefs } from '../../../../framework/core_refs'; -import { - observabilityMetricsID, - PROMQL_METRIC_SUBTYPE, -} from '../../../../../common/constants/shared'; /* * Visualization container - This module is a placeholder to add visualizations in react-grid-layout @@ -114,6 +117,7 @@ export const VisualizationContainer = ({ const onActionsMenuClick = () => setIsPopoverOpen((currPopoverOpen) => !currPopoverOpen); const closeActionsMenu = () => setIsPopoverOpen(false); const { http, pplService } = coreRefs; + const { setToast } = useToast(); const [isModalVisible, setIsModalVisible] = useState(false); const [modalContent, setModalContent] = useState(<>); @@ -180,7 +184,7 @@ export const VisualizationContainer = ({ disabled={editMode} onClick={() => { closeActionsMenu(); - if (visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE) { + if (visualizationMetaData?.metricType === PROMQL_METRIC_SUBTYPE) { window.location.assign(`${observabilityMetricsID}#/${savedVisualizationId}`); } else { onEditClick(savedVisualizationId); @@ -227,7 +231,7 @@ export const VisualizationContainer = ({ ]; if ( - visualizationMetaData?.subType === PROMQL_METRIC_SUBTYPE && + visualizationMetaData?.metricType === PROMQL_METRIC_SUBTYPE && actionMenuType === 'metricsGrid' ) { popoverPanel = [showPPLQueryPanel]; @@ -237,7 +241,7 @@ export const VisualizationContainer = ({ const fetchVisualization = async () => { return savedVisualizationId - ? await fetchVisualizationById(http, savedVisualizationId, setIsError) + ? await fetchVisualizationById(savedVisualizationId, setIsError) : inputMetaData; }; @@ -247,10 +251,22 @@ export const VisualizationContainer = ({ if (!visualization && !savedVisualizationId) return; - if (visualization.subType === PROMQL_METRIC_SUBTYPE) { + if (visualization.metricType === OTEL_METRIC_SUBTYPE) + await renderOpenTelemetryVisualization({ + visualization, + startTime: fromTime, + endTime: toTime, + setVisualizationTitle, + setVisualizationType, + setVisualizationData, + setVisualizationMetaData, + setIsLoading, + setIsError, + setToast, + }); + else if (visualization.metricType === PROMQL_METRIC_SUBTYPE) renderCatalogVisualization({ visualization, - http, pplService, catalogSource: visualizationId, startTime: fromTime, @@ -266,7 +282,7 @@ export const VisualizationContainer = ({ setIsError, queryMetaData, }); - } else + else await renderSavedVisualization({ visualization, http, diff --git a/public/components/event_analytics/explorer/explorer.tsx b/public/components/event_analytics/explorer/explorer.tsx index d5043f8e31..7da03153f8 100644 --- a/public/components/event_analytics/explorer/explorer.tsx +++ b/public/components/event_analytics/explorer/explorer.tsx @@ -723,7 +723,7 @@ export const Explorer = ({ indexFields: explorerFields, userConfigs: { ...visualizationSettings, - ...processMetricsData(explorerData.schema, visualizationSettings), + ...processMetricsData(explorerData.schema), }, appData: { fromApp: appLogEvents }, explorer: { explorerData, explorerFields, query, http, pplService }, diff --git a/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap b/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap index d8e3f5fd06..b543738ae2 100644 --- a/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap +++ b/public/components/event_analytics/home/__tests__/__snapshots__/saved_query_table.test.tsx.snap @@ -93,6 +93,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination save to panel", "objectId": "Kocoln0BYMuJGDsOwDma", "objectType": "savedVisualization", @@ -109,6 +110,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination", "objectId": "KIcoln0BYMuJGDsOhDmk", "objectType": "savedVisualization", @@ -603,6 +605,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination save to panel", "objectId": "Kocoln0BYMuJGDsOwDma", "objectType": "savedVisualization", @@ -619,6 +622,7 @@ exports[`Saved query table component Renders saved query table 1`] = ` "date_end": "now", "date_start": "now-15m", "fields": Array [], + "metricType": "customMetric", "name": "Mock Flight count by destination", "objectId": "KIcoln0BYMuJGDsOhDmk", "objectType": "savedVisualization", diff --git a/public/components/event_analytics/home/saved_objects_table.tsx b/public/components/event_analytics/home/saved_objects_table.tsx index 4d42a0b071..dc4b01ff02 100644 --- a/public/components/event_analytics/home/saved_objects_table.tsx +++ b/public/components/event_analytics/home/saved_objects_table.tsx @@ -66,7 +66,7 @@ export function SavedQueryTable({ sortable: true, truncateText: true, render: (item: any) => { - return item.subType === PROMQL_METRIC_SUBTYPE ? ( + return item.metricType === PROMQL_METRIC_SUBTYPE ? ( Final Query is as follows: @@ -267,6 +275,7 @@ export const getMetricVisConfig = (metric) => { [BREAKDOWNS]: [], queryMetaData: metric.queryMetaData, subType: metric.subType, + metricType: metric.metricType, legend: { showLegend: 'hidden' }, // force no-legend in dashboard displays }; }; @@ -301,6 +310,65 @@ export const getDefaultVisConfig = (statsToken: statsChunk) => { }; }; +export const fetchOtelMetric = async ({ + visualizationName, + startTime, + endTime, + setIsError, + setIsLoading, + setToast, +}: { + visualizationName: string; + startTime: string; + endTime: string; + setIsLoading: React.Dispatch>; + setIsError: React.Dispatch>; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; +}) => { + const indexAndDocumentName = extractIndexAndDocumentName(visualizationName); + const index = indexAndDocumentName[0]; + const documentName = indexAndDocumentName[1]; + if (documentName === undefined) + setToast('Document name is undefined', 'danger', undefined, 'right'); + + const fetchSampleDocument = await fetchSampleOTDocument(index, documentName); + const source = fetchSampleDocument.hits[0]._source; + + const dataBinsPromises = source.buckets.map(async (bucket: any) => { + try { + const formattedStartTime = convertDateTime(startTime, false, false, OTEL_METRIC_SUBTYPE); + const formattedEndTime = convertDateTime(endTime, false, false, OTEL_METRIC_SUBTYPE); + const fetchingAggregatedBinCount = await fetchAggregatedBinCount( + bucket.min.toString(), + bucket.max.toString(), + formattedStartTime, + formattedEndTime, + documentName, + index, + setIsError, + setIsLoading + ); + + return { + xAxis: bucket.min + ' - ' + bucket.max, + 'count()': fetchingAggregatedBinCount?.nested_buckets?.bucket_range?.bucket_count?.value, + }; + } catch (error) { + console.error('Error processing bucket:', error); + return null; + } + }); + const jsonData = await Promise.all(dataBinsPromises); + const formatedJsonData = { jsonData }; + + return formatedJsonData; +}; + const getSpanValue = (groupByToken: GroupByChunk) => { const timeUnitValue = TIME_INTERVAL_OPTIONS.find( (timeUnit) => timeUnit.value === groupByToken?.span?.span_expression.time_unit diff --git a/public/components/metrics/helpers/utils.tsx b/public/components/metrics/helpers/utils.tsx index be4186b90f..5c307ea57d 100644 --- a/public/components/metrics/helpers/utils.tsx +++ b/public/components/metrics/helpers/utils.tsx @@ -8,7 +8,11 @@ import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import React from 'react'; import { Layout } from 'react-grid-layout'; import { VISUALIZATION } from '../../../../common/constants/metrics'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +import { + OTEL_METRIC_SUBTYPE, + PROMQL_METRIC_SUBTYPE, + PPL_METRIC_SUBTYPE, +} from '../../../../common/constants/shared'; import PPLService from '../../../services/requests/ppl'; import { MetricType } from '../../../../common/types/metrics'; import { VisualizationType } from '../../../../common/types/custom_panels'; @@ -74,7 +78,11 @@ export const sortMetricLayout = (metricsLayout: MetricType[]) => { }); }; -export const visualizationFromMetric = (metric, span, resolution): SavedVisualizationType => { +export const visualizationFromPrometheusMetric = ( + metric, + span, + resolution +): SavedVisualizationType => { const userConfigs = JSON.stringify({ dataConfig: { chartStyles: { @@ -95,7 +103,41 @@ export const visualizationFromMetric = (metric, span, resolution): SavedVisualiz resolution, }, type: 'line', - subType: PROMQL_METRIC_SUBTYPE, + subType: PPL_METRIC_SUBTYPE, + metricType: PROMQL_METRIC_SUBTYPE, userConfigs: JSON.stringify(userConfigs), }; }; + +export const createOtelMetric = (metric: any) => { + return { + name: '[Otel Metric] ' + metric.index + '.' + metric.name, + index: metric.index, + documentName: metric.name, + description: '', + query: '', + type: 'bar', + selected_fields: { + text: '', + tokens: [], + }, + sub_type: 'metric', + metric_type: OTEL_METRIC_SUBTYPE, + user_configs: {}, + }; +}; + +export const visualizationFromOtelMetric = (metric: any) => { + return { + query: '', + index: metric.index, + documentName: metric.documentName, + dateRange: ['now-1d', 'now'], + name: '[Otel Metric] ' + metric.index + '.' + metric.name, + description: metric.description, + type: 'bar', + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, + userConfigs: JSON.stringify(metric.user_configs), + }; +}; diff --git a/public/components/metrics/index.tsx b/public/components/metrics/index.tsx index 2e7f7a1cf8..08df0c8e1f 100644 --- a/public/components/metrics/index.tsx +++ b/public/components/metrics/index.tsx @@ -5,12 +5,13 @@ import './index.scss'; import { EuiPage, EuiPageBody, EuiResizableContainer } from '@elastic/eui'; -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { HashRouter, Route, RouteComponentProps, StaticContext } from 'react-router-dom'; import { ChromeBreadcrumb } from '../../../../../src/core/public'; import { Sidebar } from './sidebar/sidebar'; import PPLService from '../../services/requests/ppl'; import { TopMenu } from './top_menu/top_menu'; +import { OptionType } from '../../../common/types/metrics'; import { MetricsGrid } from './view/metrics_grid'; import SavedObjects from '../../services/saved_objects/event_analytics/saved_objects'; @@ -23,6 +24,10 @@ interface MetricsProps { } export const Home = ({ chrome, parentBreadcrumb }: MetricsProps) => { + // Side bar constants + const [selectedDataSource, setSelectedDataSource] = useState([]); + const [selectedOTIndex, setSelectedOTIndex] = useState([]); + useEffect(() => { chrome.setBreadcrumbs([ parentBreadcrumb, @@ -49,7 +54,13 @@ export const Home = ({ chrome, parentBreadcrumb }: MetricsProps) => { {(EuiResizablePanel, EuiResizableButton) => ( <> - + diff --git a/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx b/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx index 17f3681519..80afa4f60f 100644 --- a/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx +++ b/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { PropsWithChildren } from 'react'; +import React from 'react'; import { render, screen, fireEvent } from '@testing-library/react'; import type { RenderOptions } from '@testing-library/react'; import '@testing-library/jest-dom'; @@ -11,14 +11,7 @@ import { configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import { OBSERVABILITY_CUSTOM_METRIC } from '../../../../../../common/constants/metrics'; -import { PROMQL_METRIC_SUBTYPE } from '../../../../../../common/constants/shared'; -import { - metricsReducers, - mergeMetrics, - clearSelectedMetrics, - addSelectedMetric, - metricSlice, -} from '../metrics_slice'; +import { metricsReducers, mergeMetrics, clearSelectedMetrics, metricSlice } from '../metrics_slice'; import { sampleSavedMetric } from '../../../../../../test/metrics_constants'; import httpClientMock from '../../../../../../test/__mocks__/httpClientMock'; import { Sidebar } from '../../../sidebar/sidebar'; @@ -43,7 +36,9 @@ const defaultInitialState = { resolution: 'h', recentlyUsedRanges: [], }, - refresh: 0, // set to new Date() to trigger + refresh: 0, + otelIndices: [], + otelDocumentNames: [], // set to new Date() to trigger }; // This type interface extends the default options for render from RTL, as well @@ -60,6 +55,7 @@ function configureMetricStore(additionalState = {}) { return configureStore({ reducer: { metrics: metricsReducers }, preloadedState }); } +// eslint-disable-next-line jest/no-export export function renderWithMetricsProviders( ui: React.ReactElement, { @@ -69,10 +65,6 @@ export function renderWithMetricsProviders( ...renderOptions }: ExtendedRenderOptions = {} ) { - function Wrapper({ children }: PropsWithChildren<{}>): JSX.Element { - return {children}; - } - // Return an object with the store and all of RTL's query functions return { store, ...render({ui}, { ...renderOptions }) }; } @@ -108,6 +100,9 @@ describe('Add and Remove Selected Metrics', () => { ...defaultInitialState, }, }; + const selectedDataSource = [{ label: 'Prometheus', 'data-test-subj': 'prometheusOption' }]; + const setSelectedDataSource = jest.fn(); + const setSelectedOTIndex = jest.fn(); setPPLService(new PPLService(httpClientMock)); @@ -123,7 +118,15 @@ describe('Add and Remove Selected Metrics', () => { }); // Act - renderWithMetricsProviders(, { preloadedState }); + renderWithMetricsProviders( + , + { preloadedState } + ); // Assert @@ -142,6 +145,7 @@ describe('Add and Remove Selected Metrics', () => { expect(await screen.findByText(/Selected Metrics 1 of 1/)).toBeInTheDocument(); }); }); + describe('Metrics redux state tests', () => { it('Should initially set metrics state', () => { const store = configureMetricStore(); @@ -177,26 +181,6 @@ describe('metricsSlice actions and reducers', () => { expect(newState.selectedIds).toEqual([]); }); - it('should handle updateMetricQuery', () => { - const metricsState = { - ...defaultInitialState, - metrics: { metric1: { name: 'metricName' }, metric2: { name: 'metric2' } }, - }; - // const store = configureStore( { metrics: metricsReducers }, - // preloadedState: { metrics: metricsState }, - // }); - - // const dispatchedAction = store.dispatch( - // updateMetricQuery('metric1', { availableAttributes: ['label1'] }) - // ); - // expect(dispatchedAction.type).toEqual('metrics/setMetric'); - // expect(dispatchedAction.payload).toMatchObject({ - // aggregation: 'avg', - // attributesGroupBy: [], - // availableAttributes: ['label1'], - // }); - }); - describe('loadMetrics', () => { it('should handle setSortedIds', async () => { const store = configureMetricStore(); diff --git a/public/components/metrics/redux/slices/metrics_slice.ts b/public/components/metrics/redux/slices/metrics_slice.ts index 82e169a1c3..f3f3d08fdf 100644 --- a/public/components/metrics/redux/slices/metrics_slice.ts +++ b/public/components/metrics/redux/slices/metrics_slice.ts @@ -12,9 +12,9 @@ import { REDUX_SLICE_METRICS, SAVED_VISUALIZATION, } from '../../../../../common/constants/metrics'; -import { PPL_METRIC_SUBTYPE, PROMQL_METRIC_SUBTYPE } from '../../../../../common/constants/shared'; +import { OBSERVABILITY_BASE, PPL_METRIC_SUBTYPE, PROMQL_METRIC_SUBTYPE } from '../../../../../common/constants/shared'; import { MetricType } from '../../../../../common/types/metrics'; -import { getPPLService } from '../../../../../common/utils'; +import { getOSDHttp, getPPLService } from '../../../../../common/utils'; import { coreRefs } from '../../../../framework/core_refs'; import { SavedObjectsActions } from '../../../../services/saved_objects/saved_object_client/saved_objects_actions'; import { ObservabilitySavedVisualization } from '../../../../services/saved_objects/saved_object_client/types'; @@ -50,8 +50,8 @@ export interface DateSpanFilter { const initialState = { metrics: {}, - selectedIds: [], - sortedIds: [], + selectedIds: [], // selected IDs + sortedIds: [], // all avaliable metrics search: '', metricsLayout: [], dataSources: [OBSERVABILITY_CUSTOM_METRIC], @@ -65,6 +65,9 @@ const initialState = { recentlyUsedRanges: [], }, refresh: 0, // set to new Date() to trigger + selectedDataSource: '', + otelIndices: [], + otelDocumentNames: [], }; const mergeMetricCustomizer = function (objValue, srcValue) { @@ -89,22 +92,27 @@ export const loadMetrics = () => async (dispatch) => { const customDataRequest = fetchCustomMetrics(); const remoteDataSourcesResponse = await pplServiceRequestor(pplService!, PPL_DATASOURCES_REQUEST); const remoteDataSources = remoteDataSourcesResponse.data.DATASOURCE_NAME; - dispatch(setDataSources(remoteDataSources)); dispatch(setDataSourceTitles(remoteDataSources)); dispatch( - setDataSourceIcons(coloredIconsFrom([OBSERVABILITY_CUSTOM_METRIC, ...remoteDataSources])) + setDataSourceIcons( + coloredIconsFrom([OBSERVABILITY_CUSTOM_METRIC, ...remoteDataSources, 'OpenTelemetry']) + ) ); const remoteDataRequests = await fetchRemoteMetrics(remoteDataSources); const metricsResultSet = await Promise.all([customDataRequest, ...remoteDataRequests]); const metricsResult = metricsResultSet.flat(); - const metricsMapById = keyBy(metricsResult.flat(), 'id'); dispatch(mergeMetrics(metricsMapById)); const sortedIds = sortBy(metricsResult, 'catalog', 'id').map((m) => m.id); - dispatch(setSortedIds(sortedIds)); + await dispatch(setSortedIds(sortedIds)); +}; + +export const loadOTIndices = () => async (dispatch) => { + const fetchOTindices = await fetchOpenTelemetryIndices(); + dispatch(setOtelIndices(fetchOTindices)); }; const fetchCustomMetrics = async () => { @@ -114,7 +122,6 @@ const fetchCustomMetrics = async () => { const savedMetrics = dataSet.observabilityObjectList.filter((obj) => [PROMQL_METRIC_SUBTYPE, PPL_METRIC_SUBTYPE].includes(obj.savedVisualization?.subType) ); - return savedMetrics.map((obj: any) => ({ id: obj.objectId, savedVisualizationId: obj.objectId, @@ -123,6 +130,7 @@ const fetchCustomMetrics = async () => { catalog: OBSERVABILITY_CUSTOM_METRIC, type: obj.savedVisualization.type, subType: obj.savedVisualization.subType, + metricType: 'customMetric', aggregation: obj.savedVisualization.queryMetaData?.aggregation ?? 'avg', availableAttributes: [], attributesGroupBy: obj.savedVisualization.queryMetaData?.attributesGroupBy ?? [], @@ -155,12 +163,31 @@ const fetchRemoteMetrics = (remoteDataSources: string[]) => attributesGroupBy: [], availableAttributes: [], type: 'line', - subType: PROMQL_METRIC_SUBTYPE, + subType: PPL_METRIC_SUBTYPE, + metricType: PROMQL_METRIC_SUBTYPE, recentlyCreated: false, })) ) ); +export const fetchOpenTelemetryIndices = async () => { + const http = getOSDHttp(); + return http + .get(`${OBSERVABILITY_BASE}/search/indices`, { + query: { + format: 'json', + }, + }) + .catch((error) => console.error(error)); +}; + +export const fetchOpenTelemetryDocumentNames = (selectedOtelIndex: string) => async () => { + const http = getOSDHttp(); + return http + .get(`${OBSERVABILITY_BASE}/metrics/otel/${selectedOtelIndex}/documentNames`) + .catch((error) => console.error(error)); +}; + export const metricSlice = createSlice({ name: REDUX_SLICE_METRICS, initialState, @@ -215,6 +242,15 @@ export const metricSlice = createSlice({ setRefresh: (state) => { state.refresh = Date.now(); }, + setSelectedDataSource: (state, { payload }) => { + state.selectedDataSource = payload; + }, + setOtelIndices: (state, { payload }) => { + state.otelIndices = payload; + }, + setOtelDocumentNames: (state, { payload }) => { + state.otelDocumentNames = payload; + }, }, }); @@ -229,13 +265,16 @@ export const { setDataSourceTitles, setDataSourceIcons, updateMetric, + setSelectedDataSource, + setOtelIndices, + setOtelDocumentNames, } = metricSlice.actions; /** private actions */ -const { setMetrics, setMetric, setSortedIds } = metricSlice.actions; +export const { setMetrics, setMetric, setSortedIds } = metricSlice.actions; -const getAvailableAttributes = (id, metricIndex) => async (dispatch, _getState) => { +const getAvailableAttributes = (id, metricIndex) => async (dispatch) => { const { toasts } = coreRefs; const pplService = getPPLService(); @@ -259,13 +298,13 @@ export const addSelectedMetric = (metric: MetricType) => async (dispatch, getSta const currentSelectedIds = getState().metrics.selectedIds; if (currentSelectedIds.includes(metric.id)) return; - if (metric.subType === PROMQL_METRIC_SUBTYPE) { + if (metric.metricType === PROMQL_METRIC_SUBTYPE) { await dispatch(getAvailableAttributes(metric.id, metric.index)); } await dispatch(selectMetric(metric)); }; -export const removeSelectedMetric = ({ id }) => async (dispatch, _getState) => { +export const removeSelectedMetric = ({ id }) => async (dispatch) => { dispatch(deSelectMetric(id)); }; @@ -322,4 +361,10 @@ export const metricQuerySelector = (id) => (state) => availableAttributes: [], }; +export const selectedDataSourcesSelector = (state) => state.metrics.selectedDataSource; + +export const otelIndexSelector = (state) => state.metrics.otelIndices; + +export const otelDocumentNamesSelector = (state) => state.metrics.otelDocumentNames; + export const metricsReducers = metricSlice.reducer; diff --git a/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap b/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap index a22e4729ce..781f64daed 100644 --- a/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap +++ b/public/components/metrics/sidebar/__tests__/__snapshots__/sidebar.test.tsx.snap @@ -12,7 +12,12 @@ exports[`Side Bar Component renders Side Bar Component 1`] = ` } } > - + + +
+ +
+ Data source +
+
+ +
+ + +
+
+
+

+ Select a data source +

+ +
+ +
+
+ +
+ +
+ + + +
+
+
+
+ + +
+ +
+ + +
+ + +
+
{ configure({ adapter: new Adapter() }); const store = createStore(rootReducer, applyMiddleware(thunk)); + const setSelectedDataSource = jest.fn(); + const setSelectedOTIndex = jest.fn(); beforeAll(() => { PPLService.mockImplementation(() => { @@ -50,7 +63,12 @@ describe('Side Bar Component', () => { const wrapper = mount( - + ); diff --git a/public/components/metrics/sidebar/data_source_picker.tsx b/public/components/metrics/sidebar/data_source_picker.tsx new file mode 100644 index 0000000000..e5cf7758c8 --- /dev/null +++ b/public/components/metrics/sidebar/data_source_picker.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBox, EuiTitle } from '@elastic/eui'; +import React from 'react'; +import { DATASOURCE_OPTIONS } from '../../../../common/constants/metrics'; +import { OptionType } from '../../../../common/types/metrics'; + +interface DataSourcePickerMenuProps { + selectedDataSource: OptionType[]; + setSelectedDataSource: (sources: OptionType[]) => void; +} + +export const DataSourcePicker = ({ + selectedDataSource, + setSelectedDataSource, +}: DataSourcePickerMenuProps) => { + const onChange = (selectedDataSource) => { + setSelectedDataSource(selectedDataSource); + }; + + return ( +
+ +
Data source
+
+ +
+ ); +}; diff --git a/public/components/metrics/sidebar/index_picker.tsx b/public/components/metrics/sidebar/index_picker.tsx new file mode 100644 index 0000000000..86d4c1afeb --- /dev/null +++ b/public/components/metrics/sidebar/index_picker.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiComboBox, EuiTitle } from '@elastic/eui'; +import React, { useState } from 'react'; + +export const IndexPicker = (props: { otelIndices: unknown; setSelectedOTIndex: unknown }) => { + const { otelIndices, setSelectedOTIndex } = props; + const otelIndex = otelIndices.map((item: any) => { + return { label: item.index }; + }); + const [selectedIndex, setSelectedIndex] = useState([]); + + const onChange = (selectedIndex) => { + setSelectedIndex(selectedIndex); + setSelectedOTIndex(selectedIndex); + }; + + return ( +
+ +
Otel Index
+
+ +
+ ); +}; diff --git a/public/components/metrics/sidebar/metric_name.tsx b/public/components/metrics/sidebar/metric_name.tsx index ce25c8f405..5c8bc65261 100644 --- a/public/components/metrics/sidebar/metric_name.tsx +++ b/public/components/metrics/sidebar/metric_name.tsx @@ -4,17 +4,14 @@ */ import React from 'react'; -import { EuiAvatar, EuiFacetButton, EuiIcon } from '@elastic/eui'; -import { useSelector } from 'react-redux'; -import { metricIconsSelector } from '../redux/slices/metrics_slice'; -import { OBSERVABILITY_CUSTOM_METRIC } from '../../../../common/constants/metrics'; +import { EuiFacetButton, EuiIcon } from '@elastic/eui'; +import { OBSERVABILITY_CUSTOM_METRIC, OPEN_TELEMETRY } from '../../../../common/constants/metrics'; const MetricIcon = ({ metric }) => { - const metricIcons = useSelector(metricIconsSelector); - const iconMeta = metricIcons[metric.catalog]; - if (metric.catalog === OBSERVABILITY_CUSTOM_METRIC) + const metricCatalog = metric?.catalog; + if ([OBSERVABILITY_CUSTOM_METRIC, OPEN_TELEMETRY].includes(metricCatalog)) { return ; - else return ; + } else return ; }; interface IMetricNameProps { @@ -25,15 +22,19 @@ interface IMetricNameProps { export const MetricName = (props: IMetricNameProps) => { const { metric, handleClick } = props; - const name = () => { - if (metric.catalog === 'CUSTOM_METRICS') return metric.name; - else return metric.name.split('.')[1].replace(/^prometheus_/, 'p.._'); + const name = (metricDetails: any) => { + if ( + metricDetails?.catalog === OBSERVABILITY_CUSTOM_METRIC || + metricDetails?.catalog === OPEN_TELEMETRY + ) + return metricDetails?.name; + else return metricDetails?.name.split('.')[1].replace(/^prometheus_/, 'p.._'); }; return ( handleClick(metric)} icon={} > diff --git a/public/components/metrics/sidebar/metrics_accordion.tsx b/public/components/metrics/sidebar/metrics_accordion.tsx index d3168e7102..2579d6ecaf 100644 --- a/public/components/metrics/sidebar/metrics_accordion.tsx +++ b/public/components/metrics/sidebar/metrics_accordion.tsx @@ -9,7 +9,7 @@ import { min } from 'lodash'; import { MetricName } from './metric_name'; interface IMetricNameProps { - metricsList: []; + metricsList: any; headerName: string; handleClick: (props: any) => void; dataTestSubj: string; diff --git a/public/components/metrics/sidebar/sidebar.tsx b/public/components/metrics/sidebar/sidebar.tsx index df42058e8c..070ddfbfa8 100644 --- a/public/components/metrics/sidebar/sidebar.tsx +++ b/public/components/metrics/sidebar/sidebar.tsx @@ -3,42 +3,73 @@ * SPDX-License-Identifier: Apache-2.0 */ +import './sidebar.scss'; + import { EuiSpacer } from '@elastic/eui'; import { I18nProvider } from '@osd/i18n/react'; -import React, { useEffect, useMemo } from 'react'; +import { keyBy, sortBy } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; import { batch, useDispatch, useSelector } from 'react-redux'; +import { OTEL_METRIC_SUBTYPE, PPL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +import { OptionType } from '../../../../common/types/metrics'; import { addSelectedMetric, availableMetricsSelector, clearSelectedMetrics, + coloredIconsFrom, + fetchOpenTelemetryDocumentNames, loadMetrics, + loadOTIndices, + mergeMetrics, + otelIndexSelector, removeSelectedMetric, + selectMetricByIdSelector, selectedMetricsIdsSelector, selectedMetricsSelector, - selectMetricByIdSelector, + setDataSourceIcons, + setSortedIds, } from '../redux/slices/metrics_slice'; +import { DataSourcePicker } from './data_source_picker'; +import { IndexPicker } from './index_picker'; import { MetricsAccordion } from './metrics_accordion'; import { SearchBar } from './search_bar'; -import './sidebar.scss'; +interface SideBarMenuProps { + selectedDataSource: OptionType[]; + setSelectedDataSource: (sources: OptionType[]) => void; + selectedOTIndex: React.SetStateAction>; + setSelectedOTIndex: React.Dispatch>; + additionalSelectedMetricId?: string; +} export const Sidebar = ({ + selectedDataSource, + setSelectedDataSource, + selectedOTIndex, + setSelectedOTIndex, additionalSelectedMetricId, -}: { - additionalSelectedMetricId?: string; -}) => { +}: SideBarMenuProps) => { const dispatch = useDispatch(); - - const availableMetrics = useSelector(availableMetricsSelector); + const [availableOTDocuments, setAvailableOTDocuments] = useState([]); + const availableOTDocumentsRef = useRef(); + availableOTDocumentsRef.current = availableOTDocuments; + const promethuesMetrics = useSelector(availableMetricsSelector); const selectedMetrics = useSelector(selectedMetricsSelector); const selectedMetricsIds = useSelector(selectedMetricsIdsSelector); const additionalMetric = useSelector(selectMetricByIdSelector(additionalSelectedMetricId)); + const otelIndices = useSelector(otelIndexSelector); useEffect(() => { batch(() => { dispatch(loadMetrics()); }); - }, [dispatch]); + }, [dispatch, selectedDataSource]); + + useEffect(() => { + batch(() => { + dispatch(loadOTIndices()); + }); + }, [dispatch, selectedDataSource]); useEffect(() => { if (additionalMetric) { @@ -53,7 +84,57 @@ export const Sidebar = ({ return selectedMetricsIds.map((id) => selectedMetrics[id]).filter((m) => m); // filter away null entries }, [selectedMetrics, selectedMetricsIds]); - const handleAddMetric = (metric: any) => dispatch(addSelectedMetric(metric)); + useEffect(() => { + if (selectedOTIndex.length > 0 && selectedDataSource[0]?.label === 'OpenTelemetry') { + const fetchOtelDocuments = async () => { + try { + const documentNames = await fetchOpenTelemetryDocumentNames(selectedOTIndex[0]?.label)(); + const availableOtelDocuments = documentNames?.aggregations?.distinct_names?.buckets.map( + (item: any) => { + return { + id: item.key, + name: item.key, + catalog: 'OpenTelemetry', + subType: PPL_METRIC_SUBTYPE, + metricType: OTEL_METRIC_SUBTYPE, + type: 'Histogram', + index: selectedOTIndex[0]?.label, + }; + } + ); + setAvailableOTDocuments(availableOtelDocuments); + const metricsMapById = keyBy(availableOtelDocuments, 'id'); + + dispatch(mergeMetrics(metricsMapById)); + + const sortedIds = sortBy(availableOtelDocuments, 'catalog', 'id').map((m) => m.id); + dispatch(setSortedIds(sortedIds)); + dispatch(setDataSourceIcons(coloredIconsFrom(['OpenTelemetry']))); + } catch (error) { + console.error('Error fetching OpenTelemetry documents:', error); + } + }; + fetchOtelDocuments(); + } + }, [dispatch, selectedDataSource, selectedOTIndex]); + + const indexPicker = useMemo(() => { + const isOpenTelemetry = selectedDataSource[0]?.label === 'OpenTelemetry' ? true : false; + if (isOpenTelemetry) { + return ; + } + }, [selectedDataSource]); + + const availableMetrics = useMemo(() => { + if (selectedDataSource[0]?.label === 'OpenTelemetry' && selectedOTIndex.length > 0) + return promethuesMetrics; + else if (selectedDataSource[0]?.label === 'Prometheus') return promethuesMetrics; + else return []; + }, [promethuesMetrics, selectedDataSource, availableOTDocuments, selectedOTIndex]); + + const handleAddMetric = (metric: any) => { + dispatch(addSelectedMetric(metric)); + }; const handleRemoveMetric = (metric: any) => { dispatch(removeSelectedMetric(metric)); @@ -62,9 +143,15 @@ export const Sidebar = ({ return (