diff --git a/.cypress/integration/3_panels.spec.js b/.cypress/integration/3_panels.spec.js index 023a4d2ae9..8fdcd65c3b 100644 --- a/.cypress/integration/3_panels.spec.js +++ b/.cypress/integration/3_panels.spec.js @@ -482,3 +482,168 @@ describe('Clean up all test data', () => { cy.get('.euiTextAlign').contains('No Operational Panels').should('exist'); }); }); + +const moveToOsdDashboards = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/dashboards#/`); + cy.wait(delay * 3); +}; + +const moveToEventsHome = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/observability-logs#/`); + cy.wait(6000); +}; + +const moveToPanelHome = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/observability-dashboards#/`, { + timeout: 3000, + }); + cy.wait(delay * 3); +}; + +const testPanelTableCell = (name = TEST_PANEL) => cy.get('.euiTableCellContent').contains(name); + +const moveToTestPanel = () => { + moveToPanelHome(); + testPanelTableCell().trigger('mouseover').click(); + cy.wait(delay * 3); + cy.get('h1').contains(TEST_PANEL).should('exist'); +}; + +const TEST_PANEL_RX = new RegExp(TEST_PANEL + '.*'); + +const eraseLegacyPanels = () => { + cy.request({ + method: 'GET', + failOnStatusCode: false, + url: 'api/observability/operational_panels/panels', + headers: { + 'content-type': 'application/json;charset=UTF-8', + 'osd-xsrf': true, + }, + }).then((response) => { + response.body.panels.map((panel) => { + cy.request({ + method: 'DELETE', + failOnStatusCode: false, + url: `api/observability/operational_panels/panels/${panel.id}`, + headers: { + 'content-type': 'application/json;charset=UTF-8', + 'osd-xsrf': true, + }, + }).then((response) => { + const deletedId = response.allRequestResponses[0]['Request URL'].split('/').slice(-1); + console.log('erased panel', deletedId); + }); + }); + }); +}; + +const eraseSavedObjectPaenls = () => { + return cy + .request({ + method: 'get', + failOnStatusCode: false, + url: 'api/saved_objects/_find?type=observability-panel', + headers: { + 'content-type': 'application/json;charset=UTF-8', + 'osd-xsrf': true, + }, + }) + .then((response) => { + response.body.saved_objects.map((soPanel) => { + cy.request({ + method: 'DELETE', + failOnStatusCode: false, + url: `api/saved_objects/observability-panel/${soPanel.id}`, + headers: { + 'content-type': 'application/json;charset=UTF-8', + 'osd-xsrf': true, + }, + }); + }); + }); +}; + +const eraseTestPanels = () => { + eraseLegacyPanels(); + eraseSavedObjectPaenls(); +}; +const uuidRx = /[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}/; + +const clickCreatePanelButton = () => + cy.get('a[data-test-subj="customPanels__createNewPanels"]').click(); + +const createSavedObjectPanel = (newName = TEST_PANEL) => { + const result = cy + .request({ + method: 'POST', + failOnStatusCode: false, + url: 'api/saved_objects/observability-panel', + headers: { + 'content-type': 'application/json;charset=UTF-8', + 'osd-xsrf': true, + }, + body: { + attributes: { + title: newName, + description: '', + dateCreated: 1681127334085, + dateModified: 1681127334085, + timeRange: { + to: 'now', + from: 'now-1d', + }, + queryFilter: { + query: '', + language: 'ppl', + }, + visualizations: [], + applicationId: '', + }, + }, + }) + .then((response) => console.log(response)); +}; + +const createLegacyPanel = (newName = TEST_PANEL) => { + const result = cy.request({ + method: 'POST', + failOnStatusCode: false, + url: 'api/observability/operational_panels/panels', + headers: { + 'content-type': 'application/json;charset=UTF-8', + 'osd-xsrf': true, + }, + body: { panelName: newName }, + }); +}; + +const expectUuid = (anchorElem) => { + anchorElem.invoke('attr', 'href').should('match', uuidRx); +}; + +const expectLegacyId = (anchorElem) => { + anchorElem.invoke('attr', 'href').should('not.match', uuidRx); +}; + +const clickDeleteAction = () => { + cy.get('button[data-test-subj="deleteContextMenuItem"]').click(); +}; + +const openActionsDropdown = () => { + cy.get('button[data-test-subj="operationalPanelsActionsButton"]').click(); +}; + +const selectThePanel = () => { + cy.get('.euiCheckbox__input[title="Select this row"]').then(() => { + cy.get('.euiCheckbox__input[title="Select this row"]').check({ force: true }); + }); +}; + +const expectToastWith = (title) => { + cy.get('.euiToastHeader__title').contains(title).should('exist'); +}; + +const confirmModal = () => { + cy.get('button[data-test-subj="runModalButton"]').click(); +}; diff --git a/common/constants/custom_panels.ts b/common/constants/custom_panels.ts index 0c02b97a29..f0791d08c3 100644 --- a/common/constants/custom_panels.ts +++ b/common/constants/custom_panels.ts @@ -4,5 +4,10 @@ */ export const CUSTOM_PANELS_API_PREFIX = '/api/observability/operational_panels'; -export const CUSTOM_PANELS_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/observability-plugin/operational-panels/'; -export const CREATE_PANEL_MESSAGE = 'Enter a name to describe the purpose of this custom panel.'; +export const CUSTOM_PANELS_DOCUMENTATION_URL = + 'https://opensearch.org/docs/latest/observability-plugin/operational-panels/'; +export const CREATE_PANEL_MESSAGE = 'Enter a name to describe the purpose of this Observability Dashboard.'; + +export const CUSTOM_PANELS_SAVED_OBJECT_TYPE = 'observability-panel'; + +export const CUSTOM_PANEL_SLICE = 'customPanel'; diff --git a/common/constants/shared.ts b/common/constants/shared.ts index 5a48e6b962..05a704ac56 100644 --- a/common/constants/shared.ts +++ b/common/constants/shared.ts @@ -25,7 +25,7 @@ export const DSL_ENDPOINT = '/_plugins/_dsl'; export const observabilityID = 'observability-dashboards'; export const observabilityTitle = 'Observability'; -export const observabilityPluginOrder = 6000; +export const observabilityPluginOrder = 1500; // Shared Constants export const SQL_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/search-plugins/sql/index/'; diff --git a/public/components/common/toast/index.tsx b/public/components/common/toast/index.tsx new file mode 100644 index 0000000000..6eaef004ce --- /dev/null +++ b/public/components/common/toast/index.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ToastInputFields } from '../../../../../../src/core/public'; +import { coreRefs } from '../../../framework/core_refs'; + +type Color = 'success' | 'primary' | 'warning' | 'danger' | undefined; + +export const useToast = () => { + const toasts = coreRefs.toasts!; + + const setToast = (title: string, color: Color = 'success', text?: string) => { + const newToast: ToastInputFields = { + id: new Date().toISOString(), + title, + text, + }; + switch (color) { + case 'danger': { + toasts.addDanger(newToast); + break; + } + case 'warning': { + toasts.addWarning(newToast); + break; + } + default: { + toasts.addSuccess(newToast); + break; + } + } + }; + + return { setToast }; +}; diff --git a/public/components/custom_panels/__tests__/custom_panel_view.test.tsx b/public/components/custom_panels/__tests__/custom_panel_view.test.tsx index 81cb151cd6..4718aa9b28 100644 --- a/public/components/custom_panels/__tests__/custom_panel_view.test.tsx +++ b/public/components/custom_panels/__tests__/custom_panel_view.test.tsx @@ -20,10 +20,16 @@ import PPLService from '../../../../public/services/requests/ppl'; import DSLService from '../../../../public/services/requests/dsl'; import { coreStartMock } from '../../../../test/__mocks__/coreMocks'; import { HttpResponse } from '../../../../../../src/core/public'; +import { applyMiddleware, createStore } from 'redux'; +import { rootReducer } from '../../../framework/redux/reducers'; +import thunk from 'redux-thunk'; +import { Provider } from 'react-redux'; describe('Panels View Component', () => { configure({ adapter: new Adapter() }); + const store = createStore(rootReducer, applyMiddleware(thunk)); + it('renders panel view container without visualizations', async () => { httpClientMock.get = jest.fn(() => Promise.resolve((sampleEmptyPanel as unknown) as HttpResponse) diff --git a/public/components/custom_panels/custom_panel_table.tsx b/public/components/custom_panels/custom_panel_table.tsx index 08ca2afef5..ce2392c411 100644 --- a/public/components/custom_panels/custom_panel_table.tsx +++ b/public/components/custom_panels/custom_panel_table.tsx @@ -43,6 +43,20 @@ import { CustomPanelListType } from '../../../common/types/custom_panels'; import { getSampleDataModal } from '../common/helpers/add_sample_modal'; import { pageStyles } from '../../../common/constants/shared'; import { DeleteModal } from '../common/helpers/delete_modal'; +<<<<<<< HEAD +======= +import { + createPanel, + deletePanels, + fetchPanels, + isUuid, + newPanelTemplate, + renameCustomPanel, + selectPanelList, +} from './redux/panel_slice'; +import { isNameValid } from './helpers/utils'; +import { useToast } from '../common/toast'; +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) /* * "CustomPanelTable" module, used to view all the saved panels @@ -66,9 +80,12 @@ interface Props { createCustomPanel: (newCustomPanelName: string) => void; setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; parentBreadcrumbs: EuiBreadcrumb[]; +<<<<<<< HEAD renameCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; cloneCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; deleteCustomPanelList: (customPanelIdList: string[], toastMessage: string) => any; +======= +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) addSamplePanels: () => void; } @@ -79,9 +96,12 @@ export const CustomPanelTable = ({ createCustomPanel, setBreadcrumbs, parentBreadcrumbs, +<<<<<<< HEAD renameCustomPanel, cloneCustomPanel, deleteCustomPanelList, +======= +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) addSamplePanels, }: Props) => { const [isModalVisible, setIsModalVisible] = useState(false); // Modal Toggle @@ -89,12 +109,30 @@ export const CustomPanelTable = ({ const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); const [selectedCustomPanels, setselectedCustomPanels] = useState([]); const [searchQuery, setSearchQuery] = useState(''); +<<<<<<< HEAD +======= + const location = useLocation(); + const history = useHistory(); + + const dispatch = useDispatch(); + const { setToast } = useToast(); +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) useEffect(() => { setBreadcrumbs(parentBreadcrumbs); fetchCustomPanels(); }, []); +<<<<<<< HEAD +======= + useEffect(() => { + const url = window.location.hash.split('/'); + if (url[url.length - 1] === 'create') { + createPanelModal(); + } + }, [location]); + +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) const closeModal = () => { setIsModalVisible(false); }; @@ -104,26 +142,78 @@ export const CustomPanelTable = ({ }; const onCreate = async (newCustomPanelName: string) => { +<<<<<<< HEAD createCustomPanel(newCustomPanelName); +======= + if (!isNameValid(newCustomPanelName)) { + setToast('Invalid Dashboard name', 'danger'); + } else { + const newPanel = newPanelTemplate(newCustomPanelName); + dispatch(createPanel(newPanel)); + } +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) closeModal(); }; const onRename = async (newCustomPanelName: string) => { +<<<<<<< HEAD renameCustomPanel(newCustomPanelName, selectedCustomPanels[0].id); +======= + if (!isNameValid(newCustomPanelName)) { + setToast('Invalid Dashboard name', 'danger'); + } else { + dispatch(renameCustomPanel(newCustomPanelName, selectedCustomPanels[0].id)); + } +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) closeModal(); }; const onClone = async (newName: string) => { +<<<<<<< HEAD cloneCustomPanel(newName, selectedCustomPanels[0].id); +======= + if (!isNameValid(newName)) { + setToast('Invalid Operational Panel name', 'danger'); + } else { + let sourcePanel = selectedCustomPanels[0]; + try { + if (!isUuid(sourcePanel.id)) { + // Observability Panel API returns partial record, so for duplication + // we will retrieve the entire record and allow new process to continue. + const legacyFetchResult = await coreRefs.http!.get( + `${CUSTOM_PANELS_API_PREFIX}/panels/${sourcePanel.id}` + ); + sourcePanel = legacyFetchResult.operationalPanel; + } + + const { id, ...newPanel } = { + ...sourcePanel, + title: newName, + }; + + dispatch(createPanel(newPanel)); + } catch (err) { + setToast( + 'Error cloning Observability Dashboard, please make sure you have the correct permission.', + 'danger' + ); + console.error(err); + } + } +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) closeModal(); }; const onDelete = async () => { +<<<<<<< HEAD const toastMessage = `Custom Panels ${ selectedCustomPanels.length > 1 ? 's' : ' ' + selectedCustomPanels[0].name } successfully deleted!`; const PanelList = selectedCustomPanels.map((panel) => panel.id); deleteCustomPanelList(PanelList, toastMessage); +======= + dispatch(deletePanels(selectedCustomPanels)); +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) closeModal(); }; diff --git a/public/components/custom_panels/custom_panel_view.tsx b/public/components/custom_panels/custom_panel_view.tsx index 41766d4bd8..9b81a3f9ba 100644 --- a/public/components/custom_panels/custom_panel_view.tsx +++ b/public/components/custom_panels/custom_panel_view.tsx @@ -64,7 +64,13 @@ import { } from '../common/search/autocomplete_logic'; import { AddVisualizationPopover } from './helpers/add_visualization_popover'; import { DeleteModal } from '../common/helpers/delete_modal'; +<<<<<<< HEAD import _ from 'lodash'; +======= +import { coreRefs } from '../../framework/core_refs'; +import { clonePanel } from './redux/panel_slice'; +import { useToast } from '../common/toast'; +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) /* * "CustomPanelsView" module used to render an Operational Panel @@ -102,12 +108,6 @@ interface CustomPanelViewProps { renameCustomPanel: (editedCustomPanelName: string, editedCustomPanelId: string) => Promise; deleteCustomPanel: (customPanelId: string, customPanelName: string) => Promise; cloneCustomPanel: (clonedCustomPanelName: string, clonedCustomPanelId: string) => Promise; - setToast: ( - title: string, - color?: string, - text?: React.ReactChild | undefined, - side?: string | undefined - ) => void; onEditClick: (savedVisualizationId: string) => any; startTime: string; endTime: string; @@ -138,7 +138,6 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { renameCustomPanel, deleteCustomPanel, cloneCustomPanel, - setToast, onEditClick, onAddClick, } = props; @@ -165,6 +164,13 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { const appPanel = page === 'app'; +<<<<<<< HEAD +======= + const dispatch = useDispatch(); + + const { setToast } = useToast(); + +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) const closeHelpFlyout = () => { setAddVizDisabled(false); setHelpIsFlyoutVisible(false); @@ -264,9 +270,17 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { }; const onClone = async (newCustomPanelName: string) => { +<<<<<<< HEAD cloneCustomPanel(newCustomPanelName, panelId).then((id: string) => { window.location.assign(`${last(parentBreadcrumbs)!.href}${id}`); }); +======= + if (!isNameValid(newCustomPanelName)) { + setToast('Invalid Operational Panel name', 'danger'); + } else { + dispatch(clonePanel(panel, newCustomPanelName)); + } +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) closeModal(); }; diff --git a/public/components/custom_panels/custom_panel_view_so.tsx b/public/components/custom_panels/custom_panel_view_so.tsx new file mode 100644 index 0000000000..65b10416a0 --- /dev/null +++ b/public/components/custom_panels/custom_panel_view_so.tsx @@ -0,0 +1,687 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* // eslint-disable no-console */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { + EuiBreadcrumb, + EuiButton, + EuiContextMenu, + EuiContextMenuPanelDescriptor, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiOverlayMask, + EuiPage, + EuiPageBody, + EuiPageContentBody, + EuiPageHeader, + EuiPageHeaderSection, + EuiPopover, + EuiSpacer, + EuiSuperDatePicker, + EuiTitle, + OnTimeChangeProps, + ShortDate, +} from '@elastic/eui'; +import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; +import { last } from 'lodash'; +import moment from 'moment'; +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CoreStart } from '../../../../../src/core/public'; +import { CREATE_PANEL_MESSAGE } from '../../../common/constants/custom_panels'; +import { UI_DATE_FORMAT } from '../../../common/constants/shared'; +import { CustomPanelType } from '../../../common/types/custom_panels'; +import { uiSettingsService } from '../../../common/utils'; +import { coreRefs } from '../../framework/core_refs'; +import { PPLReferenceFlyout } from '../common/helpers'; +import { DeleteModal } from '../common/helpers/delete_modal'; +import { Autocomplete } from '../common/search/autocomplete'; +import { onItemSelect, parseGetSuggestions } from '../common/search/autocomplete_logic'; +import { addVisualizationPanel } from './helpers/add_visualization_helper'; +import { AddVisualizationPopover } from './helpers/add_visualization_popover'; +import { getCustomModal } from './helpers/modal_containers'; +import { + convertDateTime, + isDateValid, + isNameValid, + isPPLFilterValid, + prependRecentlyUsedRange, +} from './helpers/utils'; +import { EmptyPanelView } from './panel_modules/empty_panel'; +import { PanelGridSO } from './panel_modules/panel_grid/panel_grid_so'; +import { VisaulizationFlyoutSO } from './panel_modules/visualization_flyout/visualization_flyout_so'; +import { + clonePanel, + deletePanels, + fetchPanel, + renameCustomPanel, + selectPanel, + setPanel, + updatePanel, +} from './redux/panel_slice'; +import { useToast } from '../common/toast'; +import PPLService from '../../services/requests/ppl'; +import DSLService from '../../services/requests/dsl'; + +/* + * "CustomPanelsView" module used to render an Observability Dashboard + * + * Props taken in as params are: + * panelId: Name of the panel opened + * page: Page where component is called + * http: http core service + * coreSavedObjects : savedObjects core service + * pplService: ppl requestor service + * dslService: dsl requestor service + * chrome: chrome core service + * parentBreadcrumb: parent breadcrumb + * renameCustomPanel: Rename function for the panel + * deleteCustomPanel: Delete function for the panel + * cloneCustomPanel: Clone function for the panel + * onEditClick: Edit function for visualization + * startTime: Starting time + * endTime: Ending time + * setStartTime: Function to change start time + * setEndTime: Function to change end time + * childBreadcrumbs: Breadcrumbs to extend + * appId: id of application that panel belongs to + * onAddClick: Function for add button instead of add visualization popover + */ + +interface CustomPanelViewProps { + panelId: string; + page: 'app' | 'operationalPanels'; + coreSavedObjects: CoreStart['savedObjects']; + chrome: CoreStart['chrome']; + parentBreadcrumbs: EuiBreadcrumb[]; + cloneCustomPanel: (clonedCustomPanelName: string, clonedCustomPanelId: string) => Promise; + onEditClick: (savedVisualizationId: string) => any; + childBreadcrumbs?: EuiBreadcrumb[]; + appId?: string; + updateAvailabilityVizId?: any; + onAddClick?: any; + pplService: PPLService; + dslService: DSLService; +} + +export const CustomPanelViewSO = (props: CustomPanelViewProps) => { + const { + panelId, + page, + appId, + pplService, + dslService, + chrome, + parentBreadcrumbs, + childBreadcrumbs, + updateAvailabilityVizId, + cloneCustomPanel, + onEditClick, + onAddClick, + } = props; + + const dispatch = useDispatch(); + const { setToast } = useToast(); + + const panel = useSelector(selectPanel); + const [loading, setLoading] = useState(true); + + const [pplFilterValue, setPPLFilterValue] = useState(''); + const [baseQuery, setBaseQuery] = useState(''); + const [onRefresh, setOnRefresh] = useState(false); + + const [inputDisabled, setInputDisabled] = useState(true); + const [addVizDisabled, setAddVizDisabled] = useState(false); + const [editDisabled, setEditDisabled] = useState(false); + const [dateDisabled, setDateDisabled] = useState(false); + const [isEditing, setIsEditing] = useState(false); + const [isModalVisible, setIsModalVisible] = useState(false); // Modal Toggle + const [modalLayout, setModalLayout] = useState(); // Modal Layout + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); // Add Visualization Flyout + const [isFlyoutReplacement, setisFlyoutReplacement] = useState(false); + const [replaceVisualizationId, setReplaceVisualizationId] = useState(''); + const [panelsMenuPopover, setPanelsMenuPopover] = useState(false); + const [editActionType, setEditActionType] = useState(''); + const [isHelpFlyoutVisible, setHelpIsFlyoutVisible] = useState(false); + + const appPanel = page === 'app'; + + const closeHelpFlyout = () => { + setAddVizDisabled(false); + setHelpIsFlyoutVisible(false); + }; + + const showHelpFlyout = () => { + setAddVizDisabled(true); + setHelpIsFlyoutVisible(true); + }; + + // DateTimePicker States/add + const [recentlyUsedRanges, setRecentlyUsedRanges] = useState([]); + + const handleQueryChange = (newQuery: string) => { + setPPLFilterValue(newQuery); + }; + + const closeModal = () => { + setIsModalVisible(false); + }; + + const showModal = () => { + setIsModalVisible(true); + }; + + const onDatePickerChange = (timeProps: OnTimeChangeProps) => { + const updatedRanges = prependRecentlyUsedRange( + timeProps.start, + timeProps.end, + recentlyUsedRanges + ); + dispatch(updatePanel({ ...panel, timeRange: { from: timeProps.start, to: timeProps.end } }, '', '')); + + setRecentlyUsedRanges(updatedRanges.slice(0, 9)); + onRefreshFilters(timeProps.start, timeProps.end); + }; + + const onDelete = async () => { + dispatch(deletePanels([panel])); + setTimeout(() => { + window.location.assign(`${last(parentBreadcrumbs)!.href}`); + }, 1000); + closeModal(); + }; + + const deletePanel = () => { + setModalLayout( + + ); + showModal(); + }; + + const onRename = async (newCustomPanelName: string) => { + if (!isNameValid(newCustomPanelName)) { + setToast('Invalid Dashboard name', 'danger'); + } else { + dispatch(renameCustomPanel(newCustomPanelName, panel.id)); + } + closeModal(); + }; + + const renamePanel = () => { + setModalLayout( + getCustomModal( + onRename, + closeModal, + 'Name', + 'Rename Dashboard', + 'Cancel', + 'Rename', + panel.title, + CREATE_PANEL_MESSAGE + ) + ); + showModal(); + }; + + const onClone = async (newCustomPanelName: string) => { + if (!isNameValid(newCustomPanelName)) { + setToast('Invalid Operational Panel name', 'danger'); + } else { + dispatch(clonePanel(panel, newCustomPanelName)); + } + closeModal(); + }; + + const clonePanelModal = () => { + setModalLayout( + getCustomModal( + onClone, + closeModal, + 'Name', + 'Duplicate Dashboard', + 'Cancel', + 'Duplicate', + panel.title + ' (copy)', + CREATE_PANEL_MESSAGE + ) + ); + showModal(); + }; + + // toggle between panel edit mode + const editPanel = (editType: string) => { + setIsEditing(!isEditing); + if (editType === 'cancel') dispatch(fetchPanel(panelId)); + setEditActionType(editType); + }; + + const closeFlyout = () => { + setIsFlyoutVisible(false); + setAddVizDisabled(false); + checkDisabledInputs(); + }; + + const showFlyout = (isReplacement?: boolean, replaceVizId?: string) => { + setisFlyoutReplacement(isReplacement); + setReplaceVisualizationId(replaceVizId); + setIsFlyoutVisible(true); + setAddVizDisabled(true); + setInputDisabled(true); + }; + + const checkDisabledInputs = () => { + // When not in edit mode and panel has no visualizations + if (panel.visualizations.length === 0 && !isEditing) { + setEditDisabled(true); + setInputDisabled(true); + setAddVizDisabled(false); + setDateDisabled(false); + } + + // When panel has visualizations + if (panel.visualizations.length > 0) { + setEditDisabled(false); + setInputDisabled(false); + setAddVizDisabled(false); + setDateDisabled(false); + } + + // When in edit mode + if (isEditing) { + setEditDisabled(false); + setInputDisabled(true); + setAddVizDisabled(true); + setDateDisabled(true); + } + }; + + const buildBaseQuery = async () => { + // const indices: string[] = []; + // for (let i = 0; i < visualizations.length; i++) { + // const visualizationId = visualizations[i].savedVisualizationId; + // // TODO: create route to get list of visualizations in one call + // const visData: SavedVisualizationType = await fetchVisualizationById( + // http, + // visualizationId, + // (error: VizContainerError) => setToast(error.errorMessage, 'danger') + // ); + + // if (!_.isEmpty(visData)) { + // const moreIndices = parseForIndices(visData.query); + // for (let j = 0; j < moreIndices.length; j++) { + // if (!indices.includes(moreIndices[j])) { + // indices.push(moreIndices[j]); + // } + // } + // } + // } + // setBaseQuery('source = ' + indices.join(', ')); + return; + }; + + const onRefreshFilters = async (start: ShortDate, end: ShortDate) => { + if (!isDateValid(convertDateTime(start), convertDateTime(end, false), setToast)) { + return; + } + + if (!isPPLFilterValid(pplFilterValue, setToast)) { + console.error(pplFilterValue); + return; + } + + await coreRefs.savedObjectsClient?.update('observability-panel', panelId, { + ...panel, + timeRange: { + to: end, + from: start, + }, + queryFilter: { + query: pplFilterValue, + language: 'ppl', + }, + }); + + setOnRefresh(!onRefresh); + }; + + const cloneVisualization = (visualzationTitle: string, savedVisualizationId: string) => { + addVisualizationToCurrentPanel({ savedVisualizationId, successMsg: `Visualization ${visualzationTitle} successfully added!`, failureMsg: `Error in adding ${visualzationTitle} visualization to the panel` }); + }; + + const cancelButton = ( + editPanel('cancel')} + > + Cancel + + ); + + const saveButton = ( + editPanel('save')}> + Save + + ); + + const editButton = ( + editPanel('edit')} + disabled={editDisabled} + > + Edit + + ); + + const addButton = ( + + Add + + ); + + // Panel Actions Button + const panelActionsButton = ( + setPanelsMenuPopover(true)} + disabled={addVizDisabled} + > + Dashboard Actions + + ); + + const addVisualizationToCurrentPanel = async ({ + savedVisualizationId, + oldVisualizationId, + successMsg, + failureMsg, + }: { + savedVisualizationId: string; + oldVisualizationId?: string; + successMsg: string; + failureMsg: string; + }) => { + const allVisualizations = panel!.visualizations; + + const visualizationsWithNewPanel = addVisualizationPanel( + savedVisualizationId, + oldVisualizationId, + allVisualizations + ); + + const updatedPanel = { ...panel, visualizations: visualizationsWithNewPanel }; + dispatch(updatePanel(updatedPanel, successMsg, failureMsg)); + }; + + const setPanelVisualizations = (newVis) => { + const newPanel: CustomPanelType = { ...panel, visualizations: newVis }; + dispatch(setPanel(newPanel)); + }; + + let flyout; + if (isFlyoutVisible) { + flyout = ( + + ); + } + + let helpFlyout; + if (isHelpFlyoutVisible) { + helpFlyout = ; + } + + const panelActionsMenu: EuiContextMenuPanelDescriptor[] = [ + { + id: 0, + title: 'Panel actions', + items: [ + { + name: 'Reload Dashboard', + 'data-test-subj': 'reloadPanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + dispatch(fetchPanel(panelId)); + }, + }, + { + name: 'Rename Dashboard', + 'data-test-subj': 'renamePanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + renamePanel(); + }, + }, + { + name: 'Duplicate Dashboard', + 'data-test-subj': 'duplicatePanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + clonePanelModal(); + }, + }, + { + name: 'Delete Dashboard', + 'data-test-subj': 'deletePanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + deletePanel(); + }, + }, + ], + }, + ]; + // Fetch the Observability Dashboard on Initial Mount + useEffect(() => { + setLoading(true); + dispatch(fetchPanel(panelId)); + }, []); + + // Toggle input type (disabled or not disabled) + // Disabled when there no visualizations in panels or when the panel is in edit mode + useEffect(() => { + if (!loading) { + checkDisabledInputs(); + } + }, [isEditing, loading]); + + // Build base query with all of the indices included in the current visualizations + useEffect(() => { + if (loading) { + if (panel.id === props.panelId) setLoading(false); + else return; + } + + checkDisabledInputs(); + buildBaseQuery(); + setLoading(false); + }, [panel, loading]); + + // Edit the breadcrumb when panel name changes + useEffect(() => { + if (!panel) return; + + let newBreadcrumb; + if (childBreadcrumbs) { + newBreadcrumb = childBreadcrumbs; + } else { + newBreadcrumb = [ + { + text: panel.title, + href: `${last(parentBreadcrumbs)!.href}${panelId}`, + }, + ]; + } + chrome.setBreadcrumbs([...parentBreadcrumbs, ...newBreadcrumb]); + }, [panelId, panel]); + + return loading ? ( + <> + ) : ( +
+ + + + {appPanel || ( + <> + + +

{panel?.title}

+
+ + + + Created on {moment(panel?.dateCreated || 0).format(UI_DATE_FORMAT)} +
+ + + {isEditing ? ( + <> + {cancelButton} + {saveButton} + + ) : ( + {editButton} + )} + + setPanelsMenuPopover(false)} + > + + + + + + + + + + )} +
+ + + + + onRefreshFilters(panel.timeRange.from, panel.timeRange.to) + } + dslService={dslService} + getSuggestions={parseGetSuggestions} + onItemSelect={onItemSelect} + isDisabled={inputDisabled} + tabId={'panels-filter'} + placeholder={ + "Use PPL 'where' clauses to add filters on all visualizations [where Carrier = 'OpenSearch-Air']" + } + possibleCommands={[{ label: 'where' }]} + append={ + + PPL + + } + /> + + + + + {appPanel && ( + <> + {isEditing ? ( + <> + {cancelButton} + {saveButton} + + ) : ( + {editButton} + )} + {addButton} + + )} + + + {panel.visualizations.length === 0 && ( + + )} + + +
+
+ {isModalVisible && modalLayout} + {flyout} + {helpFlyout} +
+ ); +}; diff --git a/public/components/custom_panels/home.tsx b/public/components/custom_panels/home.tsx index 4a5395fb0f..f48b66f0cf 100644 --- a/public/components/custom_panels/home.tsx +++ b/public/components/custom_panels/home.tsx @@ -4,10 +4,16 @@ */ /* eslint-disable no-console */ +<<<<<<< HEAD import { EuiBreadcrumb, EuiGlobalToastList, EuiLink, ShortDate } from '@elastic/eui'; import { Toast } from '@elastic/eui/src/components/toast/global_toast_list'; import _ from 'lodash'; import React, { ReactChild, useState } from 'react'; +======= +import { EuiBreadcrumb, ShortDate, htmlIdGenerator } from '@elastic/eui'; +import React, { useState } from 'react'; +import { useDispatch, batch } from 'react-redux'; +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) // eslint-disable-next-line @osd/eslint/module_migration import { StaticContext } from 'react-router'; import { Route, RouteComponentProps } from 'react-router-dom'; @@ -27,7 +33,22 @@ import { CustomPanelListType } from '../../../common/types/custom_panels'; import { ObservabilitySideBar } from '../common/side_nav'; import { CustomPanelTable } from './custom_panel_table'; import { CustomPanelView } from './custom_panel_view'; +<<<<<<< HEAD import { isNameValid } from './helpers/utils'; +======= +import { CustomPanelViewSO } from './custom_panel_view_so'; +import { fetchPanels, uuidRx } from './redux/panel_slice'; +import { REDIRECT_TAB, TAB_CREATED_TYPE, TAB_ID_TXT_PFX } from '../../../common/constants/explorer'; +import { init as initFields } from '../event_analytics/redux/slices/field_slice'; +import { init as initPatterns } from '../event_analytics/redux/slices/patterns_slice'; +import { init as initQueryResult } from '../event_analytics/redux/slices/query_result_slice'; +import { changeQuery, init as initQuery } from '../event_analytics/redux/slices/query_slice'; +import { addTab, setSelectedQueryTab } from '../event_analytics/redux/slices/query_tab_slice'; +import { useToast } from '../common/toast'; +import { coreRefs } from '../../framework/core_refs'; + +// import { ObjectFetcher } from '../common/objectFetcher'; +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) /* * "Home" module is initial page for Operantional Panels @@ -57,13 +78,16 @@ export const Home = ({ dslService, renderProps, }: PanelHomeProps) => { +<<<<<<< HEAD const [customPanelData, setcustomPanelData] = useState([]); const [toasts, setToasts] = useState([]); +======= +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) const [loading, setLoading] = useState(false); - const [toastRightSide, setToastRightSide] = useState(true); const [start, setStart] = useState(''); const [end, setEnd] = useState(''); +<<<<<<< HEAD const setToast = (title: string, color = 'success', text?: ReactChild, side?: string) => { if (!text) text = ''; setToastRightSide(!side ? true : false); @@ -72,6 +96,34 @@ export const Home = ({ const onEditClick = (savedVisualizationId: string) => { window.location.assign(`#/event_analytics/explorer/${savedVisualizationId}`); +======= + const dispatch = useDispatch(); + + const { setToast } = useToast(); + + const customPanelBreadCrumbs = [ + ...parentBreadcrumbs, + { + text: 'Dashboards', + href: `${observabilityPanelsID}#/`, + }, + ]; + + const addNewTab = async () => { + // get a new tabId + const tabId = htmlIdGenerator(TAB_ID_TXT_PFX)(); + + // create a new tab + await batch(() => { + dispatch(initQuery({ tabId })); + dispatch(initQueryResult({ tabId })); + dispatch(initFields({ tabId })); + dispatch(addTab({ tabId })); + dispatch(initPatterns({ tabId })); + }); + + return tabId; +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) }; // Fetches all saved Custom Panels @@ -288,6 +340,7 @@ export const Home = ({ }; return ( +<<<<<<< HEAD
{ return ( +======= + + + { + return ( +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) +<<<<<<< HEAD ); }} @@ -345,5 +408,48 @@ export const Home = ({ }} />
+======= + ); + }} + /> + { + const isSavedObject = !!props.match.params.id.match(uuidRx); + + return isSavedObject ? ( + + ) : ( + + ); + }} + /> + + +>>>>>>> a9d1d370 (Add Toasts to Observability Dashboards (#435)) ); }; diff --git a/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout_so.tsx b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout_so.tsx new file mode 100644 index 0000000000..0b1fff5f75 --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout_so.tsx @@ -0,0 +1,424 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { + EuiButton, + EuiButtonIcon, + EuiCallOut, + EuiCodeBlock, + EuiDatePicker, + EuiDatePickerRange, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiFormRow, + EuiIcon, + EuiLoadingChart, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSelect, + EuiSelectOption, + EuiSpacer, + EuiText, + EuiTitle, + EuiToolTip, + ShortDate, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { CoreStart } from '../../../../../../../src/core/public'; +import { SAVED_VISUALIZATION } from '../../../../../common/constants/explorer'; +import { + PPLResponse, + SavedVisualizationType, + VisualizationType, + VizContainerError, +} from '../../../../../common/types/custom_panels'; +import { uiSettingsService } from '../../../../../common/utils'; + +import PPLService from '../../../../services/requests/ppl'; +import { SavedObjectsActions } from '../../../../services/saved_objects/saved_object_client/saved_objects_actions'; +import { ObservabilitySavedVisualization } from '../../../../services/saved_objects/saved_object_client/types'; +import { FlyoutContainers } from '../../../common/flyout_containers'; +import { + convertDateTime, + displayVisualization, + getQueryResponse, + isDateValid, + parseSavedVisualizations, +} from '../../helpers/utils'; +import { replaceVizInPanel, selectPanel } from '../../redux/panel_slice'; +import './visualization_flyout.scss'; +import { useToast } from '../../../common/toast'; + +/* + * VisaulizationFlyoutSO - This module create a flyout to add visualization for SavedObjects custom Panels + * + * Props taken in as params are: + * panelId: panel Id of current Observability Dashboard + * closeFlyout: function to close the flyout + * start: start time in date filter + * end: end time in date filter + * savedObjects: savedObjects 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 + */ + +interface VisualizationFlyoutSOProps { + panelId: string; + pplFilterValue: string; + closeFlyout: () => void; + start: ShortDate; + end: ShortDate; + http: CoreStart['http']; + savedObjects: CoreStart['savedObjects']; + pplService: PPLService; + setPanelVisualizations: React.Dispatch>; + isFlyoutReplacement?: boolean | undefined; + replaceVisualizationId?: string | undefined; + appId?: string; + addVisualizationPanel: any; +} + +export const VisaulizationFlyoutSO = ({ + appId = '', + pplFilterValue, + closeFlyout, + start, + end, + pplService, + isFlyoutReplacement, + replaceVisualizationId, + addVisualizationPanel, +}: VisualizationFlyoutSOProps) => { + const dispatch = useDispatch(); + const { setToast } = useToast(); + + const panel = useSelector(selectPanel); + + const [newVisualizationTitle, setNewVisualizationTitle] = useState(''); + const [newVisualizationType, setNewVisualizationType] = useState(''); + const [newVisualizationTimeField, setNewVisualizationTimeField] = useState(''); + const [previewMetaData, setPreviewMetaData] = useState(); + const [pplQuery, setPPLQuery] = useState(''); + const [previewData, setPreviewData] = useState({} as PPLResponse); + const [previewArea, setPreviewArea] = useState(<>); + const [previewLoading, setPreviewLoading] = useState(false); + const [isPreviewError, setIsPreviewError] = useState({} as VizContainerError); + 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 [isModalVisible, setIsModalVisible] = useState(false); + const [modalContent, setModalContent] = useState(<>); + + const closeModal = () => setIsModalVisible(false); + const showModal = (modalType: string) => { + setModalContent( + + + +

{isPreviewError.errorMessage}

+
+
+ + + Error Details + + + {isPreviewError.errorDetails} + + + + + + Close + + +
+ ); + + setIsModalVisible(true); + }; + + const isInputValid = () => { + if (!isDateValid(convertDateTime(start), convertDateTime(end, false), setToast)) { + return false; + } + + if (selectValue === '') { + setToast('Please make a valid selection', 'danger', undefined); + return false; + } + + return true; + }; + + const addVisualization = () => { + if (!isInputValid()) return; + + if (isFlyoutReplacement) { + dispatch(replaceVizInPanel(panel, replaceVisualizationId, selectValue, newVisualizationTitle)); + } else { + const visualizationsWithNewPanel = addVisualizationPanel({ + savedVisualizationId: selectValue, + onSuccess: `Visualization ${newVisualizationTitle} successfully added!`, + onFailure: `Error in adding ${newVisualizationTitle} visualization to the panel` + }); + } + closeFlyout(); + }; + + const onRefreshPreview = () => { + if (!isInputValid()) return; + + getQueryResponse( + pplService, + pplQuery, + newVisualizationType, + start, + end, + setPreviewData, + setPreviewLoading, + setIsPreviewError, + pplFilterValue, + newVisualizationTimeField + ); + }; + + const timeRange = ( + + endDate} + // date-picker-preview style reduces height, need to add an empty line + // above error message so it does not overlap with DatePicker. + error={['', 'Time range is invalid.']} + > + endDate} + aria-label="Start date" + dateFormat={uiSettingsService.get('dateFormat')} + /> + } + endDateControl={ + endDate} + aria-label="End date" + dateFormat={uiSettingsService.get('dateFormat')} + /> + } + /> + + + ); + + 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} + value={selectValue} + /> + + + + + +

