diff --git a/common/utils/__tests__/visualization_helpers.test.tsx b/common/utils/__tests__/visualization_helpers.test.tsx new file mode 100644 index 0000000000..6c7f9f8577 --- /dev/null +++ b/common/utils/__tests__/visualization_helpers.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getUserConfigFrom } from '../visualization_helpers'; + +describe('Utils helper functions', () => { + describe('getUserConfigFrom', () => { + it('should return empty object from empty input', () => { + expect(getUserConfigFrom(undefined)).toEqual({}); + expect(getUserConfigFrom('')).toEqual({}); + expect(getUserConfigFrom({})).toEqual({}); + }); + it('should get object from user_configs json', () => { + const container = { user_configs: '{ "key": "value" }' }; + expect(getUserConfigFrom(container)).toEqual({ key: 'value' }); + }); + it('should get object from userConfigs', () => { + const container = { userConfigs: '{ "key": "value" }' }; + expect(getUserConfigFrom(container)).toEqual({ key: 'value' }); + }); + }); +}); diff --git a/common/utils/visualization_helpers.ts b/common/utils/visualization_helpers.ts new file mode 100644 index 0000000000..8a93cd7af3 --- /dev/null +++ b/common/utils/visualization_helpers.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { isEmpty, isString } from 'lodash'; + +/* The file contains helper functions for visualizaitons operations + * getUserConfigFrom - returns input objects' user_configs or userConfigs, JSON parsed if necessary + */ + +export const getUserConfigFrom = (container: unknown): object => { + const config = container?.user_configs || container?.userConfigs || {}; + + if (isEmpty(config)) return {}; + + if (isString(config)) return JSON.parse(config); + else return {}; +}; diff --git a/public/components/common/search/__tests__/__snapshots__/search.test.tsx.snap b/public/components/common/search/__tests__/__snapshots__/search.test.tsx.snap new file mode 100644 index 0000000000..b8a95255af --- /dev/null +++ b/public/components/common/search/__tests__/__snapshots__/search.test.tsx.snap @@ -0,0 +1,910 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Explorer Search component renders basic component 1`] = ` + + + + + + + + + PPL + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="smallContextMenuExample" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + PPL + + + + + + + + + + + + + + + + + + + + + + + + + + + + PPL + + + + + + + + + + + + + + + + + + + + } + > + + + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + } + iconType={false} + isCustom={true} + startDateControl={} + > + + + Last 15 minutes + + + Show dates + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Refresh + + + + + + + + + + + + + + + + + + + + + + } + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +`; diff --git a/public/components/common/search/__tests__/search.test.tsx b/public/components/common/search/__tests__/search.test.tsx new file mode 100644 index 0000000000..be1d8b138b --- /dev/null +++ b/public/components/common/search/__tests__/search.test.tsx @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount, shallow } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { createStore } from '@reduxjs/toolkit'; +import { rootReducer } from '../../../../framework/redux/reducers'; +import { Provider } from 'react-redux'; +import { Search } from '../search'; + +describe('Explorer Search component', () => { + configure({ adapter: new Adapter() }); + const store = createStore(rootReducer); + + it('renders basic component', () => { + const wrapper = mount( + + + + ); + wrapper.update(); + expect(wrapper).toMatchSnapshot(); + }); +}); diff --git a/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx b/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx new file mode 100644 index 0000000000..17f3681519 --- /dev/null +++ b/public/components/metrics/redux/slices/__tests__/metric_slice.test.tsx @@ -0,0 +1,207 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { PropsWithChildren } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import type { RenderOptions } from '@testing-library/react'; +import '@testing-library/jest-dom'; +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 { sampleSavedMetric } from '../../../../../../test/metrics_constants'; +import httpClientMock from '../../../../../../test/__mocks__/httpClientMock'; +import { Sidebar } from '../../../sidebar/sidebar'; +import { setOSDHttp, setPPLService } from '../../../../../../common/utils'; +import PPLService from '../../../../../services/requests/ppl'; + +jest.mock('../../../../../services/requests/ppl'); + +const defaultInitialState = { + metrics: {}, + selectedIds: [], + sortedIds: [], + search: '', + metricsLayout: [], + dataSources: [OBSERVABILITY_CUSTOM_METRIC], + dataSourceTitles: ['Observability Custom Metrics'], + dataSourceIcons: [[OBSERVABILITY_CUSTOM_METRIC, { color: 'blue' }]], + dateSpanFilter: { + start: 'now-1d', + end: 'now', + span: 1, + resolution: 'h', + recentlyUsedRanges: [], + }, + refresh: 0, // set to new Date() to trigger +}; + +// This type interface extends the default options for render from RTL, as well +// as allows the user to specify other things such as initialState, store. +interface ExtendedRenderOptions extends Omit { + preloadedState?: Partial; + store?: AppStore; +} + +function configureMetricStore(additionalState = {}) { + const preloadedState = { + metrics: { ...defaultInitialState, ...additionalState }, + }; + return configureStore({ reducer: { metrics: metricsReducers }, preloadedState }); +} + +export function renderWithMetricsProviders( + ui: React.ReactElement, + { + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ reducer: { metrics: metricsReducers }, preloadedState }), + ...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 }) }; +} + +describe('Add and Remove Selected Metrics', () => { + beforeAll(() => { + PPLService.mockImplementation(() => { + return { + fetch: jest + .fn() + .mockResolvedValueOnce({ + data: { DATASOURCE_NAME: ['datasource1', 'datasource2'] }, + }) + // datasource1 return schema + .mockResolvedValueOnce({ + jsonData: [{ TABLE_CATALOG: 'datasource1', TABLE_NAME: 'testMetric' }], + }) + // datasource2 return schema (none) plus getAvailableAttributes + .mockResolvedValue({ + jsonData: [], + }), + }; + }); + }); + + beforeEach(() => { + jest.resetModules(); + }); + + it('should render available metrics', async () => { + const preloadedState = { + metrics: { + ...defaultInitialState, + }, + }; + + setPPLService(new PPLService(httpClientMock)); + + setOSDHttp(httpClientMock); + httpClientMock.get = jest.fn().mockResolvedValue({ + observabilityObjectList: [ + { + id: sampleSavedMetric.id, + objectId: sampleSavedMetric.id, + savedVisualization: sampleSavedMetric, + }, + ], + }); + + // Act + renderWithMetricsProviders(, { preloadedState }); + + // Assert + + // wait for render after loadMetrics + expect(await screen.findByText(/Available Metrics 2 of 2/)).toBeInTheDocument(); + expect(await screen.findByText(/Selected Metrics 0 of 0/)).toBeInTheDocument(); + expect(screen.getByText(/\[Logs\]/)).toBeInTheDocument(); + + // Find the testMetric and click on it to "select" it. + const testMetricEl = screen.getByText(/testMetric/); + expect(testMetricEl).toBeInTheDocument(); + fireEvent.click(testMetricEl); + + // Selected and Available now show 1 each -- testMetric has been moved + expect(await screen.findByText(/Available Metrics 1 of 1/)).toBeInTheDocument(); + expect(await screen.findByText(/Selected Metrics 1 of 1/)).toBeInTheDocument(); + }); +}); +describe('Metrics redux state tests', () => { + it('Should initially set metrics state', () => { + const store = configureMetricStore(); + const state = store.getState().metrics; + expect(state).toHaveProperty('metrics'); + expect(state).toHaveProperty('selectedIds'); + }); +}); + +describe('metricsSlice actions and reducers', () => { + beforeEach(() => { + jest.resetModules(); + }); + + it('should handle mergeMetrics', async () => { + const newMetrics = { + metric1: { name: 'metricName', availableAttributes: undefined }, + }; + const store = configureMetricStore(); + + store.dispatch(mergeMetrics(newMetrics)); + + const newState = store.getState().metrics; + + expect(newState.metrics).toMatchObject(newMetrics); + }); + + it('should handle clearSelectedMetrics', () => { + const store = configureMetricStore({ selectedIds: ['testId'] }); + store.dispatch(clearSelectedMetrics()); + + const newState = store.getState().metrics; + 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(); + await store.dispatch(metricSlice.actions.setSortedIds(['id1', 'id2'])); + expect(store.getState().metrics.sortedIds).toEqual(['id1', 'id2']); + }); + }); +}); diff --git a/public/components/metrics/sidebar/metrics_edit_inline.tsx b/public/components/metrics/sidebar/metrics_edit_inline.tsx new file mode 100644 index 0000000000..83291e518d --- /dev/null +++ b/public/components/metrics/sidebar/metrics_edit_inline.tsx @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFormControlLayout, + EuiFormLabel, + EuiSelect, +} from '@elastic/eui'; +import { useDispatch } from 'react-redux'; +import { AGGREGATION_OPTIONS } from '../../../../common/constants/metrics'; +import { updateMetricQuery } from '../redux/slices/metrics_slice'; + +const availableAttributesLabels = (attributes) => + attributes.map((a) => ({ + label: a, + name: a, + })); + +export const MetricsEditInline = ({ visualization }: { visualizationId: string }) => { + const dispatch = useDispatch(); + + const onChangeAggregation = (e) => { + dispatch(updateMetricQuery(visualization.id, { aggregation: e.target.value })); + }; + + const onChangeAttributesGroupBy = async (selectedAttributes) => { + const attributesGroupBy = selectedAttributes.map(({ label }) => label); + dispatch(updateMetricQuery(visualization.id, { attributesGroupBy })); + }; + + const renderAggregationEditor = () => ( + + + + + + ); + + const renderAttributesGroupByEditor = () => ( + ATTRIBUTES GROUP BY} + > + ({ label, value: label })) ?? [] + } + onChange={onChangeAttributesGroupBy} + options={availableAttributesLabels(visualization?.availableAttributes ?? [])} + prepend={'ATTRIBUTES GROUP BY'} + /> + + ); + + return ( + + {renderAggregationEditor()} + {renderAttributesGroupByEditor()} + + ); +}; diff --git a/public/components/metrics/top_menu/__tests__/__snapshots__/metrics_export.test.tsx.snap b/public/components/metrics/top_menu/__tests__/__snapshots__/metrics_export.test.tsx.snap new file mode 100644 index 0000000000..41ad92b3a0 --- /dev/null +++ b/public/components/metrics/top_menu/__tests__/__snapshots__/metrics_export.test.tsx.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Export Metrics Panel Component renders Export Metrics Panel Component 1`] = ` + + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="m" + > + + + + + + + + + + + + + + + Save + + + + + + + + + + + + + +`; diff --git a/public/components/metrics/top_menu/__tests__/metrics_export.test.tsx b/public/components/metrics/top_menu/__tests__/metrics_export.test.tsx new file mode 100644 index 0000000000..d8d9d23908 --- /dev/null +++ b/public/components/metrics/top_menu/__tests__/metrics_export.test.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { PropsWithChildren } from 'react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import type { RenderOptions } from '@testing-library/react'; +import { configureStore } from '@reduxjs/toolkit'; +import { Provider } from 'react-redux'; + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { sampleSavedMetric } from '../../../../../test/metrics_constants'; +import { applyMiddleware, createStore } from '@reduxjs/toolkit'; +import { rootReducer } from '../../../../framework/redux/reducers'; +import { MetricsExport } from '../metrics_export'; +import thunk from 'redux-thunk'; +import { coreRefs } from '../../../../framework/core_refs'; +import { mockSavedObjectActions } from '../../../../../test/constants'; +import { OBSERVABILITY_CUSTOM_METRIC } from '../../../../../common/constants/metrics'; +import { metricsReducers } from '../../redux/slices/metrics_slice'; +import { panelReducer } from '../../../custom_panels/redux/panel_slice'; +import { act } from 'react-dom/test-utils'; + +const defaultInitialState = { + metrics: {}, + selectedIds: [], + sortedIds: [], + search: '', + metricsLayout: [], + dataSources: [OBSERVABILITY_CUSTOM_METRIC], + dataSourceTitles: ['Observability Custom Metrics'], + dataSourceIcons: [[OBSERVABILITY_CUSTOM_METRIC, { color: 'blue' }]], + dateSpanFilter: { + start: 'now-1d', + end: 'now', + span: 1, + resolution: 'h', + recentlyUsedRanges: [], + }, + refresh: 0, // set to new Date() to trigger +}; + +export function renderWithMetricsProviders( + ui: React.ReactElement, + { + preloadedState = {}, + // Automatically create a store instance if no store was passed in + store = configureStore({ + reducer: { metrics: metricsReducers, customPanel: panelReducer }, + preloadedState, + }), + ...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 }) }; +} + +describe('Export Metrics Panel Component', () => { + configure({ adapter: new Adapter() }); + const store = createStore(rootReducer, applyMiddleware(thunk)); + coreRefs.savedObjectsClient.find = jest.fn(() => + Promise.resolve({ + savedObjects: [], + then: () => Promise.resolve(), + }) + ); + + it('renders Export Metrics Panel Component', async () => { + mockSavedObjectActions({ get: [{ savedVisualization: sampleSavedMetric }] }); + + const wrapper = mount( + + + + ); + wrapper.update(); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); + it('opens metric export panel on Save button, closes on Cancel', async () => { + // Arrange + const preloadedState = { + metrics: { + ...defaultInitialState, + metrics: { [sampleSavedMetric.id]: sampleSavedMetric }, + selectedIds: [sampleSavedMetric.id], + }, + customPanel: { + panelList: [], + }, + }; + + // Act + renderWithMetricsProviders(, { preloadedState }); + + // Assert + const saveButton = await screen.findByText(/Save/); + expect(saveButton).toBeInTheDocument(); + fireEvent.click(saveButton); + + const modalCancelButton = await screen.findByTestId('metrics__SaveCancel'); + expect(modalCancelButton).toBeInTheDocument(); + fireEvent.click(modalCancelButton); + + await waitFor(() => { + expect(screen.queryByTestId('metrics__SaveCancel')).not.toBeInTheDocument(); + }); + + expect(screen.queryByTestId('metrics__SaveCancel')).toBeNull(); + }); + + it('opens metric export panel on Save button, closes by clicking away from panel', async () => { + // Arrange + const preloadedState = { + metrics: { + ...defaultInitialState, + metrics: { [sampleSavedMetric.id]: sampleSavedMetric }, + selectedIds: [sampleSavedMetric.id], + }, + customPanel: { + panelList: [], + }, + }; + + // Act + renderWithMetricsProviders( + + A Clickable Target + + , + { preloadedState } + ); + + // Assert + const saveButton = await screen.findByText(/Save/); + expect(saveButton).toBeInTheDocument(); + fireEvent.click(saveButton); + + expect(await screen.findByText('Custom operational dashboards/application')); + + userEvent.keyboard('{ESCAPE}'); + + await waitFor(() => { + expect(screen.queryByTestId('metrics__SaveCancel')).not.toBeInTheDocument(); + }); + + expect(screen.queryByTestId('metrics__SaveCancel')).toBeNull(); + }); + it('handles saving new metric objects on Save', async () => { + // Arrange + const preloadedState = { + metrics: { + ...defaultInitialState, + metrics: { [sampleSavedMetric.id]: sampleSavedMetric }, + selectedIds: [sampleSavedMetric.id], + }, + customPanel: { + panelList: [], + }, + }; + + // Act + renderWithMetricsProviders(, { preloadedState }); + + // Assert + const saveButton = await screen.findByText(/Save/); + expect(saveButton).toBeInTheDocument(); + await act(async () => { + fireEvent.click(saveButton); + + expect(await screen.findByTestId('metrics__querySaveName')).toBeInTheDocument(); + const modalSaveButton = await screen.findByTestId('metrics__SaveConfirm'); + expect(modalSaveButton).toBeInTheDocument(); + fireEvent.click(modalSaveButton); + }); + }); +}); diff --git a/public/components/metrics/top_menu/metrics_export.tsx b/public/components/metrics/top_menu/metrics_export.tsx new file mode 100644 index 0000000000..f23f0543dd --- /dev/null +++ b/public/components/metrics/top_menu/metrics_export.tsx @@ -0,0 +1,394 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiPopover, + EuiPopoverFooter, +} from '@elastic/eui'; +import React, { useEffect } from 'react'; +import { useSelector } from 'react-redux'; +import { max } from 'lodash'; +import semver from 'semver'; +import { render, unmountComponentAtNode } from 'react-dom'; +import { I18nProvider } from '@osd/i18n/react'; +import { MetricsExportPanel } from './metrics_export_panel'; +import { OSDSavedVisualizationClient } from '../../../services/saved_objects/saved_object_client/osd_saved_objects/saved_visualization'; +import { getSavedObjectsClient } from '../../../services/saved_objects/saved_object_client/client_factory'; +import { addMultipleVizToPanels, isUuid } from '../../custom_panels/redux/panel_slice'; +import { MetricType } from '../../../../common/types/metrics'; +import { + dateSpanFilterSelector, + selectedMetricsIdsSelector, + selectedMetricsSelector, +} from '../redux/slices/metrics_slice'; +import { coreRefs } from '../../../framework/core_refs'; +import { selectPanelList } from '../../../../public/components/custom_panels/redux/panel_slice'; +import { SavedVisualization } from '../../../../common/types/explorer'; +import { visualizationFromMetric } from '../helpers/utils'; +import { updateCatalogVisualizationQuery } from '../../common/query_utils'; +import { PROMQL_METRIC_SUBTYPE } from '../../../../common/constants/shared'; +import { SavedObjectLoader } from '../../../../../../src/plugins/saved_objects/public'; +import { MountPoint } from '../../../../../../src/core/public'; + +const Savebutton = ({ + setIsPanelOpen, +}: { + setIsPanelOpen: React.Dispatch>; +}) => { + return ( + { + setIsPanelOpen((staleState) => !staleState); + }} + data-test-subj="metrics__saveManagementPopover" + iconType="arrowDown" + > + Save + + ); +}; + +const MetricsExportPopOver = () => { + const availableObservabilityDashboards = useSelector(selectPanelList); + const [availableDashboards, setAvailableDashboards] = React.useState([]); + const [osdCoreDashboards, setOsdCoreDashboards] = React.useState([]); + + const [isPanelOpen, setIsPanelOpen] = React.useState(false); + + const selectedMetrics = useSelector(selectedMetricsSelector); + const selectedMetricsIds = useSelector(selectedMetricsIdsSelector); + + const dateSpanFilter = useSelector(dateSpanFilterSelector); + const [metricsToExport, setMetricsToExport] = React.useState([]); + + const [selectedPanelOptions, setSelectedPanelOptions] = React.useState([]); + + const { toasts } = coreRefs; + + const getCoreDashboards = async () => { + if (!coreRefs.dashboard) return []; + + const dashboardsLoader = coreRefs.dashboard.getSavedDashboardLoader(); + + const client = dashboardsLoader.savedObjectsClient; + const ds = await client.find({ type: 'dashboard' }); + + return ds?.savedObjects.map((so) => ({ + ...so, + objectId: so.type + ':' + so.id, + title: so.attributes?.title, + panelConfig: JSON.parse(so.attributes?.panelsJSON || '[]'), + })); + }; + + useEffect(() => { + (async function () { + setOsdCoreDashboards(await getCoreDashboards()); + })(); + }, []); + + useEffect(() => { + setAvailableDashboards([ + ...osdCoreDashboards, + ...availableObservabilityDashboards.filter((d) => isUuid(d.id)), + ]); + }, [osdCoreDashboards, availableObservabilityDashboards]); + + useEffect(() => { + if (selectedMetrics && selectedMetricsIds) { + const metricsArray = selectedMetricsIds.map((id) => selectedMetrics[id]); + setMetricsToExport(metricsArray); + } + }, [selectedMetrics, selectedMetricsIds]); + + const savedObjectInputFromObject = (currentObject: SavedVisualization) => { + return { + ...currentObject, + dateRange: ['now-1d', 'now'], + fields: [], + timestamp: 'timestamp', + }; + }; + + const updateSavedVisualization = async (metric: MetricType): string => { + const client = getSavedObjectsClient({ + objectId: metric.savedVisualizationId, + objectType: 'savedVisualization', + }); + const res = await client.get({ objectId: metric.savedVisualizationId }); + const currentObject = res.observabilityObjectList[0]; + const updateParams = { + objectId: metric.savedVisualizationId, + ...savedObjectInputFromObject(currentObject.savedVisualization), + name: metric.name, + }; + const savedObject = await client.update(updateParams); + return savedObject; + }; + + const datasourceMetaFrom = (catalog) => + JSON.stringify([ + { name: catalog, title: catalog, id: catalog, label: catalog, type: 'prometheus' }, + ]); + + const createSavedVisualization = async (metric): Promise => { + const [ds, index] = metric.index.split('.'); + const queryMetaData = { + catalogSourceName: ds, + catalogTableName: index, + aggregation: metric.aggregation, + attributesGroupBy: metric.attributesGroupBy, + }; + const visMetaData = visualizationFromMetric( + { + ...metric, + dataSources: datasourceMetaFrom(metric.catalog), + query: updateCatalogVisualizationQuery({ + ...queryMetaData, + ...dateSpanFilter, + }), + queryMetaData, + subType: PROMQL_METRIC_SUBTYPE, + dateRange: ['now-1d', 'now'], + fields: ['@value'], + timestamp: '@timestamp', + }, + dateSpanFilter.span, + dateSpanFilter.reoslution + ); + + const savedObject = await OSDSavedVisualizationClient.getInstance().create(visMetaData); + return savedObject; + }; + + const panelXYorGreaterThanValue = (value, panel) => { + const sum = panel.gridData.y + panel.gridData.h; + return sum > value ? sum : value; + }; + + const panelVersionOrGreaterThanValue = (value, panel) => { + return semver.compare(value, panel.version) < 0 ? panel.version : value; + }; + + const defaultPanelHeight = 12; + const defaultPanelWidth = 24; + const panelGutter = 1; + + const pushNewPanelToDashboardForMetricWith = ({ + dashboard, + referenceCount, + maxPanelY, + maxPanelVersion, + maxPanelIndex, + }: { + dashboard: unknown; + referenceCount: number; + maxPanelY: number; + maxPanelVersion: string; + maxPanelIndex: number; + }) => (metric, index) => { + const { type, id } = metric.object; + const panelIndex = maxPanelIndex + 1 + index; + const newPanelConfig = { + gridData: { + x: 0, + y: maxPanelY + panelGutter + index * (panelGutter + defaultPanelHeight), + w: defaultPanelWidth, + h: defaultPanelHeight, + i: `${panelIndex}`, + }, + panelIndex: `${panelIndex}`, + version: maxPanelVersion, + panelRefName: `panel_${referenceCount + index}`, + embeddableConfig: {}, + }; + + const newPanel = { + name: `panel_${referenceCount + index}`, + type, + id, + }; + dashboard.panelConfig.push(newPanelConfig); + dashboard.references.push(newPanel); + }; + const addMultipleVizToODSCoreDashbaords = (osdCoreSelectedDashboards, metricsToAdd) => { + const dashboardsLoader: SavedObjectLoader = coreRefs.dashboard!.getSavedDashboardLoader(); + + const client = dashboardsLoader.savedObjectsClient; + + Promise.all( + osdCoreSelectedDashboards.map(async ({ panel: dashboard }, index) => { + const referenceCount = dashboard.references.length; + const maxPanelY = dashboard.panelConfig.reduce(panelXYorGreaterThanValue, 0); + const maxPanelVersion = dashboard.panelConfig.reduce( + panelVersionOrGreaterThanValue, + '0.0.0' + ); + const maxPanelIndex = + max(dashboard.panelConfig.map((p) => parseInt(p.panelIndex, 10))) ?? 0; + + metricsToAdd.forEach( + pushNewPanelToDashboardForMetricWith({ + dashboard, + referenceCount, + maxPanelY, + maxPanelVersion, + maxPanelIndex, + }) + ); + + const panelsJSON = JSON.stringify(dashboard.panelConfig); + + const updateRes = await client.update( + dashboard.type, + dashboard.id, + { ...dashboard.attributes, panelsJSON }, + { + references: dashboard.references, + } + ); + }) + ); + }; + + const mountableToastElement = (node: React.ReactNode): MountPoint => (element: HTMLElement) => { + render({node}, element); + return () => unmountComponentAtNode(element); + }; + + const appFor = (objectType) => { + switch (objectType) { + case 'dashboard': + return 'dashboards'; + case 'observability-panel': + return 'observability-dashboards'; + default: + return 'observability-visualization'; + } + }; + + const euiLinkFor = ({ panel: dashboard }) => { + return ( + + coreRefs?.application!.navigateToApp(appFor(dashboard.type), { + path: `#/view/${dashboard.id}`, + }) + } + > + {dashboard.title} + + ); + }; + + const linkedDashboardsList = (dashboards) => { + const label = dashboards.length > 1 ? 'Dashboards ' : 'Dashboard '; + + const links = dashboards.map((d) => euiLinkFor(d)); + return [label, ...links]; + }; + + const handleSavingObjects = async () => { + let savedMetrics = []; + + try { + savedMetrics = await Promise.all( + metricsToExport.map(async (metric, index) => { + if (metric.savedVisualizationId === undefined) { + return createSavedVisualization(metric); + } else { + return updateSavedVisualization(metric); + } + }) + ); + } catch (e) { + const message = 'Issue in saving metrics'; + console.error(message, e); + toasts!.addDanger(message); + return; + } + + toasts!.add('Saved metrics successfully!'); + + if (selectedPanelOptions.length > 0) { + const osdCoreSelectedDashboards = selectedPanelOptions.filter( + (panel) => panel.panel?.type === 'dashboard' + ); + const observabilityDashboards = selectedPanelOptions.filter( + (panel) => panel.panel?.type !== 'dashboard' + ); + + try { + if (observabilityDashboards.length > 0) { + const savedVisualizationIds = savedMetrics.map((p) => p.objectId); + await addMultipleVizToPanels(observabilityDashboards, savedVisualizationIds); + } + if (osdCoreSelectedDashboards.length > 0) + await addMultipleVizToODSCoreDashbaords(osdCoreSelectedDashboards, savedMetrics); + + toasts!.add({ + text: mountableToastElement( + Saved metrics to {linkedDashboardsList(selectedPanelOptions)} successfully! + ), + }); + } catch (e) { + const message = 'Issue in saving metrics to panels'; + console.error(message, e); + toasts!.addDanger('Issue in saving metrics'); + } + } + }; + + return ( + } + isOpen={isPanelOpen} + closePopover={() => setIsPanelOpen(false)} + > + + + + + setIsPanelOpen(false)} + data-test-subj="metrics__SaveCancel" + > + Cancel + + + + { + handleSavingObjects().then(() => setIsPanelOpen(false)); + }} + data-test-subj="metrics__SaveConfirm" + > + Save + + + + + + ); +}; + +export const MetricsExport = () => { + return ; +}; diff --git a/test/metrics_constants.ts b/test/metrics_constants.ts new file mode 100644 index 0000000000..eecf742ae9 --- /dev/null +++ b/test/metrics_constants.ts @@ -0,0 +1,394 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OBSERVABILITY_CUSTOM_METRIC } from '../common/constants/metrics'; + +export const sampleMetricsVisualizations = [ + { + h: 2, + id: 'Y4muP4QBiaYaSxpXk7r8', + query: { type: 'savedCustomMetric', aggregation: 'avg', attributesGroupBy: [] }, + savedVisualizationId: 'Y4muP4QBiaYaSxpXk7r8', + w: 12, + x: 0, + y: 0, + }, + { + h: 2, + id: 'tomAP4QBiaYaSxpXALls', + metricType: 'savedCustomMetric', + savedVisualizationId: 'tomAP4QBiaYaSxpXALls', + w: 12, + x: 0, + y: 2, + }, + { + h: 2, + id: 'prometheus.process_resident_memory_bytes', + query: { type: 'prometheusMetric', aggregation: 'avg', attributesGroupBy: [] }, + savedVisualizationId: 'prometheus.process_resident_memory_bytes', + w: 12, + x: 0, + y: 4, + }, +]; + +export const sampleMetric = { + name: 'new metric', + description: '', + query: 'source = opensearch_dashboards_sample_data_logs | stats count() by span(timestamp,1h)', + type: 'line', + selected_date_range: { + start: 'now-1d', + end: 'now', + text: '', + }, + selected_timestamp: { + name: 'timestamp', + type: 'timestamp', + }, + selected_fields: { + text: '', + tokens: [], + }, + userConfigs: '{}', + subType: 'metric', +}; + +export const sampleAvailableDashboards = [ + { + attributes: { + title: '[Flights] Global Flight Dashboard', + }, + id: '7adfa750-4c81-11e8-b3d7-01146121b73d', + type: 'dashboard', + + title: '[Flights] Global Flight Dashboard', + }, + { + id: 'fdf2bb60-7a5b-11ee-929a-5f1a2dc08039', + type: 'observability-panel', + objectId: 'observability-panel:fdf2bb60-7a5b-11ee-929a-5f1a2dc08039', + name: '[Logs] Web traffic Panel', + title: '[Logs] Web traffic Panel', + savedObject: true, + }, +]; + +export const sampleMetricsToExport: MetricType[] = [ + { + id: 'my_prometheus.go_memstats_alloc_bytes', + name: 'my_prometheus.go_memstats_alloc_bytes', + catalog: 'my_prometheus', + catalogSourceName: 'my_prometheus', + catalogTableName: 'go_memstats_alloc_bytes', + index: 'my_prometheus.go_memstats_alloc_bytes', + aggregation: 'avg', + attributesGroupBy: [], + availableAttributes: [], + type: 'line', + sub_type: 'promqlmetric', + recentlyCreated: false, + }, + { + id: 'observability-visualization:84c73aa0-84aa-11ee-96e0-7bfa0b41d0fc', + savedVisualizationId: 'observability-visualization:84c73aa0-84aa-11ee-96e0-7bfa0b41d0fc', + query: + "source = my_prometheus.query_range('sum by(instance,job) (go_memstats_alloc_bytes_total)', 1700071440, 1700157840, '1h')", + name: 'my_prometheus.go_memstats_alloc_bytes_total', + catalog: 'CUSTOM_METRICS', + type: 'line', + recentlyCreated: false, + }, +]; + +export const sampleSortedMetricsLayout = [ + { + h: 2, + id: 'Y4muP4QBiaYaSxpXk7r8', + query: { type: 'savedCustomMetric', aggregation: 'avg', attributesGroupBy: [] }, + savedVisualizationId: 'Y4muP4QBiaYaSxpXk7r8', + w: 12, + x: 0, + y: 0, + }, + { + h: 2, + id: 'prometheus.process_resident_memory_bytes', + query: { type: 'prometheusMetric', aggregation: 'avg', attributesGroupBy: [] }, + savedVisualizationId: 'prometheus.process_resident_memory_bytes', + w: 12, + x: 0, + y: 2, + }, +]; + +export const samplePanelOptions = [ + { + dateCreated: 1667512665139, + dateModified: 1667513726084, + id: 'xImAP4QBiaYaSxpXBLkz', + name: '[Logs] Web traffic Panel', + }, + { + dateCreated: 1667512677437, + dateModified: 1667525552909, + id: 'zYmAP4QBiaYaSxpXNLk9', + name: 'panel1', + }, +]; + +export const sampleVisualizationById = { + id: 'Y4muP4QBiaYaSxpXk7r8', + name: 'new metric', + query: 'source = opensearch_dashboards_sample_data_logs | stats count() by span(timestamp,1h)', + type: 'line', + timeField: 'timestamp', + selected_date_range: {}, + selected_fields: {}, + userConfigs: {}, + subType: 'metric', +}; + +export const sampleAllAvailableMetrics = [ + { + id: 'HIlAQYQBiaYaSxpXJ73K', + name: '[Prometheus Metric] prometheus.process_resident_memory_bytes', + catalog: 'CUSTOM_METRICS', + type: 'line', + recentlyCreated: true, + }, + { + id: 'Y4muP4QBiaYaSxpXk7r8', + name: 'new metric', + catalog: 'CUSTOM_METRICS', + type: 'line', + recentlyCreated: false, + }, + { + id: 'HolAQYQBiaYaSxpXKL0T', + name: '[Prometheus Metric] prometheus.go_memstats_heap_sys_bytes', + catalog: 'CUSTOM_METRICS', + type: 'line', + recentlyCreated: true, + }, + { + id: 'tomAP4QBiaYaSxpXALls', + name: '[Logs] Average ram usage per day by windows os ', + catalog: 'CUSTOM_METRICS', + type: 'line', + recentlyCreated: false, + }, + { + id: 'prometheus.process_resident_memory_bytes', + name: 'prometheus.process_resident_memory_bytes', + catalog: 'prometheus', + type: 'gauge', + recentlyCreated: false, + }, +]; + +export const samplePanelVisualizations1 = [ + { + id: 'Y4muP4QBiaYaSxpXk7r8', + savedVisualizationId: 'Y4muP4QBiaYaSxpXk7r8', + x: 0, + y: 0, + h: 2, + w: 12, + metricType: 'savedCustomMetric', + }, +]; + +export const samplenewDimensions1 = { + x: 0, + y: 2, + w: 12, + h: 3, +}; + +export const samplePanelVisualizations2 = [ + { + id: 'Y4muP4QBiaYaSxpXk7r8', + savedVisualizationId: 'Y4muP4QBiaYaSxpXk7r8', + x: 0, + y: 0, + h: 2, + w: 12, + query: { type: 'savedCustomMetric', aggregation: 'avg', attributesGroupBy: [] }, + }, + { + id: 'tomAP4QBiaYaSxpXALls', + savedVisualizationId: 'tomAP4QBiaYaSxpXALls', + x: 0, + y: 2, + h: 2, + w: 12, + query: { type: 'savedCustomMetric', aggregation: 'avg', attributesGroupBy: [] }, + }, +]; + +export const samplenewDimensions2 = { + x: 0, + y: 4, + w: 12, + h: 3, +}; + +export const samplePrometheusVisualizationId = 'prometheus.process_resident_memory_bytes'; + +export const samplePrometheusVisualizationComponent = { + name: '[Prometheus Metric] prometheus.process_resident_memory_bytes', + description: '', + query: + 'source = prometheus.process_resident_memory_bytes | stats avg(@value) by span(@timestamp,1h)', + type: 'line', + timeField: '@timestamp', + selected_fields: { + text: '', + tokens: [], + }, + subType: 'metric', + userConfigs: {}, +}; + +export const sampleVisualizationsList = [ + { + id: 'panel_viz_ed409e13-4759-4e0f-9bc1-6ae32999318e', + savedVisualizationId: 'savedCustomMetric', + x: 0, + y: 0, + w: 6, + h: 4, + }, + { + id: 'panel_viz_f59ad102-943e-48d9-9c0a-3df7055070a3', + savedVisualizationId: 'prometheusMetric', + x: 0, + y: 4, + w: 6, + h: 4, + }, +]; + +export const sampleLayout = [ + { i: 'panel_viz_ed409e13-4759-4e0f-9bc1-6ae32999318e', x: 0, y: 0, w: 3, h: 2 }, + { i: 'panel_viz_f59ad102-943e-48d9-9c0a-3df7055070a3', x: 3, y: 0, w: 6, h: 4 }, +]; + +export const sampleMergedVisualizations = [ + { + id: 'panel_viz_ed409e13-4759-4e0f-9bc1-6ae32999318e', + savedVisualizationId: 'savedCustomMetric', + x: 0, + y: 0, + w: 3, + h: 2, + }, + { + id: 'panel_viz_f59ad102-943e-48d9-9c0a-3df7055070a3', + savedVisualizationId: 'prometheusMetric', + x: 3, + y: 0, + w: 6, + h: 4, + }, +]; + +export const samplePrometheusSampleUpdateWithSelections = { + dateRange: ['now-1d', 'now'], + description: '', + fields: [], + name: '[Prometheus Metric] prometheus.process_resident_memory_bytes', + query: + 'source = prometheus.process_resident_memory_bytes | stats avg(@value) by span(@timestamp,1h)', + subType: 'metric', + timestamp: '@timestamp', + type: 'line', + userConfigs: {}, +}; + +export const sampleSavedMetric = { + id: 'tomAP4QBiaYaSxpXALls', + name: '[Logs] Average ram usage per day by windows os ', + query: + "source = opensearch_dashboards_sample_data_logs | where match(machine.os,'win') | stats avg(machine.ram) by span(timestamp,1h)", + aggregation: 'avg', + attributesGroupBy: [], + catalog: OBSERVABILITY_CUSTOM_METRIC, + index: 'opensearch_dashboards_sample_data_logs', + type: 'line', + timeField: 'timestamp', + selected_date_range: { + start: 'now-1d', + end: 'now', + text: '', + }, + selected_fields: { + text: '', + tokens: [], + }, + userConfigs: { + dataConfig: { + series: [ + { + label: 'machine.ram', + name: 'machine.ram', + aggregation: 'avg', + customLabel: '', + }, + ], + dimensions: [], + span: { + time_field: [ + { + name: 'timestamp', + type: 'timestamp', + label: 'timestamp', + }, + ], + unit: [ + { + text: 'Day', + value: 'd', + label: 'Day', + }, + ], + interval: '1', + }, + }, + }, + subType: 'metric', +}; + +export const sampleSavedMetricUpdate = { + dateRange: ['now-30m', 'now'], + description: undefined, + fields: [], + name: '[Logs] Average ram usage per day by windows os ', + query: + "source = opensearch_dashboards_sample_data_logs | where match(machine.os,'win') | stats avg(machine.ram) by span(timestamp,1m)", + subType: 'metric', + timestamp: 'timestamp', + type: 'line', + userConfigs: { + dataConfig: { + series: [ + { + label: 'machine.ram', + name: 'machine.ram', + aggregation: 'avg', + customLabel: '', + }, + ], + dimensions: [], + span: { + time_field: [{ name: 'timestamp', type: 'timestamp', label: 'timestamp' }], + unit: [{ text: 'Day', value: 'd', label: 'Day' }], + interval: '1', + }, + }, + }, +};