diff --git a/dashboards-observability/public/components/app.tsx b/dashboards-observability/public/components/app.tsx index 34f9b780b..88e4694a9 100644 --- a/dashboards-observability/public/components/app.tsx +++ b/dashboards-observability/public/components/app.tsx @@ -74,6 +74,7 @@ export const App = ({ parentBreadcrumb={parentBreadcrumb} renderProps={props} pplService={pplService} + savedObjects={savedObjects} /> ); }} diff --git a/dashboards-observability/public/components/custom_panels/helpers/utils.tsx b/dashboards-observability/public/components/custom_panels/helpers/utils.tsx index d70f7c455..14e1809ab 100644 --- a/dashboards-observability/public/components/custom_panels/helpers/utils.tsx +++ b/dashboards-observability/public/components/custom_panels/helpers/utils.tsx @@ -77,6 +77,24 @@ export const mergeLayoutAndVisualizations = ( setPanelVisualizations(newPanelVisualizations); }; +/* Update Span interval for a Query + * Input query -> source = opensearch_dashboards_sample_data_logs | stats avg(bytes) by span(timestamp,1d) + * spanParam -> 1M + * + * Updates the span command interval + * Returns -> source = opensearch_dashboards_sample_data_logs | stats avg(bytes) by span(timestamp,1M) + */ +export const updateQuerySpanInterval = ( + query: string, + timestampField: string, + spanParam: string +) => { + return query.replace( + new RegExp(`span\\((.*?)${timestampField}(.*?),(.*?)\\)`), + `span(${timestampField},${spanParam})` + ); +}; + /* Builds Final Query by adding time and query filters(From panel UI) to the original visualization query * -> Final Query is as follows: * -> finalQuery = indexPartOfQuery + timeQueryFilter + panelFilterQuery + filterPartOfQuery @@ -84,6 +102,8 @@ export const mergeLayoutAndVisualizations = ( * + | where utc_time > ‘2021-07-01 00:00:00’ and utc_time < ‘2021-07-02 00:00:00’ * + | where Carrier='OpenSearch-Air' * + | stats sum(FlightDelayMin) as delays by Carrier + * + * Also, checks is span interval update is needed and retruns accordingly */ const queryAccumulator = ( originalQuery: string, @@ -103,15 +123,12 @@ const queryAccumulator = ( startTime )}' and ${timestampField} <= '${convertDateTime(endTime, false)}'`; const pplFilterQuery = panelFilterQuery === '' ? '' : ` | ${panelFilterQuery}`; + const finalQuery = indexPartOfQuery + timeQueryFilter + pplFilterQuery + filterPartOfQuery; - if (spanParam === undefined) { - return finalQuery; - } else { - return finalQuery.replace( - new RegExp(`span\\(${timestampField},(.*?)\\)`), - `span(${timestampField},${spanParam})` - ); - } + + return spanParam === undefined + ? finalQuery + : updateQuerySpanInterval(finalQuery, timestampField, spanParam); }; // PPL Service requestor @@ -400,7 +417,7 @@ export const displayVisualization = (metaData: any, data: any, type: string) => ), }, }; - + return ( { + return metricsLayout.sort((a: MetricType, b: MetricType) => { + if (a.y > b.y) return 1; + if (a.y < b.y) return -1; + else return 0; + }); +}; + +export const createPrometheusMetricById = (metricId: string) => { + return { + name: '[Prometheus Metric] ' + metricId, + description: '', + query: 'source = ' + metricId + ' | stats avg(@value) by span(@timestamp,1h)', + type: 'line', + timeField: '@timestamp', + selected_fields: { + text: '', + tokens: [], + }, + sub_type: 'metric', + units_of_measure: UNITS_OF_MEASURE[1], + user_configs: {}, + }; +}; + +export const updateMetricsWithSelections = ( + savedVisualization: any, + startTime: ShortDate, + endTime: ShortDate, + spanValue: string +) => { + return { + query: updateQuerySpanInterval( + savedVisualization.query, + savedVisualization.timeField, + spanValue + ), + fields: savedVisualization.selected_fields.tokens, + dateRange: [startTime, endTime], + timestamp: savedVisualization.timeField, + name: savedVisualization.name, + description: savedVisualization.description, + type: 'line', + subType: 'metric', + userConfigs: JSON.stringify(savedVisualization.user_configs), + unitsOfMeasure: savedVisualization.units_of_measure, + }; +}; diff --git a/dashboards-observability/public/components/metrics/index.tsx b/dashboards-observability/public/components/metrics/index.tsx index 5b2e52eb7..a0f6e938f 100644 --- a/dashboards-observability/public/components/metrics/index.tsx +++ b/dashboards-observability/public/components/metrics/index.tsx @@ -6,6 +6,7 @@ import './index.scss'; import { EuiButtonIcon, + EuiGlobalToastList, EuiPage, EuiPageBody, htmlIdGenerator, @@ -17,7 +18,7 @@ import React, { useEffect, useState } from 'react'; import { Route, RouteComponentProps } from 'react-router-dom'; import classNames from 'classnames'; import { StaticContext } from 'react-router-dom'; -import { ChromeBreadcrumb, CoreStart } from '../../../../../src/core/public'; +import { ChromeBreadcrumb, CoreStart, Toast } from '../../../../../src/core/public'; import { onTimeChange } from './helpers/utils'; import { Sidebar } from './sidebar/sidebar'; import { EmptyMetricsView } from './view/empty_view'; @@ -28,6 +29,7 @@ import { MetricsGrid } from './view/metrics_grid'; import { useSelector } from 'react-redux'; import { metricsLayoutSelector, selectedMetricsSelector } from './redux/slices/metrics_slice'; import { resolutionOptions } from '../../../common/constants/metrics'; +import SavedObjects from '../../services/saved_objects/event_analytics/saved_objects'; interface MetricsProps { http: CoreStart['http']; @@ -35,9 +37,17 @@ interface MetricsProps { parentBreadcrumb: ChromeBreadcrumb; renderProps: RouteComponentProps; pplService: PPLService; + savedObjects: SavedObjects; } -export const Home = ({ http, chrome, parentBreadcrumb, renderProps, pplService }: MetricsProps) => { +export const Home = ({ + http, + chrome, + parentBreadcrumb, + renderProps, + pplService, + savedObjects, +}: MetricsProps) => { // Redux tools const selectedMetrics = useSelector(selectedMetricsSelector); const metricsLayout = useSelector(metricsLayoutSelector); @@ -55,6 +65,8 @@ export const Home = ({ http, chrome, parentBreadcrumb, renderProps, pplService } const [resolutionValue, setResolutionValue] = useState(resolutionOptions[2].value); const [spanValue, setSpanValue] = useState(1); const resolutionSelectId = htmlIdGenerator('resolutionSelect')(); + const [toasts, setToasts] = useState([]); + const [toastRightSide, setToastRightSide] = useState(true); // Side bar constants const [isSidebarClosed, setIsSidebarClosed] = useState(false); @@ -62,6 +74,12 @@ export const Home = ({ http, chrome, parentBreadcrumb, renderProps, pplService } // Metrics constants const [panelVisualizations, setPanelVisualizations] = useState([]); + const setToast = (title: string, color = 'success', text?: ReactChild, side?: string) => { + if (!text) text = ''; + setToastRightSide(!side ? true : false); + setToasts([...toasts, { id: new Date().toISOString(), title, text, color } as Toast]); + }; + const onRefreshFilters = (startTime: ShortDate, endTime: ShortDate) => { setOnRefresh(!onRefresh); }; @@ -114,6 +132,14 @@ export const Home = ({ http, chrome, parentBreadcrumb, renderProps, pplService } return ( <> + { + setToasts(toasts.filter((toast) => toast.id !== removedToast.id)); + }} + side={toastRightSide ? 'right' : 'left'} + toastLifeTimeMs={6000} + />
; + sortedMetricsLayout: MetricType[]; + selectedPanelOptions: EuiComboBoxOptionOption[] | undefined; + setSelectedPanelOptions: React.Dispatch< + React.SetStateAction[] | undefined> + >; +} + +interface CustomPanelOptions { + id: string; + name: string; + dateCreated: string; + dateModified: string; +} + +export const MetricsExportPanel = ({ + http, + visualizationsMetaData, + setVisualizationsMetaData, + sortedMetricsLayout, + selectedPanelOptions, + setSelectedPanelOptions, +}: MetricsExportPanelProps) => { + const [options, setOptions] = useState([]); + + const [errorResponse, setErrorResponse] = useState(''); + + const getCustomPanelList = async () => { + http + .get(`${CUSTOM_PANELS_API_PREFIX}/panels`) + .then((res: any) => { + setOptions(res.panels || []); + }) + .catch((error: any) => console.error(error)); + }; + + const fetchAllvisualizationsById = async () => { + let tempVisualizationsMetaData = await Promise.all( + sortedMetricsLayout.map(async (metricLayout) => { + return metricLayout.metricType === 'savedCustomMetric' + ? await fetchVisualizationById(http, metricLayout.id, setErrorResponse) + : createPrometheusMetricById(metricLayout.id); + }) + ); + console.log('tempVisualizationsMetaData', tempVisualizationsMetaData); + setVisualizationsMetaData(tempVisualizationsMetaData); + }; + + useEffect(() => { + getCustomPanelList(); + fetchAllvisualizationsById(); + }, []); + + const onNameChange = (index: number, name: string) => { + let tempVisualizationsMetaData = [...visualizationsMetaData]; + tempVisualizationsMetaData[index].name = name; + setVisualizationsMetaData(tempVisualizationsMetaData); + }; + + const onMeasureChange = (index: number, measureOption: any) => { + let tempVisualizationsMetaData = [...visualizationsMetaData]; + tempVisualizationsMetaData[index].units_of_measure = measureOption; + setVisualizationsMetaData(tempVisualizationsMetaData); + }; + + return ( +
+ + { + setSelectedPanelOptions(options); + }} + selectedOptions={selectedPanelOptions} + options={options.map((option: CustomPanelOptions) => { + return { + panel: option, + label: option.name, + }; + })} + isClearable={true} + data-test-subj="eventExplorer__querySaveComboBox" + /> + + + {visualizationsMetaData.length > 0 && ( +
+ {visualizationsMetaData.map((metaData: any, index: number) => { + return ( + + + + + onNameChange(index, e.target.value)} + data-test-subj="metrics__querySaveName" + /> + + + + + + { + return { value: i, text: i }; + })} + value={visualizationsMetaData[index].units_of_measure} + onChange={(e) => onMeasureChange(index, e.target.value)} + data-test-subj="metrics__measureSelector" + aria-label="metrics__measureSelector" + /> + + + + + ); + })} +
+ )} +
+ ); +}; diff --git a/dashboards-observability/public/components/metrics/top_menu/top_menu.tsx b/dashboards-observability/public/components/metrics/top_menu/top_menu.tsx index 4d10c1168..a729689ee 100644 --- a/dashboards-observability/public/components/metrics/top_menu/top_menu.tsx +++ b/dashboards-observability/public/components/metrics/top_menu/top_menu.tsx @@ -1,7 +1,6 @@ import { EuiPageHeader, EuiPageHeaderSection, - EuiTitle, EuiFlexGroup, EuiFlexItem, EuiFieldText, @@ -10,8 +9,10 @@ import { ShortDate, OnTimeChangeProps, EuiButton, - EuiFieldSearch, - EuiButtonIcon, + EuiComboBoxOptionOption, + EuiButtonEmpty, + EuiPopover, + EuiPopoverFooter, } from '@elastic/eui'; import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import { useDispatch, useSelector } from 'react-redux'; @@ -20,10 +21,20 @@ import React, { useState } from 'react'; import { MetricType } from '../../../../common/types/metrics'; import { resolutionOptions } from '../../../../common/constants/metrics'; import './top_menu.scss'; -import { allAvailableMetricsSelector, selectMetric } from '../redux/slices/metrics_slice'; +import { + allAvailableMetricsSelector, + metricsLayoutSelector, + selectMetric, +} from '../redux/slices/metrics_slice'; import { SearchBar } from '../sidebar/search_bar'; +import { CoreStart } from '../../../../../../src/core/public'; +import SavedObjects from '../../../services/saved_objects/event_analytics/saved_objects'; +import { sortMetricLayout, updateMetricsWithSelections } from '../helpers/utils'; +import { CUSTOM_PANELS_API_PREFIX } from '../../../../common/constants/custom_panels'; +import { MetricsExportPanel } from './metrics_export_panel'; interface TopMenuProps { + http: CoreStart['http']; IsTopPanelDisabled: boolean; startTime: ShortDate; endTime: ShortDate; @@ -39,9 +50,12 @@ interface TopMenuProps { spanValue: number; setSpanValue: React.Dispatch>; resolutionSelectId: string; + savedObjects: SavedObjects; + setToast: (title: string, color?: string, text?: any, side?: string) => void; } export const TopMenu = ({ + http, IsTopPanelDisabled, startTime, endTime, @@ -57,12 +71,22 @@ export const TopMenu = ({ spanValue, setSpanValue, resolutionSelectId, + savedObjects, + setToast, }: TopMenuProps) => { - const [originalPanelVisualizations, setOriginalPanelVisualizations] = useState([]); - + // Redux tools const dispatch = useDispatch(); const allAvailableMetrics = useSelector(allAvailableMetricsSelector); const handleAddMetric = (metric: any) => dispatch(selectMetric(metric)); + const metricsLayout = useSelector(metricsLayoutSelector); + const sortedMetricsLayout = sortMetricLayout([...metricsLayout]); + + const [visualizationsMetaData, setVisualizationsMetaData] = useState([]); + const [originalPanelVisualizations, setOriginalPanelVisualizations] = useState([]); + const [isSavePanelOpen, setIsSavePanelOpen] = useState(false); + const [selectedPanelOptions, setSelectedPanelOptions] = useState< + EuiComboBoxOptionOption[] | undefined + >([]); // toggle between panel edit mode const editPanel = (editType: string) => { @@ -104,6 +128,75 @@ export const TopMenu = ({ Edit view ); + + const Savebutton = ( + { + setIsSavePanelOpen((staleState) => !staleState); + }} + data-test-subj="metrics__saveManagementPopover" + iconType="arrowDown" + isDisabled={IsTopPanelDisabled} + > + Save + + ); + + const handleSavingObjects = async () => { + let savedMetricIds = []; + let savedMetricsInPanels = []; + + try { + savedMetricIds = await Promise.all( + sortedMetricsLayout.map(async (metricLayout, index) => { + const updatedMetric = updateMetricsWithSelections( + visualizationsMetaData[index], + startTime, + endTime, + spanValue + resolutionValue + ); + + if (metricLayout.metricType === 'prometheusMetric') { + return await savedObjects.createSavedVisualization(updatedMetric); + } else { + return await savedObjects.updateSavedVisualizationById({ + ...updatedMetric, + objectId: metricLayout.id, + }); + } + }) + ); + } catch (e) { + const message = 'Issue in saving metrics'; + console.error(message, e); + setToast('Issue in saving metrics', 'danger'); + } + + setToast('Saved metrics successfully!'); + + if (selectedPanelOptions.length > 0) { + try { + const allMetricIds = savedMetricIds.map((metric) => metric.objectId); + savedMetricsInPanels = await Promise.all( + selectedPanelOptions.map((panel) => { + return http.post(`${CUSTOM_PANELS_API_PREFIX}/visualizations/multiple`, { + body: JSON.stringify({ + panelId: panel.panel.id, + savedVisualizationIds: allMetricIds, + }), + }); + }) + ); + } catch (e) { + const message = 'Issue in saving metrics to panels'; + console.error(message, e); + setToast('Issue in saving metrics', 'danger'); + } + setToast('Saved metrics to panels!'); + } + }; + return ( <> @@ -151,15 +244,45 @@ export const TopMenu = ({ /> - {}} - data-test-subj="metrics__savePopover" - iconType="arrowDown" - isDisabled={IsTopPanelDisabled} + setIsSavePanelOpen(false)} > - Save - + + + + + setIsSavePanelOpen(false)} + data-test-subj="metrics__SaveCancel" + > + Cancel + + + + { + handleSavingObjects().then(() => setIsSavePanelOpen(false)); + }} + data-test-subj="metrics__SaveConfirm" + > + Save + + + + + diff --git a/dashboards-observability/server/adaptors/custom_panels/custom_panel_adaptor.ts b/dashboards-observability/server/adaptors/custom_panels/custom_panel_adaptor.ts index a11bff645..2b2ba55dd 100644 --- a/dashboards-observability/server/adaptors/custom_panels/custom_panel_adaptor.ts +++ b/dashboards-observability/server/adaptors/custom_panels/custom_panel_adaptor.ts @@ -220,6 +220,12 @@ export class CustomPanelsAdaptor { user_configs: visualization.savedVisualization.hasOwnProperty('user_configs') ? JSON.parse(visualization.savedVisualization.user_configs) : {}, + sub_type: visualization.savedVisualization.hasOwnProperty('sub_type') + ? visualization.savedVisualization.sub_type + : '', + units_of_measure: visualization.savedVisualization.hasOwnProperty('units_of_measure') + ? visualization.savedVisualization.units_of_measure + : '', ...(visualization.savedVisualization.application_id ? { application_id: visualization.savedVisualization.application_id } : {}), @@ -366,6 +372,39 @@ export class CustomPanelsAdaptor { } }; + // Add Multiple visualizations in a Panel + addMultipleVisualizations = async ( + client: ILegacyScopedClusterClient, + panelId: string, + savedVisualizationIds: string[] + ) => { + try { + let allPanelVisualizations = await this.getVisualizations(client, panelId); + + let newDimensions; + let visualizationsList = [...allPanelVisualizations]; + + savedVisualizationIds.map((savedVisualizationId) => { + newDimensions = this.getNewVizDimensions(visualizationsList); + visualizationsList = [ + ...visualizationsList, + { + id: 'panel_viz_' + uuidv4(), + savedVisualizationId, + ...newDimensions, + }, + ]; + }); + + const updatePanelResponse = await this.updatePanel(client, panelId, { + visualizations: visualizationsList, + }); + return visualizationsList; + } catch (error) { + throw new Error('Add/Replace Visualization Error:' + error); + } + }; + // Edits all Visualizations in the Panel editVisualization = async ( client: ILegacyScopedClusterClient, diff --git a/dashboards-observability/server/routes/custom_panels/visualizations_router.ts b/dashboards-observability/server/routes/custom_panels/visualizations_router.ts index 3848b55c3..c92aec633 100644 --- a/dashboards-observability/server/routes/custom_panels/visualizations_router.ts +++ b/dashboards-observability/server/routes/custom_panels/visualizations_router.ts @@ -128,6 +128,48 @@ export function VisualizationsRouter(router: IRouter) { } ); + // Add multiple visualizations to the panel + router.post( + { + path: `${API_PREFIX}/visualizations/multiple`, + validate: { + body: schema.object({ + panelId: schema.string(), + savedVisualizationIds: schema.arrayOf(schema.string()), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + + try { + const allVisualizations = await customPanelBackend.addMultipleVisualizations( + opensearchNotebooksClient, + request.body.panelId, + request.body.savedVisualizationIds + ); + return response.ok({ + body: { + message: 'Visualizations Added', + visualizations: allVisualizations, + }, + }); + } catch (error) { + console.error('Issue in adding visualization:', error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + // Replace an existing visualization router.post( {