Preview

+
+
+ + + +
+ + {previewArea} + +
+ ) : ( + + <> +
{'Please use the "create new visualization" option in add visualization menu.'}
+ +
+ ); + + const flyoutFooter = ( + + + + + Cancel + + + + + Add + + + + + ); + + // Fetch all saved visualizations + const fetchSavedVisualizations = async () => { + return SavedObjectsActions.getBulk({ + objectType: [SAVED_VISUALIZATION], + sortOrder: 'desc', + fromIndex: 0, + }) + .then((response) => ({ + visualizations: response.observabilityObjectList.map(parseSavedVisualizations), + })) + .then((res) => { + if (res.visualizations.length > 0) { + setSavedVisualizations(res.visualizations); + const filterAppVis = res.visualizations.filter((vis: SavedVisualizationType) => { + return appId + ? vis.hasOwnProperty('application_id') + ? vis.application_id === appId + : false + : !vis.hasOwnProperty('application_id'); + }); + setVisualizationOptions( + filterAppVis.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 ? ( + + ) : !_.isEmpty(isPreviewError) ? ( +
+ + + +

{isPreviewError.errorMessage}

+
+ {isPreviewError.hasOwnProperty('errorDetails') && + isPreviewError.errorDetails !== '' ? ( + showModal('errorModal')} size="s"> + See error details + + ) : ( + <> + )} +
+ ) : ( +
+ {displayVisualization(previewMetaData, previewData, newVisualizationType)} +
+ )} +
+
+ + ); + setPreviewArea(previewTemplate); + }, [previewLoading]); + + // On change of selected visualization change options + useEffect(() => { + for (let i = 0; i < savedVisualizations.length; i++) { + const visualization = savedVisualizations[i]; + if (visualization.id === selectValue) { + setPPLQuery(visualization.query); + setNewVisualizationTitle(visualization.name); + setNewVisualizationType(visualization.type); + setPreviewMetaData(visualization); + setNewVisualizationTimeField(visualization.timeField); + break; + } + } + }, [selectValue]); + + // load saved visualizations + useEffect(() => { + fetchSavedVisualizations(); + }, []); + + return ( + <> + + {isModalVisible && modalContent} + + ); +}; diff --git a/public/components/custom_panels/redux/panel_slice.ts b/public/components/custom_panels/redux/panel_slice.ts new file mode 100644 index 0000000000..ae5f26a062 --- /dev/null +++ b/public/components/custom_panels/redux/panel_slice.ts @@ -0,0 +1,323 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSelector, createSlice } from '@reduxjs/toolkit'; +import { async, concat, from, Observable, of } from 'rxjs'; +import { map, mergeMap, tap, toArray } from 'rxjs/operators'; +import { forEach, last } from 'lodash'; +import { + CUSTOM_PANELS_API_PREFIX, + CUSTOM_PANELS_SAVED_OBJECT_TYPE, + CUSTOM_PANEL_SLICE, +} from '../../../../common/constants/custom_panels'; +import { + CustomPanelListType, + CustomPanelType, + ObservabilityPanelAttrs, + PanelType, + VisualizationType, +} from '../../../../common/types/custom_panels'; +import { coreRefs } from '../../../framework/core_refs'; +import { SavedObject, SimpleSavedObject } from '../../../../../../src/core/public'; +import { isNameValid } from '../helpers/utils'; +import { + addMultipleVisualizations, + addVisualizationPanel, +} from '../helpers/add_visualization_helper'; +import { useToast } from '../../../../public/components/common/toast'; + +interface InitialState { + id: string; + panel: CustomPanelType; + panelList: CustomPanelType[]; +} + +export const newPanelTemplate = (newName): PanelType => ({ + title: newName, + dateCreated: new Date().getTime(), + dateModified: new Date().getTime(), + visualizations: [], + queryFilter: { language: '', query: '' }, + timeRange: { from: 'now-1d', to: 'now' }, +}); + +const initialState: InitialState = { + id: '', + panel: newPanelTemplate(''), + panelList: [], + loadingFlag: false, +}; + +export const panelSlice = createSlice({ + name: 'customPanel', + initialState, + reducers: { + setPanelId: (state, action) => ({ ...state, id: action.payload }), + + setPanel: (state, action) => { + return { ...state, panel: action.payload }; + }, + + setPanelList: (state, action) => { + return { ...state, panelList: action.payload }; + }, + }, +}); + +export const { setPanel, setPanelList } = panelSlice.actions; + +export const panelReducer = panelSlice.reducer; + +export const selectPanel = createSelector( + (rootState) => rootState.customPanel.panel, + (panel) => normalizedPanel(panel) +); + +const normalizedPanel = (panel: CustomPanelType): CustomPanelType => ({ + ...newPanelTemplate(''), + ...panel, +}); + +export const selectPanelList = (rootState): CustomPanelType[] => rootState.customPanel.panelList; + +const {setToast} = useToast(); + +/* + ** ASYNC DISPATCH FUNCTIONS + */ + +const fetchSavedObjectPanels$ = () => + from(savedObjectPanelsClient.find()).pipe( + mergeMap((res) => res.savedObjects), + map(savedObjectToCustomPanel) + ); + +const fetchObservabilityPanels$ = () => + of(coreRefs.http.get(`${CUSTOM_PANELS_API_PREFIX}/panels`)).pipe( + mergeMap((res) => res), + mergeMap((res) => res.panels as ObservabilityPanelAttrs[]), + map((p: ObservabilityPanelAttrs) => ({ ...p, title: p.name, savedObject: false })) + ); + +// Fetches all saved Custom Panels +const fetchCustomPanels = async () => { + const panels$: Observable = concat( + fetchSavedObjectPanels$(), + fetchObservabilityPanels$() + ).pipe( + map((res) => { + return res as CustomPanelListType; + }) + ); + + return panels$.pipe(toArray()).toPromise(); +}; + +export const fetchPanels = () => async (dispatch, getState) => { + const panels = await fetchCustomPanels(); + dispatch(setPanelList(panels)); +}; + +export const fetchPanel = (id) => async (dispatch, getState) => { + const soPanel = await savedObjectPanelsClient.get(id); + const panel = savedObjectToCustomPanel(soPanel); + dispatch(setPanel(panel)); +}; + +export const fetchVisualization = () => (dispatch, getState) => {}; + +const updateLegacyPanel = (panel: CustomPanelType) => + coreRefs.http!.post(`${CUSTOM_PANELS_API_PREFIX}/panels/update`, { + body: JSON.stringify({ panelId: panel.id, panel: panel as PanelType }), + }); + +const updateSavedObjectPanel = (panel: CustomPanelType) => savedObjectPanelsClient.update(panel); + +export const uuidRx = /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/; + +export const isUuid = (id) => !!id.match(uuidRx); + +export const updatePanel = (panel: CustomPanelType, successMsg: string, failureMsg: string) => async (dispatch, getState) => { + try { + if (isUuid(panel.id)) await updateSavedObjectPanel(panel); + else await updateLegacyPanel(panel); + if (successMsg) { + setToast(successMsg) + } + dispatch(setPanel(panel)); + const panelList = getState().customPanel.panelList.map((p) => (p.id === panel.id ? panel : p)); + dispatch(setPanelList(panelList)); + } catch (e) { + if (failureMsg) { + setToast(failureMsg, 'danger') + } + console.error(e); + } +}; + +export const addVizToPanels = (panels, vizId) => async (dispatch, getState) => { + forEach(panels, (oldPanel) => { + const panel = getState().customPanel.panelList.find((p) => p.id === oldPanel.panel.id); + + const allVisualizations = panel!.visualizations; + + const visualizationsWithNewPanel = addVisualizationPanel(vizId, undefined, allVisualizations); + + const updatedPanel = { ...panel, visualizations: visualizationsWithNewPanel }; + dispatch(updatePanel(updatedPanel, '', '')); + }); +}; + +export const addMultipleVizToPanels = (panels, vizIds) => async (dispatch, getState) => { + forEach(panels, (oldPanel) => { + const panel = getState().customPanel.panelList.find((p) => p.id === oldPanel.panel.id); + + const allVisualizations = panel!.visualizations; + + const visualizationsWithNewPanel = addMultipleVisualizations(vizIds, allVisualizations); + + const updatedPanel = { ...panel, visualizations: visualizationsWithNewPanel }; + dispatch(updatePanel(updatedPanel, '', '')); + }); +}; + +export const replaceVizInPanel = (oldPanel, oldVizId, vizId, newVisualizationTitle) => async (dispatch, getState) => { + const panel = getState().customPanel.panelList.find((p) => p.id === oldPanel.id); + + const allVisualizations = panel!.visualizations; + + const visualizationsWithNewPanel = addVisualizationPanel(vizId, oldVizId, allVisualizations); + + const updatedPanel = { ...panel, visualizations: visualizationsWithNewPanel }; + + dispatch(updatePanel(updatedPanel, `Visualization ${newVisualizationTitle} successfully added!`, `Error in adding ${newVisualizationTitle} visualization to the panel`)); +}; + +const deletePanelSO = (customPanelIdList: string[]) => { + const soPanelIds = customPanelIdList.filter((id) => isUuid(id)); + return Promise.all(soPanelIds.map((id) => savedObjectPanelsClient.delete(id))); +}; + +const deleteLegacyPanels = (customPanelIdList: string[]) => { + const panelIds = customPanelIdList.filter((id) => !isUuid(id)); + if (panelIds.length === 0) return; + + const concatList = panelIds.toString(); + return coreRefs.http!.delete(`${CUSTOM_PANELS_API_PREFIX}/panelList/` + concatList); +}; + +export const deletePanels = (panelsToDelete: CustomPanelType[]) => async (dispatch, getState) => { + const toastMessage = `Observability Dashboard${ + panelsToDelete.length > 1 ? 's' : ' ' + panelsToDelete[0].title + } successfully deleted!`; + try { + const ids = panelsToDelete.map((p) => p.id); + await Promise.all([deleteLegacyPanels(ids), deletePanelSO(ids)]); + + const panelList: CustomPanelType[] = getState().customPanel.panelList.filter( + (p) => !ids.includes(p.id) + ); + dispatch(setPanelList(panelList)); + setToast(toastMessage); + } catch (e) { + setToast( + 'Error deleting Observability Dashboards, please make sure you have the correct permission.', + 'danger' + ); + console.error(e); + } +}; + +export const createPanel = (panel) => async (dispatch, getState) => { + try { + const newSOPanel = await savedObjectPanelsClient.create(panel); + const newPanel = savedObjectToCustomPanel(newSOPanel); + const panelList = getState().customPanel.panelList; + dispatch(setPanelList([...panelList, newPanel])); + setToast(`Observability Dashboard "${newPanel.title}" successfully created!`); + window.location.replace(`#/${newPanel.id}`); + } catch (e) { + setToast( + 'Error occurred while creating Observability Dashboard, please make sure you have the correct permission.', + 'danger' + ); + console.error(e); + } +}; + +export const clonePanel = (panel, newPanelName) => async (dispatch, getState) => { + try { + const { id, ...panelCopy } = { + ...panel, + title: newPanelName, + dateCreated: new Date().getTime(), + dateModified: new Date().getTime(), + } as PanelType; + + const newSOPanel = await savedObjectPanelsClient.create(panelCopy); + + const newPanel = savedObjectToCustomPanel(newSOPanel); + const panelList = getState().customPanel.panelList; + dispatch(setPanelList([...panelList, newPanel])); + dispatch(setPanel(newPanel)); + setToast(`Observability Dashboard "${newPanel.title}" successfully created!`); + window.location.replace(`#/${newPanel.id}`); + } catch (e) { + setToast( + 'Error cloning Observability Dashboard, please make sure you have the correct permission.', + 'danger' + ); + console.error(e); + } +}; + +const saveRenamedPanel = async (id, name) => { + const renamePanelObject = { + panelId: id, + panelName: name, + }; + + return http.post(`${CUSTOM_PANELS_API_PREFIX}/panels/rename`, { + body: JSON.stringify(renamePanelObject), + }); +}; + +const saveRenamedPanelSO = async (id, name) => { + const panel: SavedObject = await coreRefs.savedObjectsClient!.get( + CUSTOM_PANELS_SAVED_OBJECT_TYPE, + id + ); + panel.title = name; + await coreRefs.savedObjectsClient!.update(CUSTOM_PANELS_SAVED_OBJECT_TYPE, id, panel); +}; + +// Renames an existing CustomPanel +export const renameCustomPanel = (editedCustomPanelName: string, id: string) => async ( + dispatch, + getState +) => { + const panel = getState().customPanel.panelList.find((p) => p.id === id); + const updatedPanel = { ...panel, title: editedCustomPanelName }; + dispatch(updatePanel(updatedPanel, `Operational Panel successfully renamed into "${editedCustomPanelName}"`, 'Error renaming Operational Panel, please make sure you have the correct permission.')) +}; + +/* + ** UTILITY FUNCTIONS + */ +const savedObjectToCustomPanel = (so: SimpleSavedObject): CustomPanelType => ({ + id: so.id, + ...so.attributes, + savedObject: true, +}); + +const savedObjectPanelsClient = { + find: (options) => + coreRefs.savedObjectsClient!.find({ type: CUSTOM_PANELS_SAVED_OBJECT_TYPE, ...options }), + delete: (id) => coreRefs.savedObjectsClient!.delete(CUSTOM_PANELS_SAVED_OBJECT_TYPE, id), + update: (panel) => + coreRefs.savedObjectsClient!.update(CUSTOM_PANELS_SAVED_OBJECT_TYPE, panel.id, panel), + get: (id) => coreRefs.savedObjectsClient!.get(CUSTOM_PANELS_SAVED_OBJECT_TYPE, id), + create: (panel) => coreRefs.savedObjectsClient!.create(CUSTOM_PANELS_SAVED_OBJECT_TYPE, panel), +}; diff --git a/public/framework/core_refs.ts b/public/framework/core_refs.ts new file mode 100644 index 0000000000..dd2367f19a --- /dev/null +++ b/public/framework/core_refs.ts @@ -0,0 +1,33 @@ +/* + * 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. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { HttpStart, IToasts } from '../../../../src/core/public'; +import { SavedObjectsClientContract } from '../../../../src/core/public'; +import PPLService from '../services/requests/ppl'; + +class CoreRefs { + private static _instance: CoreRefs; + + public http?: HttpStart; + public savedObjectsClient?: SavedObjectsClientContract; + public pplService?: PPLService; + public toasts?: IToasts; + private constructor() { + // ... + } + + public static get Instance() { + // Do you need arguments? Make it a regular static method instead. + return this._instance || (this._instance = new this()); + } +} + +export const coreRefs = CoreRefs.Instance; diff --git a/public/plugin.ts b/public/plugin.ts index 1610c0dbf8..52d6dcf536 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -7,9 +7,34 @@ import './index.scss'; import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '../../../src/core/public'; import { - observabilityID, + AppCategory, + AppMountParameters, + CoreSetup, + CoreStart, + Plugin, +} from '../../../src/core/public'; +import { CREATE_TAB_PARAM, CREATE_TAB_PARAM_KEY, TAB_CHART_ID } from '../common/constants/explorer'; + +import { + observabilityApplicationsID, + observabilityApplicationsPluginOrder, + observabilityApplicationsTitle, + observabilityTracesTitle, + observabilityMetricsID, + observabilityMetricsPluginOrder, + observabilityMetricsTitle, + observabilityNotebookID, + observabilityNotebookPluginOrder, + observabilityNotebookTitle, + observabilityTracesID, + observabilityTracesPluginOrder, + observabilityPanelsID, + observabilityPanelsTitle, + observabilityPanelsPluginOrder, + observabilityLogsID, + observabilityLogsTitle, + observabilityLogsPluginOrder, observabilityPluginOrder, - observabilityTitle, } from '../common/constants/shared'; import PPLService from './services/requests/ppl'; import DSLService from './services/requests/dsl'; @@ -18,10 +43,36 @@ import SavedObjects from './services/saved_objects/event_analytics/saved_objects import { AppPluginStartDependencies, ObservabilitySetup, ObservabilityStart } from './types'; import { convertLegacyNotebooksUrl } from './components/notebooks/components/helpers/legacy_route_helpers'; import { convertLegacyTraceAnalyticsUrl } from './components/trace_analytics/components/common/legacy_route_helpers'; -import { uiSettingsService } from '../common/utils'; -import { QueryManager } from '../common/query_manager'; -export class ObservabilityPlugin implements Plugin { - public setup(core: CoreSetup): ObservabilitySetup { +import { SavedObject } from '../../../src/core/public'; +import { coreRefs } from './framework/core_refs'; + +import { + OBSERVABILITY_EMBEDDABLE, + OBSERVABILITY_EMBEDDABLE_DESCRIPTION, + OBSERVABILITY_EMBEDDABLE_DISPLAY_NAME, + OBSERVABILITY_EMBEDDABLE_ICON, + OBSERVABILITY_EMBEDDABLE_ID, +} from './embeddable/observability_embeddable'; +import { ObservabilityEmbeddableFactoryDefinition } from './embeddable/observability_embeddable_factory'; +import './index.scss'; +import DSLService from './services/requests/dsl'; +import PPLService from './services/requests/ppl'; +import SavedObjects from './services/saved_objects/event_analytics/saved_objects'; +import TimestampUtils from './services/timestamp/timestamp'; +import { + AppPluginStartDependencies, + ObservabilitySetup, + ObservabilityStart, + SetupDependencies, +} from './types'; + +export class ObservabilityPlugin + implements + Plugin { + public setup( + core: CoreSetup, + setupDeps: SetupDependencies + ): ObservabilitySetup { uiSettingsService.init(core.uiSettings, core.notifications); // redirect legacy notebooks URL to current URL under observability @@ -34,13 +85,25 @@ export class ObservabilityPlugin implements Plugin `${BASE_URL}/${obj.id}/edit`, + viewUrlPathFn: (obj: SavedObject) => `${BASE_URL}/${obj.id}`, + createLinkText: 'Observability Dashboard', + createSortText: 'Observability Dashboard', + createUrl: `${BASE_URL}/create`, + }); + + const OBSERVABILITY_APP_CATEGORIES: Record = Object.freeze({ + observability: { + id: 'observability', + label: i18n.translate('core.ui.observabilityNavList.label', { + defaultMessage: 'Observability', + }), + order: observabilityPluginOrder, }, order: observabilityPluginOrder, async mount(params: AppMountParameters) { @@ -68,6 +131,13 @@ export class ObservabilityPlugin implements Plugin ({ })); jest.setTimeout(30000); + +setOSDHttp(coreStartMock.http); +setOSDSavedObjectsClient(coreStartMock.savedObjects.client); +coreRefs.http = coreStartMock.http; +coreRefs.savedObjectsClient = coreStartMock.savedObjects.client; +coreRefs.toasts = coreStartMock.notifications.toasts;