diff --git a/common/constants/explorer.ts b/common/constants/explorer.ts index b82d86aee..3f4999327 100644 --- a/common/constants/explorer.ts +++ b/common/constants/explorer.ts @@ -10,16 +10,51 @@ */ export const RAW_QUERY = 'rawQuery'; -export const INDEX = 'index'; +export const FINAL_QUERY = 'finalQuery'; +export const SELECTED_DATE_RANGE = 'selectedDateRange'; +export const INDEX = 'indexPattern'; export const SELECTED_FIELDS = 'selectedFields'; export const UNSELECTED_FIELDS = 'unselectedFields'; export const AVAILABLE_FIELDS = 'availableFields'; +export const QUERIED_FIELDS = 'queriedFields'; export const TAB_ID_TXT_PFX = 'query-panel-'; export const TAB_TITLE = 'New query'; export const TAB_CHART_TITLE = 'Visualizations'; export const TAB_EVENT_TITLE = 'Events'; export const TAB_EVENT_ID_TXT_PFX = 'main-content-events-'; -export const TAB_CHART_ID_TXT_PFX = 'main-content-charts-'; +export const TAB_CHART_ID_TXT_PFX = 'main-content-vis-'; + +export const DATE_PICKER_FORMAT = 'YYYY-MM-DD HH:mm:ss'; +export const TIME_INTERVAL_OPTIONS = [ + { + display: 'Auto', + val: 'h' // same as value of Hour for now + }, + { + display: 'Minute', + val: 'm' + }, + { + display: 'Hour', + val: 'h' + }, + { + display: 'Day', + val: 'd' + }, + { + display: 'Week', + val: 'w' + }, + { + display: 'Month', + val: 'M' + }, + { + display: 'Year', + val: 'y' + }, +] // redux export const SELECTED_QUERY_TAB = 'selectedQueryTab'; diff --git a/common/constants/shared.ts b/common/constants/shared.ts index e39904a6d..e82bbd3d5 100644 --- a/common/constants/shared.ts +++ b/common/constants/shared.ts @@ -16,6 +16,11 @@ export const DSL_BASE = '/api/dsl'; export const DSL_SEARCH = '/search'; export const DSL_CAT = '/cat.indices'; export const DSL_MAPPING = '/indices.getFieldMapping'; +export const OBSERVABILITY_BASE = '/api/observability'; +export const EVENT_ANALYTICS = '/event_analytics'; +export const SAVED_OBJECTS = '/saved_objects'; +export const SAVED_QUERY = '/query'; +export const SAVED_VISUALIZATION = '/vis'; // Server route export const PPL_ENDPOINT = '/_plugins/_ppl'; @@ -29,11 +34,15 @@ export const observabilityPluginOrder = 6000; // Shared Constants export const UI_DATE_FORMAT = 'MM/DD/YYYY hh:mm A'; export const PPL_DATE_FORMAT = 'YYYY-MM-DD HH:mm:ss'; +export const PPL_STATS_REGEX = /\|\s*stats/i; +export const PPL_INDEX_INSERT_POINT_REGEX = /search (source|index)\s*=\s*([^\s]+)(.*)/i; export const PPL_INDEX_REGEX = /(search source|source|index)\s*=\s*([^|\s]+)/i; -// Observability plugin URI +// Observability plugin URI const BASE_OBSERVABILITY_URI = '/_plugins/_observability'; export const OPENSEARCH_PANELS_API = { - GET_PANELS: `${BASE_OBSERVABILITY_URI}/panels`, - PANEL: `${BASE_OBSERVABILITY_URI}/panel`, + OBJECT: `${BASE_OBSERVABILITY_URI}/object`, }; + +// Saved Objects +export const SAVED_OBJECT = '/object'; diff --git a/common/types/custom_panels.ts b/common/types/custom_panels.ts new file mode 100644 index 000000000..4d8f62145 --- /dev/null +++ b/common/types/custom_panels.ts @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export type CustomPanelListType = { + name: string; + id: string; + dateCreated: string; + dateModified: string; +}; + +export type VisualizationType = { + id: string; + title: string; + x: number; + y: number; + w: number; + h: number; + query: string; + type: string; + timeField: string; +}; + +export type PanelType = { + name: string; + visualizations: VisualizationType[]; + timeRange: { to: string; from: string }; + queryFilter: { query: string; language: string }; +}; + +export type SavedVisualizationType = { + id: string; + name: string; + query: string; + type: string; + timeField: string; +}; + +export type pplResponse = { + data: any; + metadata: any; + size: number; + status: number; +}; diff --git a/common/types/explorer.ts b/common/types/explorer.ts index 40a76dbb0..e4cf65af9 100644 --- a/common/types/explorer.ts +++ b/common/types/explorer.ts @@ -12,8 +12,11 @@ import { RAW_QUERY, SELECTED_FIELDS, - UNSELECTED_FIELDS + UNSELECTED_FIELDS, + AVAILABLE_FIELDS, + QUERIED_FIELDS } from '../constants/explorer'; + import SavedObjects from '../../public/services/saved_objects/event_analytics/saved_objects'; export interface IQueryTab { id: string; @@ -45,9 +48,12 @@ export interface IExplorerTabFields { export interface IExplorerFields { [SELECTED_FIELDS]: Array; [UNSELECTED_FIELDS]: Array; + [AVAILABLE_FIELDS]: Array; + [QUERIED_FIELDS]: Array; } export interface ILogExplorerProps { pplService: any; dslService: any; + savedObjects: SavedObjects; } \ No newline at end of file diff --git a/common/utils/index.ts b/common/utils/index.ts index a0d10243d..62211e2c6 100644 --- a/common/utils/index.ts +++ b/common/utils/index.ts @@ -9,4 +9,4 @@ * GitHub history for details. */ -export { getIndexPatternFromRawQuery } from './query_utils'; \ No newline at end of file +export { getIndexPatternFromRawQuery, insertDateRangeToQuery } from './query_utils'; diff --git a/common/utils/query_utils.ts b/common/utils/query_utils.ts index 8eefe7c85..5a3e72067 100644 --- a/common/utils/query_utils.ts +++ b/common/utils/query_utils.ts @@ -9,7 +9,13 @@ * GitHub history for details. */ -import { PPL_INDEX_REGEX } from "../constants/shared"; +import { isEmpty } from 'lodash'; +import datemath from '@elastic/datemath'; +import { DATE_PICKER_FORMAT } from '../../common/constants/explorer'; +import { + PPL_INDEX_REGEX, + PPL_INDEX_INSERT_POINT_REGEX +} from '../../common/constants/shared'; export const getIndexPatternFromRawQuery = (query: string) : string => { const matches = query.match(PPL_INDEX_REGEX); @@ -17,4 +23,31 @@ export const getIndexPatternFromRawQuery = (query: string) : string => { return matches[2]; } return ''; +}; + +export const insertDateRangeToQuery = ({ + rawQuery, + startTime, + endTime, + timeField = 'utc_time', +}: { + rawQuery: string; + startTime: string; + endTime: string; + timeField?: string; +}) => { + + let finalQuery = ''; + + if (isEmpty(rawQuery)) return finalQuery; + + // convert to moment + const start = datemath.parse(startTime)?.format(DATE_PICKER_FORMAT); + const end = datemath.parse(endTime)?.format(DATE_PICKER_FORMAT); + const tokens = rawQuery.match(PPL_INDEX_INSERT_POINT_REGEX); + + if (isEmpty(tokens)) return finalQuery; + finalQuery = `search ${tokens![1]}=${tokens![2]} | where ${timeField} >= timestamp('${start}') and ${timeField} <= timestamp('${end}')${tokens![3]}`; + + return finalQuery; }; \ No newline at end of file diff --git a/public/components/app.tsx b/public/components/app.tsx index a18d02bdb..4b5cdf2fe 100644 --- a/public/components/app.tsx +++ b/public/components/app.tsx @@ -20,8 +20,7 @@ import { AppPluginStartDependencies } from '../types'; import { Home as ApplicationAnalyticsHome } from './application_analytics/home'; import { renderPageWithSidebar } from './common/side_nav'; import { Home as CustomPanelsHome } from './custom_panels/home'; -import { Home as EventExplorerHome } from './explorer/home'; -import { LogExplorer } from './explorer/log_explorer'; +import { EventAnalytics } from './explorer/event_analytics'; import { Main as NotebooksHome } from './notebooks/components/main'; import { Home as TraceAnalyticsHome } from './trace_analytics/home'; @@ -30,13 +29,15 @@ interface ObservabilityAppDeps { DepsStart: AppPluginStartDependencies; pplService: any; dslService: any; + savedObjects: any; } export const App = ({ CoreStart, DepsStart, pplService, - dslService + dslService, + savedObjects }: ObservabilityAppDeps) => { const { chrome, http, notifications } = CoreStart; @@ -97,17 +98,19 @@ export const App = ({ )} /> { - chrome.setBreadcrumbs([ - parentBreadcrumb, - { - text: 'Event analytics', - href: '/explorer/events', - }, - ]); - return renderPageWithSidebar(); + return ( + + ); }} /> - } - /> diff --git a/public/components/common/search/autocomplete.tsx b/public/components/common/search/autocomplete.tsx index 19d881120..9075107ee 100644 --- a/public/components/common/search/autocomplete.tsx +++ b/public/components/common/search/autocomplete.tsx @@ -10,25 +10,42 @@ */ import './search.scss'; -import React, { useEffect } from 'react'; +import React, { + createElement, + Fragment, + useEffect, + useRef +} from 'react'; +import { render } from 'react-dom'; import { autocomplete } from '@algolia/autocomplete-js'; import { IQueryBarProps } from './search'; import { RAW_QUERY } from '../../../../common/constants/explorer'; import { createPPLSuggestionsPlugin } from './autocomplete_plugin'; export function Autocomplete(props: IQueryBarProps) { + const containerRef = useRef(null); const { query, handleQueryChange, handleQuerySearch, dslService } = props; const PPLSuggestionPlugin = createPPLSuggestionsPlugin({ + query, handleQueryChange: props.handleQueryChange, handleQuerySearch: props.handleQuerySearch, dslService: props.dslService, }); useEffect(() => { + + if (!containerRef.current) { + return undefined; + } + const search = autocomplete({ - container: '#autocomplete', - initialState: { query: props.query[RAW_QUERY] }, + container: containerRef.current, + renderer: { createElement, Fragment }, + render({ children }, root) { + render(children, root); + }, + initialState: { query: query[RAW_QUERY] }, openOnFocus: true, placeholder: 'Enter PPL query to retrieve log, traces, and metrics', plugins: [PPLSuggestionPlugin], @@ -40,5 +57,5 @@ export function Autocomplete(props: IQueryBarProps) { }; }, []); - return
; + return
; } diff --git a/public/components/common/search/autocomplete_plugin.ts b/public/components/common/search/autocomplete_plugin.ts index 0c4d5f0bf..319e509e7 100644 --- a/public/components/common/search/autocomplete_plugin.ts +++ b/public/components/common/search/autocomplete_plugin.ts @@ -21,6 +21,7 @@ interface PPLSuggestion { } interface CreatePPLSuggestionsPluginProps { + query: any; handleQueryChange: (query: string, index: string) => void; handleQuerySearch: () => void; dslService: DSLService; @@ -173,7 +174,7 @@ const getSuggestions = async (str: string, dslService: DSLService) => { item: '=', }); currField = splittedModel[splittedModel.length - 2]; - currFieldType = fieldsFromBackend.find((field) => field.label === currField).type; + currFieldType = fieldsFromBackend.find((field) => field.label === currField)?.type; return fullSuggestions.filter(({ label }) => label.startsWith(prefix) && prefix !== label); } else if (nextWhere === splittedModel.length - 2) { return fillSuggestions( @@ -267,9 +268,8 @@ export function createPPLSuggestionsPlugin( ): AutocompletePlugin { return { onStateChange: ({ state }) => { - if (state.query.length > queryLength) { + if (options.query.rawQuery !== state.query) { options.handleQueryChange(state.query, currIndex); - queryLength++; } }, onSubmit: () => { diff --git a/public/components/common/search/Filter.tsx b/public/components/common/search/date_picker.tsx similarity index 83% rename from public/components/common/search/Filter.tsx rename to public/components/common/search/date_picker.tsx index d6dae8aae..79700b23d 100644 --- a/public/components/common/search/Filter.tsx +++ b/public/components/common/search/date_picker.tsx @@ -12,21 +12,20 @@ import React from 'react'; import { EuiFlexItem, - EuiSwitch, - EuiSuperDatePicker, + EuiSuperDatePicker } from '@elastic/eui'; import { - IFilterProps + IDatePickerProps } from './search'; -export function Filter(props: IFilterProps) { +export function DatePicker(props: IDatePickerProps) { const { startTime, endTime, setStartTime, setEndTime, - setIsOutputStale + handleTimePickerChange } = props; function handleTimeChange({ @@ -69,8 +68,9 @@ export function Filter(props: IFilterProps) { showUpdateButton={ false } dateFormat="MM/DD/YYYY hh:mm:ss A" onTimeChange={(e) => { - setStartTime(e.start); - setEndTime(e.end); + const start = e.start; + const end = e.start === e.end ? 'now' : e.end; + handleTimePickerChange([start, end]); }} onRefresh={ handleRefresh } className="osdQueryBar__datePicker" diff --git a/public/components/common/search/search.tsx b/public/components/common/search/search.tsx index 336a89a51..eca65b7b5 100644 --- a/public/components/common/search/search.tsx +++ b/public/components/common/search/search.tsx @@ -11,13 +11,23 @@ import './search.scss'; -import React from 'react'; +import React, { useState } from 'react'; import PropTypes from 'prop-types'; -import { EuiFlexGroup, EuiButton, EuiFlexItem } from '@elastic/eui'; +import { isEmpty } from 'lodash'; +import { + EuiFlexGroup, + EuiButton, + EuiFlexItem, + EuiPopover, + EuiButtonEmpty, + EuiPopoverFooter, +} from '@elastic/eui'; import _ from 'lodash'; -import { Filter } from './Filter'; +import { DatePicker } from './date_picker'; import '@algolia/autocomplete-theme-classic'; import { Autocomplete } from './autocomplete'; +import { SavePanel } from '../../explorer/save_panel'; +import { useCallback } from 'react'; export interface IQueryBarProps { query: any; @@ -26,13 +36,14 @@ export interface IQueryBarProps { dslService: any; } -export interface IFilterProps { +export interface IDatePickerProps { startTime: string; endTime: string; setStartTime: () => void; setEndTime: () => void; setTimeRange: () => void; setIsOutputStale: () => void; + handleTimePickerChange: (timeRange: Array) => any; } export const Search = (props: any) => { @@ -40,15 +51,35 @@ export const Search = (props: any) => { query, handleQueryChange, handleQuerySearch, + handleTimePickerChange, dslService, startTime, endTime, setStartTime, setEndTime, setIsOutputStale, - actionItems, + explorerData, + selectedPanelName, + selectedCustomPanelOptions, + setSelectedPanelName, + setSelectedCustomPanelOptions, + handleSavingObject, + isPanelTextFieldInvalid, + savedObjects, + showSavePanelOptionsList, + showSaveButton = true } = props; + const [isSavePanelOpen, setIsSavePanelOpen] = useState(false); + + const memorizedHandleQuerySearch = useCallback(() => { + handleQuerySearch(); + }, [ + query, + startTime, + endTime + ]); + function renderAutocomplete({ query, handleQueryChange, @@ -65,13 +96,28 @@ export const Search = (props: any) => { ); } + const button = ( + { + setIsSavePanelOpen((staleState) => { + return !staleState; + }) + } + } + > + { "Save" } + + ); + return (
{renderAutocomplete({ query, handleQueryChange, handleQuerySearch, dslService })}
- { setIsOutputStale={setIsOutputStale} liveStreamChecked={props.liveStreamChecked} onLiveStreamChange={props.onLiveStreamChange} + handleTimePickerChange={ (timeRange: Array) => handleTimePickerChange(timeRange) } /> - {actionItems.length > 0 && - actionItems.map((item: any) => { - return ( - + { + memorizedHandleQuerySearch(); + }} + > + { isEmpty(explorerData) ? 'Run' : 'Refresh' } + + + { showSaveButton && ( + <> + + setIsSavePanelOpen(false)} > - - {item.text} - - - ); - })} + + + + + setIsSavePanelOpen(false)}> + { "Cancel" } + + + + handleSavingObject()}> + { "Save" } + + + + + + + + )}
); diff --git a/public/components/common/side_nav.tsx b/public/components/common/side_nav.tsx index 8299bf193..5e3a728fc 100644 --- a/public/components/common/side_nav.tsx +++ b/public/components/common/side_nav.tsx @@ -63,7 +63,7 @@ export const renderPageWithSidebar = (BodyComponent: React.ReactNode) => { { name: 'Event analytics', id: 3, - href: '#/explorer/home', + href: '#/event_analytics', }, { name: 'Operational panels', diff --git a/public/components/custom_panels/custom_panel_table.tsx b/public/components/custom_panels/custom_panel_table.tsx index 1124358c2..8c33c5005 100644 --- a/public/components/custom_panels/custom_panel_table.tsx +++ b/public/components/custom_panels/custom_panel_table.tsx @@ -54,29 +54,30 @@ const pageStyles: CSSProperties = { /* * "CustomPanelTable" module, used to view all the saved panels + * + * Props taken in as params are: * loading: loader bool for the table * fetchCustomPanels: fetch panels function * customPanels: List of panels available * createCustomPanel: create panel function * setBreadcrumbs: setter for breadcrumbs on top panel * parentBreadcrumb: parent breadcrumb - * renameCustomPanel: delete function for the panel + * renameCustomPanel: rename function for the panel * cloneCustomPanel: clone function for the panel - * deleteCustomPanel: delete function for the panel + * deleteCustomPanelList: delete function for the panels * setToast: create Toast function */ type Props = { loading: boolean; - fetchCustomPanels: () => void; + fetchCustomPanels: () => Promise; customPanels: Array; createCustomPanel: (newCustomPanelName: string) => void; setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; parentBreadcrumb: EuiBreadcrumb[]; renameCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; cloneCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; - deleteCustomPanel: (customPanelId: string, customPanelName?: string, showToast?: boolean) => void; - setToast: (title: string, color?: string, text?: string) => void; + deleteCustomPanelList: (customPanelIdList: string[], toastMessage: string) => any; }; export const CustomPanelTable = ({ @@ -88,8 +89,7 @@ export const CustomPanelTable = ({ parentBreadcrumb, renameCustomPanel, cloneCustomPanel, - deleteCustomPanel, - setToast, + deleteCustomPanelList, }: Props) => { const [isModalVisible, setIsModalVisible] = useState(false); // Modal Toggle const [modalLayout, setModalLayout] = useState(); // Modal Layout @@ -129,17 +129,8 @@ export const CustomPanelTable = ({ const toastMessage = `Custom Panels ${ selectedCustomPanels.length > 1 ? 's' : ' ' + selectedCustomPanels[0].name } successfully deleted!`; - Promise.all( - selectedCustomPanels.map((customPanel) => deleteCustomPanel(customPanel.id, undefined, false)) - ) - .then(() => setToast(toastMessage)) - .catch((err) => { - setToast( - 'Error deleting Operational Panels, please make sure you have the correct permission.', - 'danger' - ); - console.error(err.body.message); - }); + const PanelList = selectedCustomPanels.map((panel) => panel.id); + deleteCustomPanelList(PanelList, toastMessage); closeModal(); }; @@ -263,13 +254,13 @@ export const CustomPanelTable = ({ field: 'dateModified', name: 'Last updated', sortable: true, - render: (value) => moment(value).format(UI_DATE_FORMAT), + render: (value) => moment(new Date(value)).format(UI_DATE_FORMAT), }, { field: 'dateCreated', name: 'Created', sortable: true, - render: (value) => moment(value).format(UI_DATE_FORMAT), + render: (value) => moment(new Date(value)).format(UI_DATE_FORMAT), }, ] as Array< EuiTableFieldDataColumnType<{ @@ -296,7 +287,8 @@ export const CustomPanelTable = ({

- Panels ({customPanels.length}) + Panels + ({customPanels.length})

diff --git a/public/components/custom_panels/custom_panel_view.tsx b/public/components/custom_panels/custom_panel_view.tsx index a369ea0e0..d5373dc63 100644 --- a/public/components/custom_panels/custom_panel_view.tsx +++ b/public/components/custom_panels/custom_panel_view.tsx @@ -12,6 +12,7 @@ import { EuiBreadcrumb, EuiButton, + EuiContextMenu, EuiFieldText, EuiFlexGroup, EuiFlexItem, @@ -21,6 +22,7 @@ import { EuiPageContentBody, EuiPageHeader, EuiPageHeaderSection, + EuiPopover, EuiSpacer, EuiSuperDatePicker, EuiSuperDatePickerProps, @@ -32,28 +34,26 @@ import _ from 'lodash'; import React, { useEffect, useState } from 'react'; import { CoreStart } from '../../../../../src/core/public'; import { EmptyPanelView } from './panel_modules/empty_panel'; -import { AddVizView } from './panel_modules/add_visualization'; import { RENAME_VISUALIZATION_MESSAGE, CREATE_PANEL_MESSAGE, CUSTOM_PANELS_API_PREFIX, - VisualizationType, } from '../../../common/constants/custom_panels'; +import { VisualizationType } from '../../../common/types/custom_panels'; import { PanelGrid } from './panel_modules/panel_grid'; -import { - DeletePanelModal, - DeleteVisualizationModal, - getCustomModal, -} from './helpers/modal_containers'; +import { DeletePanelModal, getCustomModal } from './helpers/modal_containers'; import PPLService from '../../services/requests/ppl'; -import { convertDateTime, getNewVizDimensions, onTimeChange } from './helpers/utils'; +import { isDateValid, convertDateTime, onTimeChange, isPPLFilterValid } from './helpers/utils'; import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import { UI_DATE_FORMAT } from '../../../common/constants/shared'; import { ChangeEvent } from 'react'; import moment from 'moment'; +import { VisaulizationFlyout } from './panel_modules/visualization_flyout'; /* * "CustomPanelsView" module used to render an Operational Panel + * + * Props taken in as params are: * panelId: Name of the panel opened * http: http core service * pplService: ppl requestor service @@ -70,9 +70,17 @@ type Props = { pplService: PPLService; chrome: CoreStart['chrome']; parentBreadcrumb: EuiBreadcrumb[]; - renameCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; - deleteCustomPanel: (customPanelId: string, customPanelName?: string, showToast?: boolean) => void; - setToast: (title: string, color?: string, text?: string) => void; + renameCustomPanel: ( + editedCustomPanelName: string, + editedCustomPanelId: string + ) => Promise | undefined; + deleteCustomPanel: (customPanelId: string, customPanelName: string) => Promise; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; }; export const CustomPanelView = ({ @@ -93,34 +101,76 @@ export const CustomPanelView = ({ const [inputDisabled, setInputDisabled] = useState(true); const [addVizDisabled, setAddVizDisabled] = useState(false); const [editDisabled, setEditDisabled] = useState(false); - const [showVizPanel, setShowVizPanel] = useState(false); const [panelVisualizations, setPanelVisualizations] = useState>([]); const [editMode, setEditMode] = useState(false); const [isModalVisible, setIsModalVisible] = useState(false); // Modal Toggle const [modalLayout, setModalLayout] = useState(); // Modal Layout + const [isVizPopoverOpen, setVizPopoverOpen] = useState(false); // Add Visualization Popover + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); // Add Visualization Flyout + const [isFlyoutReplacement, setisFlyoutReplacement] = useState(false); + const [replaceVisualizationId, setReplaceVisualizationId] = useState(''); // DateTimePicker States const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([]); const [start, setStart] = useState('now-30m'); const [end, setEnd] = useState('now'); + const getVizContextPanels = (closeVizPopover?: () => void) => { + return [ + { + id: 0, + title: 'Add Visualization', + items: [ + { + name: 'Select Existing Visualization', + onClick: () => { + if (closeVizPopover != null) { + closeVizPopover(); + } + showFlyout(); + }, + }, + { + name: 'Create New Visualization', + onClick: () => { + advancedVisualization(); + }, + }, + ], + }, + ]; + }; + + const advancedVisualization = () => { + closeVizPopover(); + window.location.assign('#/event_analytics/explorer'); + }; + // Fetch Panel by id const fetchCustomPanel = async () => { return http .get(`${CUSTOM_PANELS_API_PREFIX}/panels/${panelId}`) .then((res) => { - setOpenPanelName(res.panel.name); - setPanelCreatedTime(res.panel.dateCreated); - setPanelVisualizations(res.panel.visualizations); - setPPLFilterValue(_.unescape(res.panel.queryFilter.query)); - setStart(res.panel.timeRange.from); - setEnd(res.panel.timeRange.to); + setOpenPanelName(res.operationalPanel.name); + setPanelCreatedTime(res.createdTimeMs); + setPPLFilterValue(res.operationalPanel.queryFilter.query); + setStart(res.operationalPanel.timeRange.from); + setEnd(res.operationalPanel.timeRange.to); + setPanelVisualizations(res.operationalPanel.visualizations); }) .catch((err) => { console.error('Issue in fetching the operational panels', err); }); }; + const onPopoverClick = () => { + setVizPopoverOpen(!isVizPopoverOpen); + }; + + const closeVizPopover = () => { + setVizPopoverOpen(false); + }; + const onChange = (e: ChangeEvent) => { setPPLFilterValue(e.target.value); }; @@ -134,8 +184,9 @@ export const CustomPanelView = ({ }; const onDelete = async () => { - const toastMessage = `Operational Panel ${openPanelName} successfully deleted!`; - deleteCustomPanel(panelId, openPanelName); + deleteCustomPanel(panelId, openPanelName).then((res) => { + window.location.assign(`${_.last(parentBreadcrumb).href}`); + }); closeModal(); }; @@ -152,7 +203,9 @@ export const CustomPanelView = ({ }; const onRename = async (newCustomPanelName: string) => { - renameCustomPanel(newCustomPanelName, panelId); + renameCustomPanel(newCustomPanelName, panelId).then(() => { + setOpenPanelName(newCustomPanelName); + }); closeModal(); }; @@ -174,18 +227,24 @@ export const CustomPanelView = ({ // toggle between panel edit mode const editPanel = () => { - setEditMode(!editMode); - setShowVizPanel(false); + if (editMode) { + // Save layout + setEditMode(false); + } else { + setEditMode(true); + } }; - const closeVizWindow = () => { - setShowVizPanel(false); + const closeFlyout = () => { + setIsFlyoutVisible(false); setAddVizDisabled(false); checkDisabledInputs(); }; - const addVizWindow = () => { - setShowVizPanel(true); + const showFlyout = (isReplacement?: boolean, replaceVizId?: string) => { + setisFlyoutReplacement(isReplacement); + setReplaceVisualizationId(replaceVizId); + setIsFlyoutVisible(true); setAddVizDisabled(true); setInputDisabled(true); }; @@ -204,13 +263,40 @@ export const CustomPanelView = ({ }; const onRefreshFilters = () => { - setOnRefresh(!onRefresh); + if (!isDateValid(convertDateTime(start), convertDateTime(end, false), setToast)) { + return; + } + + if (!isPPLFilterValid(pplFilterValue, setToast)) { + return; + } + + const panelFilterBody = { + panelId: panelId, + query: pplFilterValue, + language: 'ppl', + to: end, + from: start, + }; + + http + .patch(`${CUSTOM_PANELS_API_PREFIX}/panels/filter`, { + body: JSON.stringify(panelFilterBody), + }) + .then((res) => { + setOnRefresh(!onRefresh); + }) + .catch((err) => { + setToast('Error is adding filters to the operational panel', 'danger'); + console.error(err.body.message); + }); }; const cloneVisualization = ( newVisualizationTitle: string, pplQuery: string, - newVisualizationType: string + newVisualizationType: string, + newVisualizationTimeField: string ) => { setModalLayout( getCustomModal( @@ -222,8 +308,7 @@ export const CustomPanelView = ({ 'Duplicate', newVisualizationTitle + ' (copy)', RENAME_VISUALIZATION_MESSAGE, - pplQuery, - newVisualizationType + [pplQuery, newVisualizationType, newVisualizationTimeField] ) ); showModal(); @@ -232,46 +317,86 @@ export const CustomPanelView = ({ const onCloneVisualization = ( newVisualizationTitle: string, pplQuery: string, - newVisualizationType: string + newVisualizationType: string, + newVisualizationTimeField: string ) => { - const newDimensions = getNewVizDimensions(panelVisualizations); - setPanelVisualizations([ - ...panelVisualizations, - { - id: htmlIdGenerator()(), - title: newVisualizationTitle, - query: pplQuery, - type: newVisualizationType, - ...newDimensions, - }, - ]); - - //NOTE: Make a backend call to Clone Visualization + http + .post(`${CUSTOM_PANELS_API_PREFIX}/visualizations`, { + body: JSON.stringify({ + panelId: panelId, + newVisualization: { + id: 'panelViz_' + htmlIdGenerator()(), + title: newVisualizationTitle, + query: pplQuery, + type: newVisualizationType, + timeField: newVisualizationTimeField, + }, + }), + }) + .then(async (res) => { + setPanelVisualizations(res.visualizations); + setToast(`Visualization ${newVisualizationTitle} successfully added!`, 'success'); + }) + .catch((err) => { + setToast(`Error in adding ${newVisualizationTitle} visualization to the panel`, 'danger'); + console.error(err); + }); closeModal(); }; - const deleteVisualization = (visualizationId: string, visualizationName: string) => { - setModalLayout( - - ); - showModal(); + const removeVisualization = (visualizationId: string) => { + const newVisualizationList = _.reject(panelVisualizations, { + id: visualizationId, + }); + if (newVisualizationList.length === 0) { + setEditMode(false); + http + .put(`${CUSTOM_PANELS_API_PREFIX}/visualizations/edit`, { + body: JSON.stringify({ + panelId: panelId, + visualizationParams: [], + }), + }) + .then(async (res) => { + setPanelVisualizations(res.visualizations); + }) + .catch((err) => { + console.error(err); + }); + } + setPanelVisualizations(newVisualizationList); }; - const onDeleteVisualization = (visualizationId: string) => { - const filteredPanelVisualizations = panelVisualizations.filter( - (panelVisualization) => panelVisualization.id != visualizationId - ); - setPanelVisualizations([...filteredPanelVisualizations]); + //Add Visualization Button + const addVisualizationButton = ( + + Add Visualization + + ); - //NOTE: Make a backend call to Delete Visualization - closeModal(); - }; + let flyout; + if (isFlyoutVisible) { + flyout = ( + + ); + } // Fetch the custom panel on Initial Mount useEffect(() => { @@ -280,10 +405,7 @@ export const CustomPanelView = ({ // Check Validity of Time useEffect(() => { - if (convertDateTime(end, false) < convertDateTime(start)) { - setToast('Invalid Time Interval', 'danger'); - return; - } + isDateValid(convertDateTime(start), convertDateTime(end, false), setToast); }, [start, end]); // Toggle input type (disabled or not disabled) @@ -296,7 +418,10 @@ export const CustomPanelView = ({ useEffect(() => { chrome.setBreadcrumbs([ ...parentBreadcrumb, - { text: openPanelName, href: `${_.last(parentBreadcrumb).href}${panelId}` }, + { + text: openPanelName, + href: `${_.last(parentBreadcrumb).href}${panelId}`, + }, ]); }, [openPanelName]); @@ -324,9 +449,6 @@ export const CustomPanelView = ({ Rename - - Export - onChange(e)} @@ -374,46 +496,52 @@ export const CustomPanelView = ({ Refresh - - - Add visualization - + + + + - {panelVisualizations.length == 0 ? ( - !showVizPanel && ( - - ) + {panelVisualizations.length === 0 ? ( + ) : ( )} - <> - {showVizPanel && ( - - )} - + <> {isModalVisible && modalLayout} + {flyout}
); }; diff --git a/public/components/custom_panels/forms/add_new_visualizations.tsx b/public/components/custom_panels/forms/add_new_visualizations.tsx deleted file mode 100644 index f060c415d..000000000 --- a/public/components/custom_panels/forms/add_new_visualizations.tsx +++ /dev/null @@ -1,276 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -import { - EuiSpacer, - EuiFormRow, - EuiFieldText, - EuiLink, - EuiTextArea, - EuiButtonEmpty, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - EuiButtonIcon, - EuiIcon, - EuiText, - EuiSuperDatePicker, - ShortDate, - EuiSuperDatePickerProps, -} from '@elastic/eui'; -import { htmlIdGenerator } from '@elastic/eui/lib/services'; -import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; -import { UI_DATE_FORMAT } from '..././../../common/constants/shared'; -import _ from 'lodash'; -import React, { Fragment, useEffect, useState } from 'react'; -import { VisualizationType } from '../../../../common/constants/custom_panels'; -import PPLService from '../../../services/requests/ppl'; -import { Plt } from '../../visualizations/plotly/plot'; -import { - convertDateTime, - getNewVizDimensions, - getQueryResponse, - isNameValid, - onTimeChange, -} from '../helpers/utils'; - -/* - * "AddNewVisualizations" component to add new visualizations using PPL Queries - * - * closeVizWindow: function to close "add visualization" window - * pplService: PPLService Requestor - * panelVisualizations: panelVisualizations object - * setPanelVisualizations: Setter for panelVisualizations object - * setToast: Create Toast function - */ - -type Props = { - closeVizWindow: () => void; - pplService: PPLService; - panelVisualizations: VisualizationType[]; - setPanelVisualizations: React.Dispatch>; - setToast: (title: string, color?: string, text?: string) => void; -}; - -export const AddNewVisualizations = ({ - closeVizWindow, - pplService, - panelVisualizations, - setPanelVisualizations, - setToast, -}: Props) => { - const [newVisualizationTitle, setNewVisualizationTitle] = useState(''); - const [newVisualizationType, setNewVisualizationType] = useState('bar'); - const [pplQuery, setPPLQuery] = useState(''); - const [previewData, setPreviewData] = useState([]); - const [previewArea, setPreviewArea] = useState(<>); - const [showPreviewArea, setShowPreviewArea] = useState(false); - const [previewIconType, setPreviewIconType] = useState('arrowRight'); - const [previewLoading, setPreviewLoading] = useState(false); - const [isPreviewError, setIsPreviewError] = useState(''); - - // DateTimePicker States - const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([]); - const [start, setStart] = useState('now-30m'); - const [end, setEnd] = useState('now'); - - const onPreviewClick = () => { - if (previewIconType == 'arrowRight') { - setPreviewIconType('arrowUp'); - setShowPreviewArea(true); - } else { - setPreviewIconType('arrowRight'); - setShowPreviewArea(false); - } - }; - - const onRefreshPreview = () => { - if (convertDateTime(end, false) < convertDateTime(start)) { - setToast('Invalid Time Interval', 'danger'); - return; - } - - getQueryResponse( - pplService, - pplQuery, - 'bar', - start, - end, - setPreviewData, - setPreviewLoading, - setIsPreviewError, - '' - ); - }; - - useEffect(() => { - const previewTemplate = - isPreviewError == '' ? ( - <> - - - - - - - ) : ( - <> -
- - - - -

Error in rendering the visualizaiton

-
- - -

{isPreviewError}

-
-
- - ); - - setPreviewArea(previewTemplate); - }, [previewLoading]); - - const onChangeText = ( - e: React.ChangeEvent | React.ChangeEvent, - setter: React.Dispatch> - ) => { - setter(e.target.value); - }; - - const advancedVisualization = () => { - //NOTE: Add Redux functions to pass pplquery and time filters to events page - window.location.assign('#/explorer/events'); - }; - - const addVisualization = () => { - if (!isNameValid(newVisualizationTitle)) { - setToast('Invalid Visualization Name', 'danger'); - return; - } - - if (pplQuery.length == 0) { - setToast('Invalid PPL Query', 'danger'); - return; - } - - const newDimensions = getNewVizDimensions(panelVisualizations); - setPanelVisualizations([ - ...panelVisualizations, - { - id: htmlIdGenerator()(), - title: newVisualizationTitle, - query: _.escape(pplQuery), - type: newVisualizationType, - ...newDimensions, - }, - ]); - - //NOTE: Add a backend call to add a visualization - setToast(`Visualization ${newVisualizationTitle} successfully added!`, 'success'); - closeVizWindow(); - }; - - return ( - <> - - - onChangeText(e, setNewVisualizationTitle)} - /> - - - Use [example commands] to draw visaulizations.{' '} - - Learn More - {' '} - - } - fullWidth={true} - > - onChangeText(e, setPPLQuery)} - fullWidth={true} - style={{ width: '80%' }} - /> - - - ) => - onTimeChange( - props.start, - props.end, - recentlyUsedRanges, - setRecentlyUsedRanges, - setStart, - setEnd - ) - } - showUpdateButton={false} - recentlyUsedRanges={recentlyUsedRanges} - /> - - - - - - Preview - - - - - - - - {showPreviewArea && previewArea} - - More advanced edit options in visual editor... - - - - - - Add - - - - Cancel - - - - ); -}; diff --git a/public/components/custom_panels/forms/add_saved_visualizations.tsx b/public/components/custom_panels/forms/add_saved_visualizations.tsx deleted file mode 100644 index 9d208347c..000000000 --- a/public/components/custom_panels/forms/add_saved_visualizations.tsx +++ /dev/null @@ -1,135 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -import { - EuiSpacer, - EuiFormRow, - EuiSelect, - EuiSuperDatePicker, - EuiButtonEmpty, - EuiButton, - EuiFlexGroup, - EuiFlexItem, - ShortDate, - EuiSuperDatePickerProps, -} from '@elastic/eui'; -import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; -import { UI_DATE_FORMAT } from '..././../../common/constants/shared'; -import React, { useState } from 'react'; -import { VisualizationType } from '../../../../common/constants/custom_panels'; -import PPLService from '../../../services/requests/ppl'; -import { onTimeChange } from '../helpers/utils'; - -//NOTE: working this module is TBD after work on storing ppl visualizations in index is complete - -/* - * "AddSavedVisualizations" component to add saved visualizations - * - * closeVizWindow: function to close "add visualization" window - * pplService: PPLService Requestor - * panelVisualizations: panelVisualizations object - * setPanelVisualizations: Setter for panelVisualizations object - * setToast: Create Toast function - */ - -type Props = { - closeVizWindow: () => void; - pplService: PPLService; - panelVisualizations: VisualizationType[]; - setPanelVisualizations: React.Dispatch>; - setToast: (title: string, color?: string, text?: string) => void; -}; - -export const AddSavedVisualizations = ({ - closeVizWindow, - pplService, - panelVisualizations, - setPanelVisualizations, - setToast, -}: Props) => { - const [newVisualizationTitle, setNewVisualizationTitle] = useState(''); - const [newVisualizationType, setNewVisualizationType] = useState('bar'); - const [pplQuery, setPPLQuery] = useState(''); - const [previewData, setPreviewData] = useState([]); - const [previewArea, setPreviewArea] = useState(<>); - const [showPreviewArea, setShowPreviewArea] = useState(false); - const [previewIconType, setPreviewIconType] = useState('arrowRight'); - const [previewLoading, setPreviewLoading] = useState(false); - const [isPreviewError, setIsPreviewError] = useState(''); - - // DateTimePicker States - const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([]); - const [start, setStart] = useState('now-30m'); - const [end, setEnd] = useState('now'); - - const onPreviewClick = () => { - if (previewIconType == 'arrowRight') { - setPreviewIconType('arrowUp'); - setShowPreviewArea(true); - } else { - setPreviewIconType('arrowRight'); - setShowPreviewArea(false); - } - }; - - return ( - <> - - - {}} - options={[ - { value: 'option_one', text: 'Option one' }, - { value: 'option_two', text: 'Option two' }, - { value: 'option_three', text: 'Option three' }, - ]} - /> - - - ) => - onTimeChange( - props.start, - props.end, - recentlyUsedRanges, - setRecentlyUsedRanges, - setStart, - setEnd - ) - } - showUpdateButton={false} - recentlyUsedRanges={recentlyUsedRanges} - /> - - - - Preview - - - {showPreviewArea && previewArea} - - - - - Add - - - - Cancel - - - - ); -}; diff --git a/public/components/custom_panels/helpers/custom_input_modal.tsx b/public/components/custom_panels/helpers/custom_input_modal.tsx index d9c1bcd23..5dbc40070 100644 --- a/public/components/custom_panels/helpers/custom_input_modal.tsx +++ b/public/components/custom_panels/helpers/custom_input_modal.tsx @@ -35,10 +35,14 @@ import { * btn1txt - string as content to fill "close button" * btn2txt - string as content to fill "confirm button" * openPanelName - Default input value for the field + * helpText - string help for the input field + * optionalArgs - Arguments needed to pass them to runModal function */ type CustomInputModalProps = { - runModal: (value: string, value2?: string, value3?: string) => void; + runModal: + | ((value: string, value2: string, value3: string, value4: string) => void) + | ((value: string) => void); closeModal: ( event?: React.KeyboardEvent | React.MouseEvent ) => void; @@ -48,8 +52,7 @@ type CustomInputModalProps = { btn2txt: string; openPanelName?: string; helpText?: string; - optionalArg1?: string; - optionalArg2?: string; + optionalArgs?: string[]; }; export const CustomInputModal = (props: CustomInputModalProps) => { @@ -62,8 +65,7 @@ export const CustomInputModal = (props: CustomInputModalProps) => { btn2txt, openPanelName, helpText, - optionalArg1, - optionalArg2, + optionalArgs, } = props; const [value, setValue] = useState(openPanelName || ''); // sets input value @@ -88,12 +90,12 @@ export const CustomInputModal = (props: CustomInputModalProps) => { {btn1txt} - {optionalArg1 === undefined ? ( + {optionalArgs === undefined ? ( runModal(value)} fill> {btn2txt} ) : ( - runModal(value, optionalArg1, optionalArg2)} fill> + runModal(value, ...optionalArgs)} fill> {btn2txt} )} diff --git a/public/components/custom_panels/helpers/flyout_containers.tsx b/public/components/custom_panels/helpers/flyout_containers.tsx new file mode 100644 index 000000000..ed23fddf5 --- /dev/null +++ b/public/components/custom_panels/helpers/flyout_containers.tsx @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { EuiFlyout } from '@elastic/eui'; +import React from 'react'; + +/* + * "FlyoutContainers" component used to create flyouts + * + * Props taken in as params are: + * flyoutHeader - header JSX element of flyout + * flyoutBody - body JSX element of flyout + * flyoutFooter - footer JSX element of flyout + * ariaLabel - aria-label for focus of flyout + */ + +type Props = { + closeFlyout: () => void; + flyoutHeader: JSX.Element; + flyoutBody: JSX.Element; + flyoutFooter: JSX.Element; + ariaLabel: string; +}; + +export const FlyoutContainers = ({ + closeFlyout, + flyoutHeader, + flyoutBody, + flyoutFooter, + ariaLabel, +}: Props) => { + return ( +
+ closeFlyout()} aria-labelledby={ariaLabel}> + {flyoutHeader} + {flyoutBody} + {flyoutFooter} + +
+ ); +}; diff --git a/public/components/custom_panels/helpers/modal_containers.tsx b/public/components/custom_panels/helpers/modal_containers.tsx index 5a0b3b478..136c32135 100644 --- a/public/components/custom_panels/helpers/modal_containers.tsx +++ b/public/components/custom_panels/helpers/modal_containers.tsx @@ -35,7 +35,9 @@ import { CustomInputModal } from './custom_input_modal'; */ export const getCustomModal = ( - runModal: ((value: string, value2: string, value3: string) => void) | ((value: string) => void), + runModal: + | ((value: string, value2: string, value3: string, value4: string) => void) + | ((value: string) => void), closeModal: ( event?: React.KeyboardEvent | React.MouseEvent ) => void, @@ -45,8 +47,7 @@ export const getCustomModal = ( btn2txt: string, openPanelName?: string, helpText?: string, - optionalArg1?: string, - optionalArg2?: string + optionalArgs?: string[] ) => { return ( ); }; @@ -162,41 +162,3 @@ export const DeletePanelModal = ({ ); }; - -export const DeleteVisualizationModal = ({ - onConfirm, - onCancel, - visualizationId, - visualizationName, - panelName, -}: { - onConfirm: (value: string) => void; - onCancel: ( - event?: - | React.KeyboardEvent - | React.MouseEvent - | undefined - ) => void; - visualizationId: string; - visualizationName: string; - panelName: string; -}) => { - return ( - - onConfirm(visualizationId)} - cancelButtonText="Cancel" - confirmButtonText="Remove" - buttonColor="danger" - defaultFocusedButton="confirm" - > -

- Remove “{visualizationName}” from “{panelName}”? You can still retrieve the visualization - by name. -

-
-
- ); -}; diff --git a/public/components/custom_panels/helpers/utils.tsx b/public/components/custom_panels/helpers/utils.tsx index 74a5577a3..5ebbcfbc3 100644 --- a/public/components/custom_panels/helpers/utils.tsx +++ b/public/components/custom_panels/helpers/utils.tsx @@ -13,12 +13,23 @@ import dateMath from '@elastic/datemath'; import { ShortDate } from '@elastic/eui'; import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import _ from 'lodash'; -import { VisualizationType } from '../../../../common/constants/custom_panels'; +import { Moment } from 'moment-timezone'; import { PPL_DATE_FORMAT, PPL_INDEX_REGEX } from '../../../../common/constants/shared'; import PPLService from '../../../services/requests/ppl'; +import React from 'react'; +import { Bar } from '../../visualizations/charts/bar'; +import { HorizontalBar } from '../../visualizations/charts/horizontal_bar'; +import { Line } from '../../visualizations/charts/line'; /* * "Utils" This file contains different reused functions in operational panels + * isNameValid - Validates string to length > 0 and < 50 + * convertDateTime - Converts input datetime string to required format + * getQueryResponse - Get response of PPL query to load visualizations + * onTimeChange - Function to store recently used time filters and set start and end time. + * isDateValid - Function to check date validity + * isPPLFilterValid - Validate if the panel PPL query doesn't contain any Index/Time/Field filters + * displayVisualization - This function renders the visualzation based of its type */ // Name validation 0>Name<=50 @@ -27,18 +38,26 @@ export const isNameValid = (name: string) => { }; // DateTime convertor to required format -export const convertDateTime = (datetime: string, isStart = true) => { - if (isStart) return dateMath.parse(datetime).format(PPL_DATE_FORMAT); - return dateMath.parse(datetime, { roundUp: true }).format(PPL_DATE_FORMAT); +export const convertDateTime = (datetime: string, isStart = true, formatted = true) => { + let returnTime: undefined | Moment; + if (isStart) { + returnTime = dateMath.parse(datetime); + } else { + returnTime = dateMath.parse(datetime, { roundUp: true }); + } + + if (formatted) return returnTime.format(PPL_DATE_FORMAT); + return returnTime; }; -// Builds Final Query by adding time and query filters to the original visualization query -// -> Final Query is as follows: -// -> finalQuery = indexPartOfQuery + timeQueryFilter + panelFilterQuery + filterPartOfQuery -// -> finalQuery = source=opensearch_dashboards_sample_data_flights -// + | where utc_time > timestamp(‘2021-07-01 00:00:00’) and utc_time < timestamp(‘2021-07-02 00:00:00’) -// + | where Carrier='OpenSearch-Air' -// + | stats sum(FlightDelayMin) as delays by Carrier +/* 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 + * -> finalQuery = source=opensearch_dashboards_sample_data_flights + * + | where utc_time > timestamp(‘2021-07-01 00:00:00’) and utc_time < timestamp(‘2021-07-02 00:00:00’) + * + | where Carrier='OpenSearch-Air' + * + | stats sum(FlightDelayMin) as delays by Carrier + */ const queryAccumulator = ( originalQuery: string, timestampField: string, @@ -59,8 +78,32 @@ const queryAccumulator = ( return indexPartOfQuery + timeQueryFilter + pplFilterQuery + filterPartOfQuery; }; +//PPL Service requestor +const pplServiceRequestor = async ( + pplService: PPLService, + finalQuery: string, + type: string, + setVisualizationData: React.Dispatch>, + setIsLoading: React.Dispatch>, + setIsError: React.Dispatch> +) => { + await pplService + .fetch({ query: finalQuery, format: 'viz' }) + .then((res) => { + if (res === undefined) setIsError('Please check the PPL Filter Value'); + setVisualizationData(res); + }) + .catch((error: Error) => { + setIsError(error.stack); + console.error(error); + }) + .finally(() => { + setIsLoading(false); + }); +}; + // Get PPL Query Response -export const getQueryResponse = async ( +export const getQueryResponse = ( pplService: PPLService, query: string, type: string, @@ -77,53 +120,14 @@ export const getQueryResponse = async ( let finalQuery = ''; try { - finalQuery = queryAccumulator( - _.unescape(query), - timestampField, - startTime, - endTime, - filterQuery - ); + finalQuery = queryAccumulator(query, timestampField, startTime, endTime, filterQuery); } catch (error) { - console.log('Issue in building final query', error.stack); + console.error('Issue in building final query', error.stack); setIsLoading(false); return; } - await pplService - .fetch({ query: finalQuery, format: 'viz' }) - .then((res) => { - setVisualizationData([ - { - x: res.data[res.metadata.xfield.name], - y: res.data[res.metadata.yfield.name], - type: type, - }, - ]); - }) - .catch((error: Error) => { - setIsError(error.stack); - console.error(err); - }) - .finally(() => { - setIsLoading(false); - }); -}; - -// Calculate new visualization dimensions -// New visualization always joins to the end of the panel -export const getNewVizDimensions = (panelVisualizations: VisualizationType[]) => { - let maxY: number = 0, - maxYH: number = 0; - - panelVisualizations.map((panelVisualization: VisualizationType) => { - if (maxY < panelVisualization.y) { - maxY = panelVisualization.y; - maxYH = panelVisualization.h; - } - }); - - return { x: 0, y: maxY + maxYH, w: 6, h: 4 }; + pplServiceRequestor(pplService, finalQuery, type, setVisualizationData, setIsLoading, setIsError); }; // Function to store recently used time filters and set start and end time. @@ -142,7 +146,99 @@ export const onTimeChange = ( recentlyUsedRange.unshift({ start, end }); setStart(start); setEnd(end); - setRecentlyUsedRanges( - recentlyUsedRange.length > 10 ? recentlyUsedRange.slice(0, 9) : recentlyUsedRange - ); + setRecentlyUsedRanges(recentlyUsedRange.slice(0, 9)); +}; + +// Function to check date validity +export const isDateValid = ( + start: string | Moment | undefined, + end: string | Moment | undefined, + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void, + side?: string | undefined +) => { + if (end! < start!) { + setToast('Time range entered is invalid', 'danger', undefined, side); + return false; + } else return true; +}; + +// Check for time filter in query +const checkIndexExists = (query: string) => { + return PPL_INDEX_REGEX.test(query); +}; + +// Check PPL Query in Panel UI +// Validate if the query doesn't contain any Index +export const isPPLFilterValid = ( + query: string, + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void +) => { + if (checkIndexExists(query)) { + setToast('Please remove index from PPL Filter', 'danger', undefined); + return false; + } + return true; +}; + +// This function renders the visualzation based of its type +export const displayVisualization = (data: any, type: string) => { + if (data === undefined) return; + + let vizComponent!: JSX.Element; + switch (type) { + case 'bar': { + vizComponent = ( + + ); + break; + } + case 'horizontal_bar': { + vizComponent = ( + + ); + break; + } + case 'line': { + vizComponent = ( + + ); + break; + } + default: { + vizComponent = <>; + break; + } + } + return vizComponent; }; diff --git a/public/components/custom_panels/home.tsx b/public/components/custom_panels/home.tsx index 1ffe5b33f..4742b0cd6 100644 --- a/public/components/custom_panels/home.tsx +++ b/public/components/custom_panels/home.tsx @@ -11,10 +11,11 @@ import { EuiBreadcrumb, EuiGlobalToastList, EuiLink } from '@elastic/eui'; import { Toast } from '@elastic/eui/src/components/toast/global_toast_list'; +import { CustomPanelListType } from '../../../common/types/custom_panels'; import _ from 'lodash'; import React, { ReactChild, useState } from 'react'; import { StaticContext } from 'react-router'; -import { Route, RouteComponentProps, RouteProps } from 'react-router-dom'; +import { Route, RouteComponentProps } from 'react-router-dom'; import { CoreStart } from '../../../../../src/core/public'; import { CUSTOM_PANELS_API_PREFIX, @@ -27,6 +28,8 @@ import { isNameValid } from './helpers/utils'; /* * "Home" module is initial page for Operantional Panels + * + * Props taken in as params are: * http: http core service; * chrome: chrome core service; * parentBreadcrumb: parent breadcrumb name and link @@ -42,20 +45,15 @@ type Props = { renderProps: RouteComponentProps; }; -export type CustomPanelListType = { - name: string; - id: string; - dateCreated: string; - dateModified: string; -}; - export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps }: Props) => { const [customPanelData, setcustomPanelData] = useState>([]); const [toasts, setToasts] = useState>([]); const [loading, setLoading] = useState(false); + const [toastRightSide, setToastRightSide] = useState(true); - const setToast = (title: string, color = 'success', text?: ReactChild) => { + 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]); }; @@ -63,7 +61,9 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } const fetchCustomPanels = () => { return http .get(`${CUSTOM_PANELS_API_PREFIX}/panels`) - .then((res) => setcustomPanelData(res.panels)) + .then((res) => { + setcustomPanelData(res.panels); + }) .catch((err) => { console.error('Issue in fetching the operational panels', err.body.message); }); @@ -77,14 +77,14 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } } return http - .post(`${CUSTOM_PANELS_API_PREFIX}/panel`, { + .post(`${CUSTOM_PANELS_API_PREFIX}/panels`, { body: JSON.stringify({ - name: newCustomPanelName, + panelName: newCustomPanelName, }), }) .then(async (res) => { setToast(`Operational Panel "${newCustomPanelName}" successfully created!`); - window.location.assign(`${_.last(parentBreadcrumb).href}${res}`); + window.location.assign(`${_.last(parentBreadcrumb).href}${res.newPanelId}`); }) .catch((err) => { setToast( @@ -99,25 +99,25 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } }; // Renames an existing CustomPanel - const renameCustomPanel = (editedCustomPanelName: string, editedCustomPanelID: string) => { + const renameCustomPanel = (editedCustomPanelName: string, editedCustomPanelId: string) => { if (!isNameValid(editedCustomPanelName)) { setToast('Invalid Custom Panel name', 'danger'); return; } const renamePanelObject = { - name: editedCustomPanelName, - panelId: editedCustomPanelID, + panelId: editedCustomPanelId, + panelName: editedCustomPanelName, }; return http - .put(`${CUSTOM_PANELS_API_PREFIX}/panel/rename`, { + .patch(`${CUSTOM_PANELS_API_PREFIX}/panels/rename`, { body: JSON.stringify(renamePanelObject), }) .then((res) => { setcustomPanelData((prevCustomPanelData) => { const newCustomPanelData = [...prevCustomPanelData]; const renamedCustomPanel = newCustomPanelData.find( - (customPanel) => customPanel.id === editedCustomPanelID + (customPanel) => customPanel.id === editedCustomPanelId ); if (renamedCustomPanel) renamedCustomPanel.name = editedCustomPanelName; return newCustomPanelData; @@ -134,21 +134,18 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } }; // Clones an existing Custom Panel, return new Custom Panel id - const cloneCustomPanel = ( - clonedCustomPanelName: string, - clonedCustomPanelID: string - ): Promise => { + const cloneCustomPanel = (clonedCustomPanelName: string, clonedCustomPanelId: string) => { if (!isNameValid(clonedCustomPanelName)) { setToast('Invalid Operational Panel name', 'danger'); return Promise.reject(); } const clonePanelObject = { - name: clonedCustomPanelName, - panelId: clonedCustomPanelID, + panelId: clonedCustomPanelId, + panelName: clonedCustomPanelName, }; return http - .post(`${CUSTOM_PANELS_API_PREFIX}/panel/clone`, { + .post(`${CUSTOM_PANELS_API_PREFIX}/panels/clone`, { body: JSON.stringify(clonePanelObject), }) .then((res) => { @@ -157,14 +154,13 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } ...prevCustomPanelData, { name: clonedCustomPanelName, - id: res.body.id, - dateCreated: res.body.dateCreated, - dateModified: res.body.dateModified, + id: res.clonePanelId, + dateCreated: res.dateCreated, + dateModified: res.dateModified, }, ]; }); setToast(`Operational Panel "${clonedCustomPanelName}" successfully created!`); - return res.body.id; }) .catch((err) => { setToast( @@ -176,14 +172,37 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } }; // Deletes an existing Operational Panel - const deleteCustomPanel = (customPanelId: string, customPanelName?: string, showToast = true) => { + const deleteCustomPanelList = (customPanelIdList: string[], toastMessage: string) => { + const concatList = customPanelIdList.toString(); return http - .delete(`${CUSTOM_PANELS_API_PREFIX}/panel/` + customPanelId) + .delete(`${CUSTOM_PANELS_API_PREFIX}/panelList/` + concatList) + .then((res) => { + setcustomPanelData((prevCustomPanelData) => { + return prevCustomPanelData.filter( + (customPanel) => !customPanelIdList.includes(customPanel.id) + ); + }); + setToast(toastMessage); + return res; + }) + .catch((err) => { + setToast( + 'Error deleting Operational Panels, please make sure you have the correct permission.', + 'danger' + ); + console.error(err.body.message); + }); + }; + + // Deletes an existing Operational Panel + const deleteCustomPanel = (customPanelId: string, customPanelName: string) => { + return http + .delete(`${CUSTOM_PANELS_API_PREFIX}/panels/` + customPanelId) .then((res) => { setcustomPanelData((prevCustomPanelData) => { return prevCustomPanelData.filter((customPanel) => customPanel.id !== customPanelId); }); - if (showToast) setToast(`Operational Panel "${customPanelName}" successfully deleted!`); + setToast(`Operational Panel "${customPanelName}" successfully deleted!`); return res; }) .catch((err) => { @@ -202,6 +221,7 @@ export const Home = ({ http, chrome, parentBreadcrumb, pplService, renderProps } dismissToast={(removedToast) => { setToasts(toasts.filter((toast) => toast.id !== removedToast.id)); }} + side={toastRightSide ? 'right' : 'left'} toastLifeTimeMs={6000} /> ); }} diff --git a/public/components/custom_panels/panel_modules/add_visualization.tsx b/public/components/custom_panels/panel_modules/add_visualization.tsx deleted file mode 100644 index 52469e10e..000000000 --- a/public/components/custom_panels/panel_modules/add_visualization.tsx +++ /dev/null @@ -1,107 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -import { - EuiFlexGroup, - EuiFlexItem, - EuiPanel, - EuiForm, - EuiText, - EuiHorizontalRule, - EuiCheckableCard, -} from '@elastic/eui'; -import React, { useState } from 'react'; -import PPLService from '../../../services/requests/ppl'; -import { VisualizationType } from '../../../../common/constants/custom_panels'; -import { htmlIdGenerator } from '@elastic/eui/lib/services'; -import { AddSavedVisualizations } from '../forms/add_saved_visualizations'; -import { AddNewVisualizations } from '../forms/add_new_visualizations'; - -/* - * "AddNewVisualizations" component to add new visualizations using PPL Queries - * - * closeVizWindow: function to close "add visualization" window - * pplService: PPLService Requestor - * panelVisualizations: panelVisualizations object - * setPanelVisualizations: Setter for panelVisualizations object - * setToast: Create Toast function - */ - -type Props = { - closeVizWindow: () => void; - pplService: PPLService; - panelVisualizations: VisualizationType[]; - setPanelVisualizations: React.Dispatch>; - setToast: (title: string, color?: string, text?: string) => void; -}; - -export const AddVizView = ({ - closeVizWindow, - pplService, - panelVisualizations, - setPanelVisualizations, - setToast, -}: Props) => { - const radioName = htmlIdGenerator()(); - const [radio, setRadio] = useState('radio1'); - - return ( -
- - -

Add visualization

-
- -
- - - setRadio('radio1')} - /> - - - setRadio('radio2')} - /> - - - {radio === 'radio1' ? ( - - ) : ( - - )} -
-
-
- ); -}; diff --git a/public/components/custom_panels/panel_modules/empty_panel.tsx b/public/components/custom_panels/panel_modules/empty_panel.tsx index e6dfd6116..7b1785d2d 100644 --- a/public/components/custom_panels/panel_modules/empty_panel.tsx +++ b/public/components/custom_panels/panel_modules/empty_panel.tsx @@ -9,22 +9,62 @@ * GitHub history for details. */ -import { EuiSpacer, EuiText, EuiFlexGroup, EuiFlexItem, EuiButton } from '@elastic/eui'; -import React from 'react'; +import { + EuiSpacer, + EuiText, + EuiFlexGroup, + EuiFlexItem, + EuiButton, + EuiPopover, + EuiContextMenu, +} from '@elastic/eui'; +import React, { useState } from 'react'; /* -* EmptyPanelView -* This Sub-component is shown to the user when a operational panel is empty -* Props: -* addVizWindow -> This function shows the add visualization window in operational panels view -*/ + * EmptyPanelView - This Sub-component is shown to the user when a operational panel is empty + * + * Props taken in as params are: + * addVizDisabled -> Boolean to enable/disable the add visualization button + * getVizContextPanels -> Function to populate the add visualization popover + */ type Props = { - addVizWindow: () => void; addVizDisabled: boolean; + getVizContextPanels: ( + closeVizPopover?: (() => void) | undefined + ) => { + id: number; + title: string; + items: { + name: string; + onClick: () => void; + }[]; + }[]; }; -export const EmptyPanelView = ({ addVizWindow, addVizDisabled }: Props) => { +export const EmptyPanelView = ({ addVizDisabled, getVizContextPanels }: Props) => { + const [isVizPopoverOpen, setVizPopoverOpen] = useState(false); + + const onPopoverClick = () => { + setVizPopoverOpen(!isVizPopoverOpen); + }; + + const closeVizPopover = () => { + setVizPopoverOpen(false); + }; + + //Add Visualization Button + const addVisualizationButton = ( + + Add Visualization + + ); + return (
@@ -38,9 +78,16 @@ export const EmptyPanelView = ({ addVizWindow, addVizDisabled }: Props) => { - - Add Visualization - + + + diff --git a/public/components/custom_panels/panel_modules/panel_grid/index.ts b/public/components/custom_panels/panel_modules/panel_grid/index.ts new file mode 100644 index 000000000..15e88cc23 --- /dev/null +++ b/public/components/custom_panels/panel_modules/panel_grid/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { PanelGrid } from "./panel_grid"; diff --git a/public/components/custom_panels/panel_modules/panel_grid/panel_grid.scss b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.scss new file mode 100644 index 000000000..1b36abe7a --- /dev/null +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.scss @@ -0,0 +1,21 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +.react-grid-layout { + & .react-grid-placeholder { + background: $euiColorWarning; + } +} + +.full-width { + min-width: 100%; + max-width: 100%; +} diff --git a/public/components/custom_panels/panel_modules/panel_grid.tsx b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx similarity index 55% rename from public/components/custom_panels/panel_modules/panel_grid.tsx rename to public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx index 0e50d67a5..039f9f4e3 100644 --- a/public/components/custom_panels/panel_modules/panel_grid.tsx +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid.tsx @@ -13,49 +13,72 @@ import _ from 'lodash'; import React, { useEffect, useState } from 'react'; import { Layout, Layouts, 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'; -import { VisualizationContainer } from './visualization_container'; -import { VisualizationType } from '../../../../common/constants/custom_panels'; +import { CoreStart } from '../../../../../../../src/core/public'; +import PPLService from '../../../../services/requests/ppl'; +import { VisualizationContainer } from '../visualiation_container'; +import { VisualizationType } from '../../../../../common/types/custom_panels'; +import { CUSTOM_PANELS_API_PREFIX } from '../../../../../common/constants/custom_panels'; +import './panel_grid.scss'; // HOC container to provide dynamic width for Grid layout const ResponsiveGridLayout = WidthProvider(Responsive); /* -* PanelGrid - This module is places all visualizations in react-grid-layout -* chrome: CoreStart['chrome']; -* panelVisualizations: VisualizationType[]; -* editMode: boolean; -* pplService: PPLService; -* startTime: string; -* endTime: string; -* onRefresh: boolean; -*/ + * PanelGrid - This module is places all visualizations in react-grid-layout + * + * Props taken in as params are: + * http: http core service; + * chrome: chrome core service; + * panelVisualizations: list of panel visualizations + * setPanelVisualizations: function to set panel visualizations + * editMode: boolean to check if the panel is in edit mode + * pplService: ppl requestor service + * startTime: start time in date filter + * endTime: end time in date filter + * onRefresh: boolean value to trigger refresh of visualizations + * cloneVisualization: function to clone a visualization in panel + * pplFilterValue: string with panel PPL filter value + * showFlyout: function to show the flyout + * removeVisualization: function to remove all the visualizations + */ type Props = { + http: CoreStart['http']; chrome: CoreStart['chrome']; + panelId: string; panelVisualizations: VisualizationType[]; + setPanelVisualizations: React.Dispatch>; editMode: boolean; pplService: PPLService; startTime: string; endTime: string; onRefresh: boolean; - cloneVisualization: (newVisualizationTitle: string, pplQuery: string, newVisualizationType: string) => void; - deleteVisualization: (visualizationId: string, visualizationName: string)=> void; + cloneVisualization: ( + newVisualizationTitle: string, + pplQuery: string, + newVisualizationType: string, + newVisualizationTimeField: string + ) => void; pplFilterValue: string; + showFlyout: (isReplacement?: boolean | undefined, replaceVizId?: string | undefined) => void; + removeVisualization: (visualizationId: string) => void; }; export const PanelGrid = ({ + http, chrome, + panelId, panelVisualizations, + setPanelVisualizations, editMode, pplService, startTime, endTime, onRefresh, cloneVisualization, - deleteVisualization, pplFilterValue, + showFlyout, + removeVisualization, }: Props) => { const [layout, setLayout] = useState([]); const [editedLayout, setEditedLayout] = useState([]); @@ -69,7 +92,7 @@ export const PanelGrid = ({ // Reload the Layout const reloadLayout = () => { - const tempLayout:Layout[] = panelVisualizations.map((panelVisualization) => { + const tempLayout: Layout[] = panelVisualizations.map((panelVisualization) => { return { i: panelVisualization.id, x: panelVisualization.x, @@ -82,6 +105,22 @@ export const PanelGrid = ({ setLayout(tempLayout); }; + const saveVisualizationLayouts = async (panelId: string, visualizationParams: any) => { + return http + .put(`${CUSTOM_PANELS_API_PREFIX}/visualizations/edit`, { + body: JSON.stringify({ + panelId: panelId, + visualizationParams: visualizationParams, + }), + }) + .then(async (res) => { + setPanelVisualizations(res.visualizations); + }) + .catch((err) => { + console.error(err); + }); + }; + // Update layout whenever user edit gets completed useEffect(() => { if (editMode) { @@ -90,8 +129,9 @@ export const PanelGrid = ({ const newLayout = editedLayout.map((element) => { return { ...element, static: true }; }); + const visualizationParams = newLayout.map((layout) => _.omit(layout, ['static', 'moved'])); setLayout(newLayout); - // NOTE: need to add backend call to change visualization sizes + if (visualizationParams.length !== 0) saveVisualizationLayouts(panelId, visualizationParams); } }, [editMode]); @@ -110,13 +150,12 @@ export const PanelGrid = ({ return ( - {panelVisualizations.map((panelVisualization: VisualizationType, index: number) => ( + {panelVisualizations.map((panelVisualization: VisualizationType) => (
))} diff --git a/public/components/custom_panels/panel_modules/visualiation_container/index.ts b/public/components/custom_panels/panel_modules/visualiation_container/index.ts new file mode 100644 index 000000000..ae7fc7cec --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualiation_container/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { VisualizationContainer } from "./visualization_container"; diff --git a/public/components/custom_panels/panel_modules/visualiation_container/visualization_container.scss b/public/components/custom_panels/panel_modules/visualiation_container/visualization_container.scss new file mode 100644 index 000000000..79e517fc2 --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualiation_container/visualization_container.scss @@ -0,0 +1,55 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + + .mouseGrabber { + & :hover { + cursor: -webkit-grab; + cursor: grab; + } + & :active { + cursor: -webkit-grabbing; + cursor: grabbing; + } +} + +.visualization-div { + width: 100%; + height: 90%; + overflow: scroll; + text-align: center; +} + +%center-div { + top: 50%; + left: 50%; + -ms-transform: translate(-50%, -50%); + -moz-transform: translate(-50%, -50%); + -webkit-transform: translate(-50%, -50%); + -o-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.visualization-loading-chart { + margin: 0; + position: absolute; + @extend %center-div; +} + +.visualization-error-div { + overflow: scroll; + position: relative; + @extend %center-div; +} + +.panel-full-width { + width: 100%; + height: 100%; +} diff --git a/public/components/custom_panels/panel_modules/visualiation_container/visualization_container.tsx b/public/components/custom_panels/panel_modules/visualiation_container/visualization_container.tsx new file mode 100644 index 000000000..bb035c50e --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualiation_container/visualization_container.tsx @@ -0,0 +1,206 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + EuiButtonIcon, + EuiContextMenuItem, + EuiContextMenuPanel, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLoadingChart, + EuiPanel, + EuiPopover, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import React, { useEffect, useMemo, useState } from 'react'; +import PPLService from '../../../../services/requests/ppl'; +import { displayVisualization, getQueryResponse } from '../../helpers/utils'; +import './visualization_container.scss'; + +/* + * Visualization container - This module is a placeholder to add visualizations in react-grid-layout + * + * Props taken in as params are: + * editMode: boolean to check if the panel is in edit mode + * visualizationId: unique visualization id + * visualizationTitle: visualization name + * query: ppl query to load the visualization + * pplService: ppl requestor service + * type: type of visualization [bar, horizontal_bar, line] + * fromTime: start time in date filter + * toTime: end time in date filter + * onRefresh: boolean value to trigger refresh of visualizations + * cloneVisualization: function to clone a visualization in panel + * pplFilterValue: string with panel PPL filter value + * showFlyout: function to show the flyout + * removeVisualization: function to remove all the visualizations + */ + +type Props = { + editMode: boolean; + visualizationId: string; + visualizationTitle: string; + query: string; + pplService: PPLService; + type: string; + timeField: string; + fromTime: string; + toTime: string; + onRefresh: boolean; + cloneVisualization: ( + newVisualizationTitle: string, + pplQuery: string, + newVisualizationType: string, + newVisualizationTimeField: string + ) => void; + pplFilterValue: string; + showFlyout: (isReplacement?: boolean | undefined, replaceVizId?: string | undefined) => void; + removeVisualization: (visualizationId: string) => void; +}; + +export const VisualizationContainer = ({ + editMode, + visualizationId, + visualizationTitle, + pplService, + query, + type, + timeField, + fromTime, + toTime, + onRefresh, + cloneVisualization, + pplFilterValue, + showFlyout, + removeVisualization, +}: Props) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + const [disablePopover, setDisablePopover] = useState(false); + const [visualizationData, setVisualizationData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [isError, setIsError] = useState(''); + const onActionsMenuClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen); + const closeActionsMenu = () => setIsPopoverOpen(false); + + const popoverPanel = [ + { + closeActionsMenu(); + showFlyout(true, visualizationId); + }} + > + Replace + , + { + closeActionsMenu(); + cloneVisualization(visualizationTitle, query, type, timeField); + }} + > + Duplicate + , + ]; + + const loadVisaulization = async () => { + await getQueryResponse( + pplService, + query, + type, + fromTime, + toTime, + setVisualizationData, + setIsLoading, + setIsError, + pplFilterValue, + timeField + ); + }; + + const memoisedVisualizationBox = useMemo( + () => ( +
+ {isLoading ? ( + + ) : isError != '' ? ( +
+ + + + +

Error in rendering the visualizaiton

+
+ + +

{isError}

+
+
+ ) : ( + displayVisualization(visualizationData, type) + )} +
+ ), + [onRefresh, isLoading, isError, visualizationData, type] + ); + + useEffect(() => { + loadVisaulization(); + }, [onRefresh]); + + useEffect(() => { + editMode ? setDisablePopover(true) : setDisablePopover(false); + }, [editMode]); + + return ( + +
+ + + +
{visualizationTitle}
+
+
+ + {disablePopover ? ( + { + removeVisualization(visualizationId); + }} + /> + ) : ( + + } + isOpen={isPopoverOpen} + closePopover={closeActionsMenu} + anchorPosition="downLeft" + > + + + )} + +
+
+ {memoisedVisualizationBox} +
+ ); +}; diff --git a/public/components/custom_panels/panel_modules/visualization_container.tsx b/public/components/custom_panels/panel_modules/visualization_container.tsx deleted file mode 100644 index 2555234a2..000000000 --- a/public/components/custom_panels/panel_modules/visualization_container.tsx +++ /dev/null @@ -1,174 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -import { - EuiButtonIcon, - EuiContextMenuItem, - EuiContextMenuPanel, - EuiFlexGroup, - EuiFlexItem, - EuiIcon, - EuiLoadingChart, - EuiPanel, - EuiPopover, - EuiSpacer, - EuiText, -} from '@elastic/eui'; -import { Plt } from '../../visualizations/plotly/plot'; -import React, { useEffect, useRef, useState } from 'react'; -import PPLService from '../../../services/requests/ppl'; -import { getQueryResponse } from '../helpers/utils'; - -// Visualization Panel module allows view added viz modules. - -type Props = { - editMode: boolean; - visualizationId: string; - visualizationTitle: string; - query: string; - pplService: PPLService; - type: string; - fromTime: string; - toTime: string; - onRefresh: boolean; - cloneVisualization: ( - newVisualizationTitle: string, - pplQuery: string, - newVisualizationType: string - ) => void; - deleteVisualization: (visualizationId: string, visualizationName: string) => void; - pplFilterValue: string; -}; - -export const VisualizationContainer = ({ - editMode, - visualizationId, - visualizationTitle, - pplService, - query, - type, - fromTime, - toTime, - onRefresh, - cloneVisualization, - deleteVisualization, - pplFilterValue, -}: Props) => { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - const [disablePopover, setDisablePopover] = useState(false); - const [visualizationData, setVisualizationData] = useState([]); - const [isLoading, setIsLoading] = useState(true); - const [isError, setIsError] = useState(''); - const onActionsMenuClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen); - const closeActionsMenu = () => setIsPopoverOpen(false); - - const popoverPanel = [ - - View events - , - - Edit - , - cloneVisualization(visualizationTitle, query, type)} - > - Duplicate - , - deleteVisualization(visualizationId, visualizationTitle)} - > - Remove - , - ]; - - const loadVisaulization = async () => { - await getQueryResponse( - pplService, - query, - type, - fromTime, - toTime, - setVisualizationData, - setIsLoading, - setIsError, - pplFilterValue, - ); - }; - - useEffect(() => { - loadVisaulization(); - }, [query, onRefresh]); - - useEffect(() => { - editMode ? setDisablePopover(true) : setDisablePopover(false); - }, [editMode]); - - return ( - - - - -
{visualizationTitle}
-
-
- - } - isOpen={isPopoverOpen} - closePopover={closeActionsMenu} - anchorPosition="downLeft" - > - - - -
-
- {isLoading ? ( - - ) : isError != '' ? ( -
- - - - -

Error in rendering the visualizaiton

-
- - -

{isError}

-
-
- ) : ( - - )} -
-
- ); -}; diff --git a/public/components/custom_panels/panel_modules/visualization_flyout/index.ts b/public/components/custom_panels/panel_modules/visualization_flyout/index.ts new file mode 100644 index 000000000..980a5dae8 --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualization_flyout/index.ts @@ -0,0 +1,12 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export { VisaulizationFlyout } from "./visualization_flyout"; diff --git a/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.scss b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.scss new file mode 100644 index 000000000..d86d7ca53 --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.scss @@ -0,0 +1,34 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +.visualization-div { + min-height: 200; +} + +.visualization-loading-chart { + margin: 0; + position: absolute; + top: 50%; + left: 50%; + -ms-transform: translate(-50%, -50%); + -moz-transform: translate(-50%, -50%); + -webkit-transform: translate(-50%, -50%); + -o-transform: translate(-50%, -50%); + transform: translate(-50%, -50%); +} + +.visualization-error-div { + overflow: scroll; +} + +.date-picker-height { + height: 3vh; +} diff --git a/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.tsx b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.tsx new file mode 100644 index 000000000..4b90d439b --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.tsx @@ -0,0 +1,399 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiButtonIcon, + EuiCallOut, + EuiDatePicker, + EuiDatePickerRange, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiIcon, + EuiLoadingChart, + EuiSelect, + EuiSelectOption, + EuiSpacer, + EuiText, + EuiTitle, + htmlIdGenerator, + ShortDate, +} from '@elastic/eui'; +import _ from 'lodash'; +import { UI_DATE_FORMAT } from '../../../../../common/constants/shared'; +import React, { useEffect, useState } from 'react'; +import { FlyoutContainers } from '../../helpers/flyout_containers'; +import { displayVisualization, getQueryResponse, isDateValid } from '../../helpers/utils'; +import { convertDateTime } from '../../helpers/utils'; +import PPLService from '../../../../services/requests/ppl'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { CUSTOM_PANELS_API_PREFIX } from '../../../../../common/constants/custom_panels'; +import { + pplResponse, + SavedVisualizationType, + VisualizationType, +} from '../../../../../common/types/custom_panels'; +import './visualization_flyout.scss'; + +/* + * VisaulizationFlyout - This module create a flyout to add visualization + * + * Props taken in as params are: + * panelId: panel Id of current operational panel + * closeFlyout: function to close the flyout + * start: start time in date filter + * end: end time in date filter + * setToast: function to set toast in the panel + * http: http core service + * pplService: ppl requestor service + * setPanelVisualizations: function set the visualization list in panel + * isFlyoutReplacement: boolean to see if the flyout is trigger for add or replace visualization + * replaceVisualizationId: string id of the visualization to be replaced + */ + +type Props = { + panelId: string; + closeFlyout: () => void; + start: ShortDate; + end: ShortDate; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; + http: CoreStart['http']; + pplService: PPLService; + setPanelVisualizations: React.Dispatch>; + isFlyoutReplacement?: boolean | undefined; + replaceVisualizationId?: string | undefined; +}; + +export const VisaulizationFlyout = ({ + panelId, + closeFlyout, + start, + end, + setToast, + http, + pplService, + setPanelVisualizations, + isFlyoutReplacement, + replaceVisualizationId, +}: Props) => { + const [newVisualizationTitle, setNewVisualizationTitle] = useState(''); + const [newVisualizationType, setNewVisualizationType] = useState(''); + const [newVisualizationTimeField, setNewVisualizationTimeField] = useState(''); + const [pplQuery, setPPLQuery] = useState(''); + const [previewData, setPreviewData] = useState({} as pplResponse); + const [previewArea, setPreviewArea] = useState(<>); + const [showPreviewArea, setShowPreviewArea] = useState(false); + const [previewIconType, setPreviewIconType] = useState('arrowRight'); + const [previewLoading, setPreviewLoading] = useState(false); + const [isPreviewError, setIsPreviewError] = useState(''); + const [savedVisualizations, setSavedVisualizations] = useState([]); + const [visualizationOptions, setVisualizationOptions] = useState([]); + const [selectValue, setSelectValue] = useState(''); + + // DateTimePicker States + const startDate = convertDateTime(start, true, false); + const endDate = convertDateTime(end, false, false); + + const onPreviewClick = () => { + if (previewIconType == 'arrowRight') { + setPreviewIconType('arrowUp'); + setShowPreviewArea(true); + } else { + setPreviewIconType('arrowRight'); + setShowPreviewArea(false); + } + }; + + const isInputValid = () => { + if (!isDateValid(convertDateTime(start), convertDateTime(end, false), setToast, 'left')) { + return false; + } + + if (selectValue === '') { + setToast('Please make a valid selection', 'danger', undefined, 'left'); + return false; + } + + return true; + }; + + const addVisualization = () => { + if (!isInputValid()) return; + + if (isFlyoutReplacement) { + http + .post(`${CUSTOM_PANELS_API_PREFIX}/visualizations/replace`, { + body: JSON.stringify({ + panelId: panelId, + oldVisualizationId: replaceVisualizationId, + newVisualization: { + id: 'panelViz_' + htmlIdGenerator()(), + title: newVisualizationTitle, + query: pplQuery, + type: newVisualizationType, + timeField: newVisualizationTimeField, + }, + }), + }) + .then(async (res) => { + setPanelVisualizations(res.visualizations); + setToast(`Visualization ${newVisualizationTitle} successfully added!`, 'success'); + }) + .catch((err) => { + setToast(`Error in adding ${newVisualizationTitle} visualization to the panel`, 'danger'); + console.error(err); + }); + } else { + http + .post(`${CUSTOM_PANELS_API_PREFIX}/visualizations`, { + body: JSON.stringify({ + panelId: panelId, + newVisualization: { + id: 'panelViz_' + htmlIdGenerator()(), + title: newVisualizationTitle, + query: pplQuery, + type: newVisualizationType, + timeField: newVisualizationTimeField, + }, + }), + }) + .then(async (res) => { + setPanelVisualizations(res.visualizations); + setToast(`Visualization ${newVisualizationTitle} successfully added!`, 'success'); + }) + .catch((err) => { + setToast(`Error in adding ${newVisualizationTitle} visualization to the panel`, 'danger'); + console.error(err); + }); + } + closeFlyout(); + }; + + const onRefreshPreview = () => { + if (!isInputValid()) return; + + getQueryResponse( + pplService, + pplQuery, + newVisualizationType, + start, + end, + setPreviewData, + setPreviewLoading, + setIsPreviewError, + '', + newVisualizationTimeField + ); + }; + + const timeRange = ( + + endDate} + aria-label="Start date" + dateFormat={UI_DATE_FORMAT} + /> + } + endDateControl={ + endDate} + aria-label="End date" + dateFormat={UI_DATE_FORMAT} + /> + } + /> + + ); + + const flyoutHeader = ( + + +

+ {isFlyoutReplacement ? 'Replace Visualization' : 'Select Existing Visualization'} +

+
+
+ ); + + const onChangeSelection = (e: React.ChangeEvent) => { + setSelectValue(e.target.value); + }; + + const emptySavedVisualizations = ( + +

No saved visualizations found!

+
+ ); + + const flyoutBody = + savedVisualizations.length > 0 ? ( + + <> + + + onChangeSelection(e)} + options={visualizationOptions} + /> + + + + + + + Preview + + + + + + + + {showPreviewArea && previewArea} + + + + ) : ( + + <> +
+ You don't have any saved visualizations. Please use the "create new visualization" + option in add visualization menu. +
+ +
+ ); + + const flyoutFooter = ( + + + + Cancel + + + + Add + + + + + ); + + // Fetch all saved visualizations + const fetchSavedVisualizations = async () => { + return http + .get(`${CUSTOM_PANELS_API_PREFIX}/visualizations`) + .then((res) => { + if (res.visualizations.length > 0) { + setSavedVisualizations(res.visualizations); + setVisualizationOptions( + res.visualizations.map((visualization: SavedVisualizationType) => { + return { value: visualization.id, text: visualization.name }; + }) + ); + } + }) + .catch((err) => { + console.error('Issue in fetching the operational panels', err); + }); + }; + + useEffect(() => { + const previewTemplate = ( + <> + {timeRange} + {previewLoading ? ( + + ) : isPreviewError != '' ? ( +
+ + + + +

Error in rendering the visualizaiton

+
+ + +

{isPreviewError}

+
+
+ ) : ( + + + {displayVisualization(previewData, newVisualizationType)} + + + )} + + ); + setPreviewArea(previewTemplate); + }, [previewLoading]); + + // On change of selected visualization change options + useEffect(() => { + for (var i = 0; i < savedVisualizations.length; i++) { + const visualization = savedVisualizations[i]; + if (visualization.id === selectValue) { + setPPLQuery(visualization.query); + setNewVisualizationTitle(visualization.name); + setNewVisualizationType(visualization.type); + setNewVisualizationTimeField(visualization.timeField); + break; + } + } + }, [selectValue]); + + // load saved visualizations + useEffect(() => { + fetchSavedVisualizations(); + }, []); + + return ( + + ); +}; diff --git a/public/components/explorer/data_grid.tsx b/public/components/explorer/data_grid.tsx index 84c7eaa00..c159f2508 100644 --- a/public/components/explorer/data_grid.tsx +++ b/public/components/explorer/data_grid.tsx @@ -14,29 +14,31 @@ import './data_grid.scss'; import React, { useMemo } from 'react'; import { uniqueId } from 'lodash'; import { DocViewRow } from './docTable/index'; -import { IExplorerFields } from '../../../common/types/explorer'; +import { IExplorerFields, IField } from '../../../common/types/explorer'; interface DataGridProps { rows: Array; + rowsAll: Array; explorerFields: IExplorerFields; } export function DataGrid(props: DataGridProps) { const { rows, + rowsAll, explorerFields } = props; const getTrs = ( docs: Array = [], - explorerFields: IExplorerFields + explorerFields: Array ) => { return docs.map((doc) => { return ( ); }); @@ -48,9 +50,9 @@ export function DataGrid(props: DataGridProps) { '_source' ]; - const getHeaders = (fields: IExplorerFields) => { + const getHeaders = (fields: any) => { let tableHeadContent = null; - if (!fields.selectedFields || fields.selectedFields.length === 0) { + if (!fields || fields.length === 0) { tableHeadContent = ( <> { defaultCols.map((colName: string) => { @@ -63,12 +65,11 @@ export function DataGrid(props: DataGridProps) { ); } else { - tableHeadContent = fields.selectedFields.map(selField => { + tableHeadContent = fields.map(selField => { return ( { selField.name } ); }); - tableHeadContent.unshift(Time); tableHeadContent.unshift(); } @@ -80,21 +81,63 @@ export function DataGrid(props: DataGridProps) { }; - const headers = useMemo(() => getHeaders(explorerFields), [ explorerFields ]); - const tableRows = useMemo(() => getTrs(rows, explorerFields), [ rows, explorerFields ]); + const Queriedheaders = useMemo( + () => getHeaders(explorerFields.queriedFields), + [ explorerFields.queriedFields ] + ); + const QueriedtableRows = useMemo( + () => getTrs(rows, explorerFields.queriedFields), + [ rows, explorerFields.queriedFields ] + ); + const headers = useMemo( + () => getHeaders(explorerFields.selectedFields), + [ explorerFields.selectedFields ] + ); + const tableRows = useMemo( + () => { + const dataToRender = explorerFields?.queriedFields && explorerFields.queriedFields.length > 0 ? rowsAll : rows + return getTrs(dataToRender, explorerFields.selectedFields); + }, + [ rows, explorerFields.selectedFields ] + ); + return ( <> - - - { headers } - - - { tableRows } - -
+ { + explorerFields?.queriedFields && + explorerFields.queriedFields.length > 0 && + ( + + + { Queriedheaders } + + + { QueriedtableRows } + +
+ ) + } + { + ( + explorerFields?.queriedFields && + explorerFields?.queriedFields?.length > 0 && + explorerFields.selectedFields?.length === 0 + ) ? null : ( + + + { headers } + + + { tableRows } + +
+ ) + } ) } diff --git a/public/components/explorer/docTable/docViewRow.tsx b/public/components/explorer/docTable/docViewRow.tsx index bd24e6719..3e4d62389 100644 --- a/public/components/explorer/docTable/docViewRow.tsx +++ b/public/components/explorer/docTable/docViewRow.tsx @@ -148,15 +148,6 @@ export const DocViewRow = (props: IDocViewRowProps) => { }) ); }); - - if (has(doc, 'timestamp')) { - cols.unshift( - getTdTmpl({ - clsName: timestampClsName, - content: doc['timestamp'] - }) - ); - } } // Add detail toggling column @@ -188,7 +179,6 @@ export const DocViewRow = (props: IDocViewRowProps) => { key={ uniqueId('grid-td-detail-') } colSpan={ selectedCols.length ? selectedCols.length + 2 : 3 } > - diff --git a/public/components/explorer/event_analytics.tsx b/public/components/explorer/event_analytics.tsx new file mode 100644 index 000000000..38d4c88fe --- /dev/null +++ b/public/components/explorer/event_analytics.tsx @@ -0,0 +1,74 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { HashRouter, Route, Switch } from 'react-router-dom'; +import { LogExplorer } from './log_explorer'; +import { Home as EventExplorerHome } from './home'; +import { renderPageWithSidebar } from '../common/side_nav'; + +export const EventAnalytics = ({ + chrome, + parentBreadcrumb, + pplService, + dslService, + savedObjects, + http, + ...props +}: any) => { + + const eventAnalyticsBreadcrumb = { + text: 'Event analytics', + href: '#/event_analytics', + }; + + return ( + + + { + chrome.setBreadcrumbs([ + parentBreadcrumb, + eventAnalyticsBreadcrumb, + { + text: 'Explorer', + href: '#/event_analytics/explorer', + }, + ]); + return ( + + ); + }} + /> + { + chrome.setBreadcrumbs([ + parentBreadcrumb, + eventAnalyticsBreadcrumb, + { + text: 'Home', + href: '#/event_analytics', + } + ]); + return renderPageWithSidebar(); + }} + /> + + + ); +} \ No newline at end of file diff --git a/public/components/explorer/explorer.tsx b/public/components/explorer/explorer.tsx index 075ad1a31..992f29ec2 100644 --- a/public/components/explorer/explorer.tsx +++ b/public/components/explorer/explorer.tsx @@ -14,18 +14,26 @@ import { batch, useDispatch, useSelector } from 'react-redux'; import { uniqueId, isEmpty, - cloneDeep + cloneDeep, + isEqual, + concat } from 'lodash'; import { FormattedMessage } from '@osd/i18n/react'; import { EuiText, + EuiButton, EuiButtonIcon, EuiTabbedContent, EuiTabbedContentTab, EuiFlexGroup, - EuiFlexItem + EuiFlexItem, + EuiPopover, + EuiPopoverTitle, + EuiPopoverFooter, + EuiButtonEmpty, + htmlIdGenerator } from '@elastic/eui'; import classNames from 'classnames'; import { Search } from '../common/search/search'; @@ -47,17 +55,25 @@ import { TAB_EVENT_ID_TXT_PFX, TAB_CHART_ID_TXT_PFX, RAW_QUERY, + SELECTED_DATE_RANGE, SELECTED_FIELDS, UNSELECTED_FIELDS, - INDEX + AVAILABLE_FIELDS, + INDEX, + TIME_INTERVAL_OPTIONS } from '../../../common/constants/explorer'; -import { getIndexPatternFromRawQuery } from '../../../common/utils'; +import { PPL_STATS_REGEX } from '../../../common/constants/shared'; +import { + getIndexPatternFromRawQuery, + insertDateRangeToQuery +} from '../../../common/utils'; import { useFetchEvents, - useFetchVisualizations + useFetchVisualizations, } from './hooks'; import { changeQuery, + changeDateRange, selectQueries } from './slices/query_slice'; import { selectQueryResult } from './slices/query_result_slice'; @@ -70,6 +86,7 @@ import { selectCountDistribution } from './slices/count_distribution_slice'; import { selectExplorerVisualization } from './slices/visualization_slice'; import PPLService from '../../services/requests/ppl'; import DSLService from '../../services/requests/dsl'; +import SavedObjects from '../../services/saved_objects/event_analytics/saved_objects'; const TAB_EVENT_ID = uniqueId(TAB_EVENT_ID_TXT_PFX); const TAB_CHART_ID = uniqueId(TAB_CHART_ID_TXT_PFX); @@ -77,13 +94,16 @@ const TAB_CHART_ID = uniqueId(TAB_CHART_ID_TXT_PFX); interface IExplorerProps { pplService: PPLService; dslService: DSLService; - tabId: string + tabId: string; + savedObjects: SavedObjects; } export const Explorer = ({ pplService, dslService, - tabId + http, + tabId, + savedObjects }: IExplorerProps) => { const dispatch = useDispatch(); @@ -111,13 +131,19 @@ export const Explorer = ({ const countDistribution = useSelector(selectCountDistribution)[tabId]; const explorerVisualizations = useSelector(selectExplorerVisualization)[tabId]; const [selectedContentTabId, setSelectedContentTab] = useState(TAB_EVENT_ID); - const [startTime, setStartTime] = useState('now-15m'); - const [endTime, setEndTime] = useState('now'); + const [selectedCustomPanelOptions, setSelectedCustomPanelOptions] = useState([]); + const [selectedPanelName, setSelectedPanelName] = useState(''); + const [curVisId, setCurVisId] = useState('bar'); + const [isPanelTextFieldInvalid, setIsPanelTextFieldInvalid ] = useState(false); const [liveStreamChecked, setLiveStreamChecked] = useState(false); const [isSidebarClosed, setIsSidebarClosed] = useState(false); const [fixedScrollEl, setFixedScrollEl] = useState(); const queryRef = useRef(); + const selectedPanelNameRef = useRef(); + const explorerFieldsRef = useRef(); queryRef.current = query; + selectedPanelNameRef.current = selectedPanelName; + explorerFieldsRef.current = explorerFields; const fixedScrollRef = useCallback( (node: HTMLElement) => { @@ -128,13 +154,32 @@ export const Explorer = ({ [setFixedScrollEl] ); - const fetchData = () => { - const searchQuery = queryRef.current[RAW_QUERY]; - if (!searchQuery) return; - if (searchQuery.match(/\|\s*stats/i)) { - const index = getIndexPatternFromRawQuery(searchQuery); - if (!index) return; - getAvailableFields(`search source=${index}`); + const composeFinalQuery = (curQuery: any) => { + if (isEmpty(curQuery![RAW_QUERY])) return ''; + return insertDateRangeToQuery({ + rawQuery: curQuery![RAW_QUERY], + startTime: curQuery!['selectedDateRange'][0], + endTime: curQuery!['selectedDateRange'][1] + }); + }; + + const fetchData = async () => { + const curQuery = queryRef.current; + const rawQueryStr = curQuery![RAW_QUERY]; + if (isEmpty(rawQueryStr)) return; + const index = getIndexPatternFromRawQuery(rawQueryStr); + if (!isEmpty(index)) getAvailableFields(`search source=${index}`); + + const finalQuery = composeFinalQuery(curQuery); + + await dispatch(changeQuery({ + tabId, + query: { + finalQuery, + } + })); + + if (rawQueryStr.match(PPL_STATS_REGEX)) { getVisualizations(); } else { getEvents(); @@ -146,9 +191,19 @@ export const Explorer = ({ fetchData(); }, []); - const handleAddField = (field: IField) => toggleFields(field, UNSELECTED_FIELDS, SELECTED_FIELDS); + const handleAddField = (field: IField) => toggleFields(field, AVAILABLE_FIELDS, SELECTED_FIELDS); - const handleRemoveField = (field: IField) => toggleFields(field, SELECTED_FIELDS, UNSELECTED_FIELDS); + const handleRemoveField = (field: IField) => toggleFields(field, SELECTED_FIELDS, AVAILABLE_FIELDS); + + const handleTimePickerChange = async (timeRange: Array) => { + await dispatch(changeDateRange({ + tabId: requestParams.tabId, + data: { + [SELECTED_DATE_RANGE]: timeRange + } + })); + fetchData(); + } /** * Toggle fields between selected and unselected sets @@ -181,8 +236,6 @@ export const Explorer = ({ }); }; - const handleLiveStreamChecked = () => setLiveStreamChecked(!liveStreamChecked); - const sidebarClassName = classNames({ closed: isSidebarClosed, }); @@ -206,6 +259,7 @@ export const Explorer = ({
handleAddField(field) } handleRemoveField={ (field: IField) => handleRemoveField(field) } /> @@ -228,7 +282,7 @@ export const Explorer = ({ />
- { (explorerData && !isEmpty(explorerData)) ? ( + { (explorerData && !isEmpty(explorerData.jsonData)) ? (
{ @@ -243,7 +297,7 @@ export const Explorer = ({ > {} } /> @@ -252,44 +306,7 @@ export const Explorer = ({ > { getCountVisualizations(intrv); }} @@ -318,6 +335,7 @@ export const Explorer = ({
@@ -364,8 +382,13 @@ export const Explorer = ({ const getExplorerVis = () => { return ( ); }; @@ -393,6 +416,8 @@ export const Explorer = ({ return getMainContentTabs(); }, [ + curVisId, + isPanelTextFieldInvalid, explorerData, explorerFields, isSidebarClosed, @@ -401,41 +426,9 @@ export const Explorer = ({ ] ); - const actionItems = [ - { - text: 'Refresh', - iconType: 'refresh', - handlers: { - onClick: () => { - console.log('refresh clicked'); - } - } - }, - { - text: 'Live', - iconType: 'play', - handlers: { - onClick: () => { - console.log('refresh clicked'); - } - } - }, - { - text: 'Save', - iconType: 'heart', - handlers: { - onClick: () => { - console.log('refresh clicked'); - } - } - } - ]; - const handleContentTabClick = (selectedTab: IQueryTab) => setSelectedContentTab(selectedTab.id); - const handleQuerySearch = () => { - fetchData(); - } + const handleQuerySearch = () => fetchData(); const handleQueryChange = (query: string, index: string) => { dispatch(changeQuery({ @@ -446,29 +439,88 @@ export const Explorer = ({ }, })); } - + + const handleSavingObject = () => { + + const currQuery = queryRef.current; + const currFields = explorerFieldsRef.current; + if (isEmpty(currQuery![RAW_QUERY])) return; + + if (isEmpty(selectedPanelNameRef.current)) { + setIsPanelTextFieldInvalid(true); + return; + } else { + setIsPanelTextFieldInvalid(false); + } + + if (isEqual(selectedContentTabId, TAB_EVENT_ID)) { + + // create new saved query + savedObjects.createSavedQuery({ + query: currQuery![RAW_QUERY], + fields: currFields![SELECTED_FIELDS], + dateRange: currQuery![SELECTED_DATE_RANGE], + name: selectedPanelNameRef.current + }); + + // to-dos - update selected custom panel + if (!isEmpty(selectedCustomPanelOptions)) { + // update custom panel - query + } + + } else if (isEqual(selectedContentTabId, TAB_CHART_ID)) { + + // create new saved visualization + savedObjects.createSavedVisualization({ + query: currQuery![RAW_QUERY], + fields: currFields![SELECTED_FIELDS], + dateRange: currQuery![SELECTED_DATE_RANGE], + type: curVisId, + name: selectedPanelNameRef.current + }); + + // update custom panel - visualization + if (!isEmpty(selectedCustomPanelOptions)) { + + savedObjects.bulkUpdateCustomPanel({ + selectedCustomPanels: selectedCustomPanelOptions, + query: currQuery![RAW_QUERY], + type: curVisId, + timeField: !isEmpty(currQuery!['selectedTimestamp']) ? currQuery!['selectedTimestamp'] : 'utc_time', // temprary + name: selectedPanelNameRef.current + }); + } + } + }; + + const dateRange = isEmpty(query['selectedDateRange']) ? ['now/15m', 'now'] : + [query['selectedDateRange'][0], query['selectedDateRange'][1]]; + return (
-

testing

{ handleQueryChange(query, index) } } + handleQueryChange={ (query: string, index: string = '') => { handleQueryChange(query, index) } } handleQuerySearch={ () => { handleQuerySearch() } } dslService = { dslService } - startTime={ startTime } - endTime={ endTime } - setStartTime={ setStartTime } - setEndTime={ setEndTime } - setIsOutputStale={ () => {} } - liveStreamChecked={ liveStreamChecked } - onLiveStreamChange={ handleLiveStreamChecked } - actionItems={ actionItems } + startTime={ dateRange[0] } + endTime={ dateRange[1] } + handleTimePickerChange={ (timeRange: Array) => handleTimePickerChange(timeRange) } + selectedPanelName={ selectedPanelNameRef.current } + selectedCustomPanelOptions={ selectedCustomPanelOptions } + setSelectedPanelName={ setSelectedPanelName } + setSelectedCustomPanelOptions={ setSelectedCustomPanelOptions } + handleSavingObject={ handleSavingObject } + isPanelTextFieldInvalid={ isPanelTextFieldInvalid } + savedObjects={ savedObjects } + showSavePanelOptionsList={ isEqual(selectedContentTabId, TAB_CHART_ID) } /> - { handleQueryChange(query, index) } } - /> + /> */} { - const {pplService, dslService} = props; +interface IHomeProps { + pplService: any; + dslService: any; + savedObjects: SavedObjects; + http: any; +} + +export const Home = (props: IHomeProps) => { + const { + pplService, + dslService, + savedObjects, + http + } = props; const history = useHistory(); const dispatch = useDispatch(); const query = useSelector(selectQueries)[initialTabId][RAW_QUERY]; + const [savedHistories, setSavedHistories] = useState([]); - const queryHistories = [ - { - query: "search source=opensearch_dashboards_sample_data_logs | where utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00')", - iconType: "tokenEnum" - } - ]; - - const visHistories = [ - { - query: "search source=opensearch_dashboards_sample_data_logs | where utc_time > timestamp('2021-07-01 00:00:00') and utc_time < timestamp('2021-07-02 00:00:00') | stats count() by span(utc_time, '15m')", - iconType: "tokenHistogram" - } - ]; + const fetchHistories = async () => { + const res = await savedObjects.fetchSavedObjects({ + objectType: ['savedQuery', 'savedVisualization'], + sortOrder: 'desc', + fromIndex: 0, + maxItems: 10 + }); + setSavedHistories(res['observabilityObjectList'] || []); + }; - const actionItems = [ - { - text: 'Run', - iconType: '', - attributes: { - fill: true - }, - handlers: { - onClick: () => { - history.push('/explorer/events'); - } - } - }, - { - text: 'Live', - iconType: 'play', - handlers: { - onClick: () => {} - } - } - ]; + useEffect(() => { + fetchHistories(); + }, []); return (
@@ -100,7 +92,7 @@ export const Home = (props: any) => { })); } } handleQuerySearch={ () => { - history.push('/explorer/events'); + history.push('/event_analytics/explorer'); } } pplService={ pplService } dslService={ dslService } @@ -111,7 +103,7 @@ export const Home = (props: any) => { setIsOutputStale={ () => {} } liveStreamChecked={ false } onLiveStreamChange={ () => {} } - actionItems={ actionItems } + showSaveButton={ false } /> { wrapText={ true } > -

Query History

-
- - { - queryHistories.map((h) => { - return ( - { - dispatch(changeQuery({ - tabId: initialTabId, - query: { - [RAW_QUERY]: item.target.outerText - } - })); - history.push('/explorer/events'); - }} - label={ h.query } - color="primary" - size="s" - iconType={ h.iconType } - /> - ); - }) - } - - - - - -

Visualization History

+

{ "Histories" }

- { - visHistories.map((h) => { - return ( - { - dispatch(changeQuery({ - tabId: initialTabId, - query: { - [RAW_QUERY]: item.target.outerText - } - })); - history.push('/explorer/events'); - }} - label={ h.query } - color="primary" - size="s" - iconType={ h.iconType } - /> - ); - }) - }
diff --git a/public/components/explorer/hooks/use_fetch_events.ts b/public/components/explorer/hooks/use_fetch_events.ts index 81e52a036..ce0865545 100644 --- a/public/components/explorer/hooks/use_fetch_events.ts +++ b/public/components/explorer/hooks/use_fetch_events.ts @@ -11,18 +11,22 @@ import { useState, useRef } from 'react'; import { batch } from 'react-redux'; +import { isEmpty } from 'lodash'; import { useDispatch, useSelector } from 'react-redux'; import { RAW_QUERY, + FINAL_QUERY, SELECTED_FIELDS, UNSELECTED_FIELDS, - AVAILABLE_FIELDS + AVAILABLE_FIELDS, + QUERIED_FIELDS } from '../../../../common/constants/explorer'; import { fetchSuccess, reset as queryResultReset } from '../slices/query_result_slice'; import { selectQueries } from '../slices/query_slice'; +import { reset as visualizationReset } from '../slices/visualization_slice'; import { updateFields, sortFields @@ -66,10 +70,14 @@ export const useFetchEvents = ({ }); }; - const getEvents = () => { + const getEvents = (query: string = '') => { const cur = queriesRef.current; - fetchEvents({ query: cur[requestParams.tabId][RAW_QUERY] }, 'jdbc', (res: any) => { + const searchQuery = isEmpty(query) ? cur![requestParams.tabId][FINAL_QUERY] : query; + fetchEvents({ query: searchQuery }, 'jdbc', (res: any) => { batch(() => { + dispatch(queryResultReset({ + tabId: requestParams.tabId + })); dispatch(fetchSuccess({ tabId: requestParams.tabId, data: { @@ -80,12 +88,17 @@ export const useFetchEvents = ({ tabId: requestParams.tabId, data: { [SELECTED_FIELDS]: [], - [UNSELECTED_FIELDS]: res.schema ? [ ...res.schema ] : [] + [UNSELECTED_FIELDS]: res?.schema ? [ ...res.schema ] : [], + [QUERIED_FIELDS]: [], + [AVAILABLE_FIELDS]: res?.schema ? [...res.schema] : [] } })); dispatch(sortFields({ tabId: requestParams.tabId, - data: [UNSELECTED_FIELDS] + data: [AVAILABLE_FIELDS, UNSELECTED_FIELDS] + })); + dispatch(visualizationReset({ + tabId: requestParams.tabId, })); }); }); @@ -94,20 +107,21 @@ export const useFetchEvents = ({ const getAvailableFields = (query: string) => { fetchEvents({ query, }, 'jdbc', (res: any) => { batch(() => { - dispatch(queryResultReset({ - tabId: requestParams.tabId + dispatch(fetchSuccess({ + tabId: requestParams.tabId, + data: { + jsonDataAll: res['jsonData'] + } })); dispatch(updateFields({ tabId: requestParams.tabId, data: { - [SELECTED_FIELDS]: [], - [UNSELECTED_FIELDS]: [], [AVAILABLE_FIELDS]: res?.schema ? [...res.schema] : [] } })); dispatch(sortFields({ tabId: requestParams.tabId, - data: [AVAILABLE_FIELDS] + data: [AVAILABLE_FIELDS, UNSELECTED_FIELDS] })); }); }); diff --git a/public/components/explorer/hooks/use_fetch_visualizations.ts b/public/components/explorer/hooks/use_fetch_visualizations.ts index d2e622af7..79e3e3c32 100644 --- a/public/components/explorer/hooks/use_fetch_visualizations.ts +++ b/public/components/explorer/hooks/use_fetch_visualizations.ts @@ -11,16 +11,25 @@ import { useState, useRef } from 'react'; import { + batch, useDispatch, useSelector } from 'react-redux'; import { - RAW_QUERY + FINAL_QUERY, + QUERIED_FIELDS, + RAW_QUERY, + SELECTED_FIELDS } from '../../../../common/constants/explorer'; import { render as renderCountDis } from '../slices/count_distribution_slice'; import { selectQueries } from '../slices/query_slice'; import { render as renderExplorerVis } from '../slices/visualization_slice'; +import { + updateFields, + sortFields +} from '../slices/field_slice'; import PPLService from '../../../services/requests/ppl'; +import { fetchSuccess } from '../slices/query_result_slice'; interface IFetchVisualizationsParams { pplService: PPLService; @@ -62,7 +71,7 @@ export const useFetchVisualizations = ({ const getCountVisualizations = (interval: string) => { const cur = queriesRef.current; - const rawQuery = cur[requestParams.tabId][RAW_QUERY]; + const rawQuery = cur![requestParams.tabId][FINAL_QUERY]; fetchVisualizations({ query: `${rawQuery} | stats count() by span(timestamp, '1${interval = interval ? interval: 'm' }')` }, 'viz', @@ -76,15 +85,34 @@ export const useFetchVisualizations = ({ const getVisualizations = () => { const cur = queriesRef.current; - const rawQuery = cur[requestParams.tabId][RAW_QUERY]; + const rawQuery = cur![requestParams.tabId][FINAL_QUERY]; fetchVisualizations({ query: rawQuery }, 'viz', (res: any) => { - dispatch(renderExplorerVis({ - tabId: requestParams.tabId, - data: res - })); + batch(() => { + dispatch(renderExplorerVis({ + tabId: requestParams.tabId, + data: res + })); + dispatch(fetchSuccess({ + tabId: requestParams.tabId, + data: { + jsonData: res.jsonData + } + })); + dispatch(updateFields({ + tabId: requestParams.tabId, + data: { + [QUERIED_FIELDS]: res?.metadata.fields, + [SELECTED_FIELDS]: [] + } + })); + dispatch(sortFields({ + tabId: requestParams.tabId, + data: [QUERIED_FIELDS] + })); + }); }); } diff --git a/public/components/explorer/log_explorer.tsx b/public/components/explorer/log_explorer.tsx index 49f6609af..fedde6f06 100644 --- a/public/components/explorer/log_explorer.tsx +++ b/public/components/explorer/log_explorer.tsx @@ -50,7 +50,9 @@ import { export const LogExplorer = ({ pplService, - dslService + dslService, + savedObjects, + http }: ILogExplorerProps) => { const dispatch = useDispatch(); @@ -143,7 +145,9 @@ export const LogExplorer = ({ key={`explorer_${tabId}`} pplService={ pplService } dslService={ dslService } + http={ http } tabId={ tabId } + savedObjects={ savedObjects } /> ) }; diff --git a/public/components/explorer/no_results.tsx b/public/components/explorer/no_results.tsx index e9d460b4c..490f70ed1 100644 --- a/public/components/explorer/no_results.tsx +++ b/public/components/explorer/no_results.tsx @@ -44,16 +44,14 @@ export const NoResults = () => {

-

diff --git a/public/components/explorer/reducers/fetch_reducers.ts b/public/components/explorer/reducers/fetch_reducers.ts index 3b50693ef..3cbf49b17 100644 --- a/public/components/explorer/reducers/fetch_reducers.ts +++ b/public/components/explorer/reducers/fetch_reducers.ts @@ -10,5 +10,8 @@ */ export const fetchSuccess = (state, { payload }) => { - state[payload.tabId] = payload.data; + state[payload.tabId] = { + ...state[payload.tabId], + ...payload.data + }; }; \ No newline at end of file diff --git a/public/components/explorer/visualizations/shared_components/save_panel/index.ts b/public/components/explorer/save_panel/index.ts similarity index 100% rename from public/components/explorer/visualizations/shared_components/save_panel/index.ts rename to public/components/explorer/save_panel/index.ts diff --git a/public/components/explorer/save_panel/savePanel.tsx b/public/components/explorer/save_panel/savePanel.tsx new file mode 100644 index 000000000..ce909a336 --- /dev/null +++ b/public/components/explorer/save_panel/savePanel.tsx @@ -0,0 +1,105 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { + EuiTitle, + EuiComboBox, + EuiFormRow, + EuiSpacer, + EuiFieldText +} from '@elastic/eui'; +import { useEffect } from 'react'; +import SavedObjects from '../../../services/saved_objects/event_analytics/saved_objects'; +import { isEmpty } from 'lodash'; + +interface ISavedPanelProps { + selectedOptions: any; + handleNameChange: any; + handleOptionChange: any; + savedObjects: SavedObjects; + isTextFieldInvalid: boolean; + savePanelName: string; + showOptionList: boolean; +} + +type CustomPanelOptions = { + id: string; + name: string; + dateCreated: string; + dateModified: string; +} + +export const SavePanel = ({ + selectedOptions, + handleNameChange, + handleOptionChange, + savedObjects, + isTextFieldInvalid, + savePanelName, + showOptionList, +}: ISavedPanelProps) => { + + const [options, setOptions] = useState([]); + + const getCustomPabnelList = async (savedObjects: SavedObjects) => { + const optionRes = await savedObjects.fetchCustomPanels(); + setOptions(optionRes['panels']); + }; + + useEffect(() => { + getCustomPabnelList(savedObjects); + }, []); + + return ( + <> + { showOptionList && ( + <> + +

{ 'Custom operational dashboards/application' }

+
+ + { handleOptionChange(options) } } + selectedOptions={ selectedOptions } + options={ options.map((option: CustomPanelOptions) => { + return { + panel: option, + label: option['name'], + } + }) } + isClearable={true} + data-test-subj="demoComboBox" + /> + + + )} + +

{ 'Name' }

+
+ + { + handleNameChange(e.target.value); + }} + /> + + + ); +}; \ No newline at end of file diff --git a/public/components/explorer/sidebar/field.tsx b/public/components/explorer/sidebar/field.tsx index 6eefe466e..4c39d416c 100644 --- a/public/components/explorer/sidebar/field.tsx +++ b/public/components/explorer/sidebar/field.tsx @@ -23,6 +23,7 @@ import { IField } from '../../../../common/types/explorer'; interface IFieldProps { field: IField; selected: boolean; + showToggleButton: boolean; onToggleField: (field: IField) => void; } @@ -31,6 +32,7 @@ export const Field = (props: IFieldProps) => { const { field, selected, + showToggleButton = true, onToggleField } = props; @@ -68,21 +70,25 @@ export const Field = (props: IFieldProps) => { ) } > - ) => { - if (e.type === 'click') { - e.currentTarget.focus(); - } - e.preventDefault(); - e.stopPropagation(); - toggleField(field); - }} - data-test-subj={`fieldToggle-${field.name}`} - aria-label={ selected ? removeLabelAria : addLabelAria } - /> + { + showToggleButton ? ( + ) => { + if (e.type === 'click') { + e.currentTarget.focus(); + } + e.preventDefault(); + e.stopPropagation(); + toggleField(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={ selected ? removeLabelAria : addLabelAria } + /> + ) : <> + } ); }; @@ -92,7 +98,7 @@ export const Field = (props: IFieldProps) => { ownFocus display="block" isOpen={ isFieldDetailsOpen } - closePopover={ () => setIsFieldDetailsOpen(false) } + closePopover={ () => togglePopover } anchorPosition="rightUp" panelClassName="dscSidebarItem__fieldPopoverPanel" button={ @@ -113,7 +119,7 @@ export const Field = (props: IFieldProps) => { } fieldAction={ getFieldActionDOM() } - onClick={() => togglePopover()} + onClick={() => {}} /> } > diff --git a/public/components/explorer/sidebar/sidebar.tsx b/public/components/explorer/sidebar/sidebar.tsx index 65ae6c6ca..317f23a54 100644 --- a/public/components/explorer/sidebar/sidebar.tsx +++ b/public/components/explorer/sidebar/sidebar.tsx @@ -26,6 +26,7 @@ import { IExplorerFields, IField } from '../../../../common/types/explorer'; interface ISidebarProps { explorerFields: IExplorerFields; + explorerData: any; handleAddField: (field: IField) => void; handleRemoveField: (field: IField) => void; } @@ -34,6 +35,7 @@ export const Sidebar = (props: ISidebarProps) => { const { explorerFields, + explorerData, handleAddField, handleRemoveField } = props; @@ -60,13 +62,50 @@ export const Sidebar = (props: ISidebarProps) => {
- { explorerFields && !isEmpty(explorerFields) && ( + { explorerData && !isEmpty(explorerData.jsonData) && !isEmpty(explorerFields) && ( <> + { + explorerFields?.queriedFields && explorerFields.queriedFields?.length > 0 && ( + <> + +

+ +

+
+ +
    + { explorerFields.queriedFields && explorerFields.queriedFields.map(field => { + return ( +
  • + +
  • + )}) + } +
+ + ) + }

@@ -76,7 +115,10 @@ export const Sidebar = (props: ISidebarProps) => { aria-labelledby="selected_fields" data-test-subj={`fieldList-selected`} > - { explorerFields.selectedFields && explorerFields.selectedFields.map(field => { + { explorerData && + !isEmpty(explorerData.jsonData) && + explorerFields.selectedFields && + explorerFields.selectedFields.map(field => { return (
  • {

    @@ -132,8 +174,10 @@ export const Sidebar = (props: ISidebarProps) => { data-test-subj={`fieldList-unpopular`} > { - explorerFields.unselectedFields && - explorerFields.unselectedFields.filter( + explorerData && + !isEmpty(explorerData.jsonData) && + explorerFields.availableFields && + explorerFields.availableFields.filter( (field) => searchTerm === '' || field.name.indexOf(searchTerm) !== -1) .map((field) => { return ( diff --git a/public/components/explorer/slices/field_slice.ts b/public/components/explorer/slices/field_slice.ts index 1320c4622..e58e90042 100644 --- a/public/components/explorer/slices/field_slice.ts +++ b/public/components/explorer/slices/field_slice.ts @@ -17,13 +17,17 @@ import { initialTabId } from '../../../framework/redux/store/shared_state'; import { SELECTED_FIELDS, UNSELECTED_FIELDS, + AVAILABLE_FIELDS, + QUERIED_FIELDS, REDUX_EXPL_SLICE_FIELDS } from '../../../../common/constants/explorer'; import { IField } from '../../../../common/types/explorer'; const initialFields = { [SELECTED_FIELDS]: [], - [UNSELECTED_FIELDS]: [] + [UNSELECTED_FIELDS]: [], + [AVAILABLE_FIELDS]: [], + [QUERIED_FIELDS]: [], }; const initialState = { @@ -43,6 +47,7 @@ export const fieldSlice = createSlice({ }, updateFields: (state, { payload }) => { state[payload.tabId] = { + ...state[payload.tabId], ...payload.data }; }, diff --git a/public/components/explorer/slices/query_slice.ts b/public/components/explorer/slices/query_slice.ts index 838210e20..e09b3e8d9 100644 --- a/public/components/explorer/slices/query_slice.ts +++ b/public/components/explorer/slices/query_slice.ts @@ -15,14 +15,22 @@ import { import { initialTabId } from '../../../framework/redux/store/shared_state'; import { RAW_QUERY, + FINAL_QUERY, + SELECTED_DATE_RANGE, REDUX_EXPL_SLICE_QUERIES, INDEX } from '../../../../common/constants/explorer'; +const initialQueryState = { + [RAW_QUERY]: '', + [FINAL_QUERY]: '', + [INDEX]: '', + [SELECTED_DATE_RANGE]: ['now-15m', 'now'] +}; + const initialState = { [initialTabId]: { - [RAW_QUERY]: '', - [INDEX]: '' + ...initialQueryState } }; @@ -32,13 +40,19 @@ export const queriesSlice = createSlice({ reducers: { changeQuery: (state, { payload }) => { state[payload.tabId] = { + ...state[payload.tabId], ...payload.query } }, + changeDateRange: (state, { payload }) => { + state[payload.tabId] = { + ...state[payload.tabId], + ...payload.data + } + }, init: (state, { payload }) => { state[payload.tabId] = { - [RAW_QUERY]: '', - [INDEX]: '' + ...initialQueryState }; }, remove: (state, { payload }) => { @@ -50,6 +64,7 @@ export const queriesSlice = createSlice({ export const { changeQuery, + changeDateRange, remove, init } = queriesSlice.actions; diff --git a/public/components/explorer/slices/visualization_slice.ts b/public/components/explorer/slices/visualization_slice.ts index 3860e5efc..3ddd6b8a4 100644 --- a/public/components/explorer/slices/visualization_slice.ts +++ b/public/components/explorer/slices/visualization_slice.ts @@ -25,13 +25,19 @@ export const explorerVisualizationSlice = createSlice({ reducers: { render: (state, { payload }) => { state[payload.tabId] = payload.data; - } + }, + reset: (state, { payload }) => { + state[payload.tabId] = { + ...initialState + } + }, }, extraReducers: (builder) => {} }); export const { - render + render, + reset } = explorerVisualizationSlice.actions; export const selectExplorerVisualization = (state) => state.explorerVisualization; diff --git a/public/components/explorer/timechart_header/timechart_header.tsx b/public/components/explorer/timechart_header/timechart_header.tsx index a21ac0bbd..cf34ceb0f 100644 --- a/public/components/explorer/timechart_header/timechart_header.tsx +++ b/public/components/explorer/timechart_header/timechart_header.tsx @@ -117,8 +117,8 @@ export function TimechartHeader({ label: display, }; })} - value={interval} - onChange={handleIntervalChange} + value={ interval } + onChange={ handleIntervalChange } append={ undefined } /> diff --git a/public/components/explorer/visualizations/assets/chart_bar_horizontal.tsx b/public/components/explorer/visualizations/assets/chart_bar_horizontal.tsx new file mode 100644 index 000000000..489a20e0d --- /dev/null +++ b/public/components/explorer/visualizations/assets/chart_bar_horizontal.tsx @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { EuiIconProps } from '@elastic/eui'; + +export const LensIconChartBarHorizontal = ({ + title, + titleId, + ...props +}: Omit) => ( + + {title ? {title} : null} + + + +); diff --git a/public/components/explorer/visualizations/count_distribution/count_distribution.tsx b/public/components/explorer/visualizations/count_distribution/count_distribution.tsx index 43f935df9..a31df4287 100644 --- a/public/components/explorer/visualizations/count_distribution/count_distribution.tsx +++ b/public/components/explorer/visualizations/count_distribution/count_distribution.tsx @@ -16,9 +16,8 @@ export const CountDistribution = ({ countDistribution }: any) => { - if (!countDistribution) return null; - - const data = countDistribution.data; + if (!countDistribution || !countDistribution.data) return null; + const meta = countDistribution.metadata; const xkey = meta?.xfield?.name; const ykey = meta?.yfield?.name; @@ -33,17 +32,24 @@ export const CountDistribution = ({ }, height: 220 }; + const config = {}; + const xaxis = { + autorange: true + }; + const yaxis = { + fixedrange: true + }; if (!xkey || !ykey) { return null; } return ( - ); }; \ No newline at end of file diff --git a/public/components/explorer/visualizations/frame_layout.scss b/public/components/explorer/visualizations/frame_layout.scss index 6f68babbd..fbc2e4ff6 100644 --- a/public/components/explorer/visualizations/frame_layout.scss +++ b/public/components/explorer/visualizations/frame_layout.scss @@ -48,7 +48,7 @@ $lnsSuggestionWidth: 150px; } .lnsFrameLayout__sidebar { - margin: 0; + margin: 0 16px 0; flex: 1 0 18%; min-width: $lnsPanelMinWidth + $euiSize; display: flex; diff --git a/public/components/explorer/visualizations/frame_layout.tsx b/public/components/explorer/visualizations/frame_layout.tsx index b568d869b..a06e49f37 100644 --- a/public/components/explorer/visualizations/frame_layout.tsx +++ b/public/components/explorer/visualizations/frame_layout.tsx @@ -31,9 +31,6 @@ export function FrameLayout(props: FrameLayoutProps) { {props.workspacePanel} - - {props.configPanel} -
  • ); diff --git a/public/components/explorer/visualizations/index.tsx b/public/components/explorer/visualizations/index.tsx index 984e6321d..34b77b6e1 100644 --- a/public/components/explorer/visualizations/index.tsx +++ b/public/components/explorer/visualizations/index.tsx @@ -16,24 +16,41 @@ import _ from 'lodash'; import React from 'react'; import { FrameLayout } from './frame_layout'; import { DataPanel } from './datapanel'; +import { Sidebar } from '../sidebar/sidebar'; import { WorkspacePanel } from './workspace_panel'; import { ConfigPanelWrapper } from './config_panel'; export const ExplorerVisualizations = ({ + curVisId, + setCurVisId, explorerVis, - explorerFields + explorerFields, + explorerData, + handleAddField, + handleRemoveField, + savedObjects, + onSaveVisualization, + getSavedVisualization }: any) => { return ( } workspacePanel={ } configPanel={ diff --git a/public/components/explorer/visualizations/shared_components/save_panel/savePanel.tsx b/public/components/explorer/visualizations/shared_components/save_panel/savePanel.tsx deleted file mode 100644 index e574e71fa..000000000 --- a/public/components/explorer/visualizations/shared_components/save_panel/savePanel.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -import React, { useState } from 'react'; -import { - EuiTitle, - EuiComboBox, - EuiFormRow, - EuiSpacer, - EuiFieldText -} from '@elastic/eui'; - -export const SavePanel = () => { - return ( - <> - -

    { 'Custom operational dashboards/application' }

    -
    - - - - - -

    { 'Visualization name' }

    -
    - - {}} - aria-label="Use aria labels when no actual label is in use" - /> - - - ); -}; \ No newline at end of file diff --git a/public/components/explorer/visualizations/workspace_panel/workspace_panel.tsx b/public/components/explorer/visualizations/workspace_panel/workspace_panel.tsx index a5bc53449..dae5982c4 100644 --- a/public/components/explorer/visualizations/workspace_panel/workspace_panel.tsx +++ b/public/components/explorer/visualizations/workspace_panel/workspace_panel.tsx @@ -15,31 +15,50 @@ import { DragDrop } from '../drag_drop'; import { WorkspacePanelWrapper } from './workspace_panel_wrapper'; import { Bar } from '../../../visualizations/charts/bar'; import { Line } from '../../../visualizations/charts/line'; +import { HorizontalBar } from '../../../visualizations/charts/horizontal_bar'; import { LensIconChartBar } from '../assets/chart_bar'; import { LensIconChartLine } from '../assets/chart_line'; +import { LensIconChartBarHorizontal } from '../assets/chart_bar_horizontal'; +import { EmptyPlaceholder } from '../shared_components/empty_placeholder'; +import SavedObjects from '../../../../services/saved_objects/event_analytics/saved_objects'; -const layout = { +const plotlySharedlayout = { showlegend: true, margin: { - l: 60, + l: 50, r: 10, - b: 15, + b: 30, t: 30, pad: 0, }, - height: 300 + height: 500, + legend: { + orientation: 'v', + traceorder: 'normal', + } }; -export function WorkspacePanel({ - visualizations -}: any) { +const plotlySharedConfig = { + displayModeBar: true, + displaylogo: false, + responsive: true, + editable: true +}; - if (!visualizations || !visualizations.data) return null; +interface IWorkSpacePanel { + curVisId: string; + setCurVisId: any; + visualizations: any; + savedObjects: SavedObjects; + onSaveVisualization: any; + getSavedObjects: any; +} - const data = visualizations.data; - const meta = visualizations.metadata; - const xkey = meta?.xfield?.name; - const ykey = meta?.yfield?.name; +export function WorkspacePanel({ + curVisId, + setCurVisId, + visualizations +}: IWorkSpacePanel) { const memorizedVisualizationTypes = useMemo(() => { return ([ @@ -52,11 +71,31 @@ export function WorkspacePanel({ selection: { dataLoss: 'nothing' }, - chart: : + }, + { + id: 'horizontal_bar', + label: 'H. Bar', + fullLabel: 'H. Bar', + icon: LensIconChartBarHorizontal, + visualizationId: uniqueId('vis-horizontal-bar-'), + selection: { + dataLoss: 'nothing' + }, + chart: (!visualizations || !visualizations.data) ? + : }, { @@ -68,17 +107,22 @@ export function WorkspacePanel({ selection: { dataLoss: 'nothing' }, - chart: : } ]); - }, [visualizations]); + }, [ + curVisId, + visualizations + ]); - const [curVisId, setCurVisId] = useState(memorizedVisualizationTypes[0]['id']); + const [savePanelName, setSavePanelName] = useState(''); function onDrop() {} @@ -99,12 +143,17 @@ export function WorkspacePanel({ setVis={ setCurVisId } vis={ getCurChart() } visualizationTypes={ memorizedVisualizationTypes } + handleSavePanelNameChange={ (name: string) => { + console.log('vis updating state name: ', name); + setSavePanelName(name) + } } + savePanelName={ savePanelName } >
    diff --git a/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.scss b/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.scss index 689e4fdf1..8a6bb7f8b 100644 --- a/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.scss +++ b/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.scss @@ -14,8 +14,6 @@ .lnsWorkspacePanelWrapper { @include euiScrollBar; overflow: hidden; - // Override panel size padding - padding: 0 !important; // sass-lint:disable-line no-important margin-bottom: $euiSize; display: flex; flex-direction: column; @@ -25,8 +23,6 @@ .lnsWorkspacePanelWrapper__pageContentHeader { @include euiTitle('xs'); padding: $euiSizeM; - // override EuiPage - margin-bottom: 0 !important; // sass-lint:disable-line no-important } .lnsWorkspacePanelWrapper__pageContentHeader--unsaved { diff --git a/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.tsx b/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.tsx index 04e4d6fa1..1fcdab8f1 100644 --- a/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.tsx +++ b/public/components/explorer/visualizations/workspace_panel/workspace_panel_wrapper.tsx @@ -11,7 +11,7 @@ import './workspace_panel_wrapper.scss'; -import React, { useState } from 'react'; +import React from 'react'; import { i18n } from '@osd/i18n'; import classNames from 'classnames'; import { @@ -20,14 +20,8 @@ import { EuiPageContentHeader, EuiFlexGroup, EuiFlexItem, - EuiPopover, - EuiButton, - EuiButtonEmpty, - EuiPopoverFooter, - EuiPopoverTitle } from '@elastic/eui'; import { ChartSwitch } from './chart_switch'; -import { SavePanel } from '../shared_components/save_panel' export function WorkspacePanelWrapper({ children, @@ -35,24 +29,9 @@ export function WorkspacePanelWrapper({ emptyExpression, setVis, vis, - visualizationTypes + visualizationTypes, }: any) { - const [isPopoverOpen, setIsPopoverOpen] = useState(false); - - const onButtonClick = () => setIsPopoverOpen((isPopoverOpen) => !isPopoverOpen); - const closePopover = () => setIsPopoverOpen(false); - const button = ( - - Save - - ); - return ( <>
    @@ -72,36 +51,6 @@ export function WorkspacePanelWrapper({ visualizationTypes={ visualizationTypes } /> - - - {"Save to..."} - - - - - {}}> - Cancel - - - - {}}> - Save - - - - - -
    diff --git a/public/components/index.tsx b/public/components/index.tsx index 5fc1f746b..8aa74880a 100644 --- a/public/components/index.tsx +++ b/public/components/index.tsx @@ -20,7 +20,8 @@ export const Observability = ( DepsStart: AppPluginStartDependencies, AppMountParameters: AppMountParameters, pplService: any, - dslService: any + dslService: any, + savedObjects: any ) => { ReactDOM.render( , AppMountParameters.element ); diff --git a/public/components/notebooks/components/helpers/reporting_context_menu_helper.tsx b/public/components/notebooks/components/helpers/reporting_context_menu_helper.tsx index 601e9138e..4a9f37b2b 100644 --- a/public/components/notebooks/components/helpers/reporting_context_menu_helper.tsx +++ b/public/components/notebooks/components/helpers/reporting_context_menu_helper.tsx @@ -140,7 +140,10 @@ export const generateInContextReport = async ( rest = {} ) => { toggleReportingLoadingModal(true); - let baseUrl = location.pathname + location.hash + '?view=output_only'; + let baseUrl = + location.pathname + + location.hash.replace(/\?view=(view_both|input_only|output_only)/, '') + + '?view=output_only'; // Add selected tenant info to url try { const tenant = await getTenantInfoIfExists(); diff --git a/public/components/visualizations/charts/bar.tsx b/public/components/visualizations/charts/bar.tsx index cf5b0b557..638630dc0 100644 --- a/public/components/visualizations/charts/bar.tsx +++ b/public/components/visualizations/charts/bar.tsx @@ -10,40 +10,55 @@ */ import React from 'react'; + +import { + take, + merge +} from 'lodash'; import { Plt } from '../plotly/plot'; export const Bar = ({ - xvalues, - yvalues, - name, - layoutConfig, + visualizations, + barConfig = {}, + layoutConfig = {}, }: any) => { + + const { data, metadata: { fields, } } = visualizations; + const stackLength = fields.length - 1; + const barValues = take(fields, stackLength > 0 ? stackLength : 1).map((field: any) => { + return { + x: barConfig.orientation !== 'h' ? data[fields[stackLength].name] : data[field.name], + y: barConfig.orientation !== 'h' ? data[field.name] : data[fields[stackLength].name], + type: 'bar', + name: field.name, + ...barConfig + }; + }); + + const barLayoutConfig = merge({ + xaxis: { + automargin: true + }, + yaxis: { + automargin: true + }, + }, layoutConfig); + return ( ); -}; \ No newline at end of file +}; diff --git a/public/components/visualizations/charts/horizontal_bar.tsx b/public/components/visualizations/charts/horizontal_bar.tsx new file mode 100644 index 000000000..325f38e0e --- /dev/null +++ b/public/components/visualizations/charts/horizontal_bar.tsx @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { merge } from 'lodash'; +import { Bar } from './bar'; + +interface IBarTrace { + xvalues: Array; + yvalues: Array; + mode: string; + name: string +} + +export interface IStackedBarProps { + name: string; + barValues: Array; + layoutConfig?: any +}; + +export const HorizontalBar = ({ + visualizations, + horizontalConfig, + layoutConfig = {} +}: any) => { + + const horizontalBarConfig = merge({ + orientation: 'h' + }, horizontalConfig); + + const horizontalBarLayoutConfig = merge({ + yaxis: { + automargin: true + }, + }, layoutConfig); + + return ( + + ); +}; diff --git a/public/components/visualizations/charts/line.tsx b/public/components/visualizations/charts/line.tsx index a7b879db7..1013bdc3d 100644 --- a/public/components/visualizations/charts/line.tsx +++ b/public/components/visualizations/charts/line.tsx @@ -10,25 +10,40 @@ */ import React from 'react'; +import { + take, + merge +} from 'lodash'; import { Plt } from '../plotly/plot'; export const Line = ({ - xvalues, - yvalues, - name, - layoutConfig, + visualizations, + lineConfig = {}, + layoutConfig = {}, }: any) => { + + const { data, metadata: { fields, } } = visualizations; + const lineLength = fields.length - 1; + const lineValues = take(fields, lineLength).map((field: any) => { + return { + x: data[fields[lineLength].name], + y: data[field.name], + type: 'line', + name: field.name + }; + }); + + const config = { + barmode: 'line', + xaxis: { + automargin: true + } + }; + const lineLayoutConfig = merge(config, layoutConfig); return ( ); -}; \ No newline at end of file +}; diff --git a/public/components/visualizations/plotly/plot.tsx b/public/components/visualizations/plotly/plot.tsx index 545a03772..04563eba5 100644 --- a/public/components/visualizations/plotly/plot.tsx +++ b/public/components/visualizations/plotly/plot.tsx @@ -16,24 +16,25 @@ import Plotly from 'plotly.js-dist'; interface PltProps { data: Plotly.Data[]; layout?: Partial; + config?: Partial; onHoverHandler?: (event: Readonly) => void; onUnhoverHandler?: (event: Readonly) => void; - onClickHandler?: (event: Readonly) => void; height?: string; } export function Plt(props: PltProps) { const PlotComponent = plotComponentFactory(Plotly); - return ( { @@ -37,12 +38,14 @@ export class ObservabilityPlugin implements Plugin | string; + objectType?: Array | string; + sortField?: string; + sortOrder?: "asc" | "desc"; + fromIndex?: number; + maxItems?: number; + name?: string; + lastUpdatedTimeMs?: string; + createdTimeMs?: string; +} + +interface ISelectedPanelsParams { + selectedCustomPanels: Array + name: string; + query: string; + type: string; + timeField: string; +} + +interface IBulkUpdateSavedVisualizationRquest { + query: string; + fields: Array; + dateRange: Array; + type: string; + name: string; + savedObjectList: Array; +} + +export default class SavedObjects { + + constructor(private readonly http: any) {} + + buildRequestBody ({ + query, + fields, + dateRange, + name = '', + chartType = '', + description = '' + }: any) { + + const objRequest = { + object: { + query, + selected_date_range: { + start: dateRange[0] || 'now/15m', + end: dateRange[1] || 'now', + text: '' + }, + selected_fields: { + tokens: fields, + text: '' + }, + name: name || '', + description: description || '' + } + }; + + if (!isEmpty(chartType)) { + objRequest['object']['type'] = chartType; + } + + return objRequest; + } + + private stringifyList( + targetObj: any, + key: string, + joinBy: string + ) { + if (has(targetObj, key) && isArray(targetObj[key])) { + targetObj[key] = targetObj[key].join(joinBy); + } + return targetObj; + } + + async fetchSavedObjects(savedObjectRequestParams: ISavedObjectRequestParams) { + + // turn array into string. exmaple objectType ['savedQuery', 'savedVisualization'] => + // 'savedQuery,savedVisualization' + CONCAT_FIELDS.map((arrayField) => { + this.stringifyList( + savedObjectRequestParams, + arrayField, + ',' + ); + }); + + const res = await this.http.get( + `${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}`, + { + query: { + ...savedObjectRequestParams + }, + } + ).catch((error: any) => console.log(error)); + + return res; + } + + async fetchCustomPanels() { + return await this.http.get(`${CUSTOM_PANELS_API_PREFIX}/panels`).catch((error: any) => console.log(error)); + } + + async bulkUpdateCustomPanel (selectedPanelsParams: ISelectedPanelsParams) { + const finalParams = { + panelId: '', + newVisualization: { + id: `panelViz_'${htmlIdGenerator()()}`, + title: selectedPanelsParams['name'], + query: selectedPanelsParams['query'], + type: selectedPanelsParams['type'], + timeField: selectedPanelsParams['timeField'] + } + }; + + const responses = await Promise.all( + selectedPanelsParams['selectedCustomPanels'].map((panel) => { + finalParams['panelId'] = panel['panel']['id']; + return this.http.post(`${CUSTOM_PANELS_API_PREFIX}/visualizations/event_explorer`, { + body: JSON.stringify(finalParams) + }); + }) + ).catch((error) => {}); + + }; + + async bulkUpdateSavedVisualization(bulkUpdateSavedVisualizationRquest: IBulkUpdateSavedVisualizationRquest) { + + const finalParams = this.buildRequestBody({ + query: bulkUpdateSavedVisualizationRquest['query'], + fields: bulkUpdateSavedVisualizationRquest['fields'], + dateRange: bulkUpdateSavedVisualizationRquest['dateRange'], + chartType: bulkUpdateSavedVisualizationRquest['type'], + name: bulkUpdateSavedVisualizationRquest['name'] + }); + + const responses = await Promise.all( + bulkUpdateSavedVisualizationRquest.savedObjectList.map((objectToUpdate) => { + finalParams['object_id'] = objectToUpdate['saved_object']['objectId']; + return this.http.put(`${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_VISUALIZATION}`, { + body: JSON.stringify(finalParams) + }); + }) + ).catch((error) => {}); + } + + async updateSavedVisualizationById(updateVisualizationRequest: any) { + const finalParams = this.buildRequestBody({ + query: updateVisualizationRequest['query'], + fields: updateVisualizationRequest['fields'], + dateRange: updateVisualizationRequest['dateRange'], + }); + + finalParams['object_id'] = updateVisualizationRequest['objectId']; + + return await this.http.post(`${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_QUERY}`, { + body: JSON.stringify(finalParams) + }).catch((error: any) => console.log(error)); + + } + + async createSavedQuery(createQueryRequest: any) { + + const finalParams = this.buildRequestBody({ + query: createQueryRequest['query'], + fields: createQueryRequest['fields'], + dateRange: createQueryRequest['dateRange'], + name: createQueryRequest['name'] + }); + + return await this.http.post(`${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_QUERY}`, { + body: JSON.stringify(finalParams) + }).catch((error: any) => console.log(error)); + } + + async createSavedVisualization(createVisualizationRequest: any) { + + const finalParams = this.buildRequestBody({ + query: createVisualizationRequest['query'], + fields: createVisualizationRequest['fields'], + dateRange: createVisualizationRequest['dateRange'], + chartType: createVisualizationRequest['type'], + name: createVisualizationRequest['name'] + }); + + return await this.http.post(`${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_VISUALIZATION}`, { + body: JSON.stringify(finalParams) + }).catch((error: any) => console.log(error)); + } + + deleteSavedObjectsById(deleteObjectRequest: any) {} + + deleteSavedObjectsByIdList(deleteObjectRequesList: any) {} + +} \ No newline at end of file diff --git a/server/adaptors/custom_panels/custom_panel_adaptor.ts b/server/adaptors/custom_panels/custom_panel_adaptor.ts index 80e3a4bb2..f5992c712 100644 --- a/server/adaptors/custom_panels/custom_panel_adaptor.ts +++ b/server/adaptors/custom_panels/custom_panel_adaptor.ts @@ -9,20 +9,19 @@ * GitHub history for details. */ -import { PanelType } from '../../../common/constants/custom_panels'; +import { PanelType, VisualizationType } from '../../../common/types/custom_panels'; import { ILegacyScopedClusterClient } from '../../../../../src/core/server'; -// NOTE: Need to add more functions for using panel APIs export class CustomPanelsAdaptor { // index a panel indexPanel = async function ( client: ILegacyScopedClusterClient, - body: any - ): Promise<{ panelId: string }> { + panelBody: PanelType + ): Promise<{ objectId: string }> { try { - const response = await client.callAsCurrentUser('observability.createPanel', { + const response = await client.callAsCurrentUser('observability.createObject', { body: { - panel: body, + operationalPanel: panelBody, }, }); return response; @@ -35,13 +34,13 @@ export class CustomPanelsAdaptor { updatePanel = async function ( client: ILegacyScopedClusterClient, panelId: string, - updateBody: Partial + updatePanelBody: Partial ) { try { - const response = await client.callAsCurrentUser('observability.updatePanelById', { - panelId: panelId, + const response = await client.callAsCurrentUser('observability.updateObjectById', { + objectId: panelId, body: { - panel: updateBody, + operationalPanel: updatePanelBody, }, }); return response; @@ -50,44 +49,325 @@ export class CustomPanelsAdaptor { } }; - //fetch a panel by id + // fetch a panel by id getPanel = async function (client: ILegacyScopedClusterClient, panelId: string) { try { - const response = await client.callAsCurrentUser('observability.getPanelById', { - panelId: panelId, + const response = await client.callAsCurrentUser('observability.getObjectById', { + objectId: panelId, }); - return response.panelDetails; + return response.observabilityObjectList[0]; } catch (error) { throw new Error('Get Panel Error:' + error); } }; // gets list of panels stored in index - viewPanels = async function (client: ILegacyScopedClusterClient) { - try { - const response = await client.callAsCurrentUser('observability.getPanels'); - return response.panelsDetailsList.map((panel) => ({ - path: panel.panel.name, - id: panel.id, - dateCreated: panel.panel.dateCreated, - dateModified: panel.panel.dateModified, + viewPanelList = async function (client: ILegacyScopedClusterClient) { + try { + const response = await client.callAsCurrentUser('observability.getObject', { + objectType: 'operationalPanel', + }); + return response.observabilityObjectList.map((panel: any) => ({ + name: panel.operationalPanel.name, + id: panel.objectId, + dateCreated: panel.createdTimeMs, + dateModified: panel.lastUpdatedTimeMs, })); } catch (error) { - if (error.body.error.type === 'index_not_found_exception') { - return []; - } else throw new Error('View Panels Error:' + error); + throw new Error('View Panel List Error:' + error); } }; // Delete a panel by Id - deleteNote = async function (client: ILegacyScopedClusterClient, panelId: string) { + deletePanel = async function (client: ILegacyScopedClusterClient, panelId: string) { try { - const response = await client.callAsCurrentUser('observability.deletePanelById', { - panelId: panelId, + const response = await client.callAsCurrentUser('observability.deleteObjectById', { + objectId: panelId, }); return { status: 'OK', message: response }; } catch (error) { throw new Error('Delete Panel Error:' + error); } }; + + // Delete a panel by Id + deletePanelList = async function (client: ILegacyScopedClusterClient, panelIdList: string) { + try { + const response = await client.callAsCurrentUser('observability.deleteObjectByIdList', { + objectIdList: panelIdList, + }); + return { status: 'OK', message: response }; + } catch (error) { + throw new Error('Delete Panel List Error:' + error); + } + }; + + // Create a new Panel + createNewPanel = async (client: ILegacyScopedClusterClient, panelName: string) => { + const panelBody = { + name: panelName, + visualizations: [], + timeRange: { + to: 'now', + from: 'now-1d', + }, + queryFilter: { + query: '', + language: 'ppl', + }, + }; + + try { + const response = await this.indexPanel(client, panelBody); + return response.objectId; + } catch (error) { + throw new Error('Create New Panel Error:' + error); + } + }; + + // Rename an existing panel + renamePanel = async (client: ILegacyScopedClusterClient, panelId: string, panelName: string) => { + const updatePanelBody = { + name: panelName, + }; + try { + const response = await this.updatePanel(client, panelId, updatePanelBody); + return response.objectId; + } catch (error) { + throw new Error('Rename Panel Error:' + error); + } + }; + + // Clone an existing panel + clonePanel = async (client: ILegacyScopedClusterClient, panelId: string, panelName: string) => { + const updatePanelBody = { + name: panelName, + }; + try { + const getPanel = await this.getPanel(client, panelId); + const clonePanelBody = { + name: panelName, + visualizations: getPanel.operationalPanel.visualizations, + timeRange: getPanel.operationalPanel.timeRange, + queryFilter: getPanel.operationalPanel.queryFilter, + }; + const indexResponse = await this.indexPanel(client, clonePanelBody); + const getClonedPanel = await this.getPanel(client, indexResponse.objectId); + return { + clonePanelId: getClonedPanel.objectId, + dateCreated: getClonedPanel.createdTimeMs, + dateModified: getClonedPanel.lastUpdatedTimeMs, + }; + } catch (error) { + throw new Error('Clone Panel Error:' + error); + } + }; + + // Add filters to an existing panel + addPanelFilter = async ( + client: ILegacyScopedClusterClient, + panelId: string, + query: string, + language: string, + to: string, + from: string + ) => { + const updatePanelBody = { + timeRange: { + to: to, + from: from, + }, + queryFilter: { + query: query, + language: language, + }, + }; + try { + const response = await this.updatePanel(client, panelId, updatePanelBody); + return response.objectId; + } catch (error) { + throw new Error('Add Panel Filter Error:' + error); + } + }; + + // gets list of panels stored in index + viewSavedVisualiationList = async (client: ILegacyScopedClusterClient) => { + try { + const response = await client.callAsCurrentUser('observability.getObject', { + objectType: 'savedVisualization', + }); + return response.observabilityObjectList.map((visualization: any) => ({ + id: visualization.objectId, + name: visualization.savedVisualization.name, + query: visualization.savedVisualization.query, + type: visualization.savedVisualization.type, + timeField: visualization.savedVisualization.selected_timestamp.name, + })); + } catch (error) { + throw new Error('View Saved Visualizations Error:' + error); + } + }; + + //Get All Visualizations from a Panel + //Add Visualization + getVisualizations = async (client: ILegacyScopedClusterClient, panelId: string) => { + try { + const response = await client.callAsCurrentUser('observability.getObjectById', { + objectId: panelId, + }); + return response.observabilityObjectList[0].operationalPanel.visualizations; + } catch (error) { + throw new Error('Get Visualizations Error:' + error); + } + }; + + // Calculate new visualization dimensions + // New visualization always joins to the end of the panel + getNewVizDimensions = (panelVisualizations: VisualizationType[]) => { + let maxY: number = 0; + let maxYH: number = 0; + + panelVisualizations.map((panelVisualization: VisualizationType) => { + if (panelVisualization.y >= maxY) { + maxY = panelVisualization.y; + maxYH = panelVisualization.h; + } + }); + + return { x: 0, y: maxY + maxYH, w: 6, h: 4 }; + }; + + //Add Visualization in the Panel + addVisualization = async ( + client: ILegacyScopedClusterClient, + panelId: string, + newVisualization: { + id: string; + title: string; + query: string; + type: string; + timeField: string; + }, + oldVisualizationId?: string + ) => { + try { + const allPanelVisualizations = await this.getVisualizations(client, panelId); + + let newDimensions; + let visualizationsList = []; + if (oldVisualizationId === undefined) { + newDimensions = this.getNewVizDimensions(allPanelVisualizations); + visualizationsList = allPanelVisualizations; + } else { + allPanelVisualizations.map((visualization: VisualizationType) => { + if (visualization.id != oldVisualizationId) { + visualizationsList.push(visualization); + } else { + newDimensions = { + x: visualization.x, + y: visualization.y, + w: visualization.w, + h: visualization.h, + }; + } + }); + } + const newPanelVisualizations = [ + ...visualizationsList, + { ...newVisualization, ...newDimensions }, + ]; + const updatePanelResponse = await this.updatePanel(client, panelId, { + visualizations: newPanelVisualizations, + }); + return newPanelVisualizations; + } catch (error) { + throw new Error('Add/Replace Visualization Error:' + error); + } + }; + + //Add Visualization in the Panel from Event Explorer + addVisualizationFromEvents = async ( + client: ILegacyScopedClusterClient, + panelId: string, + paramVisualization: { + id: string; + title: string; + query: string; + type: string; + timeField: string; + } + ) => { + try { + const allPanelVisualizations = await this.getVisualizations(client, panelId); + const newDimensions = this.getNewVizDimensions(allPanelVisualizations); + const newPanelVisualizations = [ + ...allPanelVisualizations, + { ...paramVisualization, ...newDimensions }, + ]; + const updatePanelResponse = await this.updatePanel(client, panelId, { + visualizations: newPanelVisualizations, + }); + return newPanelVisualizations; + } catch (error) { + throw new Error('Add/Replace Visualization Error:' + error); + } + }; + + //Delete a Visualization in the Panel + deleteVisualization = async ( + client: ILegacyScopedClusterClient, + panelId: string, + visualizationId: string + ) => { + try { + const allPanelVisualizations = await this.getVisualizations(client, panelId); + const filteredPanelVisualizations = allPanelVisualizations.filter( + (panelVisualization: VisualizationType) => panelVisualization.id != visualizationId + ); + const updatePanelResponse = await this.updatePanel(client, panelId, { + visualizations: filteredPanelVisualizations, + }); + return filteredPanelVisualizations; + } catch (error) { + throw new Error('Delete Visualization Error:' + error); + } + }; + + //Edits all Visualizations in the Panel + editVisualization = async ( + client: ILegacyScopedClusterClient, + panelId: string, + visualizationParams: { + i: string; + x: number; + y: number; + w: number; + h: number; + }[] + ) => { + try { + const allPanelVisualizations = await this.getVisualizations(client, panelId); + let filteredPanelVisualizations = >[]; + + for (let i = 0; i < allPanelVisualizations.length; i++) { + for (let j = 0; j < visualizationParams.length; j++) { + if (allPanelVisualizations[i].id === visualizationParams[j].i) { + filteredPanelVisualizations.push({ + ...allPanelVisualizations[i], + x: visualizationParams[j].x, + y: visualizationParams[j].y, + w: visualizationParams[j].w, + h: visualizationParams[j].h, + }); + } + } + } + const updatePanelResponse = await this.updatePanel(client, panelId, { + visualizations: filteredPanelVisualizations, + }); + return filteredPanelVisualizations; + } catch (error) { + throw new Error('Edit Visualizations Error:' + error); + } + }; } diff --git a/server/adaptors/notebooks/default_backend.ts b/server/adaptors/notebooks/default_backend.ts index 565676301..5ce8736bf 100644 --- a/server/adaptors/notebooks/default_backend.ts +++ b/server/adaptors/notebooks/default_backend.ts @@ -59,9 +59,9 @@ export class DefaultBackend implements NotebookAdaptor { indexNote = async function ( client: ILegacyScopedClusterClient, body: any - ): Promise<{ notebookId: string }> { + ): Promise<{ objectId: string }> { try { - const response = await client.callAsCurrentUser('observability.createNotebook', { + const response = await client.callAsCurrentUser('observability.createObject', { body: { notebook: body, }, @@ -79,8 +79,8 @@ export class DefaultBackend implements NotebookAdaptor { updateBody: Partial ) { try { - const response = await client.callAsCurrentUser('observability.updateNotebookById', { - notebookId: noteId, + const response = await client.callAsCurrentUser('observability.updateObjectById', { + objectId: noteId, body: { notebook: updateBody, }, @@ -94,10 +94,13 @@ export class DefaultBackend implements NotebookAdaptor { // fetched a notebook by Id getNote = async function (client: ILegacyScopedClusterClient, noteId: string) { try { - const response = await client.callAsCurrentUser('observability.getNotebookById', { - notebookId: noteId, + const response = await client.callAsCurrentUser('observability.getObjectById', { + objectId: noteId, }); - return response.notebookDetails; + if (response.observabilityObjectList.length === 0) { + throw 'notebook id not found' + } + return response.observabilityObjectList[0]; } catch (error) { throw new Error('Get Doc Error:' + error); } @@ -106,10 +109,12 @@ export class DefaultBackend implements NotebookAdaptor { // gets first `FETCH_SIZE` notebooks available viewNotes = async function (client: ILegacyScopedClusterClient, _wreckOptions: optionsType) { try { - const response = await client.callAsCurrentUser('observability.getNotebooks'); - return response.notebookDetailsList.map((notebook) => ({ + const response = await client.callAsCurrentUser('observability.getObject', { + objectType: 'notebook' + }); + return response.observabilityObjectList.map((notebook) => ({ path: notebook.notebook.name, - id: notebook.id, + id: notebook.objectId, dateCreated: notebook.notebook.dateCreated, dateModified: notebook.notebook.dateModified, })); @@ -155,7 +160,7 @@ export class DefaultBackend implements NotebookAdaptor { return { status: 'OK', message: opensearchClientResponse, - body: opensearchClientResponse.notebookId, + body: opensearchClientResponse.objectId, }; } catch (error) { throw new Error('Creating New Notebook Error:' + error); @@ -177,7 +182,7 @@ export class DefaultBackend implements NotebookAdaptor { const notebook = notebooks[i]; await this.indexNote(client, notebook.notebook).then((response) => { newNotebooks.push({ - id: response.notebookId, + id: response.objectId, name: notebook.notebook.name, dateModified: notebook.dateModified, dateCreated: notebook.dateCreated, @@ -228,7 +233,7 @@ export class DefaultBackend implements NotebookAdaptor { const opensearchClientIndexResponse = await this.indexNote(client, cloneNotebook); return { status: 'OK', - body: { ...cloneNotebook, id: opensearchClientIndexResponse.notebookId }, + body: { ...cloneNotebook, id: opensearchClientIndexResponse.objectId }, }; } catch (error) { throw new Error('Cloning Notebook Error:' + error); @@ -244,8 +249,8 @@ export class DefaultBackend implements NotebookAdaptor { _wreckOptions: optionsType ) { try { - const response = await client.callAsCurrentUser('observability.deleteNotebookById', { - notebookId: noteId, + const response = await client.callAsCurrentUser('observability.deleteObjectById', { + objectId: noteId, }); return { status: 'OK', message: response }; } catch (error) { @@ -286,7 +291,7 @@ export class DefaultBackend implements NotebookAdaptor { return { status: 'OK', message: opensearchClientIndexResponse, - body: opensearchClientIndexResponse.notebookId, + body: opensearchClientIndexResponse.objectId, }; } catch (error) { throw new Error('Import Notebook Error:' + error); diff --git a/server/adaptors/opensearch_observability_plugin.ts b/server/adaptors/opensearch_observability_plugin.ts index 368ba08de..fdf3283e1 100644 --- a/server/adaptors/opensearch_observability_plugin.ts +++ b/server/adaptors/opensearch_observability_plugin.ts @@ -9,138 +9,121 @@ * GitHub history for details. */ -import { OPENSEARCH_NOTEBOOKS_API } from '../../common/constants/notebooks'; -import { OPENSEARCH_PANELS_API } from '../../common/constants/shared'; +import { OPENSEARCH_PANELS_API } from "../../common/constants/shared"; -export function OpenSearchObservabilityPlugin(Client: any, config: any, components: any) { +export function OpenSearchObservabilityPlugin( + Client: any, + config: any, + components: any +) { const clientAction = components.clientAction.factory; Client.prototype.observability = components.clientAction.namespaceFactory(); const observability = Client.prototype.observability.prototype; - observability.getPanels = clientAction({ + // Get Object + observability.getObject = clientAction({ url: { - fmt: OPENSEARCH_PANELS_API.GET_PANELS, + fmt: OPENSEARCH_PANELS_API.OBJECT, params: { + objectId: { + type: "string", + }, + objectIdList: { + type: "string", + }, + objectType: { + type: "string", + }, + sortField: { + type: "string", + }, + sortOrder: { + type: "string", + }, fromIndex: { - type: 'number', + type: "number", }, maxItems: { - type: 'number', + type: "number", }, - }, - }, - method: 'GET', - }); - - observability.createPanel = clientAction({ - url: { - fmt: OPENSEARCH_PANELS_API.PANEL, - }, - method: 'POST', - needBody: true, - }); - - observability.getPanelById = clientAction({ - url: { - fmt: `${OPENSEARCH_PANELS_API.PANEL}/<%=panelId%>`, - req: { - panelId: { - type: 'string', - required: true, + name: { + type: "string", }, - }, - }, - method: 'GET', - }); - - observability.updatePanelById = clientAction({ - url: { - fmt: `${OPENSEARCH_PANELS_API.PANEL}/<%=panelId%>`, - req: { - panelId: { - type: 'string', - required: true, + lastUpdatedTimeMs: { + type: "string", + }, + createdTimeMs: { + type: "string", }, }, }, - method: 'PUT', - needBody: true, + method: "GET", }); - observability.deletePanelById = clientAction({ + // Get Object by Id + observability.getObjectById = clientAction({ url: { - fmt: `${OPENSEARCH_PANELS_API.PANEL}/<%=panelId%>`, + fmt: `${OPENSEARCH_PANELS_API.OBJECT}/<%=objectId%>`, req: { - panelId: { - type: 'string', + objectId: { + type: "string", required: true, }, }, }, - method: 'DELETE', - }); - - observability.getNotebooks = clientAction({ - url: { - fmt: OPENSEARCH_NOTEBOOKS_API.GET_NOTEBOOKS, - params: { - fromIndex: { - type: 'number', - }, - maxItems: { - type: 'number', - }, - }, - }, - method: 'GET', + method: "GET", }); - observability.createNotebook = clientAction({ + // Create new Object + observability.createObject = clientAction({ url: { - fmt: OPENSEARCH_NOTEBOOKS_API.NOTEBOOK, + fmt: OPENSEARCH_PANELS_API.OBJECT, }, - method: 'POST', + method: "POST", needBody: true, }); - observability.getNotebookById = clientAction({ + // Update Object by Id + observability.updateObjectById = clientAction({ url: { - fmt: `${OPENSEARCH_NOTEBOOKS_API.NOTEBOOK}/<%=notebookId%>`, + fmt: `${OPENSEARCH_PANELS_API.OBJECT}/<%=objectId%>`, req: { - notebookId: { - type: 'string', + objectId: { + type: "string", required: true, }, }, }, - method: 'GET', + method: "PUT", + needBody: true, }); - observability.updateNotebookById = clientAction({ + // Delete Object by Id + observability.deleteObjectById = clientAction({ url: { - fmt: `${OPENSEARCH_NOTEBOOKS_API.NOTEBOOK}/<%=notebookId%>`, + fmt: `${OPENSEARCH_PANELS_API.OBJECT}/<%=objectId%>`, req: { - notebookId: { - type: 'string', + objectId: { + type: "string", required: true, }, }, }, - method: 'PUT', - needBody: true, + method: "DELETE", }); - observability.deleteNotebookById = clientAction({ + // Delete Object by Id List + observability.deleteObjectByIdList = clientAction({ url: { - fmt: `${OPENSEARCH_NOTEBOOKS_API.NOTEBOOK}/<%=notebookId%>`, - req: { - notebookId: { - type: 'string', + fmt: OPENSEARCH_PANELS_API.OBJECT, + params: { + objectIdList: { + type: "string", required: true, }, }, }, - method: 'DELETE', + method: "DELETE", }); } diff --git a/server/adaptors/ppl_datasource.ts b/server/adaptors/ppl_datasource.ts index b5103c20a..993021780 100644 --- a/server/adaptors/ppl_datasource.ts +++ b/server/adaptors/ppl_datasource.ts @@ -25,6 +25,43 @@ export class PPLDataSource { ) { if (this.dataType === 'jdbc') { this.addSchemaRowMapping(); + } else if (this.dataType === 'viz') { + this.addStatsMapping(); + } + } + + private addStatsMapping = () => { + const visData = this.pplDataSource; + + /** + * Add vis mapping for runtime fields + * json data structure added to response will be + * [{ + * agent: "mozilla", + * avg(bytes): 5756 + * ... + * }, { + * agent: "MSIE", + * avg(bytes): 5605 + * ... + * }, { + * agent: "chrome", + * avg(bytes): 5648 + * ... + * }] + */ + let res = []; + if (visData?.metadata?.fields) { + const queriedFields = visData.metadata.fields; + for (let i = 0; i < visData.size; i++) { + const entry: any = {}; + queriedFields.map((field: any) => { + const statsDataSet = visData?.data; + entry[field.name] = statsDataSet[field.name][i]; + }); + res.push(entry); + } + visData['jsonData'] = res; } } @@ -40,7 +77,7 @@ export class PPLDataSource { _.forEach(pplRes.datarows, (row) => { const record: any = {}; - for (let i = 0; i < pplRes.schema.length; i++) { + for (let i = 0; i < pplRes.schema.length; i++) { const cur = pplRes.schema[i]; diff --git a/server/common/types/index.ts b/server/common/types/index.ts index 42cc4c25f..43380bac3 100644 --- a/server/common/types/index.ts +++ b/server/common/types/index.ts @@ -17,6 +17,7 @@ export interface ISchema { export interface IPPLVisualizationDataSource { data: any; metadata: any; + jsonData?: Array; size: Number; status: Number; } diff --git a/server/plugin.ts b/server/plugin.ts index 7a217e79a..cdacc2459 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -36,7 +36,10 @@ export class ObservabilityPlugin const openSearchObservabilityClient: ILegacyClusterClient = core.opensearch.legacy.createClient( 'opensearch_observability', { - plugins: [PPLPlugin, OpenSearchObservabilityPlugin], + plugins: [ + PPLPlugin, + OpenSearchObservabilityPlugin, + ], } ); diff --git a/server/routes/custom_panels/panels_router.ts b/server/routes/custom_panels/panels_router.ts index d047f63f7..e999dbf2f 100644 --- a/server/routes/custom_panels/panels_router.ts +++ b/server/routes/custom_panels/panels_router.ts @@ -10,16 +10,17 @@ */ import { schema } from '@osd/config-schema'; +import { CustomPanelsAdaptor } from '../../adaptors/custom_panels/custom_panel_adaptor'; import { IRouter, IOpenSearchDashboardsResponse, ResponseError, - IScopedClusterClient, + ILegacyScopedClusterClient, } from '../../../../../src/core/server'; import { CUSTOM_PANELS_API_PREFIX as API_PREFIX } from '../../../common/constants/custom_panels'; export function PanelsRouter(router: IRouter) { - // NOTE: Currently the API calls are dummy and are not connected to esclient. + const customPanelBackend = new CustomPanelsAdaptor(); // Fetch all the custom panels available router.get( { @@ -31,34 +32,19 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const panelsList = [ - { - name: 'Demo Panel 2', - id: '2FG6FWGY5', - dateCreated: '2021-07-19T21:01:14.871Z', - dateModified: '2021-07-19T21:01:14.871Z', - }, - { - name: 'Demo Panel 1', - id: 'AUJFBY234', - dateCreated: '2021-07-19T21:01:14.871Z', - dateModified: '2021-07-19T21:01:14.871Z', - }, - { - name: 'Demo Panel 3', - id: 'AUJFBY674', - dateCreated: '2021-07-19T21:01:14.871Z', - dateModified: '2021-07-19T21:01:14.871Z', - }, - ]; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + try { + const panelsList = await customPanelBackend.viewPanelList(opensearchNotebooksClient); return response.ok({ body: { panels: panelsList, }, }); } catch (error) { - console.log('Issue in fetching panels:', error); + console.error('Issue in fetching panel list:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -67,8 +53,7 @@ export function PanelsRouter(router: IRouter) { } ); - // Fetch the required panel by id - // returns a panel object + // Fetch the required panel by id router.get( { path: `${API_PREFIX}/panels/{panelId}`, @@ -83,140 +68,20 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - let panelObject; - if (request.params.panelId == '2FG6FWGY5') { - panelObject = { - panel: { - name: 'Demo Panel 2', - dateCreated: '2021-07-19T21:01:14.871Z', - dateModified: '2021-07-19T21:01:14.871Z', - visualizations: [ - { - id: '1', - title: 'Demo Viz 1', - x: 0, - y: 0, - w: 4, - h: 2, - query: - 'source=opensearch_dashboards_sample_data_flights | fields Carrier,Origin | where Carrier='OpenSearch-Air' | stats count() by Origin', - type: 'line', - }, - { - id: '2', - title: 'Demo Viz 2', - x: 4, - y: 0, - w: 4, - h: 2, - query: - 'source=opensearch_dashboards_sample_data_flights | fields Carrier,Origin | where Carrier='OpenSearch-Air' | stats count() by Origin', - type: 'bar', - }, - { - id: '3', - title: 'Demo Viz 3', - x: 8, - y: 0, - w: 4, - h: 2, - query: - 'source=opensearch_dashboards_sample_data_flights | fields Carrier,Origin | where Carrier='OpenSearch-Air' | stats count() by Origin', - type: 'bar', - }, - { - id: '4', - title: 'Demo Viz 4', - x: 0, - y: 2, - w: 6, - h: 2, - query: - 'source=opensearch_dashboards_sample_data_flights | fields Carrier,Origin | where Carrier='OpenSearch-Air' | stats count() by Origin', - type: 'bar', - }, - { - id: '5', - title: 'Demo Viz 5', - x: 6, - y: 2, - w: 6, - h: 2, - query: - 'source=opensearch_dashboards_sample_data_flights | fields Carrier,FlightDelayMin | stats sum(FlightDelayMin) as delays by Carrier', - type: 'bar', - }, - ], - filters: [], - timeRange: { - to: 'now', - from: 'now-1d', - }, - queryFilter: { - query: '', - language: 'ppl', - }, - refreshConfig: { - pause: true, - value: 15, - }, - }, - }; - } - if (request.params.panelId == 'AUJFBY234') { - panelObject = { - panel: { - name: 'Demo Panel 1', - dateCreated: '2021-07-19T21:01:14.871Z', - dateModified: '2021-07-19T21:01:14.871Z', - visualizations: [], - filters: [], - timeRange: { - to: 'now', - from: 'now-1d', - }, - queryFilter: { - query: '', - language: 'ppl', - }, - refreshConfig: { - pause: true, - value: 15, - }, - }, - }; - } - - if (request.params.panelId == 'AUJFBY674') { - panelObject = { - panel: { - name: 'Demo Panel 3', - dateCreated: '2021-07-19T21:01:14.871Z', - dateModified: '2021-07-19T21:01:14.871Z', - visualizations: [], - filters: [], - timeRange: { - to: 'now', - from: 'now-1d', - }, - queryFilter: { - query: 'where Carrier='OpenSearch-Air'', - language: 'ppl', - }, - refreshConfig: { - pause: true, - value: 15, - }, - }, - }; - } + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const panelObject = await customPanelBackend.getPanel( + opensearchNotebooksClient, + request.params.panelId + ); return response.ok({ body: panelObject, }); } catch (error) { - console.log('Issue in fetching panel:', error); + console.error('Issue in fetching panel:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -225,13 +90,12 @@ export function PanelsRouter(router: IRouter) { } ); - // create a new panel - // returns new Panel Id + //Create a new panel router.post( { path: `${API_PREFIX}/panels`, validate: { - params: schema.object({ + body: schema.object({ panelName: schema.string(), }), }, @@ -241,9 +105,14 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const newPanelId = ''; - + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const newPanelId = await customPanelBackend.createNewPanel( + opensearchNotebooksClient, + request.body.panelName + ); return response.ok({ body: { message: 'Panel Created', @@ -251,7 +120,7 @@ export function PanelsRouter(router: IRouter) { }, }); } catch (error) { - console.log('Issue in creating new panel', error); + console.error('Issue in creating new panel', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -260,12 +129,12 @@ export function PanelsRouter(router: IRouter) { } ); - // rename an existing panel + // rename an existing panel router.patch( { path: `${API_PREFIX}/panels/rename`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), panelName: schema.string(), }), @@ -276,16 +145,23 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const newPanelId = ''; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const responseBody = await customPanelBackend.renamePanel( + opensearchNotebooksClient, + request.body.panelId, + request.body.panelName + ); return response.ok({ body: { message: 'Panel Renamed', }, }); } catch (error) { - console.log('Issue in renaming panel', error); + console.error('Issue in renaming panel', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -294,13 +170,13 @@ export function PanelsRouter(router: IRouter) { } ); - // clones an existing panel + // clones an existing panel // returns new panel Id router.post( { path: `${API_PREFIX}/panels/clone`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), panelName: schema.string(), }), @@ -311,17 +187,26 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const newPanelId = ''; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const cloneResponse = await customPanelBackend.clonePanel( + opensearchNotebooksClient, + request.body.panelId, + request.body.panelName + ); return response.ok({ body: { message: 'Panel Cloned', - newPanelId: newPanelId, + clonePanelId: cloneResponse.clonePanelId, + dateCreated: cloneResponse.dateCreated, + dateModified: cloneResponse.dateModified, }, }); } catch (error) { - console.log('Issue in renaming panel', error); + console.error('Issue in cloning panel', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -330,7 +215,7 @@ export function PanelsRouter(router: IRouter) { } ); - // delete an existing panel + // delete an existing panel router.delete( { path: `${API_PREFIX}/panels/{panelId}`, @@ -345,16 +230,22 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const panelId = request.params.panelId; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const deleteResponse = await customPanelBackend.deletePanel( + opensearchNotebooksClient, + request.params.panelId + ); return response.noContent({ body: { message: 'Panel Deleted', }, }); } catch (error) { - console.log('Issue in deleting panel', error); + console.error('Issue in deleting panel', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -363,15 +254,13 @@ export function PanelsRouter(router: IRouter) { } ); - // replaces the ppl query filter in panel - router.patch( + // delete an existing panel(s) + router.delete( { - path: `${API_PREFIX}/panels/query`, + path: `${API_PREFIX}/panelList/{panelIdList}`, validate: { params: schema.object({ - panelId: schema.string(), - query: schema.string(), - language: schema.string(), + panelIdList: schema.string(), }), }, }, @@ -380,16 +269,22 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const panelId = request.params.panelId; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { - return response.ok({ + const deleteResponse = await customPanelBackend.deletePanelList( + opensearchNotebooksClient, + request.params.panelIdList + ); + return response.noContent({ body: { - message: 'Panel PPL Filter Changed', + message: 'Panel Deleted', }, }); } catch (error) { - console.log('Issue in adding query filter', error); + console.error('Issue in deleting panel', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -398,13 +293,15 @@ export function PanelsRouter(router: IRouter) { } ); - // replaces the datetime filter in panel + // replaces the ppl query filter in panel router.patch( { - path: `${API_PREFIX}/panels/datetime`, + path: `${API_PREFIX}/panels/filter`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), + query: schema.string(), + language: schema.string(), to: schema.string(), from: schema.string(), }), @@ -415,16 +312,26 @@ export function PanelsRouter(router: IRouter) { request, response ): Promise> => { - const panelId = request.params.panelId; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const panelFilterResponse = await customPanelBackend.addPanelFilter( + opensearchNotebooksClient, + request.body.panelId, + request.body.query, + request.body.language, + request.body.to, + request.body.from + ); return response.ok({ body: { - message: 'Panel DateTime Filter Changed', + message: 'Panel PPL Filter Changed', }, }); } catch (error) { - console.log('Issue in adding datetime filter', error); + console.error('Issue in adding query filter', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, diff --git a/server/routes/custom_panels/visualizations_router.ts b/server/routes/custom_panels/visualizations_router.ts index b1b41efa5..c935b9d0c 100644 --- a/server/routes/custom_panels/visualizations_router.ts +++ b/server/routes/custom_panels/visualizations_router.ts @@ -10,31 +10,63 @@ */ import { schema } from '@osd/config-schema'; +import { CustomPanelsAdaptor } from '../../adaptors/custom_panels/custom_panel_adaptor'; import { IRouter, IOpenSearchDashboardsResponse, ResponseError, - IScopedClusterClient, + ILegacyScopedClusterClient, } from '../../../../../src/core/server'; import { CUSTOM_PANELS_API_PREFIX as API_PREFIX } from '../../../common/constants/custom_panels'; export function VisualizationsRouter(router: IRouter) { + // Fetch all the savedVisualzations + const customPanelBackend = new CustomPanelsAdaptor(); + router.get( + { + path: `${API_PREFIX}/visualizations`, + validate: {}, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + try { + const visualizationList = await customPanelBackend.viewSavedVisualiationList( + opensearchNotebooksClient + ); + return response.ok({ + body: { + visualizations: visualizationList, + }, + }); + } catch (error) { + console.error('Issue in fetching saved visualizations:', error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + // Add a new visualization to the panel router.post( { path: `${API_PREFIX}/visualizations`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), newVisualization: schema.object({ id: schema.string(), title: schema.string(), - x: schema.number(), - y: schema.number(), - w: schema.number(), - h: schema.number(), query: schema.string(), type: schema.string(), + timeField: schema.string(), }), }), }, @@ -44,17 +76,24 @@ export function VisualizationsRouter(router: IRouter) { request, response ): Promise> => { - const visualizations = {}; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const newVisualizations = await customPanelBackend.addVisualization( + opensearchNotebooksClient, + request.body.panelId, + request.body.newVisualization + ); return response.ok({ body: { message: 'Visualization Added', - visualizations: visualizations, + visualizations: newVisualizations, }, }); } catch (error) { - console.log('Issue in adding visualization:', error); + console.error('Issue in adding visualization:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -63,23 +102,21 @@ export function VisualizationsRouter(router: IRouter) { } ); - // Replace an existing visualization + // Add a new visualization to panel from event explorer + // NOTE: This is a separate endpoint for adding event explorer visualizations to Operational Panels + // Please use `id = 'panelViz_' + htmlIdGenerator()()` to create unique visualization Id router.post( { - path: `${API_PREFIX}/visualizations/replace`, + path: `${API_PREFIX}/visualizations/event_explorer`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), - oldVisualizationId: schema.string(), newVisualization: schema.object({ id: schema.string(), title: schema.string(), - x: schema.number(), - y: schema.number(), - w: schema.number(), - h: schema.number(), query: schema.string(), type: schema.string(), + timeField: schema.string(), }), }), }, @@ -89,17 +126,24 @@ export function VisualizationsRouter(router: IRouter) { request, response ): Promise> => { - const visualizations = {}; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const newVisualizations = await customPanelBackend.addVisualizationFromEvents( + opensearchNotebooksClient, + request.body.panelId, + request.body.newVisualization + ); return response.ok({ body: { - message: 'Visualization Replaced', - visualizations: visualizations, + message: 'Visualization Added', + visualizations: newVisualizations, }, }); } catch (error) { - console.log('Issue in replacing visualization:', error); + console.error('Issue in adding visualization:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -108,21 +152,20 @@ export function VisualizationsRouter(router: IRouter) { } ); - // Clone an existing visualization + // Replace an existing visualization router.post( { - path: `${API_PREFIX}/visualizations/clone`, + path: `${API_PREFIX}/visualizations/replace`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), - cloneVisualizattionId: schema.string(), - newVisualizationParams: schema.object({ + oldVisualizationId: schema.string(), + newVisualization: schema.object({ id: schema.string(), title: schema.string(), - x: schema.number(), - y: schema.number(), - w: schema.number(), - h: schema.number(), + query: schema.string(), + type: schema.string(), + timeField: schema.string(), }), }), }, @@ -132,17 +175,25 @@ export function VisualizationsRouter(router: IRouter) { request, response ): Promise> => { - const visualizations = {}; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const newVisualizations = await customPanelBackend.addVisualization( + opensearchNotebooksClient, + request.body.panelId, + request.body.newVisualization, + request.body.oldVisualizationId + ); return response.ok({ body: { - message: 'Visualization Cloned', - visualizations: visualizations, + message: 'Visualization Replaced', + visualizations: newVisualizations, }, }); } catch (error) { - console.log('Issue in replacing visualization:', error); + console.error('Issue in replacing visualization:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -167,16 +218,24 @@ export function VisualizationsRouter(router: IRouter) { request, response ): Promise> => { - const visualizations = {}; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { - return response.noContent({ + const newVisualizations = await customPanelBackend.deleteVisualization( + opensearchNotebooksClient, + request.params.panelId, + request.params.visualizationId + ); + return response.ok({ body: { message: 'Visualization Deleted', + visualizations: newVisualizations, }, }); } catch (error) { - console.log('Issue in deleting visualization:', error); + console.error('Issue in deleting visualization:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, @@ -186,15 +245,16 @@ export function VisualizationsRouter(router: IRouter) { ); // changes the position of the mentioned visualizations + // Also removes the visualiations not mentioned router.put( { - path: `${API_PREFIX}/visualizations/resize`, + path: `${API_PREFIX}/visualizations/edit`, validate: { - params: schema.object({ + body: schema.object({ panelId: schema.string(), visualizationParams: schema.arrayOf( schema.object({ - id: schema.string(), + i: schema.string(), x: schema.number(), y: schema.number(), w: schema.number(), @@ -209,17 +269,24 @@ export function VisualizationsRouter(router: IRouter) { request, response ): Promise> => { - const visualizations = {}; + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); try { + const newVisualizations = await customPanelBackend.editVisualization( + opensearchNotebooksClient, + request.body.panelId, + request.body.visualizationParams + ); return response.ok({ body: { - message: 'Visualization Resized', - visualizations: visualizations, + message: 'Visualizations Edited', + visualizations: newVisualizations, }, }); } catch (error) { - console.log('Issue in deleting visualization:', error); + console.error('Issue in Editing visualizations:', error); return response.custom({ statusCode: error.statusCode || 500, body: error.message, diff --git a/server/routes/event_analytics/event_analytics_router.ts b/server/routes/event_analytics/event_analytics_router.ts new file mode 100644 index 000000000..54cbaf5c4 --- /dev/null +++ b/server/routes/event_analytics/event_analytics_router.ts @@ -0,0 +1,212 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { schema } from '@osd/config-schema'; +import { + IRouter, + IOpenSearchDashboardsResponse, + ResponseError, +} from '../../../../../src/core/server'; +import { + OBSERVABILITY_BASE, + EVENT_ANALYTICS, + SAVED_OBJECTS, + SAVED_QUERY, + SAVED_VISUALIZATION +} from '../../../common/constants/shared'; +import SavedObjectFacet from '../../services/facets/saved_objects'; + +export const registerEventAnalyticsRouter = ({ + router, savedObjectFacet +}: { + router: IRouter, + savedObjectFacet: SavedObjectFacet +}) => { + + router.get({ + path: `${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}`, + validate: false + }, + async ( + context, + req, + res + ) : Promise> => { + const savedRes = await savedObjectFacet.getSavedQuery(req); + const result: any = { + body: { + ...savedRes['data'] + } + }; + + if (savedRes['success']) return res.ok(result); + + result['statusCode'] = 500; + result['message'] = savedRes['data']; + return res.custom(result); + }); + + router.post({ + path: `${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_QUERY}`, + validate: { + body: schema.object({ + object: schema.object({ + query: schema.string(), + selected_date_range: schema.object({ + start: schema.string(), + end: schema.string(), + text: schema.string(), + }), + selected_fields: schema.object({ + tokens: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + text: schema.string(), + }), + name: schema.string(), + description: schema.string(), + }) + })} + }, + async ( + context, + req, + res + ) : Promise> => { + const savedRes = await savedObjectFacet.createSavedQuery(req); + const result: any = { + body: { + ...savedRes['data'] + } + }; + + if (savedRes['success']) return res.ok(result); + + result['statusCode'] = 500; + result['message'] = savedRes['data']; + return res.custom(result); + }); + + router.post({ + path: `${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_VISUALIZATION}`, + validate: { + body: schema.object({ + object: schema.object({ + query: schema.string(), + selected_date_range: schema.object({ + start: schema.string(), + end: schema.string(), + text: schema.string(), + }), + selected_fields: schema.object({ + tokens: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + text: schema.string(), + }), + type: schema.string(), + name: schema.string(), + description: schema.string(), + }) + })} + }, + async ( + context, + req, + res + ) : Promise> => { + const savedRes = await savedObjectFacet.createSavedVisualization(req); + const result: any = { + body: { + ...savedRes['data'] + } + }; + + if (savedRes['success']) return res.ok(result); + + result['statusCode'] = 500; + result['message'] = savedRes['data']; + return res.custom(result); + }); + + router.put({ + path: `${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_QUERY}`, + validate: { + body: schema.object({ + object_id: schema.string(), + object: schema.object({ + query: schema.string(), + selected_date_range: schema.object({ + start: schema.string(), + end: schema.string(), + text: schema.string(), + }), + selected_fields: schema.object({ + tokens: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + text: schema.string(), + }), + name: schema.string(), + description: schema.string(), + }) + })} + }, + async ( + context, + req, + res + ) : Promise> => { + const savedRes = await savedObjectFacet.updateSavedQuery(req); + const result: any = { + body: { + ...savedRes['data'] + } + }; + if (savedRes['success']) return res.ok(result); + result['statusCode'] = 500; + result['message'] = savedRes['data']; + return res.custom(result); + }); + + router.put({ + path: `${OBSERVABILITY_BASE}${EVENT_ANALYTICS}${SAVED_OBJECTS}${SAVED_VISUALIZATION}`, + validate: { + body: schema.object({ + object_id: schema.string(), + object: schema.object({ + query: schema.string(), + selected_date_range: schema.object({ + start: schema.string(), + end: schema.string(), + text: schema.string(), + }), + selected_fields: schema.object({ + tokens: schema.arrayOf(schema.object({}, { unknowns: 'allow' })), + text: schema.string(), + }), + type: schema.string(), + name: schema.string(), + description: schema.string(), + }) + })} + }, + async ( + context, + req, + res + ) : Promise> => { + const savedRes = await savedObjectFacet.updateSavedVisualization(req); + const result: any = { + body: { + ...savedRes['data'] + } + }; + if (savedRes['success']) return res.ok(result); + result['statusCode'] = 500; + result['message'] = savedRes['data']; + return res.custom(result); + }); +} \ No newline at end of file diff --git a/server/routes/index.ts b/server/routes/index.ts index 82291c15f..a385588fa 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -14,6 +14,7 @@ import { registerPplRoute } from './ppl'; import PPLFacet from '../services/facets/ppl_facet'; import { registerDslRoute } from './dsl'; import DSLFacet from '../services/facets/dsl_facet'; +import SavedObjectFacet from '../services/facets/saved_objects'; import { PanelsRouter } from './custom_panels/panels_router'; import { VisualizationsRouter } from './custom_panels/visualizations_router'; import { registerTraceAnalyticsDslRouter } from './trace_analytics_dsl_router'; @@ -22,15 +23,16 @@ import { registerNoteRoute } from './notebooks/noteRouter'; import { registerVizRoute } from './notebooks/vizRouter'; import QueryService from '../services/queryService'; import { registerSqlRoute } from './notebooks/sqlRouter'; +import { registerEventAnalyticsRouter } from './event_analytics/event_analytics_router'; export function setupRoutes({ router, client }: { router: IRouter; client: ILegacyClusterClient }) { PanelsRouter(router); VisualizationsRouter(router); - const pplFacet = new PPLFacet(client); - registerPplRoute({ router, facet: pplFacet }); - const dslFacet = new DSLFacet(client); - registerDslRoute({ router, facet: dslFacet}) + registerPplRoute({ router, facet: new PPLFacet(client) }); + registerDslRoute({ router, facet: new DSLFacet(client)}); + registerEventAnalyticsRouter({ router, savedObjectFacet: new SavedObjectFacet(client) }); + // TODO remove trace analytics route when DSL route for autocomplete is added registerTraceAnalyticsDslRouter(router); diff --git a/server/routes/notebooks/noteRouter.ts b/server/routes/notebooks/noteRouter.ts index 92531de25..59580ca62 100644 --- a/server/routes/notebooks/noteRouter.ts +++ b/server/routes/notebooks/noteRouter.ts @@ -128,7 +128,7 @@ export function registerNoteRoute(router: IRouter) { wreckOptions ); return response.ok({ - body: addResponse.message.notebookId, + body: addResponse.message.objectId, }); } catch (error) { return response.custom({ diff --git a/server/routes/ppl.ts b/server/routes/ppl.ts index 85b9c001f..9993fb003 100644 --- a/server/routes/ppl.ts +++ b/server/routes/ppl.ts @@ -41,16 +41,18 @@ export function registerPplRoute({ req, res ) : Promise> => { - const queryRes = await facet.describeQuery(req); - const result: any = { - body: { - ...queryRes['data'] - } - }; + const queryRes: any = await facet.describeQuery(req); if (queryRes['success']) { + const result: any = { + body: { + ...queryRes['data'] + } + }; return res.ok(result); } - result['statusCode'] = 500; - return res.custom(result); + return res.custom({ + statusCode: queryRes.data.statusCode || queryRes.data.status || 500, + body: queryRes.data.body || queryRes.data.message || '', + }); }); } diff --git a/server/services/facets/ppl_facet.ts b/server/services/facets/ppl_facet.ts index 6c21c97ee..f7cc906cf 100644 --- a/server/services/facets/ppl_facet.ts +++ b/server/services/facets/ppl_facet.ts @@ -34,15 +34,15 @@ export default class PPLFacet { } }; if (request.body.format !== 'jdbc') { - params['format'] = request.body.format; + params['format'] = request.body.format; } const queryRes = await this.client.asScoped(request).callAsCurrentUser(format, params); const pplDataSource = new PPLDataSource(queryRes, request.body.format); res['success'] = true; res['data'] = pplDataSource.getDataSource(); } catch (err: any) { - console.log('pplfacet err: ', err); - res['data'] = err.body; + console.error('PPL query fetch err: ', err); + res['data'] = err; } return res }; diff --git a/server/services/facets/saved_objects.ts b/server/services/facets/saved_objects.ts new file mode 100644 index 000000000..cd1976008 --- /dev/null +++ b/server/services/facets/saved_objects.ts @@ -0,0 +1,149 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +export default class SavedObjectFacet { + constructor(private client: any) { + this.client = client; + } + + fetch = async ( + request: any, + format: string + ) => { + const res = { + success: false, + data: {} + }; + try { + const params = { + ...request.url.query + }; + const savedQueryRes = await this.client.asScoped(request).callAsCurrentUser(format, params); + res['success'] = true; + res['data'] = savedQueryRes; + } catch (err: any) { + console.error('Event analytics fetch error: ', err); + res['data'] = err; + } + return res; + }; + + create = async ( + request: any, + format: string, + objectType: string + ) => { + const res = { + success: false, + data: {} + }; + try { + const params = { + body: { + [objectType]: { + ...request.body.object + } + } + }; + const savedQueryRes = await this.client.asScoped(request).callAsCurrentUser(format, params); + res['success'] = true; + res['data'] = savedQueryRes; + } catch (err: any) { + console.error('Event analytics create error: ', err); + res['data'] = err; + } + return res; + }; + + update = async ( + request: any, + format: string, + objectType: string + ) => { + const res = { + success: false, + data: {} + }; + try { + const params = { + objectId: request.body.object_id, + body: { + [objectType]: { + ...request.body.object + } + } + }; + const savedQueryRes = await this.client.asScoped(request).callAsCurrentUser(format, params); + res['success'] = true; + res['data'] = savedQueryRes; + } catch (err: any) { + console.error('Event analytics update error: ', err); + res['data'] = err; + } + return res; + }; + + delete = async ( + request: any, + format: string, + objectType: string + ) => { + const res = { + success: false, + data: {} + }; + try { + const params = { + objectId: request.body.object_id, + body: { + [objectType]: { + ...request.body.object + } + } + }; + const savedQueryRes = await this.client.asScoped(request).callAsCurrentUser(format, params); + res['success'] = true; + res['data'] = savedQueryRes; + } catch (err: any) { + console.error('Event analytics delete error: ', err); + res['data'] = err; + } + return res; + }; + + getSavedQuery = async (request: any) => { + return this.fetch(request, 'observability.getObject'); + }; + + getSavedVisualization = async (request: any) => { + return this.fetch(request, 'observability.getObject'); + }; + + createSavedQuery = async (request: any) => { + return this.create(request, 'observability.createObject', 'savedQuery'); + }; + + createSavedVisualization = (request: any) => { + return this.create(request, 'observability.createObject', 'savedVisualization'); + }; + + updateSavedQuery = (request: any) => { + return this.update(request, 'observability.updateObjectById', 'savedQuery'); + }; + + updateSavedVisualization = (request: any) => { + return this.update(request, 'observability.updateObjectById', 'savedVisualization'); + }; + + deleteSavedQuery = async (request: any) => { + return this.delete(request, 'observability.deleteObjectByIdList', 'savedQuery'); + }; +} \ No newline at end of file