diff --git a/.cypress/.DS_Store b/.cypress/.DS_Store new file mode 100644 index 0000000000..ab1b61f97e Binary files /dev/null and b/.cypress/.DS_Store differ diff --git a/.cypress/integration/3_panels.spec.js b/.cypress/integration/3_panels.spec.ts similarity index 74% rename from .cypress/integration/3_panels.spec.js rename to .cypress/integration/3_panels.spec.ts index 900472fa27..4fb9cf69c6 100644 --- a/.cypress/integration/3_panels.spec.js +++ b/.cypress/integration/3_panels.spec.ts @@ -18,24 +18,6 @@ import { import { suppressResizeObserverIssue } from '../utils/constants'; -const moveToEventsHome = () => { - cy.visit(`${Cypress.env('opensearchDashboards')}/app/observability-dashboards#/event_analytics/`); - cy.wait(delay * 3); -}; - -const moveToPanelHome = () => { - cy.visit( - `${Cypress.env('opensearchDashboards')}/app/observability-dashboards#/operational_panels/` - ); - cy.wait(delay * 3); -}; - -const moveToTestPanel = () => { - moveToPanelHome(); - cy.get('.euiTableCellContent').contains(TEST_PANEL).trigger('mouseover').click(); - cy.wait(delay * 3); - cy.get('h1').contains(TEST_PANEL).should('exist'); -}; describe('Adding sample data and visualization', () => { it('Adds sample flights data for visualization paragraph', () => { @@ -96,19 +78,20 @@ describe('Creating visualizations', () => { }); }); -describe('Testing panels table', () => { +describe.only('Testing panels table', () => { beforeEach(() => { moveToPanelHome(); + eraseTestPanels(); }); it('Displays error toast for invalid panel name', () => { - cy.get('button[data-test-subj="customPanels__createNewPanels"]').click(); - cy.get('button[data-test-subj="runModalButton"]').click(); - cy.get('.euiToastHeader__title').contains('Invalid Operational Panel name').should('exist'); + clickCreatePanelButton(); + confirmModal(); + expectToastWith('Invalid Operational Panel name'); }); it('Creates a panel and redirects to the panel', () => { - cy.get('button[data-test-subj="customPanels__createNewPanels"]').click(); + clickCreatePanelButton(); cy.get('input.euiFieldText').focus().type(TEST_PANEL, { delay: 50, }); @@ -116,16 +99,34 @@ describe('Testing panels table', () => { cy.contains(TEST_PANEL).should('exist'); }); - it('Duplicates a panel', () => { - cy.get('.euiCheckbox__input[title="Select this row"]').eq(0).trigger('mouseover').click(); - cy.get('button[data-test-subj="operationalPanelsActionsButton"]').click(); + it('Duplicates a legacy panel', () => { + createLegacyPanel() + selectThePanel(); + openActionsDropdown(); cy.get('button[data-test-subj="duplicateContextMenuItem"]').click(); cy.get('button[data-test-subj="runModalButton"]').click(); + cy.contains(TEST_PANEL + " (copy)").should('exist'); + const duplicate = testPanelTableCell() + expectUuid(duplicate) }); - it('Renames a panel', () => { - cy.get('.euiCheckbox__input[title="Select this row"]').eq(0).trigger('mouseover').click(); - cy.get('button[data-test-subj="operationalPanelsActionsButton"]').click(); + it.only('Duplicates a legacy panel', () => { + createLegacyPanel() + selectThePanel(); + openActionsDropdown(); + cy.get('button[data-test-subj="duplicateContextMenuItem"]').click(); + cy.get('button[data-test-subj="runModalButton"]').click(); + cy.contains(TEST_PANEL + " (copy)").should('exist'); + const duplicate = testPanelTableCell() + expectUuid(duplicate) + }); + + it('Renames a saved-objects panel', () => { + createSavedObjectPanel() + cy.reload() + + selectThePanel() + openActionsDropdown(); cy.get('button[data-test-subj="renameContextMenuItem"]').click(); cy.get('input.euiFieldText').focus().type(' (rename)', { delay: 50, @@ -133,7 +134,22 @@ describe('Testing panels table', () => { cy.get('button[data-test-subj="runModalButton"]').click(); }); + it('Renames a panel', () => { + createLegacyPanel(); + cy.reload(); + const cell = cy.get('.euiTableCellContent') + expectLegacyId(cell); + selectThePanel() + openActionsDropdown(); + cy.get('button[data-test-subj="renameContextMenuItem"]').click(); + cy.get('input.euiFieldText').focus().type(' (rename)'); + cy.get('button[data-test-subj="runModalButton"]').click(); + const renamed = testPanelTableCell() + expectUuid(renamed) + }); + it('Searches existing panel', () => { + createLegacyPanel() cy.get('input[data-test-subj="operationalPanelSearchBar"]') .focus() .type('this panel should not exist', { @@ -154,9 +170,24 @@ describe('Testing panels table', () => { .should('exist'); }); + it('Deletes saved object panels', () => { + createSavedObjectPanel() + cy.get('input[data-test-subj="checkboxSelectAll"]').click(); + openActionsDropdown(); + cy.get('button[data-test-subj="deleteContextMenuItem"]').click(); + cy.get('button[data-test-subj="popoverModal__deleteButton"]').should('be.disabled'); + + cy.get('input.euiFieldText[placeholder="delete"]').focus().type('delete', { + delay: 50, + }); + cy.get('button[data-test-subj="popoverModal__deleteButton"]').should('not.be.disabled'); + cy.get('button[data-test-subj="popoverModal__deleteButton"]').click(); + cy.get('h2[data-test-subj="customPanels__noPanelsHome"]').should('exist'); + }); + it('Deletes panels', () => { cy.get('input[data-test-subj="checkboxSelectAll"]').click(); - cy.get('button[data-test-subj="operationalPanelsActionsButton"]').click(); + openActionsDropdown(); cy.get('button[data-test-subj="deleteContextMenuItem"]').click(); cy.get('button[data-test-subj="popoverModal__deleteButton"]').should('be.disabled'); @@ -170,7 +201,7 @@ describe('Testing panels table', () => { it('Create a panel for testing', () => { // keep a panel for testing - cy.get('button[data-test-subj="customPanels__createNewPanels"]').click(); + clickCreatePanelButton(); cy.get('input.euiFieldText').focus().type(TEST_PANEL, { delay: 50, }); @@ -470,8 +501,8 @@ describe('Clean up all test data', () => { it('Deletes test panel', () => { moveToPanelHome(); cy.get('.euiCheckbox__input[data-test-subj="checkboxSelectAll"]').trigger('mouseover').click(); - cy.get('button[data-test-subj="operationalPanelsActionsButton"]').click(); - cy.get('button[data-test-subj="deleteContextMenuItem"]').click(); + openActionsDropdown(); + clickDeleteAction(); cy.get('button.euiButton--danger').should('be.disabled'); cy.get('input.euiFieldText[placeholder="delete"]').focus().type('delete', { delay: 50, @@ -482,3 +513,169 @@ describe('Clean up all test data', () => { cy.get('.euiTextAlign').contains('No Operational Panels').should('exist'); }); }); + + +const moveToEventsHome = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/observability-dashboards#/event_analytics/`); + cy.wait(delay * 3); +}; + +const moveToPanelHome = () => { + cy.visit( + `${Cypress.env('opensearchDashboards')}/app/observability-dashboards#/operational_panels/` + , { timeout: 3000 }); + cy.wait(delay * 3); +}; + +const testPanelTableCell = () => cy.get('.euiTableCellContent').contains(TEST_PANEL) + +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 = () => { + 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": TEST_PANEL, + "description": "", + "dateCreated": 1681127334085, + "dateModified": 1681127334085, + "timeRange": { + "to": "now", + "from": "now-1d" + }, + "queryFilter": { + "query": "", + "language": "ppl" + }, + "visualizations": [], + "applicationId": "" + } + } + }).then(response => console.log(response)) + +} + +const createLegacyPanel = () => { + 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: TEST_PANEL } + }) +} + +const expectUuid = (cell) => { + cell.find('a').its('href').should('match', uuidRx) + // const id = url.split('/').slice(-1) + // expect(id).not.to.match(uuidRx) +} + +const expectLegacyId = (cell) => { + cell.find('a').its('href').should('not.match', uuidRx) + // const id = url.split('/').slice(-1) + // expect(id).not.to.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"]').eq(0).trigger('mouseover').click(); +} + +const expectToastWith = (title) => { + cy.get('.euiToastHeader__title').contains(title).should('exist'); +} + +const confirmModal = () => { + cy.get('button[data-test-subj="runModalButton"]').click(); +} + diff --git a/.cypress/plugins/index.js b/.cypress/plugins/index.js index 8ac1f10667..ddc95c953b 100644 --- a/.cypress/plugins/index.js +++ b/.cypress/plugins/index.js @@ -17,10 +17,14 @@ // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) + /** * @type {Cypress.PluginConfig} */ module.exports = (on, config) => { // `on` is used to hook into various events Cypress emits // `config` is the resolved Cypress config + require('cypress-watch-and-reload/plugins')(config) + + return config } diff --git a/.cypress/support/index.js b/.cypress/support/index.js index 6b25b7b27e..2fa692d7a1 100644 --- a/.cypress/support/index.js +++ b/.cypress/support/index.js @@ -18,6 +18,8 @@ // https://on.cypress.io/configuration // *********************************************************** +import 'cypress-watch-and-reload/support' + // Import commands.js using ES2015 syntax: import './commands'; diff --git a/common/constants/custom_panels.ts b/common/constants/custom_panels.ts index 0c02b97a29..1062d63a29 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 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_SAVED_OBJECT_TYPE = 'observability-panel'; + +export const CUSTOM_PANEL_SLICE = 'customPanel'; diff --git a/common/types/custom_panels.ts b/common/types/custom_panels.ts index ca75e41def..a24cbe839f 100644 --- a/common/types/custom_panels.ts +++ b/common/types/custom_panels.ts @@ -4,11 +4,19 @@ */ export interface CustomPanelListType { - name: string; + title: string; id: string; dateCreated: number; dateModified: number; applicationId?: string; + savedObject: boolean; +} + +export interface BoxType { + x1: number; + y1: number; + x2: number; + y2: number; } export interface VisualizationType { @@ -21,13 +29,19 @@ export interface VisualizationType { } export interface PanelType { - name: string; + title: string; + dateCreated: number; + dateModified: number; visualizations: VisualizationType[]; timeRange: { to: string; from: string }; queryFilter: { query: string; language: string }; applicationId?: string; } +export interface CustomPanelType extends PanelType { + id: string; +} + export interface SavedVisualizationType { id: string; name: string; @@ -39,7 +53,7 @@ export interface SavedVisualizationType { user_configs: any; } -export interface pplResponse { +export interface PPLResponse { data: any; metadata: any; size: number; @@ -50,3 +64,20 @@ export interface VizContainerError { errorMessage: string; errorDetails?: string; } + +export interface ObservabilityPanelAttrs { + title: string; + description: string; + dateCreated: number; + dateModified: number; + timeRange: { + to: string; + from: string; + }; + queryFilter: { + query: string; + language: string; + }; + visualizations: VisualizationType[]; + applicationId: string; +} diff --git a/cypress.json b/cypress.json index dbe41c79b7..e06125f702 100644 --- a/cypress.json +++ b/cypress.json @@ -1,7 +1,7 @@ { - "baseUrl": "http://localhost:5601", + "baseUrl": "http://localhost:5602", "video": true, - "chromeWebSecurity": false, + "chromeWebSecurity": true, "fixturesFolder": ".cypress/fixtures", "integrationFolder": ".cypress/integration", "pluginsFile": ".cypress/plugins/index.js", @@ -17,7 +17,10 @@ "experimentalNetworkStubbing": true, "env": { "opensearch": "localhost:9200", - "opensearchDashboards": "localhost:5601", + "opensearchDashboards": "localhost:5602", "security_enabled": true + }, + "cypress-watch-and-reload": { + "watch": ["common/**", "public/**", "server/**"] } } diff --git a/package.json b/package.json index cdd01ec2c6..0b71cea6b9 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "@types/react-test-renderer": "^16.9.1", "antlr4ts-cli": "^0.5.0-alpha.4", "cypress": "^6.0.0", + "cypress-watch-and-reload": "^1.10.6", "eslint": "^6.8.0", "husky": "6.0.0", "jest-dom": "^4.0.0", diff --git a/public/components/app.tsx b/public/components/app.tsx index 74c1dc4d90..3e2db80427 100644 --- a/public/components/app.tsx +++ b/public/components/app.tsx @@ -57,7 +57,7 @@ export const App = ({ queryManager, startPage, }: ObservabilityAppDeps) => { - const { chrome, http, notifications } = CoreStartProp; + const { chrome, http, notifications, savedObjects: coreSavedObjects } = CoreStartProp; const parentBreadcrumb = { text: observabilityTitle, href: `${observabilityID}#/`, diff --git a/public/components/custom_panels/custom_panel_table.tsx b/public/components/custom_panels/custom_panel_table.tsx index 50054523ad..c8956b86a2 100644 --- a/public/components/custom_panels/custom_panel_table.tsx +++ b/public/components/custom_panels/custom_panel_table.tsx @@ -32,6 +32,9 @@ import { import React, { ReactElement, useEffect, useState } from 'react'; import moment from 'moment'; import _ from 'lodash'; +import { useHistory, useLocation } from 'react-router-dom'; +import { coreRefs } from 'public/framework/core_refs'; +import { useDispatch, useSelector } from 'react-redux'; import { ChromeBreadcrumb } from '../../../../../src/core/public'; import { CREATE_PANEL_MESSAGE, @@ -43,7 +46,7 @@ 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'; -import { useHistory, useLocation } from 'react-router-dom'; +import { createPanel, fetchPanels, renameCustomPanel, selectPanelList } from './redux/panel_slice'; /* * "CustomPanelTable" module, used to view all the saved panels @@ -62,12 +65,9 @@ import { useHistory, useLocation } from 'react-router-dom'; interface Props { loading: boolean; - fetchCustomPanels: () => void; - customPanels: CustomPanelListType[]; createCustomPanel: (newCustomPanelName: string) => void; setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; parentBreadcrumbs: EuiBreadcrumb[]; - renameCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; cloneCustomPanel: (newCustomPanelName: string, customPanelId: string) => void; deleteCustomPanelList: (customPanelIdList: string[], toastMessage: string) => any; addSamplePanels: () => void; @@ -75,16 +75,14 @@ interface Props { export const CustomPanelTable = ({ loading, - fetchCustomPanels, - customPanels, createCustomPanel, setBreadcrumbs, parentBreadcrumbs, - renameCustomPanel, cloneCustomPanel, deleteCustomPanelList, addSamplePanels, }: Props) => { + const customPanels = useSelector(selectPanelList); const [isModalVisible, setIsModalVisible] = useState(false); // Modal Toggle const [modalLayout, setModalLayout] = useState(); // Modal Layout const [isActionsPopoverOpen, setIsActionsPopoverOpen] = useState(false); @@ -93,15 +91,21 @@ export const CustomPanelTable = ({ const location = useLocation(); const history = useHistory(); + const dispatch = useDispatch(); + useEffect(() => { setBreadcrumbs(parentBreadcrumbs); - fetchCustomPanels(); + dispatch(fetchPanels()); }, []); + // useEffect(() => + // console.log({ customPanels, selectedCustomPanels }, [customPanels, selectedCustomPanels]) + // ); + useEffect(() => { - const url = window.location.hash.split('/') - if (url[url.length-1] === 'create') { - createPanel(); + const url = window.location.hash.split('/'); + if (url[url.length - 1] === 'create') { + createPanelModal(); } }, [location]); @@ -119,30 +123,36 @@ export const CustomPanelTable = ({ }; const onRename = async (newCustomPanelName: string) => { - renameCustomPanel(newCustomPanelName, selectedCustomPanels[0].id); + dispatch(renameCustomPanel(newCustomPanelName, selectedCustomPanels[0].id)); closeModal(); }; const onClone = async (newName: string) => { - cloneCustomPanel(newName, selectedCustomPanels[0].id); + const sourcePanel = selectedCustomPanels[0]; + console.log('onClone', { sourcePanel }); + if (sourcePanel.savedObject) { + dispatch(createPanel({ ...sourcePanel, name: sourcePanel.name + ' (copy)', id: undefined })); + } else { + cloneCustomPanel(newName, selectedCustomPanels[0].id); + } closeModal(); }; const onDelete = async () => { const toastMessage = `Custom Panels ${ - selectedCustomPanels.length > 1 ? 's' : ' ' + selectedCustomPanels[0].name + selectedCustomPanels.length > 1 ? 's' : ' ' + selectedCustomPanels[0].title } successfully deleted!`; const PanelList = selectedCustomPanels.map((panel) => panel.id); deleteCustomPanelList(PanelList, toastMessage); closeModal(); }; - const createPanel = () => { + const createPanelModal = () => { setModalLayout( getCustomModal( onCreate, () => { - closeModal() + closeModal(); history.goBack(); }, 'Name', @@ -222,7 +232,7 @@ export const CustomPanelTable = ({ ); - const popoverItems: ReactElement[] = [ + const popoverItems = (): ReactElement[] => [ >; + // console.log('rendering', { customPanels, selectedCustomPanels }); return (
@@ -332,13 +343,13 @@ export const CustomPanelTable = ({ isOpen={isActionsPopoverOpen} closePopover={() => setIsActionsPopoverOpen(false)} > - + Create panel @@ -363,7 +374,7 @@ export const CustomPanelTable = ({ items={ searchQuery ? customPanels.filter((customPanel) => - customPanel.name.toLowerCase().includes(searchQuery.toLowerCase()) + customPanel.title.toLowerCase().includes(searchQuery.toLowerCase()) ) : customPanels } diff --git a/public/components/custom_panels/custom_panel_view.tsx b/public/components/custom_panels/custom_panel_view.tsx index 41766d4bd8..9f84ac5a0e 100644 --- a/public/components/custom_panels/custom_panel_view.tsx +++ b/public/components/custom_panels/custom_panel_view.tsx @@ -2,7 +2,6 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -/* eslint-disable no-console */ /* eslint-disable react-hooks/exhaustive-deps */ import { @@ -30,6 +29,8 @@ import { last } from 'lodash'; import React, { useEffect, useState } from 'react'; import { DurationRange } from '@elastic/eui/src/components/date_picker/types'; import moment from 'moment'; +import _ from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; import DSLService from '../../services/requests/dsl'; import { CoreStart } from '../../../../../src/core/public'; import { EmptyPanelView } from './panel_modules/empty_panel'; @@ -48,9 +49,10 @@ import PPLService from '../../services/requests/ppl'; import { isDateValid, convertDateTime, - onTimeChange, + prependRecentlyUsedRange as onTimeChange, isPPLFilterValid, fetchVisualizationById, + prependRecentlyUsedRange, } from './helpers/utils'; import { UI_DATE_FORMAT } from '../../../common/constants/shared'; import { VisaulizationFlyout } from './panel_modules/visualization_flyout'; @@ -64,7 +66,7 @@ import { } from '../common/search/autocomplete_logic'; import { AddVisualizationPopover } from './helpers/add_visualization_popover'; import { DeleteModal } from '../common/helpers/delete_modal'; -import _ from 'lodash'; +import { selectPanel, updatePanel } from './redux/panel_slice'; /* * "CustomPanelsView" module used to render an Operational Panel @@ -142,6 +144,10 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { onEditClick, onAddClick, } = props; + + const dispatch = useDispatch(); + const panel = useSelector(selectPanel); + const [openPanelName, setOpenPanelName] = useState(''); const [panelCreatedTime, setPanelCreatedTime] = useState(''); const [pplFilterValue, setPPLFilterValue] = useState(''); @@ -208,14 +214,14 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { }; const onDatePickerChange = (timeProps: OnTimeChangeProps) => { - onTimeChange( + const updatedRanges = prependRecentlyUsedRange( timeProps.start, timeProps.end, - recentlyUsedRanges, - setRecentlyUsedRanges, - setStartTime, - setEndTime + recentlyUsedRanges ); + dispatch(updatePanel({ ...panel, timeRange: { from: timeProps.start, to: timeProps.end } })); + + setRecentlyUsedRanges(updatedRanges.slice(0, 9)); onRefreshFilters(timeProps.start, timeProps.end); }; @@ -637,8 +643,8 @@ export const CustomPanelView = (props: CustomPanelViewProps) => { Promise; + cloneCustomPanel: (clonedCustomPanelName: string, clonedCustomPanelId: string) => Promise; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; + onEditClick: (savedVisualizationId: string) => any; + childBreadcrumbs?: EuiBreadcrumb[]; + appId?: string; + updateAvailabilityVizId?: any; + onAddClick?: any; +} + +export const CustomPanelViewSO = (props: CustomPanelViewProps) => { + const { + panelId, + page, + appId, + pplService, + dslService, + chrome, + parentBreadcrumbs, + childBreadcrumbs, + updateAvailabilityVizId, + deleteCustomPanel, + cloneCustomPanel, + setToast, + onEditClick, + onAddClick, + } = props; + + const dispatch = useDispatch(); + + const panel = useSelector(selectPanel); + + 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 () => { + deleteCustomPanel(panelId, panel?.title).then((res) => { + setTimeout(() => { + window.location.assign(`${last(parentBreadcrumbs)!.href}`); + }, 1000); + }); + closeModal(); + }; + + const deletePanel = () => { + setModalLayout( + + ); + showModal(); + }; + + const renameCustomPanel = (editedCustomPanelName: string) => { + if (!isNameValid(editedCustomPanelName)) { + setToast('Invalid Custom Panel name', 'danger'); + return Promise.reject(); + } + + const updatedPanel = { ...panel, name: editedCustomPanelName }; + + return coreRefs.savedObjectsClient + .update(CUSTOM_PANELS_SAVED_OBJECT_TYPE, panel.id, panel) + .then((res) => { + setOpenPanelName(editedCustomPanelName); + setToast(`Operational Panel successfully renamed into "${editedCustomPanelName}"`); + }) + .catch((err) => { + setToast( + 'Error renaming Operational Panel, please make sure you have the correct permission.', + 'danger' + ); + console.error(err.body.message); + }); + }; + const onRename = async (newCustomPanelName: string) => { + const newPanel = { ...panel, title: newCustomPanelName }; + dispatch(updatePanel(newPanel)); + closeModal(); + }; + + const renamePanel = () => { + setModalLayout( + getCustomModal( + onRename, + closeModal, + 'Name', + 'Rename Panel', + 'Cancel', + 'Rename', + panel.title, + CREATE_PANEL_MESSAGE + ) + ); + showModal(); + }; + + const onClone = async (newCustomPanelName: string) => { + cloneCustomPanel(newCustomPanelName, panelId).then((id: string) => { + window.location.assign(`${last(parentBreadcrumbs)!.href}${id}`); + }); + closeModal(); + }; + + const clonePanel = () => { + setModalLayout( + getCustomModal( + onClone, + closeModal, + 'Name', + 'Duplicate Panel', + 'Cancel', + 'Duplicate', + panel.title + ' (copy)', + CREATE_PANEL_MESSAGE + ) + ); + showModal(); + }; + + // toggle between panel edit mode + + const startEdit = () => { + setIsEditing(true); + }; + + const applyEdits = () => { + console.log('applyEdits', panel); + dispatch(updatePanel(panel)); + setIsEditing(false); + }; + + const cancelEdit = () => { + console.log('cancelEdits'); + dispatch(fetchPanel(panelId)); + setIsEditing(false); + }; + + 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 = (start: ShortDate, end: ShortDate) => { + if (!isDateValid(convertDateTime(start), convertDateTime(end, false), setToast)) { + return; + } + + if (!isPPLFilterValid(pplFilterValue, setToast)) { + console.log(pplFilterValue); + return; + } + + const panelFilterBody = { + panelId, + query: pplFilterValue, + language: 'ppl', + to: end, + from: start, + }; + + setOnRefresh(!onRefresh); + }; + + const cloneVisualization = (visualzationTitle: string, savedVisualizationId: string) => { + // http + // .post(`${CUSTOM_PANELS_API_PREFIX}/visualizations`, { + // body: JSON.stringify({ + // panelId, + // savedVisualizationId, + // }), + // }) + // .then(async (res) => { + // setPanelVisualizations(res.visualizations); + // setToast(`Visualization ${visualzationTitle} successfully added!`, 'success'); + // }) + // .catch((err) => { + // setToast(`Error in adding ${visualzationTitle} visualization to the panel`, 'danger'); + // console.error(err); + // }); + }; + + const cancelButton = ( + + Cancel + + ); + + const saveButton = ( + + Save + + ); + + const editButton = ( + + Edit + + ); + + const addButton = ( + + Add + + ); + + // Panel Actions Button + const panelActionsButton = ( + setPanelsMenuPopover(true)} + disabled={addVizDisabled} + > + Panel actions + + ); + + const addVisualizationToCurrentPanel = async ({ + savedVisualizationId, + oldVisualizationId, + }: { + savedVisualizationId: string; + oldVisualizationId?: string; + }) => { + const allVisualizations = panel!.visualizations; + + const visualizationsWithNewPanel = addVisualizationPanel( + savedVisualizationId, + oldVisualizationId, + allVisualizations + ); + + const updatedPanel = { ...panel, visualizations: visualizationsWithNewPanel }; + try { + dispatch(updatePanel(updatedPanel)); + } catch (err) { + setToast('Error adding visualization to this panel', 'danger'); + console.error(err?.body?.message || err); + } + }; + + 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 panel', + 'data-test-subj': 'reloadPanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + dispatch(fetchPanel(panelId)); + }, + }, + { + name: 'Rename panel', + 'data-test-subj': 'renamePanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + renamePanel(); + }, + }, + { + name: 'Duplicate panel', + 'data-test-subj': 'duplicatePanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + clonePanel(); + }, + }, + { + name: 'Delete panel', + 'data-test-subj': 'deletePanelContextMenuItem', + onClick: () => { + setPanelsMenuPopover(false); + deletePanel(); + }, + }, + ], + }, + ]; + // Fetch the custom panel on Initial Mount + useEffect(() => { + 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(() => { + checkDisabledInputs(); + }, [isEditing]); + + // Build base query with all of the indices included in the current visualizations + useEffect(() => { + checkDisabledInputs(); + buildBaseQuery(); + }, [panel]); + + // 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 ( +
+ + + + {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/helpers/add_visualization_helper.ts b/public/components/custom_panels/helpers/add_visualization_helper.ts new file mode 100644 index 0000000000..9b7b5c2369 --- /dev/null +++ b/public/components/custom_panels/helpers/add_visualization_helper.ts @@ -0,0 +1,98 @@ +import { v4 as uuidv4 } from 'uuid'; +import { BoxType, VisualizationType } from '../../../../common/types/custom_panels'; + +const calculatOverlapArea = (bb1: BoxType, bb2: BoxType) => { + const xLeft = Math.max(bb1.x1, bb2.x1); + const yTop = Math.max(bb1.y1, bb2.y1); + const xRight = Math.min(bb1.x2, bb2.x2); + const yBottom = Math.min(bb1.y2, bb2.y2); + + if (xRight < xLeft || yBottom < yTop) return 0; + return (xRight - xLeft) * (yBottom - yTop); +}; + +const getTotalOverlapArea = (panelVisualizations: VisualizationType[]) => { + const newVizBox = { x1: 0, y1: 0, x2: 6, y2: 4 }; + const currentVizBoxes = panelVisualizations.map((visualization) => { + return { + x1: visualization.x, + y1: visualization.y, + x2: visualization.x + visualization.w, + y2: visualization.y + visualization.h, + }; + }); + + let isOverlapping = 0; + currentVizBoxes.map((viz) => { + isOverlapping += calculatOverlapArea(viz, newVizBox); + }); + return isOverlapping; +}; + +// We want to check if the new visualization being added, can be placed at { x: 0, y: 0, w: 6, h: 4 }; +// To check this we try to calculate overlap between all the current visualizations and new visualization +// if there is no overalap (i.e Total Overlap Area is 0), we place the new viz. in default position +// else, we add it to the bottom of the panel +const getNewVizDimensions = (panelVisualizations: VisualizationType[]) => { + let maxY: number = 0; + let maxYH: number = 0; + + // check if we can place the new visualization at default location + if (getTotalOverlapArea(panelVisualizations) === 0) { + return { x: 0, y: 0, w: 6, h: 4 }; + } + + // else place the new visualization at the bottom of the panel + 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 +export const addVisualizationPanel = ( + // client: ILegacyScopedClusterClient, + // panelId: string, + savedVisualizationId: string, + oldVisualizationId?: string, + allPanelVisualizations: VisualizationType[] +) => { + try { + // const allPanelVisualizations = await this.getVisualizations(client, panelId); + + let newDimensions; + let visualizationsList = [] as VisualizationType[]; + if (oldVisualizationId === undefined) { + newDimensions = 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, + { + id: 'panel_viz_' + uuidv4(), + savedVisualizationId, + ...newDimensions, + }, + ]; + return newPanelVisualizations; + } catch (error) { + throw new Error('Add/Replace Visualization Error:' + error); + } +}; diff --git a/public/components/custom_panels/helpers/panel_state_reducer.ts b/public/components/custom_panels/helpers/panel_state_reducer.ts new file mode 100644 index 0000000000..1fd5bf7aeb --- /dev/null +++ b/public/components/custom_panels/helpers/panel_state_reducer.ts @@ -0,0 +1,15 @@ +import { CUSTOM_PANELS_SAVED_OBJECT_TYPE } from "common/constants/custom_panels" +import { coreRefs } from "public/framework/core_refs" + + +const FETCH = 'fetch' + +/* +** ACTIONS +*/ +const fetchPanel = (id) => ({ type: FETCH, id }) + +export const Actions = { fetchPanel } + + + diff --git a/public/components/custom_panels/helpers/utils.tsx b/public/components/custom_panels/helpers/utils.tsx index 3126f0899f..16aa3e6bcc 100644 --- a/public/components/custom_panels/helpers/utils.tsx +++ b/public/components/custom_panels/helpers/utils.tsx @@ -370,25 +370,19 @@ export const renderCatalogVisualization = async ( }; // Function to store recently used time filters and set start and end time. -export const onTimeChange = ( +export const prependRecentlyUsedRange = ( start: ShortDate, end: ShortDate, - recentlyUsedRanges: DurationRange[], - setRecentlyUsedRanges: React.Dispatch>, - setStart: React.Dispatch>, - setEnd: React.Dispatch> + recentlyUsedRanges: DurationRange[] ) => { - const recentlyUsedRangeObject = recentlyUsedRanges.filter((recentlyUsedRange) => { - const isDuplicate = recentlyUsedRange.start === start && recentlyUsedRange.end === end; - return !isDuplicate; - }); - - recentlyUsedRangeObject.unshift({ start, end }); - setStart(start); - setEnd(end); - setRecentlyUsedRanges(recentlyUsedRangeObject.slice(0, 9)); + const deduplicatedRanges = rejectRecentRange(recentlyUsedRanges, { start, end }); + + return [{ start, end }, ...deduplicatedRanges]; }; +const rejectRecentRange = (rangeList, toReject) => { + return rangeList.filter((r) => !(r.start === toReject.start && r.end === toReject.end)); +}; /** * Convert an ObservabilitySavedVisualization into SavedVisualizationType, * which is used in panels. diff --git a/public/components/custom_panels/home.tsx b/public/components/custom_panels/home.tsx index bf5f127131..2d31001837 100644 --- a/public/components/custom_panels/home.tsx +++ b/public/components/custom_panels/home.tsx @@ -8,25 +8,41 @@ import { Toast } from '@elastic/eui/src/components/toast/global_toast_list'; import _ from 'lodash'; import React, { ReactChild, useState } from 'react'; // eslint-disable-next-line @osd/eslint/module_migration -import { StaticContext, Switch } from 'react-router'; -import { Route, RouteComponentProps, useHistory } from 'react-router-dom'; +import { StaticContext } from 'react-router'; +import { Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { map, mergeMap, tap, toArray } from 'rxjs/operators'; +import { concat, from, Observable, of } from 'rxjs'; import PPLService from '../../services/requests/ppl'; import DSLService from '../../services/requests/dsl'; -import { CoreStart } from '../../../../../src/core/public'; +import { CoreStart, SavedObjectsStart } from '../../../../../src/core/public'; + import { CUSTOM_PANELS_API_PREFIX, CUSTOM_PANELS_DOCUMENTATION_URL, + CUSTOM_PANELS_SAVED_OBJECT_TYPE, } from '../../../common/constants/custom_panels'; import { EVENT_ANALYTICS, OBSERVABILITY_BASE, SAVED_OBJECTS, } from '../../../common/constants/shared'; -import { CustomPanelListType } from '../../../common/types/custom_panels'; +import { + CustomPanelListType, + ObservabilityPanelAttrs, + PanelType, +} from '../../../common/types/custom_panels'; import { ObservabilitySideBar } from '../common/side_nav'; import { CustomPanelTable } from './custom_panel_table'; import { CustomPanelView } from './custom_panel_view'; import { isNameValid } from './helpers/utils'; +import { SavedObject } from '../../../../../src/core/types'; +import { CustomPanelViewSO } from './custom_panel_view_so'; +import { coreRefs } from '../../framework/core_refs'; +import { CustomPanelType } from '../../../common/types/custom_panels'; +import { fetchPanels } from './redux/panel_slice'; +import { useDispatch } from 'react-redux'; + +// import { ObjectFetcher } from '../common/objectFetcher'; /* * "Home" module is initial page for Operantional Panels @@ -46,6 +62,7 @@ interface PanelHomeProps { pplService: PPLService; dslService: DSLService; renderProps: RouteComponentProps; + coreSavedObjects: SavedObjectsStart; } export const Home = ({ @@ -55,14 +72,15 @@ export const Home = ({ pplService, dslService, renderProps, + coreSavedObjects, }: PanelHomeProps) => { - const [customPanelData, setcustomPanelData] = useState([]); const [toasts, setToasts] = useState([]); const [loading, setLoading] = useState(false); const [toastRightSide, setToastRightSide] = useState(true); const [start, setStart] = useState(''); const [end, setEnd] = useState(''); - const history = useHistory(); + + const dispatch = useDispatch() const setToast = (title: string, color = 'success', text?: ReactChild, side?: string) => { if (!text) text = ''; @@ -74,37 +92,35 @@ export const Home = ({ window.location.assign(`#/event_analytics/explorer/${savedVisualizationId}`); }; - // Fetches all saved Custom Panels - const fetchCustomPanels = () => { - setLoading(true); - http - .get(`${CUSTOM_PANELS_API_PREFIX}/panels`) - .then((res) => { - setcustomPanelData(res.panels); - }) - .catch((err) => { - console.error('Issue in fetching the operational panels', err.body.message); - }); - setLoading(false); - }; - // Creates a new CustomPanel - const createCustomPanel = (newCustomPanelName: string) => { + const createCustomPanel = async (newCustomPanelName: string) => { if (!isNameValid(newCustomPanelName)) { setToast('Invalid Operational Panel name', 'danger'); - window.location.assign(`${_.last(parentBreadcrumbs)!.href}`); return; } - return http - .post(`${CUSTOM_PANELS_API_PREFIX}/panels`, { - body: JSON.stringify({ - panelName: newCustomPanelName, - }), - }) + const newPanel: ObservabilityPanelAttrs = { + title: newCustomPanelName, + description: '', + dateCreated: new Date().getTime(), + dateModified: new Date().getTime(), + timeRange: { + to: 'now', + from: 'now-1d', + }, + queryFilter: { + query: '', + language: 'ppl', + }, + visualizations: [], + applicationId: '', + }; + + return coreSavedObjects.client + .create('observability-panel', newPanel, {}) .then(async (res) => { setToast(`Operational Panel "${newCustomPanelName}" successfully created!`); - window.location.assign(`${_.last(parentBreadcrumbs)!.href}${res.newPanelId}`); + window.location.assign(`${_.last(parentBreadcrumbs)!.href}${res.id}`); }) .catch((err) => { setToast( @@ -114,47 +130,31 @@ export const Home = ({ Documentation ); - console.error(err); + console.error('create error', err); }); }; - // Renames an existing CustomPanel - const renameCustomPanel = (editedCustomPanelName: string, editedCustomPanelId: string) => { - if (!isNameValid(editedCustomPanelName)) { - setToast('Invalid Custom Panel name', 'danger'); - return Promise.reject(); - } - const renamePanelObject = { - panelId: editedCustomPanelId, - panelName: editedCustomPanelName, - }; + 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}$/; - return http - .post(`${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 - ); - if (renamedCustomPanel) renamedCustomPanel.name = editedCustomPanelName; - return newCustomPanelData; - }); - setToast(`Operational Panel successfully renamed into "${editedCustomPanelName}"`); - }) - .catch((err) => { - setToast( - 'Error renaming Operational Panel, please make sure you have the correct permission.', - 'danger' - ); - console.error(err.body.message); - }); + const isUuid = (id) => !!id.match(uuidRx); + + + const fetchSavedObjectPanel = async (id: string) => { + const soPanel = await coreRefs.savedObjectsClient?.get(CUSTOM_PANELS_SAVED_OBJECT_TYPE, id); + return savedObjectToCustomPanel(soPanel); + }; + + // Fetch Panel by id + const fetchLegacyPanel = async (id: string) => { + return http.get(`${CUSTOM_PANELS_API_PREFIX}/panels/${id}`); + // .then((res) => res.operationalPanel) + // .catch((err) => { + // console.error('Issue in fetching the operational panel to duplicate', err); + // }); }; // Clones an existing Custom Panel, return new Custom Panel id - const cloneCustomPanel = ( + const cloneCustomPanel = async ( clonedCustomPanelName: string, clonedCustomPanelId: string ): Promise => { @@ -162,80 +162,104 @@ export const Home = ({ setToast('Invalid Operational Panel name', 'danger'); return Promise.reject(); } - const clonePanelObject = { - panelId: clonedCustomPanelId, - panelName: clonedCustomPanelName, - }; - return http - .post(`${CUSTOM_PANELS_API_PREFIX}/panels/clone`, { - body: JSON.stringify(clonePanelObject), - }) - .then((res) => { - setcustomPanelData((prevCustomPanelData) => { - return [ - ...prevCustomPanelData, - { - name: clonedCustomPanelName, - id: res.clonePanelId, - dateCreated: res.dateCreated, - dateModified: res.dateModified, - }, - ]; - }); - setToast(`Operational Panel "${clonedCustomPanelName}" successfully created!`); - return res.clonePanelId; - }) - .catch((err) => { - setToast( - 'Error cloning Operational Panel, please make sure you have the correct permission.', - 'danger' - ); - console.error(err.body.message); - }); + const fetchPanelFn = isUuid(clonedCustomPanelId) ? fetchSavedObjectPanel : fetchLegacyPanel; + + try { + // const panelToClone = await fetchPanelfn(clonedCustomPanelId) + + // const newPanel: PanelType = { + // ...panelToClone, + // title: clonedCustomPanelName, + // dateCreated: new Date().getTime(), + // dateModified: new Date().getTime() + // } + + // const clonedPanel: CustomPanelType = await coreRefs.savedObjectsClient!.create( + // CUSTOM_PANELS_SAVED_OBJECT_TYPE, newPanel, { id: panelToClone.id } + // ) + + + // setcustomPanelData((prevCustomPanelData) => { + // const newPanelData = [ + // ...prevCustomPanelData, + // { + // id: clonedPanel.id, + // title: clonedCustomPanelName, + // dateCreated: clonedPanel.dateCreated, + // dateModified: clonedPanel.dateModified, + // }, + // ]; + // console.log("setcustomPanelData", newPanelData) + // return newPanelData + // }); + // setToast(`Operational Panel "${clonedCustomPanelName}" successfully created!`); + // return clonedPanel.id; + } catch (err) { + setToast( + 'Error cloning Operational Panel, please make sure you have the correct permission.', + 'danger' + ); + } + + console.error(err.body.message); + }; + + const deletePanelSO = (customPanelIdList: string[]) => { + const soPanelIds = customPanelIdList.filter((id) => id.match(uuidRx)); + return Promise.all( + soPanelIds.map((id) => + coreRefs.savedObjectsClient?.delete(CUSTOM_PANELS_SAVED_OBJECT_TYPE, id) + ) + ); + }; + + const deletePanels = (customPanelIdList: string[]) => { + const panelIds = customPanelIdList.filter((id) => !id.match(uuidRx)); + const concatList = panelIds.toString(); + return http.delete(`${CUSTOM_PANELS_API_PREFIX}/panelList/` + concatList); }; // Deletes multiple existing Operational Panels const deleteCustomPanelList = (customPanelIdList: string[], toastMessage: string) => { - const concatList = customPanelIdList.toString(); - return http - .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); - }); + // Promise.all([ + // deletePanelSO(customPanelIdList), + // deletePanels(customPanelIdList) + // ]).then((res) => { + // setcustomPanelData((prevCustomPanelData) => { + // return prevCustomPanelData.filter( + // (customPanel) => !customPanelIdList.includes(customPanel.id) + // ); + // }); + // setToast(toastMessage); + // }) + // .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); - }); - setToast(`Operational Panel "${customPanelName}" successfully deleted!`); - return res; - }) - .catch((err) => { - setToast( - 'Error deleting Operational Panel, please make sure you have the correct permission.', - 'danger' - ); - console.error(err.body.message); - }); + const deleteCustomPanel = async (customPanelId: string, customPanelName: string) => { + // return http + // .delete(`${CUSTOM_PANELS_API_PREFIX}/panels/` + customPanelId) + // .then((res) => { + // setcustomPanelData((prevCustomPanelData) => { + // return prevCustomPanelData.filter((customPanel) => customPanel.id !== customPanelId); + // }); + // setToast(`Operational Panel "${customPanelName}" successfully deleted!`); + // return res; + // }) + // .catch((err) => { + // setToast( + // 'Error deleting Operational Panel, please make sure you have the correct permission.', + // 'danger' + // ); + // console.error(err.body.message); + // }); }; const addSamplePanels = async () => { @@ -277,12 +301,13 @@ export const Home = ({ }), }) .then((res) => { - setcustomPanelData([...customPanelData, ...res.demoPanelsData]); + dispatch(fetchPanels()) + // setcustomPanelData([...customPanelData, ...res.demoPanelsData]); }); setToast(`Sample panels successfully added.`); } catch (err: any) { setToast('Error adding sample panels.', 'danger'); - console.error(err.body.message); + console.error(err.body?.message || err); } finally { setLoading(false); } @@ -304,25 +329,38 @@ export const Home = ({ path={['/operational_panels/create', '/operational_panels']} render={(props) => { return ( - + + + ); }} /> { - return ( + const isSavedObject = !!props.match.params.id.match(uuidRx); + + return isSavedObject ? ( + + ) : ( ); }} diff --git a/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx b/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx new file mode 100644 index 0000000000..cd7bb74e98 --- /dev/null +++ b/public/components/custom_panels/panel_modules/panel_grid/panel_grid_so.tsx @@ -0,0 +1,206 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* eslint-disable react-hooks/exhaustive-deps */ + +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 { VisualizationContainer } from '../visualization_container'; +import { VisualizationType } from '../../../../../common/types/custom_panels'; +import { CUSTOM_PANELS_API_PREFIX } from '../../../../../common/constants/custom_panels'; +import './panel_grid.scss'; +import { mergeLayoutAndVisualizations } from '../../helpers/utils'; +import { coreRefs } from '../../../../framework/core_refs'; + +// HOC container to provide dynamic width for Grid layout +const ResponsiveGridLayout = WidthProvider(Responsive); + +/* + * PanelGrid - This module is places all visualizations in react-grid-layout + * + * Props taken in as params are: + * chrome: chrome core service; + * panelId: OpenPanel Id + * updateAvailabilityVizId: function to update application if availabilityViz is removed from panel + * panelVisualizations: list of panel visualizations + * setPanelVisualizations: function to set panel visualizations + * editMode: boolean to check if the panel is in edit mode + * 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 + * editActionType: Type of action done while clicking the edit button + */ + +interface PanelGridProps { + chrome: CoreStart['chrome']; + panelId: string; + updateAvailabilityVizId?: any; + panelVisualizations: VisualizationType[]; + setPanelVisualizations: React.Dispatch>; + editMode: boolean; + startTime: string; + endTime: string; + onEditClick: (savedVisualizationId: string) => any; + onRefresh: boolean; + cloneVisualization: (visualzationTitle: string, savedVisualizationId: string) => void; + pplFilterValue: string; + showFlyout: (isReplacement?: boolean | undefined, replaceVizId?: string | undefined) => void; + editActionType: string; + setEditVizId?: any; +} + +export const PanelGridSO = (props: PanelGridProps) => { + const { + chrome, + panelId, + updateAvailabilityVizId, + panelVisualizations, + setPanelVisualizations, + editMode, + startTime, + endTime, + onEditClick, + onRefresh, + cloneVisualization, + pplFilterValue, + showFlyout, + editActionType, + } = props; + const [currentLayout, setCurrentLayout] = useState([]); + const [postEditLayout, setPostEditLayout] = useState([]); + const [gridData, setGridData] = useState(panelVisualizations.map(() => <>)); + const isLocked = useObservable(chrome.getIsNavDrawerLocked$()); + + // Reset Size of Visualizations when layout is changed + const layoutChanged = (currLayouts: Layout[], allLayouts: Layouts) => { + window.dispatchEvent(new Event('resize')); + setPostEditLayout(currLayouts); + }; + + const loadVizComponents = () => { + const gridDataComps = panelVisualizations.map( + (panelVisualization: VisualizationType, index) => ( + + ) + ); + setGridData(gridDataComps); + }; + + // Reload the Layout + const reloadLayout = () => { + const tempLayout: Layout[] = panelVisualizations.map((panelVisualization) => { + return { + i: panelVisualization.id, + x: panelVisualization.x, + y: panelVisualization.y, + w: panelVisualization.w, + h: panelVisualization.h, + static: !editMode, + } as Layout; + }); + setCurrentLayout(tempLayout); + }; + + // remove visualization from panel in edit mode + const removeVisualization = (visualizationId: string) => { + const newVisualizationList = _.reject(panelVisualizations, { + id: visualizationId, + }); + console.log('removeVisualization', newVisualizationList); + mergeLayoutAndVisualizations(postEditLayout, newVisualizationList, setPanelVisualizations); + }; + + // Save Visualization Layouts when not in edit mode anymore (after users saves the panel) + const saveVisualizationLayouts = async (panelID: string, visualizationParams: any) => { + return 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); + }); + }; + + // Update layout whenever user edit gets completed + useEffect(() => { + if (editMode) { + reloadLayout(); + loadVizComponents(); + } + }, [editMode]); + + useEffect(() => { + if (editActionType === 'save') { + const visualizationParams = postEditLayout.map((layout) => + _.omit(layout, ['static', 'moved']) + ); + saveVisualizationLayouts(panelId, visualizationParams); + if (updateAvailabilityVizId) { + updateAvailabilityVizId(panelVisualizations); + } + } + }, [editActionType]); + + // Update layout whenever visualizations are updated + useEffect(() => { + reloadLayout(); + loadVizComponents(); + }, [panelVisualizations]); + + // Reset Size of Panel Grid when Nav Dock is Locked + useEffect(() => { + setTimeout(function () { + window.dispatchEvent(new Event('resize')); + }, 300); + }, [isLocked]); + + useEffect(() => { + loadVizComponents(); + }, [onRefresh]); + + useEffect(() => { + loadVizComponents(); + }, []); + + return ( + + {panelVisualizations.map((panelVisualization: VisualizationType, index) => ( +
{gridData[index]}
+ ))} +
+ ); +}; 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 index 971c4821da..944db72377 100644 --- a/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.tsx +++ b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout.tsx @@ -38,7 +38,7 @@ import { CoreStart } from '../../../../../../../src/core/public'; import { CUSTOM_PANELS_API_PREFIX } from '../../../../../common/constants/custom_panels'; import { SAVED_VISUALIZATION } from '../../../../../common/constants/explorer'; import { - pplResponse, + PplResponse, SavedVisualizationType, VisualizationType, VizContainerError, @@ -112,7 +112,7 @@ export const VisaulizationFlyout = ({ const [newVisualizationTimeField, setNewVisualizationTimeField] = useState(''); const [previewMetaData, setPreviewMetaData] = useState(); const [pplQuery, setPPLQuery] = useState(''); - const [previewData, setPreviewData] = useState({} as pplResponse); + const [previewData, setPreviewData] = useState({} as PplResponse); const [previewArea, setPreviewArea] = useState(<>); const [previewLoading, setPreviewLoading] = useState(false); const [isPreviewError, setIsPreviewError] = useState({} as VizContainerError); @@ -182,6 +182,7 @@ export const VisaulizationFlyout = ({ }), }) .then(async (res) => { + console.log('addVisualization Replacement', res); setPanelVisualizations(res.visualizations); setToast(`Visualization ${newVisualizationTitle} successfully added!`, 'success'); }) @@ -198,6 +199,7 @@ export const VisaulizationFlyout = ({ }), }) .then(async (res) => { + console.log('addVisualization New', res); setPanelVisualizations(res.visualizations); setToast(`Visualization ${newVisualizationTitle} successfully added!`, 'success'); }) 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..daa8918d6f --- /dev/null +++ b/public/components/custom_panels/panel_modules/visualization_flyout/visualization_flyout_so.tsx @@ -0,0 +1,439 @@ +/* + * 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 _, { isError } from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { v4 as uuidv4 } from 'uuid'; +import { FlyoutContainers } from '../../../common/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 { + BoxType, + PplResponse, + SavedVisualizationType, + VisualizationType, + VizContainerError, +} from '../../../../../common/types/custom_panels'; +import './visualization_flyout.scss'; +import { uiSettingsService } from '../../../../../common/utils'; +import { ILegacyScopedClusterClient } from '../../../../../../../src/core/server'; + +/* + * VisaulizationFlyoutSO - This module create a flyout to add visualization for SavedObjects custom Panels + * + * 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 + * 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']; + setToast: ( + title: string, + color?: string, + text?: React.ReactChild | undefined, + side?: string | undefined + ) => void; + savedObjects: CoreStart['savedObjects']; + pplService: PPLService; + setPanelVisualizations: React.Dispatch>; + isFlyoutReplacement?: boolean | undefined; + replaceVisualizationId?: string | undefined; + appId?: string; + addVisualizationPanel: any; +} + +export const VisaulizationFlyoutSO = ({ + panelId, + appId = '', + pplFilterValue, + closeFlyout, + start, + end, + http, + setToast, + savedObjects, + pplService, + setPanelVisualizations, + isFlyoutReplacement, + replaceVisualizationId, + addVisualizationPanel, +}: VisualizationFlyoutSOProps) => { + 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, '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, + // savedVisualizationId: selectValue, + // oldVisualizationId: replaceVisualizationId, + // }), + // }) + // .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 { + const visualizationsWithNewPanel = addVisualizationPanel({ + savedVisualizationId: selectValue, + }); + + // http + // .post(`${CUSTOM_PANELS_API_PREFIX}/visualizations`, { + // body: JSON.stringify({ + // panelId, + // savedVisualizationId: selectValue, + // }), + // }) + // .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, + pplFilterValue, + newVisualizationTimeField + ); + }; + + const timeRange = ( + + + 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 http + .get(`${CUSTOM_PANELS_API_PREFIX}/visualizations`) + .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..219b9f5b0e --- /dev/null +++ b/public/components/custom_panels/redux/panel_slice.ts @@ -0,0 +1,227 @@ +import { createSelector, createSlice } from '@reduxjs/toolkit'; +import { concat, from, Observable, of } from 'rxjs'; +import { map, mergeMap, tap, toArray } from 'rxjs/operators'; +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'; + +interface InitialState { + id: string; + panel: CustomPanelType; + visualizations: VisualizationType[]; + panelList: CustomPanelType[]; +} + +const initialState: InitialState = { + id: '', + panel: { + visualizations: [], + queryFilter: { language: '', query: '' }, + timeRange: { from: 'now', to: 'now-1d' }, + }, + panelList: [], +}; + +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 = (rootState): CustomPanelType => rootState.customPanel.panel; + +export const selectPanelList = (rootState): CustomPanelType[] => { + // console.log('selectPanelList', { rootState, panelList: rootState.customPanel.panelList }); + return rootState.customPanel.panelList; +}; + +// export const selectPanelList = createSelector( +// rootState => { console.log("selectPanelList", { rootState }); return rootState.customPanel.panelList }, +// panelList => panelList.map(p => p as CustomPanelListType) +// ); + +/* + ** ASYNC DISPATCH FUNCTIONS + */ + +const fetchSavedObjectPanels$ = () => + from(savedObjectPanelsClient.find()).pipe( + mergeMap((res) => res.savedObjects), + map(savedObjectToCustomPanel) + // tap((res) => console.log('panel', res)) + ); + +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 })) + // tap((res) => console.log('observability panels', res)) + ); + +// Fetches all saved Custom Panels +const fetchCustomPanels = async () => { + const panels$: Observable = concat( + fetchSavedObjectPanels$(), + fetchObservabilityPanels$() + ).pipe(map((res) => { + console.log("fetchCustomPanels", res); + return res as CustomPanelListType + })); + + return panels$.pipe(toArray()).toPromise(); +}; + +export const fetchPanels = () => async (dispatch, getState) => { + const panels = await fetchCustomPanels() + console.log('fetchPanels', { panels }); + 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); + + +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 isUuid = (id) => !!id.match(uuidRx); + + +export const updatePanel = (panel: CustomPanelType) => async (dispatch, getState) => { + try { + if (isUuid(panel.id)) + await updateSavedObjectPanel(panel) + else + await updateLegacyPanel(panel) + + dispatch(setPanel(panel)); + const panelList = getState().customPanel.panelList.map((p) => (p.id === panel.id ? panel : p)); + dispatch(setPanelList(panelList)); + } catch (err) { + console.log("Error updating panel", { err, panel }) + } +}; + +export const deletePanel = (id) => async (dispatch, getState) => { + await savedObjectPanelsClient.delete(id); + const panelList: CustomPanelType[] = getState().panelList.filter((p) => p.id !== id); + dispatch(setPanelList(panelList)); +}; + +export const createPanel = (panel) => async (dispatch, getState) => { + const newPanel = await savedObjectPanelsClient.create(panel); + const panelList = getState().panelList; + dispatch(setPanelList([...panelList, newPanel])); +}; + + +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) => { + console.log("renameCustomPanel dispatched", { editedCustomPanelName, id }) + + if (!isNameValid(editedCustomPanelName)) { + console.log('Invalid Custom Panel name', 'danger'); + return Promise.reject(); + } + + const panel = getState().customPanel.panelList.find(p => p.id === id) + const updatedPanel = { ...panel, title: editedCustomPanelName } + dispatch(updatePanel(updatedPanel)) + + // try { + // // await savePanelFn(editedCustomPanelId, editedCustomPanelName); + + // // setcustomPanelData((prevCustomPanelData) => { + // // const newCustomPanelData = [...prevCustomPanelData]; + // // const renamedCustomPanel = newCustomPanelData.find( + // // (customPanel) => customPanel.id === editedCustomPanelId + // // ); + // // if (renamedCustomPanel) renamedCustomPanel.name = editedCustomPanelName; + // // return newCustomPanelData; + // // }); + // // setToast(`Operational Panel successfully renamed into "${editedCustomPanelName}"`); + // } catch (err) { + // console.log( + // 'Error renaming Operational Panel, please make sure you have the correct permission.', + // 'danger' + // ); + // console.error(err.body.message); + // } +}; + +/* + ** 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/components/event_analytics/explorer/save_panel/save_panel.tsx b/public/components/event_analytics/explorer/save_panel/save_panel.tsx index 600d8fb6cc..a2dc5b44bf 100644 --- a/public/components/event_analytics/explorer/save_panel/save_panel.tsx +++ b/public/components/event_analytics/explorer/save_panel/save_panel.tsx @@ -14,7 +14,12 @@ import { } from '@elastic/eui'; import { useEffect } from 'react'; import { isEmpty } from 'lodash'; +import { useDispatch, useSelector } from 'react-redux'; import SavedObjects from '../../../../services/saved_objects/event_analytics/saved_objects'; +import { + fetchPanels, + selectPanelList, +} from '../../../../../public/components/custom_panels/redux/panel_slice'; interface ISavedPanelProps { selectedOptions: any; @@ -49,6 +54,14 @@ export const SavePanel = ({ const [checked, setChecked] = useState(false); const [svpnlError, setSvpnlError] = useState(null); + const customPanels = useSelector(selectPanelList); + + const dispatch = useDispatch(); + + useEffect(() => { + dispatch(fetchPanels()); + }, []); + const getCustomPabnelList = async (svobj: SavedObjects) => { const optionRes = await svobj .fetchCustomPanels() @@ -86,10 +99,10 @@ export const SavePanel = ({ handleOptionChange(daOptions); }} selectedOptions={selectedOptions} - options={options.map((option: CustomPanelOptions) => { + options={customPanels.map((option: any) => { return { panel: option, - label: option.name, + label: option.title, }; })} isClearable={true} diff --git a/public/framework/core_refs.ts b/public/framework/core_refs.ts new file mode 100644 index 0000000000..e9a3e0e604 --- /dev/null +++ b/public/framework/core_refs.ts @@ -0,0 +1,32 @@ +/* + * 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 } 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; + 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/framework/redux/reducers/index.ts b/public/framework/redux/reducers/index.ts index c602dbc97a..d392b8265d 100644 --- a/public/framework/redux/reducers/index.ts +++ b/public/framework/redux/reducers/index.ts @@ -14,6 +14,7 @@ import { explorerVisualizationReducer } from '../../../components/event_analytic import { explorerVisualizationConfigReducer } from '../../../components/event_analytics/redux/slices/viualization_config_slice'; import { patternsReducer } from '../../../components/event_analytics/redux/slices/patterns_slice'; import { metricsReducers } from '../../../components/metrics/redux/slices/metrics_slice'; +import { panelReducer } from '../../../components/custom_panels/redux/panel_slice'; const combinedReducer = combineReducers({ // explorer reducers @@ -26,6 +27,7 @@ const combinedReducer = combineReducers({ explorerVisualizationConfig: explorerVisualizationConfigReducer, patterns: patternsReducer, metrics: metricsReducers, + customPanel: panelReducer, }); export type RootState = ReturnType; diff --git a/public/plugin.ts b/public/plugin.ts index f7f3e7bc6a..6e582fc47c 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import './index.scss'; + import { i18n } from '@osd/i18n'; import { AppCategory, @@ -43,6 +45,16 @@ import { } from '../common/utils'; 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'; +import { DashboardSetup } from '../../../src/plugins/dashboard/public'; +import { SavedObject } from '../../../src/core/public'; +import { coreRefs } from './framework/core_refs'; + +// export class ObservabilityPlugin implements Plugin { +// constructor(private initializerContext: PluginInitializerContext) {} + +// public setup(core: CoreSetup, { dashboard }: { dashboard: DashboardSetup }): {} { import { OBSERVABILITY_EMBEDDABLE, OBSERVABILITY_EMBEDDABLE_DESCRIPTION, @@ -71,6 +83,7 @@ export class ObservabilityPlugin core: CoreSetup, setupDeps: SetupDependencies ): ObservabilitySetup { + console.log('core: ', core, ', setupDeps: ', setupDeps); uiSettingsService.init(core.uiSettings, core.notifications); const pplService = new PPLService(core.http); const qm = new QueryManager(); @@ -90,10 +103,18 @@ export class ObservabilityPlugin window.location.assign(convertLegacyTraceAnalyticsUrl(window.location)); } - // // redirect legacy notebooks URL to current URL under observability - // if (window.location.pathname.includes('application_analytics')) { - // window.location.assign(convertLegacyAppAnalyticsUrl(window.location)); - // } + setupDeps.dashboard.registerDashboardProvider({ + appId: 'observability-panel', + savedObjectsType: 'observability-panel', + savedObjectsName: 'Observability Panel', + editUrlPathFn: (obj: SavedObject) => + `/app/observability-dashboards#/operational_panels/${obj.id}/edit`, + viewUrlPathFn: (obj: SavedObject) => + `/app/observability-dashboards#/operational_panels/${obj.id}`, + createLinkText: 'Observability Panel', + createSortText: 'Observability Panel', + createUrl: '/app/observability-dashboards#/operational_panels/create', + }); const OBSERVABILITY_APP_CATEGORIES: Record = Object.freeze({ observability: { @@ -239,6 +260,12 @@ export class ObservabilityPlugin return {}; } public start(core: CoreStart): ObservabilityStart { + const pplService: PPLService = new PPLService(core.http); + + coreRefs.http = core.http; + coreRefs.savedObjectsClient = core.savedObjects.client; + coreRefs.pplService = pplService; + return {}; } public stop() {} diff --git a/server/adaptors/custom_panels/custom_panel_adaptor.ts b/server/adaptors/custom_panels/custom_panel_adaptor.ts index 2b2ba55dd0..f687dca9c4 100644 --- a/server/adaptors/custom_panels/custom_panel_adaptor.ts +++ b/server/adaptors/custom_panels/custom_panel_adaptor.ts @@ -142,6 +142,7 @@ export class CustomPanelsAdaptor { } }; + // Rename an existing panel renamePanel = async (client: ILegacyScopedClusterClient, panelId: string, panelName: string) => { const updatePanelBody = { diff --git a/server/plugin.ts b/server/plugin.ts index bf86ea997b..f315f809b4 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -10,6 +10,7 @@ import { Logger, Plugin, PluginInitializerContext, + SavedObjectsType, } from '../../../src/core/server'; import { OpenSearchObservabilityPlugin } from './adaptors/opensearch_observability_plugin'; import { PPLPlugin } from './adaptors/ppl_plugin'; @@ -43,6 +44,42 @@ export class ObservabilityPlugin }; }); + const obsPanelType: SavedObjectsType = { + name: 'observability-panel', + hidden: false, + namespaceType: 'single', + mappings: { + dynamic: false, + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + }, + }, + management: { + importableAndExportable: true, + getInAppUrl() { + return { + path: `/app/management/observability/settings`, + uiCapabilitiesPath: 'advancedSettings.show', + }; + }, + getTitle(obj) { + return `Observability Settings [${obj.id}]`; + }, + }, + migrations: { + '3.0.0': (doc) => ({ ...doc, description: '' }), + '3.0.1': (doc) => ({ ...doc, description: 'Some Description Text' }), + '3.0.2': (doc) => ({ ...doc, dateCreated: parseInt(doc.dateCreated || '0', 10) }), + }, + }; + + core.savedObjects.registerType(obsPanelType); + // Register server side APIs setupRoutes({ router, client: openSearchObservabilityClient }); diff --git a/server/routes/custom_panels/panels_router.ts b/server/routes/custom_panels/panels_router.ts index 27b452f756..0bba2c73cd 100644 --- a/server/routes/custom_panels/panels_router.ts +++ b/server/routes/custom_panels/panels_router.ts @@ -125,6 +125,49 @@ export function PanelsRouter(router: IRouter) { } ); + + // update an existing panel + router.post( + { + path: `${API_PREFIX}/panels/update`, + validate: { + body: schema.object({ + panelId: schema.string(), + panel: schema.any(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + + try { + const responseBody = await customPanelBackend.updatePanel( + opensearchNotebooksClient, + request.body.panelId, + request.body.panel + ); + return response.ok({ + body: { + message: 'Panel Updated', + }, + }); + } catch (error: any) { + console.error('Issue in updating panel', error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + // rename an existing panel router.post( { diff --git a/yarn.lock b/yarn.lock index 13d4f5ca6e..1ff24c249e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -494,6 +494,14 @@ any-observable@^0.3.0: resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b" integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog== +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.3.tgz#790c58b19ba1720a84205b57c618d5ad8524973e" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== + dependencies: + normalize-path "^3.0.0" + picomatch "^2.0.4" + arch@^2.1.2: version "2.2.0" resolved "https://registry.yarnpkg.com/arch/-/arch-2.2.0.tgz#1bc47818f305764f23ab3306b0bfc086c5a29d11" @@ -528,6 +536,11 @@ astral-regex@^2.0.0: resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-2.0.0.tgz#483143c567aeed4785759c0865786dc77d7d2e31" integrity sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ== +async-wait-until@1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/async-wait-until/-/async-wait-until-1.2.6.tgz#b6d8ada89913028af1928ee078925af75862b108" + integrity sha512-7I1zd0bnMEo7WfLfDoLZp+iPYKv/dl7kcW8wphazZn+BAElTGvtkDuQuonr480JzkS7f42VcGyP90mk3+3IfWA== + async@^3.2.0: version "3.2.3" resolved "https://registry.yarnpkg.com/async/-/async-3.2.3.tgz#ac53dafd3f4720ee9e8a160628f18ea91df196c9" @@ -575,6 +588,11 @@ bcrypt-pbkdf@^1.0.0: dependencies: tweetnacl "^0.14.3" +binary-extensions@^2.0.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" + integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== + blob-util@2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/blob-util/-/blob-util-2.0.2.tgz#3b4e3c281111bb7f11128518006cdc60b403a1eb" @@ -593,7 +611,7 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.2: +braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -693,6 +711,21 @@ check-more-types@^2.24.0: resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600" integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA= +chokidar@3.5.3: + version "3.5.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" + integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== + dependencies: + anymatch "~3.1.2" + braces "~3.0.2" + glob-parent "~5.1.2" + is-binary-path "~2.1.0" + is-glob "~4.0.1" + normalize-path "~3.0.0" + readdirp "~3.6.0" + optionalDependencies: + fsevents "~2.3.2" + ci-info@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-2.0.0.tgz#67a9e964be31a51e15e5010d58e6f12834002f46" @@ -889,6 +922,15 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.0.8.tgz#d2266a792729fb227cd216fb572f43728e1ad340" integrity sha512-jXKhWqXPmlUeoQnF/EhTtTl4C9SnrxSH/jZUih3jmO6lBKr99rP3/+FmrMj4EFpOXzMtXHAZkd3x0E6h6Fgflw== +cypress-watch-and-reload@^1.10.6: + version "1.10.6" + resolved "https://registry.yarnpkg.com/cypress-watch-and-reload/-/cypress-watch-and-reload-1.10.6.tgz#52423344fa52b94b818652f524df0cbcafc6a1ad" + integrity sha512-OI+3zZFSfMOjCH2xO9SUFfBurusbDOXctNtC6Q8VTokIURP+r0cwWZ5NVt6Ty3dtIMrWfiBsT+zsgAPvbmfTkA== + dependencies: + async-wait-until "1.2.6" + chokidar "3.5.3" + ws "8.13.0" + cypress@^6.0.0: version "6.9.1" resolved "https://registry.yarnpkg.com/cypress/-/cypress-6.9.1.tgz#ce1106bfdc47f8d76381dba63f943447883f864c" @@ -1404,6 +1446,11 @@ fs.realpath@^1.0.0: resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f" integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8= +fsevents@~2.3.2: + version "2.3.2" + resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + function-bind@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" @@ -1449,7 +1496,7 @@ getpass@^0.1.1: dependencies: assert-plus "^1.0.0" -glob-parent@^5.0.0, glob-parent@^6.0.1: +glob-parent@^5.0.0, glob-parent@^6.0.1, glob-parent@~5.1.2: version "6.0.2" resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3" integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A== @@ -1721,6 +1768,13 @@ is-arguments@^1.0.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-binary-path@~2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" + integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== + dependencies: + binary-extensions "^2.0.0" + is-buffer@^1.1.4: version "1.1.6" resolved "https://registry.yarnpkg.com/is-buffer/-/is-buffer-1.1.6.tgz#efaa2ea9daa0d7ab2ea13a97b2b8ad51fefbe8be" @@ -1772,7 +1826,7 @@ is-fullwidth-code-point@^4.0.0: resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-4.0.0.tgz#fae3167c729e7463f8461ce512b080a49268aa88" integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== -is-glob@^4.0.0, is-glob@^4.0.3: +is-glob@^4.0.0, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== @@ -2250,7 +2304,7 @@ nice-try@^1.0.4: resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== -normalize-path@^3.0.0: +normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== @@ -2435,7 +2489,7 @@ performance-now@^2.1.0: resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" integrity sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns= -picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== @@ -2697,6 +2751,13 @@ readable-stream@^2.2.2: string_decoder "~1.1.1" util-deprecate "~1.0.1" +readdirp@~3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" + integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== + dependencies: + picomatch "^2.2.1" + redux-persist@^6.0.0: version "6.0.0" resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8" @@ -3493,6 +3554,11 @@ write@1.0.3: dependencies: mkdirp "^0.5.1" +ws@8.13.0: + version "8.13.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" + integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== + x-is-string@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/x-is-string/-/x-is-string-0.1.0.tgz#474b50865af3a49a9c4657f05acd145458f77d82"