From 2ab83698235f958aa28d51465111e3cb996cf4a3 Mon Sep 17 00:00:00 2001 From: Sumukh Swamy Date: Mon, 22 Jul 2024 15:06:37 -0700 Subject: [PATCH] added changes for moving notebooks to .kibana (#1937) * added changes for moving notebooks to .kibana Signed-off-by: sumukhswamy * updated tests Signed-off-by: sumukhswamy * added changes for older notebooks viewing Signed-off-by: sumukhswamy * mds changes for notebooks -s Signed-off-by: sumukhswamy * resolved conflicts Signed-off-by: sumukhswamy * linter fixes Signed-off-by: sumukhswamy * added changes for mds, persistance and sample notebooks Signed-off-by: sumukhswamy * changed route to /savednotebook Signed-off-by: sumukhswamy * addressed comments Signed-off-by: sumukhswamy * edge cases for migration Signed-off-by: sumukhswamy * adeed unit tests Signed-off-by: sumukhswamy * updated snapshots Signed-off-by: sumukhswamy * updated snapshots Signed-off-by: sumukhswamy * added changes for cypress tests Signed-off-by: sumukhswamy * changed cypress test as rename and duplicate has been removed Signed-off-by: sumukhswamy --------- Signed-off-by: sumukhswamy --- .../notebooks_test/notebooks.spec.js | 21 +- common/types/notebooks.ts | 12 +- .../observability_saved_object_attributes.ts | 2 + .../custom_panel_view.test.tsx.snap | 12 +- .../custom_panel_view_so.test.tsx.snap | 6 +- .../__snapshots__/top_menu.test.tsx.snap | 4 +- .../__snapshots__/notebook.test.tsx.snap | 690 ++---------------- .../components/__tests__/note_table.test.tsx | 69 +- .../components/__tests__/notebook.test.tsx | 171 +++-- .../components/helpers/default_parser.tsx | 14 +- .../components/helpers/notebooks_parser.tsx | 9 + .../components/notebooks/components/main.tsx | 130 +++- .../notebooks/components/note_table.tsx | 66 -- .../notebooks/components/notebook.tsx | 318 +++++--- .../__snapshots__/paragraphs.test.tsx.snap | 3 + .../__tests__/paragraphs.test.tsx | 1 + .../paragraph_components/paragraphs.tsx | 74 +- server/adaptors/notebooks/default_backend.ts | 68 +- server/adaptors/notebooks/notebook_adaptor.ts | 28 +- .../saved_objects_notebooks_router.tsx | 92 +++ .../saved_objects_paragraphs_router.tsx | 306 ++++++++ .../notebooks/default_notebook_schema.ts | 2 + .../helpers/notebooks/sample_notebooks.ts | 60 +- .../helpers/notebooks/wreck_requests.ts | 4 +- server/plugin.ts | 2 + server/routes/index.ts | 4 +- server/routes/notebooks/noteRouter.ts | 293 ++++++++ server/routes/notebooks/paraRouter.ts | 226 +++++- server/routes/notebooks/sqlRouter.ts | 16 +- server/routes/notebooks/vizRouter.ts | 27 +- .../observability_saved_object.ts | 38 +- server/services/queryService.ts | 35 +- test/notebooks_constants.ts | 16 + 33 files changed, 1751 insertions(+), 1068 deletions(-) create mode 100644 public/components/notebooks/components/helpers/notebooks_parser.tsx create mode 100644 server/adaptors/notebooks/saved_objects_notebooks_router.tsx create mode 100644 server/adaptors/notebooks/saved_objects_paragraphs_router.tsx diff --git a/.cypress/integration/notebooks_test/notebooks.spec.js b/.cypress/integration/notebooks_test/notebooks.spec.js index 550161696..f1a6d044c 100644 --- a/.cypress/integration/notebooks_test/notebooks.spec.js +++ b/.cypress/integration/notebooks_test/notebooks.spec.js @@ -93,33 +93,16 @@ describe('Testing notebooks table', () => { cy.contains(TEST_NOTEBOOK).should('exist'); }); - it('Duplicates a notebook', () => { - cy.get('.euiCheckbox__input[title="Select this row"]').eq(0).click(); - cy.get('button[data-test-subj="notebookTableActionBtn"]').click(); - cy.get('button[data-test-subj="duplicateNotebookBtn"]').click(); - cy.get('button[data-test-subj="custom-input-modal-confirm-button"]').click(); - - cy.get('.euiCheckbox__input[title="Select this row"]').eq(1).click(); - }); - - it('Renames a notebook', () => { - cy.get('.euiCheckbox__input[title="Select this row"]').eq(0).click(); - cy.get('button[data-test-subj="notebookTableActionBtn"]').click(); - cy.get('button[data-test-subj="renameNotebookBtn"]').click(); - cy.get('input[data-test-subj="custom-input-modal-input"]').focus().type(' (rename)'); - cy.get('button[data-test-subj="custom-input-modal-confirm-button"]').click(); - }); - it('Searches existing notebooks', () => { cy.get('input.euiFieldSearch').focus().type('this notebook should not exist'); cy.get('.euiTableCellContent__text').contains('No items found').should('exist'); cy.get('.euiFormControlLayoutClearButton').click(); cy.get('input.euiFieldSearch') .focus() - .type(TEST_NOTEBOOK + ' (copy) (rename)'); + .type(TEST_NOTEBOOK); cy.get('a.euiLink') - .contains(TEST_NOTEBOOK + ' (copy) (rename)') + .contains(TEST_NOTEBOOK) .should('exist'); }); diff --git a/common/types/notebooks.ts b/common/types/notebooks.ts index 5c193ab5d..65570b2b0 100644 --- a/common/types/notebooks.ts +++ b/common/types/notebooks.ts @@ -4,8 +4,6 @@ */ import { RefObject } from 'react'; -import { DashboardStart } from "../../../../src/plugins/dashboard/public"; -import { NavigationPublicPluginStart } from "../../../../src/plugins/navigation/public"; export interface NotebooksPluginSetup { getGreeting: () => string; @@ -13,7 +11,7 @@ export interface NotebooksPluginSetup { // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface NotebooksPluginStart {} -export interface optionsType { +export interface OptionsType { baseUrl: string; payload?: any; headers?: any; @@ -27,7 +25,7 @@ export interface optionsType { ciphers?: string; // The TLS ciphers to support } -export type ParaType = { +export interface ParaType { uniqueId: string; isRunning: boolean; inQueue: boolean; @@ -41,7 +39,7 @@ export type ParaType = { inp: string; lang: string; editorLanguage: string; - typeOut: Array; + typeOut: string[]; out: any[]; isInputExpanded: boolean; isOutputStale: boolean; @@ -50,4 +48,6 @@ export type ParaType = { visStartTime?: string; visEndTime?: string; visSavedObjId?: string; -}; + dataSourceMDSId?: string; + dataSourceMDSLabel?: string; +} diff --git a/common/types/observability_saved_object_attributes.ts b/common/types/observability_saved_object_attributes.ts index a98f0b4b2..6393c3a5d 100644 --- a/common/types/observability_saved_object_attributes.ts +++ b/common/types/observability_saved_object_attributes.ts @@ -8,9 +8,11 @@ import { SavedQuery, SavedVisualization } from './explorer'; export const VISUALIZATION_SAVED_OBJECT = 'observability-visualization'; export const SEARCH_SAVED_OBJECT = 'observability-search'; +export const NOTEBOOK_SAVED_OBJECT = 'observability-notebook'; export const OBSERVABILTY_SAVED_OBJECTS = [ VISUALIZATION_SAVED_OBJECT, SEARCH_SAVED_OBJECT, + NOTEBOOK_SAVED_OBJECT, ] as const; export const SAVED_OBJECT_VERSION = 1; diff --git a/public/components/custom_panels/__tests__/__snapshots__/custom_panel_view.test.tsx.snap b/public/components/custom_panels/__tests__/__snapshots__/custom_panel_view.test.tsx.snap index e21d4d876..6bb678ffe 100644 --- a/public/components/custom_panels/__tests__/__snapshots__/custom_panel_view.test.tsx.snap +++ b/public/components/custom_panels/__tests__/__snapshots__/custom_panel_view.test.tsx.snap @@ -303,7 +303,7 @@ exports[`Panels View Component render panel view container and refresh panel 1`] class="euiToolTipAnchor" > - - -
@@ -107,6 +66,47 @@ exports[` spec Renders the empty component 1`] = `
+
+
+
+ +
+
+
@@ -378,92 +378,46 @@ exports[` spec Renders the visualization component 1`] = `
+
-
-
- -
-
+ Upgrade Notebook + + +
-
-
-
- -
-
+ Delete this notebook + + +
spec Renders the visualization component 1`] = ` class="euiTitle euiTitle--large" data-test-subj="notebookTitle" /> +
+
+
+ Upgrade this notebook to take full advantage of the latest features +
+
+
@@ -524,501 +491,6 @@ exports[` spec Renders the visualization component 1`] = `
-
-
-
-
-
- -
-
- - Code block - -
-

- Write contents directly using markdown, SQL or PPL. -

-
-
- -
-
-
-
-
- -
-
- - Visualization - -
-

- Import OpenSearch Dashboards or Observability visualizations to the notes. -

-
-
- -
-
-
-
-
-
-
-
-
-
-`; - -exports[` spec test reporting action button 1`] = ` -
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-

- sample-notebook-1 -

-
-
-
-
-
-

- Created -
- - 12/14/2023 06:49 PM -

-
-
-
-
-
-
-
-
-
-

- No paragraphs -

-
- Add a paragraph to compose your document or story. Notebooks now support two types of input: -
-
-
-
-
-
-
-
-
- -
-
- - Code block - -
-

- Write contents directly using markdown, SQL or PPL. -

-
-
- -
-
-
-
-
- -
-
- - Visualization - -
-

- Import OpenSearch Dashboards or Observability visualizations to the notes. -

-
-
- -
-
-
-
diff --git a/public/components/notebooks/components/__tests__/note_table.test.tsx b/public/components/notebooks/components/__tests__/note_table.test.tsx index 76a4d16c1..7fbb42b46 100644 --- a/public/components/notebooks/components/__tests__/note_table.test.tsx +++ b/public/components/notebooks/components/__tests__/note_table.test.tsx @@ -70,7 +70,6 @@ describe(' spec', () => { fireEvent.click(utils.getByText('Cancel')); fireEvent.click(utils.getAllByLabelText('Select this row')[0]); fireEvent.click(utils.getByText('Actions')); - fireEvent.click(utils.getByText('Rename')); }); it('create notebook modal', async () => { @@ -123,70 +122,6 @@ describe(' spec', () => { expect(props.createNotebook).toHaveBeenCalledTimes(1); }); - it('renames a notebook', () => { - const notebooks = [ - { - path: 'path-1', - id: 'id-1', - dateCreated: 'date-created', - dateModified: 'date-modified', - }, - ]; - const { getByText, getByLabelText, getAllByText, getByTestId } = renderNoteTable({ notebooks }); - - // Select a notebook - fireEvent.click(getByLabelText('Select this row')); - - // Open Actions dropdown and click Rename - fireEvent.click(getByText('Actions')); - fireEvent.click(getByText('Rename')); - - // Ensure the modal is open (you may need to adjust based on your modal implementation) - expect(getAllByText('Rename notebook')).toHaveLength(1); - - // Mock user input and submit - fireEvent.input(getByTestId('custom-input-modal-input'), { - target: { value: 'test-notebook-newname' }, - }); - fireEvent.click(getByTestId('custom-input-modal-confirm-button')); - - // Assert that the renameNotebook function is called - expect(props.renameNotebook).toHaveBeenCalledTimes(1); - expect(props.renameNotebook).toHaveBeenCalledWith('test-notebook-newname', 'id-1'); - }); - - it('clones a notebook', () => { - const notebooks = [ - { - path: 'path-1', - id: 'id-1', - dateCreated: 'date-created', - dateModified: 'date-modified', - }, - ]; - const { getByText, getByLabelText, getAllByText, getByTestId } = renderNoteTable({ notebooks }); - - // Select a notebook - fireEvent.click(getByLabelText('Select this row')); - - // Open Actions dropdown and click Duplicate - fireEvent.click(getByText('Actions')); - fireEvent.click(getByText('Duplicate')); - - // Ensure the modal is open (you may need to adjust based on your modal implementation) - expect(getAllByText('Duplicate notebook')).toHaveLength(1); - - // Mock user input and submit - fireEvent.input(getByTestId('custom-input-modal-input'), { - target: { value: 'new-copy' }, - }); - fireEvent.click(getByTestId('custom-input-modal-confirm-button')); - - // Assert that the cloneNotebook function is called - expect(props.cloneNotebook).toHaveBeenCalledTimes(1); - expect(props.cloneNotebook).toHaveBeenCalledWith('new-copy', 'id-1'); - }); - it('deletes a notebook', () => { const notebooks = [ { @@ -244,14 +179,14 @@ describe(' spec', () => { fireEvent.click(getByText('Actions')); // Ensure the action panel is open - expect(queryByTestId('renameNotebookBtn')).toBeInTheDocument(); + expect(queryByTestId('deleteNotebookBtn')).toBeInTheDocument(); await act(async () => { fireEvent.click(getByText('Actions')); }); // Ensure the action panel is closed - expect(queryByTestId('renameNotebookBtn')).not.toBeInTheDocument(); + expect(queryByTestId('deleteNotebookBtn')).not.toBeInTheDocument(); }); it('closes the delete modal', () => { diff --git a/public/components/notebooks/components/__tests__/notebook.test.tsx b/public/components/notebooks/components/__tests__/notebook.test.tsx index eb33888af..0caef1648 100644 --- a/public/components/notebooks/components/__tests__/notebook.test.tsx +++ b/public/components/notebooks/components/__tests__/notebook.test.tsx @@ -16,6 +16,7 @@ import { codeBlockNotebook, codePlaceholderText, emptyNotebook, + migrateBlockNotebook, notebookPutResponse, runCodeBlockResponse, sampleNotebook1, @@ -64,31 +65,7 @@ describe(' spec', () => { const utils = render( - ); - await waitFor(() => { - expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); - }); - expect(utils.container.firstChild).toMatchSnapshot(); - }); - - it('test reporting action button', async () => { - httpClient.get = jest.fn(() => Promise.resolve((emptyNotebook as unknown) as HttpResponse)); - const utils = render( - spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); }); expect(utils.container.firstChild).toMatchSnapshot(); - - act(() => { - fireEvent.click(utils.getByText('Reporting actions')); - }); - - expect(utils.queryByTestId('download-notebook-pdf')).toBeInTheDocument(); - - act(() => { - fireEvent.click(utils.getByText('Reporting actions')); - }); - - await waitFor(() => { - expect(utils.queryByTestId('download-notebook-pdf')).toBeNull(); - }); }); it('Adds a code block', async () => { @@ -133,7 +97,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -171,7 +136,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -217,7 +183,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -260,7 +227,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -327,7 +295,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -411,7 +380,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -470,7 +440,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -524,7 +495,7 @@ describe(' spec', () => { const utils = render( spec', () => { setToast={setToast} location={location} history={history} + dataSourceEnabled={false} /> ); await waitFor(() => { @@ -605,6 +577,7 @@ describe(' spec', () => { setToast={setToast} location={location} history={history} + dataSourceManagement={{ ui: { DataSourceSelector: <> } }} /> ); @@ -612,4 +585,108 @@ describe(' spec', () => { expect(utils.container.firstChild).toMatchSnapshot(); }); }); + + it('Renders a old notebook and migrates it', async () => { + httpClient.get = jest.fn(() => Promise.resolve((codeBlockNotebook as unknown) as HttpResponse)); + httpClient.put = jest.fn(() => + Promise.resolve((clearOutputNotebook as unknown) as HttpResponse) + ); + httpClient.delete = jest.fn(() => + Promise.resolve(({ paragraphs: [] } as unknown) as HttpResponse) + ); + const migrateNotebookMock = jest.fn(() => Promise.resolve('dummy-string')); + httpClient.get = jest.fn(() => + Promise.resolve((migrateBlockNotebook as unknown) as HttpResponse) + ); + const utils = render( + + ); + await waitFor(() => { + expect( + utils.getByText('Upgrade this notebook to take full advantage of the latest features') + ).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByText('Upgrade Notebook')); + }); + + act(() => { + fireEvent.click(utils.getByTestId('custom-input-modal-confirm-button')); + }); + + expect(migrateNotebookMock).toHaveBeenCalledTimes(1); + }); + + it('Checks old notebook delete action', async () => { + const renameNotebookMock = jest.fn(() => + Promise.resolve((notebookPutResponse as unknown) as HttpResponse) + ); + const cloneNotebookMock = jest.fn(() => Promise.resolve('dummy-string')); + httpClient.get = jest.fn(() => Promise.resolve((codeBlockNotebook as unknown) as HttpResponse)); + + httpClient.put = jest.fn(() => { + return Promise.resolve((notebookPutResponse as unknown) as HttpResponse); + }); + + httpClient.post = jest.fn(() => { + return Promise.resolve((addCodeBlockResponse as unknown) as HttpResponse); + }); + + const utils = render( + + ); + await waitFor(() => { + expect(utils.getByText('sample-notebook-1')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.click(utils.getByText('Delete this notebook')); + }); + + await waitFor(() => { + expect(utils.queryByTestId('delete-notebook-modal-input')).toBeInTheDocument(); + }); + + act(() => { + fireEvent.input(utils.getByTestId('delete-notebook-modal-input'), { + target: { value: 'delete' }, + }); + }); + + act(() => { + fireEvent.click(utils.getByTestId('delete-notebook-modal-delete-button')); + }); + + expect(deleteNotebook).toHaveBeenCalledTimes(1); + }); }); diff --git a/public/components/notebooks/components/helpers/default_parser.tsx b/public/components/notebooks/components/helpers/default_parser.tsx index c38dad3cd..f80cad6da 100644 --- a/public/components/notebooks/components/helpers/default_parser.tsx +++ b/public/components/notebooks/components/helpers/default_parser.tsx @@ -9,14 +9,14 @@ import { ParaType } from '../../../../../common/types/notebooks'; // Param: Default Backend Paragraph const parseOutput = (paraObject: any) => { try { - let outputType = []; - let result = []; + const outputType = []; + const result = []; paraObject.output.map((output: { outputType: string; result: string }) => { outputType.push(output.outputType); result.push(output.result); }); return { - outputType: outputType, + outputType, outputData: result, }; } catch (error) { @@ -46,7 +46,7 @@ const parseInputType = (paraObject: any) => { const parseVisualization = (paraObject: any) => { try { if (paraObject.input.inputType.includes('VISUALIZATION')) { - let vizContent = paraObject.input.inputText; + const vizContent = paraObject.input.inputText; const startDate = new Date(); startDate.setDate(startDate.getDate() - 30); let visStartTime = startDate.toISOString(); @@ -79,14 +79,14 @@ const parseVisualization = (paraObject: any) => { // Placeholder for default parser // Param: Default Backend Paragraph export const defaultParagraphParser = (defaultBackendParagraphs: any) => { - let parsedPara: Array = []; + const parsedPara: ParaType[] = []; try { defaultBackendParagraphs.map((paraObject: any, index: number) => { const codeLanguage = parseInputType(paraObject); const vizParams = parseVisualization(paraObject); const message = parseOutput(paraObject); - let tempPara: ParaType = { + const tempPara: ParaType = { uniqueId: paraObject.id, isRunning: false, inQueue: false, @@ -109,6 +109,8 @@ export const defaultParagraphParser = (defaultBackendParagraphs: any) => { visStartTime: vizParams.visStartTime, visEndTime: vizParams.visEndTime, visSavedObjId: vizParams.visSavedObjId, + dataSourceMDSId: paraObject.dataSourceMDSId, + dataSourceMDSLabel: paraObject.dataSourceMDSLabel, }; parsedPara.push(tempPara); }); diff --git a/public/components/notebooks/components/helpers/notebooks_parser.tsx b/public/components/notebooks/components/helpers/notebooks_parser.tsx new file mode 100644 index 000000000..084e6e261 --- /dev/null +++ b/public/components/notebooks/components/helpers/notebooks_parser.tsx @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export function isValidUUID(id: string): boolean { + const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-5][0-9a-f]{3}-[089ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + return UUID_REGEX.test(id); +} diff --git a/public/components/notebooks/components/main.tsx b/public/components/notebooks/components/main.tsx index 82625a86c..8d866e64e 100644 --- a/public/components/notebooks/components/main.tsx +++ b/public/components/notebooks/components/main.tsx @@ -9,16 +9,22 @@ import React, { ReactChild } from 'react'; // eslint-disable-next-line @osd/eslint/module_migration import { Route, Switch } from 'react-router'; import { HashRouter, RouteComponentProps } from 'react-router-dom'; -import PPLService from '../../../services/requests/ppl'; -import { ChromeBreadcrumb, CoreStart } from '../../../../../../src/core/public'; +import { + ChromeBreadcrumb, + CoreStart, + MountPoint, + SavedObjectsStart, +} from '../../../../../../src/core/public'; import { DashboardStart } from '../../../../../../src/plugins/dashboard/public'; +import { DataSourceManagementPluginSetup } from '../../../../../../src/plugins/data_source_management/public'; import { NOTEBOOKS_API_PREFIX, NOTEBOOKS_DOCUMENTATION_URL, } from '../../../../common/constants/notebooks'; -import { Notebook } from './notebook'; +import PPLService from '../../../services/requests/ppl'; +import { isValidUUID } from './helpers/notebooks_parser'; import { NoteTable } from './note_table'; - +import { Notebook } from './notebook'; /* * "Main" component renders the whole Notebooks as a single page application * @@ -37,6 +43,10 @@ type MainProps = RouteComponentProps & { notifications: CoreStart['notifications']; parentBreadcrumb: ChromeBreadcrumb; setBreadcrumbs: (newBreadcrumbs: ChromeBreadcrumb[]) => void; + dataSourceEnabled: boolean; + dataSourceManagement: DataSourceManagementPluginSetup; + setActionMenu: (menuMount: MountPoint | undefined) => void; + savedObjectsMDSClient: SavedObjectsStart; }; interface MainState { @@ -44,6 +54,8 @@ interface MainState { openedNotebook: NotebookType | undefined; toasts: Toast[]; loading: boolean; + defaultMDSId: string; + defaultMDSLabel: string; } export interface NotebookType { @@ -61,6 +73,8 @@ export class Main extends React.Component { openedNotebook: undefined, toasts: [], loading: false, + defaultMDSId: '', + defaultMDSLabel: '', }; } @@ -81,16 +95,23 @@ export class Main extends React.Component { // Fetches path and id for all stored notebooks fetchNotebooks = () => { - return this.props.http - .get(`${NOTEBOOKS_API_PREFIX}/`) - .then((res) => this.setState(res)) + return Promise.all([ + this.props.http.get(`${NOTEBOOKS_API_PREFIX}/savedNotebook/`), + this.props.http.get(`${NOTEBOOKS_API_PREFIX}/`), + ]) + .then(([savedNotebooksResponse, secondResponse]) => { + const combinedData = { + data: [...savedNotebooksResponse.data, ...secondResponse.data], + }; + this.setState(combinedData); + }) .catch((err) => { console.error('Issue in fetching the notebooks', err.body.message); }); }; // Creates a new notebook - createNotebook = (newNoteName: string) => { + createNotebook = async (newNoteName: string) => { if (newNoteName.length >= 50 || newNoteName.length === 0) { this.setToast('Invalid notebook name', 'danger'); window.location.assign('#/'); @@ -101,7 +122,7 @@ export class Main extends React.Component { }; return this.props.http - .post(`${NOTEBOOKS_API_PREFIX}/note`, { + .post(`${NOTEBOOKS_API_PREFIX}/note/savedNotebook`, { body: JSON.stringify(newNoteObject), }) .then(async (res) => { @@ -121,7 +142,7 @@ export class Main extends React.Component { }; // Renames an existing notebook - renameNotebook = (editedNoteName: string, editedNoteID: string): Promise => { + renameNotebook = async (editedNoteName: string, editedNoteID: string): Promise => { if (editedNoteName.length >= 50 || editedNoteName.length === 0) { this.setToast('Invalid notebook name', 'danger'); return; @@ -132,7 +153,7 @@ export class Main extends React.Component { }; return this.props.http - .put(`${NOTEBOOKS_API_PREFIX}/note/rename`, { + .put(`${NOTEBOOKS_API_PREFIX}/note/savedNotebook/rename`, { body: JSON.stringify(renameNoteObject), }) .then((res) => { @@ -155,7 +176,7 @@ export class Main extends React.Component { }; // Clones an existing notebook, return new notebook's id - cloneNotebook = (clonedNoteName: string, clonedNoteID: string): Promise => { + cloneNotebook = async (clonedNoteName: string, clonedNoteID: string): Promise => { if (clonedNoteName.length >= 50 || clonedNoteName.length === 0) { this.setToast('Invalid notebook name', 'danger'); return Promise.reject(); @@ -166,7 +187,7 @@ export class Main extends React.Component { }; return this.props.http - .post(`${NOTEBOOKS_API_PREFIX}/note/clone`, { + .post(`${NOTEBOOKS_API_PREFIX}/note/savedNotebook/clone`, { body: JSON.stringify(cloneNoteObject), }) .then((res) => { @@ -175,14 +196,14 @@ export class Main extends React.Component { ...prevState.data, { path: clonedNoteName, - id: res.body.id, - dateCreated: res.body.dateCreated, - dateModified: res.body.dateModified, + id: res.id, + dateCreated: res.attributes.dateCreated, + dateModified: res.attributes.dateModified, }, ], })); this.setToast(`Notebook "${clonedNoteName}" successfully created!`); - return res.body.id; + return res.id; }) .catch((err) => { this.setToast( @@ -194,27 +215,76 @@ export class Main extends React.Component { }; // Deletes existing notebooks - deleteNotebook = (notebookList: string[], toastMessage?: string) => { - return this.props.http - .delete(`${NOTEBOOKS_API_PREFIX}/note/${notebookList.join(',')}`) - .then((res) => { + deleteNotebook = async (notebookList: string[], toastMessage?: string) => { + const deleteNotebook = (id: string) => { + const isValid = isValidUUID(id); + const route = isValid + ? `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/${id}` + : `${NOTEBOOKS_API_PREFIX}/note/${id}`; + return this.props.http.delete(route).then((res) => { this.setState((prevState) => ({ - data: prevState.data.filter((notebook) => !notebookList.includes(notebook.id)), + data: prevState.data.filter((notebook) => notebook.id !== id), })); + return res; + }); + }; + + const promises = notebookList.map((id) => + deleteNotebook(id).catch((err) => { + this.setToast( + 'Error deleting notebook, please make sure you have the correct permission.', + 'danger' + ); + console.error(err.body.message); + }) + ); + + Promise.allSettled(promises) + .then(() => { const message = toastMessage || `Notebook${notebookList.length > 1 ? 's' : ''} successfully deleted!`; this.setToast(message); - return res; + }) + .catch((err) => { + console.error('Error in deleting multiple notebooks', err); + }); + }; + migrateNotebook = async (migrateNoteName: string, migrateNoteID: string): Promise => { + if (migrateNoteName.length >= 50 || migrateNoteName.length === 0) { + this.setToast('Invalid notebook name', 'danger'); + return Promise.reject(); + } + const migrateNoteObject = { + name: migrateNoteName, + noteId: migrateNoteID, + }; + return this.props.http + .post(`${NOTEBOOKS_API_PREFIX}/note/migrate`, { + body: JSON.stringify(migrateNoteObject), + }) + .then((res) => { + this.setState((prevState) => ({ + data: [ + ...prevState.data, + { + path: migrateNoteName, + id: res.id, + dateCreated: res.attributes.dateCreated, + dateModified: res.attributes.dateModified, + }, + ], + })); + this.setToast(`Notebook "${migrateNoteName}" successfully created!`); + return res.id; }) .catch((err) => { this.setToast( - 'Error deleting notebook, please make sure you have the correct permission.', + 'Error migrating notebook, please make sure you have the correct permission.', 'danger' ); console.error(err.body.message); }); }; - addSampleNotebooks = async () => { try { this.setState({ loading: true }); @@ -270,7 +340,7 @@ export class Main extends React.Component { }) .then((resp) => visIds.push(resp.saved_objects[0].id)); await this.props.http - .post(`${NOTEBOOKS_API_PREFIX}/note/addSampleNotebooks`, { + .post(`${NOTEBOOKS_API_PREFIX}/note/savedNotebook/addSampleNotebooks`, { body: JSON.stringify({ visIds }), }) .then((res) => { @@ -310,7 +380,7 @@ export class Main extends React.Component { ( + render={(_props) => ( { setToast={this.setToast} location={props.location} history={props.history} + migrateNotebook={this.migrateNotebook} + dataSourceManagement={this.props.dataSourceManagement} + setActionMenu={this.props.setActionMenu} + notifications={this.props.notifications} + dataSourceEnabled={this.props.dataSourceEnabled} + savedObjectsMDSClient={this.props.savedObjectsMDSClient} /> )} /> diff --git a/public/components/notebooks/components/note_table.tsx b/public/components/notebooks/components/note_table.tsx index 7462ff615..4e51d32bf 100644 --- a/public/components/notebooks/components/note_table.tsx +++ b/public/components/notebooks/components/note_table.tsx @@ -65,8 +65,6 @@ export function NoteTable({ addSampleNotebooks, notebooks, createNotebook, - renameNotebook, - cloneNotebook, deleteNotebook, parentBreadcrumb, setBreadcrumbs, @@ -111,16 +109,6 @@ export function NoteTable({ closeModal(); }; - const onRename = async (newNoteName: string) => { - renameNotebook(newNoteName, selectedNotebooks[0].id); - closeModal(); - }; - - const onClone = async (newName: string) => { - cloneNotebook(newName, selectedNotebooks[0].id); - closeModal(); - }; - const onDelete = async () => { const toastMessage = `Notebook${ selectedNotebooks.length > 1 ? 's' : ' "' + selectedNotebooks[0].path + '"' @@ -151,38 +139,6 @@ export function NoteTable({ showModal(); }; - const renameNote = () => { - setModalLayout( - getCustomModal( - onRename, - closeModal, - 'Name', - 'Rename notebook', - 'Cancel', - 'Rename', - selectedNotebooks[0].path, - CREATE_NOTE_MESSAGE - ) - ); - showModal(); - }; - - const cloneNote = () => { - setModalLayout( - getCustomModal( - onClone, - closeModal, - 'Name', - 'Duplicate notebook', - 'Cancel', - 'Duplicate', - selectedNotebooks[0].path + ' (copy)', - CREATE_NOTE_MESSAGE - ) - ); - showModal(); - }; - const deleteNote = () => { const notebookString = `notebook${selectedNotebooks.length > 1 ? 's' : ''}`; setModalLayout( @@ -218,28 +174,6 @@ export function NoteTable({ ); const popoverItems: ReactElement[] = [ - { - setIsActionsPopoverOpen(false); - renameNote(); - }} - data-test-subj="renameNotebookBtn" - > - Rename - , - { - setIsActionsPopoverOpen(false); - cloneNote(); - }} - data-test-subj="duplicateNotebookBtn" - > - Duplicate - , void; location: RouteComponentProps['location']; history: RouteComponentProps['history']; + migrateNotebook: (newNoteName: string, noteId: string) => Promise; + dataSourceManagement: DataSourceManagementPluginSetup; + setActionMenu: (menuMount: MountPoint | undefined) => void; + notifications: CoreStart['notifications']; + dataSourceEnabled: boolean; + savedObjectsMDSClient: SavedObjectsStart; } interface NotebookState { @@ -97,6 +111,10 @@ interface NotebookState { modalLayout: React.ReactNode; showQueryParagraphError: boolean; queryParagraphErrorMessage: string; + savedObjectNotebook: boolean; + dataSourceMDSId: string | undefined | null; + dataSourceMDSLabel: string | undefined | null; + dataSourceMDSEnabled: boolean; } export class Notebook extends Component { constructor(props: Readonly) { @@ -119,6 +137,10 @@ export class Notebook extends Component { modalLayout: , showQueryParagraphError: false, queryParagraphErrorMessage: '', + savedObjectNotebook: true, + dataSourceMDSId: null, + dataSourceMDSLabel: null, + dataSourceMDSEnabled: false, }; } @@ -183,7 +205,7 @@ export class Notebook extends Component { deleteParagraphButton = (para: ParaType, index: number) => { if (index !== -1) { return this.props.http - .delete(`${NOTEBOOKS_API_PREFIX}/paragraph`, { + .delete(`${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph`, { query: { noteId: this.props.openedNoteId, paragraphId: para.uniqueId, @@ -228,7 +250,7 @@ export class Notebook extends Component { async () => { this.setState({ isModalVisible: false }); await this.props.http - .delete(`${NOTEBOOKS_API_PREFIX}/paragraph`, { + .delete(`${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph`, { query: { noteId: this.props.openedNoteId, }, @@ -275,7 +297,7 @@ export class Notebook extends Component { (newName: string) => { this.props.renameNotebook(newName, this.props.openedNoteId).then((res) => { this.setState({ isModalVisible: false }); - window.location.assign(`#/${res.message.objectId}`); + window.location.assign(`#/${res.id}`); setTimeout(() => { this.loadNotebook(); }, 300); @@ -317,6 +339,30 @@ export class Notebook extends Component { this.setState({ isModalVisible: true }); }; + showMigrateModal = () => { + this.setState({ + modalLayout: getCustomModal( + (newName: string) => { + this.props.migrateNotebook(newName, this.props.openedNoteId).then((id: string) => { + window.location.assign(`#/${id}`); + setTimeout(() => { + this.loadNotebook(); + }, 300); + }); + this.setState({ isModalVisible: false }); + }, + () => this.setState({ isModalVisible: false }), + 'Name', + 'Migrate notebook', + 'Cancel', + 'Migrate', + this.state.path + ' (migrated)', + CREATE_NOTE_MESSAGE + ), + }); + this.setState({ isModalVisible: true }); + }; + showDeleteNotebookModal = () => { this.setState({ modalLayout: ( @@ -342,7 +388,12 @@ export class Notebook extends Component { // Function for delete Visualization from notebook deleteVizualization = (uniqueId: string) => { this.props.http - .delete(`${NOTEBOOKS_API_PREFIX}/paragraph/` + this.props.openedNoteId + '/' + uniqueId) + .delete( + `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/` + + this.props.openedNoteId + + '/' + + uniqueId + ) .then((res) => { this.setState({ paragraphs: res.paragraphs }); this.parseAllParagraphs(); @@ -366,7 +417,7 @@ export class Notebook extends Component { }; return this.props.http - .post(`${NOTEBOOKS_API_PREFIX}/paragraph/`, { + .post(`${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/`, { body: JSON.stringify(addParaObj), }) .then((res) => { @@ -418,7 +469,7 @@ export class Notebook extends Component { }; return this.props.http - .post(`${NOTEBOOKS_API_PREFIX}/set_paragraphs/`, { + .post(`${NOTEBOOKS_API_PREFIX}/savedNotebook/set_paragraphs/`, { body: JSON.stringify(moveParaObj), }) .then((_res) => this.setState({ paragraphs, parsedPara })) @@ -449,7 +500,7 @@ export class Notebook extends Component { noteId: this.props.openedNoteId, }; this.props.http - .put(`${NOTEBOOKS_API_PREFIX}/paragraph/clearall/`, { + .put(`${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/clearall/`, { body: JSON.stringify(clearParaObj), }) .then((res) => { @@ -482,15 +533,20 @@ export class Notebook extends Component { paragraphId: para.uniqueId, paragraphInput: para.inp, paragraphType: paraType || '', + dataSourceMDSId: this.state.dataSourceMDSId || '', + dataSourceMDSLabel: this.state.dataSourceMDSLabel || '', }; - + const isValid = isValidUUID(this.props.openedNoteId); + const route = isValid + ? `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/update/run/` + : `${NOTEBOOKS_API_PREFIX}/paragraph/update/run/`; return this.props.http - .post(`${NOTEBOOKS_API_PREFIX}/paragraph/update/run/`, { + .post(route, { body: JSON.stringify(paraUpdateObject), }) .then(async (res) => { if (res.output[0]?.outputType === 'QUERY') { - await this.loadQueryResultsFromInput(res); + await this.loadQueryResultsFromInput(res, this.state.dataSourceMDSId); const checkErrorJSON = JSON.parse(res.output[0].result); if (this.checkQueryOutputError(checkErrorJSON)) { return; @@ -570,17 +626,49 @@ export class Notebook extends Component { this.paragraphSelector(scrollToIndex !== undefined ? scrollToIndex : -1); }; - loadNotebook = () => { + loadNotebook = async () => { this.showParagraphRunning('queue'); + const isValid = isValidUUID(this.props.openedNoteId); + this.setState({ + savedObjectNotebook: isValid, + dataSourceMDSEnabled: isValid && this.props.dataSourceEnabled, + }); + const route = isValid + ? `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/${this.props.openedNoteId}` + : `${NOTEBOOKS_API_PREFIX}/note/${this.props.openedNoteId}`; this.props.http - .get(`${NOTEBOOKS_API_PREFIX}/note/` + this.props.openedNoteId) + .get(route) .then(async (res) => { this.setBreadcrumbs(res.path); let index = 0; for (index = 0; index < res.paragraphs.length; ++index) { // if the paragraph is a query, load the query output - if (res.paragraphs[index].output[0]?.outputType === 'QUERY') { + if ( + res.paragraphs[index].output[0]?.outputType === 'QUERY' && + this.props.dataSourceEnabled && + res.paragraphs[index].dataSourceMDSId + ) { + await this.loadQueryResultsFromInput( + res.paragraphs[index], + res.paragraphs[index].dataSourceMDSId + ); + } else if ( + res.paragraphs[index].output[0]?.outputType === 'QUERY' && + !this.props.dataSourceEnabled && + res.paragraphs[index].dataSourceMDSId + ) { + res.paragraphs[index].output[0] = []; + this.props.setToast( + `Data source ${res.paragraphs[index].dataSourceMDSLabel} is not available. Please configure your dataSources`, + 'danger' + ); + } else if ( + res.paragraphs[index].output[0]?.outputType === 'QUERY' && + !this.state.savedObjectNotebook + ) { await this.loadQueryResultsFromInput(res.paragraphs[index]); + } else if (res.paragraphs[index].output[0]?.outputType === 'QUERY') { + await this.loadQueryResultsFromInput(res.paragraphs[index], ''); } } this.setState(res, this.parseAllParagraphs); @@ -594,12 +682,20 @@ export class Notebook extends Component { }); }; - loadQueryResultsFromInput = async (paragraph: any) => { + handleSelectedDataSourceChange = (id: string | undefined, label: string | undefined) => { + this.setState({ dataSourceMDSId: id, dataSourceMDSLabel: label }); + }; + + loadQueryResultsFromInput = async (paragraph: any, dataSourceMDSId?: any) => { const queryType = paragraph.input.inputText.substring(0, 4) === '%sql' ? 'sqlquery' : 'pplquery'; + const query = { + dataSourceMDSId, + }; await this.props.http .post(`/api/sql/${queryType}`, { body: JSON.stringify(paragraph.output[0].result), + ...(this.props.dataSourceEnabled && { query }), }) .then((response) => { paragraph.output[0].result = response.data.resp; @@ -941,58 +1037,86 @@ export class Notebook extends Component { )} - - {showReportingContextMenu} + {this.state.savedObjectNotebook && ( + + + this.setState({ + isParaActionsPopoverOpen: !this.state.isParaActionsPopoverOpen, + }) + } + > + Paragraph actions + + } + isOpen={this.state.isParaActionsPopoverOpen} + closePopover={() => this.setState({ isParaActionsPopoverOpen: false })} + > + + + + )} + {this.state.savedObjectNotebook ? ( + + + this.setState({ + isNoteActionsPopoverOpen: !this.state.isNoteActionsPopoverOpen, + }) + } + > + Notebook actions + + } + isOpen={this.state.isNoteActionsPopoverOpen} + closePopover={() => this.setState({ isNoteActionsPopoverOpen: false })} + > + + + + ) : ( + <> + - this.setState({ - isParaActionsPopoverOpen: !this.state.isParaActionsPopoverOpen, - }) - } + data-test-subj="migrate-notebook" + onClick={() => this.showMigrateModal()} > - Paragraph actions + Upgrade Notebook - } - isOpen={this.state.isParaActionsPopoverOpen} - closePopover={() => this.setState({ isParaActionsPopoverOpen: false })} - > - - - - {showReportingContextMenu} - - + - this.setState({ - isNoteActionsPopoverOpen: !this.state.isNoteActionsPopoverOpen, - }) - } + data-test-subj="delete-notebook" + onClick={() => this.showDeleteNotebookModal()} > - Notebook actions + Delete this notebook - } - isOpen={this.state.isNoteActionsPopoverOpen} - closePopover={() => this.setState({ isNoteActionsPopoverOpen: false })} - > - - - + + + )}

{this.state.path}

+ {!this.state.savedObjectNotebook && ( + + Upgrade this notebook to take full advantage of the latest features + + )} @@ -1032,10 +1156,18 @@ export class Notebook extends Component { movePara={this.movePara} showQueryParagraphError={this.state.showQueryParagraphError} queryParagraphErrorMessage={this.state.queryParagraphErrorMessage} + dataSourceManagement={this.props.dataSourceManagement} + setActionMenu={this.props.setActionMenu} + notifications={this.props.notifications} + dataSourceEnabled={this.state.dataSourceMDSEnabled} + savedObjectsMDSClient={this.props.savedObjectsMDSClient} + handleSelectedDataSourceChange={this.handleSelectedDataSourceChange} + paradataSourceMDSId={this.state.parsedPara[index].dataSourceMDSId} + dataSourceMDSLabel={this.state.parsedPara[index].dataSourceMDSLabel} />
))} - {this.state.selectedViewId !== 'output_only' && ( + {this.state.selectedViewId !== 'output_only' && this.state.savedObjectNotebook && ( <> { - - - - } - title="Code block" - description="Write contents directly using markdown, SQL or PPL." - footer={ - this.addPara(0, '', 'CODE')} - style={{ marginBottom: 17 }} - > - Add code block - - } - /> - - - } - title="Visualization" - description="Import OpenSearch Dashboards or Observability visualizations to the notes." - footer={ - this.addPara(0, '', 'VISUALIZATION')} - style={{ marginBottom: 17 }} - > - Add visualization - - } - /> - - - + {this.state.savedObjectNotebook && ( + + + + } + title="Code block" + description="Write contents directly using markdown, SQL or PPL." + footer={ + this.addPara(0, '', 'CODE')} + style={{ marginBottom: 17 }} + > + Add code block + + } + /> + + + } + title="Visualization" + description="Import OpenSearch Dashboards or Observability visualizations to the notes." + footer={ + this.addPara(0, '', 'VISUALIZATION')} + style={{ marginBottom: 17 }} + > + Add visualization + + } + /> + + + + )}
diff --git a/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/paragraphs.test.tsx.snap b/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/paragraphs.test.tsx.snap index 9cda04ed4..ec8ae4936 100644 --- a/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/paragraphs.test.tsx.snap +++ b/public/components/notebooks/components/paragraph_components/__tests__/__snapshots__/paragraphs.test.tsx.snap @@ -74,6 +74,9 @@ exports[` spec renders the component 1`] = `
+
diff --git a/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx b/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx index f2b054ff3..666ca5bd1 100644 --- a/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx +++ b/public/components/notebooks/components/paragraph_components/__tests__/paragraphs.test.tsx @@ -59,6 +59,7 @@ describe(' spec', () => { movePara={movePara} showQueryParagraphError={false} queryParagraphErrorMessage="error-message" + dataSourceEnabled={false} /> ); expect(utils.container.firstChild).toMatchSnapshot(); diff --git a/public/components/notebooks/components/paragraph_components/paragraphs.tsx b/public/components/notebooks/components/paragraph_components/paragraphs.tsx index 98ac5c47d..01ed29da9 100644 --- a/public/components/notebooks/components/paragraph_components/paragraphs.tsx +++ b/public/components/notebooks/components/paragraph_components/paragraphs.tsx @@ -24,11 +24,12 @@ import { import filter from 'lodash/filter'; import moment from 'moment'; import React, { forwardRef, useEffect, useImperativeHandle, useState } from 'react'; -import { CoreStart } from '../../../../../../../src/core/public'; +import { CoreStart, MountPoint, SavedObjectsStart } from '../../../../../../../src/core/public'; import { DashboardContainerInput, DashboardStart, } from '../../../../../../../src/plugins/dashboard/public'; +import { DataSourceManagementPluginSetup } from '../../../../../../../src/plugins/data_source_management/public'; import { ViewMode } from '../../../../../../../src/plugins/embeddable/public'; import { NOTEBOOKS_API_PREFIX } from '../../../../../common/constants/notebooks'; import { @@ -77,7 +78,12 @@ interface ParagraphProps { paraCount: number; paragraphSelector: (index: number) => void; textValueEditor: (evt: React.ChangeEvent, index: number) => void; - handleKeyPress: (evt: React.KeyboardEvent, para: ParaType, index: number) => void; + handleKeyPress: ( + evt: React.KeyboardEvent, + para: ParaType, + index: number, + dataSourceMDSID: string + ) => void; addPara: (index: number, newParaContent: string, inputType: string) => void; DashboardContainerByValueRenderer: DashboardStart['DashboardContainerByValueRenderer']; deleteVizualization: (uniqueId: string) => void; @@ -85,11 +91,28 @@ interface ParagraphProps { selectedViewId: string; setSelectedViewId: (viewId: string, scrollToIndex?: number) => void; deletePara: (para: ParaType, index: number) => void; - runPara: (para: ParaType, index: number, vizObjectInput?: string, paraType?: string) => void; + runPara: ( + para: ParaType, + index: number, + vizObjectInput?: string, + paraType?: string, + dataSourceMDSId?: string + ) => void; clonePara: (para: ParaType, index: number) => void; movePara: (index: number, targetIndex: number) => void; showQueryParagraphError: boolean; queryParagraphErrorMessage: string; + dataSourceManagement: DataSourceManagementPluginSetup; + setActionMenu: (menuMount: MountPoint | undefined) => void; + notifications: CoreStart['notifications']; + dataSourceEnabled: boolean; + savedObjectsMDSClient: SavedObjectsStart; + handleSelectedDataSourceChange: ( + dataSourceMDSId: string | undefined, + dataSourceMDSLabel: string | undefined + ) => void; + paradataSourceMDSId: string; + dataSourceMDSLabel: string; } export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { @@ -104,6 +127,13 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { showQueryParagraphError, queryParagraphErrorMessage, http, + dataSourceEnabled, + dataSourceManagement, + notifications, + savedObjectsMDSClient, + handleSelectedDataSourceChange, + paradataSourceMDSId, + dataSourceMDSLabel, } = props; const [visOptions, setVisOptions] = useState([ @@ -115,6 +145,7 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { const [selectedVisOption, setSelectedVisOption] = useState([]); const [visInput, setVisInput] = useState(undefined); const [visType, setVisType] = useState(''); + const [dataSourceMDSId, setDataSourceMDSId] = useState(''); // output is available if it's not cleared and vis paragraph has a selected visualization const isOutputAvailable = @@ -131,7 +162,7 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { let opt1: EuiComboBoxOptionOption[] = []; let opt2: EuiComboBoxOptionOption[] = []; await http - .get(`${NOTEBOOKS_API_PREFIX}/visualizations`) + .get(`${NOTEBOOKS_API_PREFIX}/visualizations/${dataSourceMDSId ?? ''}`) .then((res) => { opt1 = res.savedVisualizations.map((vizObject) => ({ label: vizObject.label, @@ -175,7 +206,7 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { if (para.visSavedObjId !== '') setVisInput(JSON.parse(para.vizObjectInput)); fetchVisualizations(); } - }, []); + }, [dataSourceMDSId]); const createDashboardVizObject = (objectId: string) => { const vizUniqueId = htmlIdGenerator()(); @@ -235,7 +266,7 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { newVisObjectInput = JSON.stringify(inputTemp); } setRunParaError(false); - return props.runPara(para, index, newVisObjectInput, visType); + return props.runPara(para, index, newVisObjectInput, visType, dataSourceMDSId); }; const setStartTime = (time: string) => { @@ -511,11 +542,36 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { const paraClass = `notebooks-paragraph notebooks-paragraph-${ uiSettingsService.get('theme:darkMode') ? 'dark' : 'light' }`; - + let DataSourceSelector; + const onSelectedDataSource = (e) => { + const dataConnectionId = e[0] ? e[0].id : undefined; + const dataConnectionLabel = e[0] ? e[0].label : undefined; + setDataSourceMDSId(dataConnectionId); + handleSelectedDataSourceChange(dataConnectionId, dataConnectionLabel); + }; + if (dataSourceEnabled) { + DataSourceSelector = dataSourceManagement.ui.DataSourceSelector; + } return ( <> {renderParaHeader(!para.isVizualisation ? 'Code block' : 'Visualization', index)} + {dataSourceEnabled && !para.isVizualisation && ( + + )} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events */}
paragraphSelector(index)}> {para.isInputExpanded && ( @@ -542,6 +598,10 @@ export const Paragraphs = forwardRef((props: ParagraphProps, ref) => { selectedVisOption={selectedVisOption} setSelectedVisOption={setSelectedVisOption} setVisType={setVisType} + dataSourceManagement={dataSourceManagement} + notifications={notifications} + dataSourceEnabled={dataSourceEnabled} + savedObjectsMDSClient={savedObjectsMDSClient} /> {runParaError && ( diff --git a/server/adaptors/notebooks/default_backend.ts b/server/adaptors/notebooks/default_backend.ts index 1d93f85e3..ae49be07d 100644 --- a/server/adaptors/notebooks/default_backend.ts +++ b/server/adaptors/notebooks/default_backend.ts @@ -6,7 +6,7 @@ import now from 'performance-now'; import { v4 as uuid } from 'uuid'; import { ILegacyScopedClusterClient } from '../../../../../src/core/server'; -import { optionsType } from '../../../common/types/notebooks'; +import { OptionsType } from '../../../common/types/notebooks'; import { DefaultNotebooks, DefaultOutput, @@ -86,7 +86,7 @@ export class DefaultBackend implements NotebookAdaptor { }; // gets first `FETCH_SIZE` notebooks available - viewNotes = async function (client: ILegacyScopedClusterClient, _wreckOptions: optionsType) { + viewNotes = async function (client: ILegacyScopedClusterClient, _wreckOptions: OptionsType) { try { const response = await client.callAsCurrentUser('observability.getObject', { objectType: 'notebook', @@ -110,7 +110,7 @@ export class DefaultBackend implements NotebookAdaptor { fetchNote = async function ( client: ILegacyScopedClusterClient, noteId: string, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const noteObject = await this.getNote(client, noteId); @@ -131,7 +131,7 @@ export class DefaultBackend implements NotebookAdaptor { addNote = async function ( client: ILegacyScopedClusterClient, params: { name: string }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const newNotebook = this.createNewNotebook(params.name); @@ -152,7 +152,7 @@ export class DefaultBackend implements NotebookAdaptor { addSampleNotes = async function ( client: ILegacyScopedClusterClient, visIds: string[], - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const notebooks = getSampleNotebooks(visIds); @@ -181,7 +181,7 @@ export class DefaultBackend implements NotebookAdaptor { renameNote = async function ( client: ILegacyScopedClusterClient, params: { name: string; noteId: string }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const updateNotebook = { @@ -202,7 +202,7 @@ export class DefaultBackend implements NotebookAdaptor { cloneNote = async function ( client: ILegacyScopedClusterClient, params: { name: string; noteId: string }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const noteObject = await this.getNote(client, params.noteId); @@ -225,7 +225,7 @@ export class DefaultBackend implements NotebookAdaptor { deleteNote = async function ( client: ILegacyScopedClusterClient, noteList: string, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const response = await client.callAsCurrentUser('observability.deleteObjectByIdList', { @@ -243,7 +243,7 @@ export class DefaultBackend implements NotebookAdaptor { exportNote = async function ( client: ILegacyScopedClusterClient, noteId: string, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const opensearchClientGetResponse = await this.getNote(client, noteId); @@ -259,7 +259,7 @@ export class DefaultBackend implements NotebookAdaptor { importNote = async function ( client: ILegacyScopedClusterClient, noteObj: any, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const newNoteObject = { ...noteObj }; @@ -352,7 +352,7 @@ export class DefaultBackend implements NotebookAdaptor { runParagraph = async function ( paragraphs: DefaultParagraph[], paragraphId: string, - client: ILegacyScopedClusterClient + _client: ILegacyScopedClusterClient ) { try { const updatedParagraphs = []; @@ -431,7 +431,7 @@ export class DefaultBackend implements NotebookAdaptor { updateRunFetchParagraph = async function ( client: ILegacyScopedClusterClient, request: any, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const scopedClient = client.asScoped(request); @@ -452,11 +452,7 @@ export class DefaultBackend implements NotebookAdaptor { paragraphs: updatedOutputParagraphs, dateModified: new Date().toISOString(), }; - const opensearchClientResponse = await this.updateNote( - scopedClient, - params.noteId, - updateNotebook - ); + await this.updateNote(scopedClient, params.noteId, updateNotebook); let resultParagraph = {}; let index = 0; @@ -481,7 +477,7 @@ export class DefaultBackend implements NotebookAdaptor { updateFetchParagraph = async function ( client: ILegacyScopedClusterClient, params: { noteId: string; paragraphId: string; paragraphInput: string }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const opensearchClientGetResponse = await this.getNote(client, params.noteId); @@ -495,7 +491,7 @@ export class DefaultBackend implements NotebookAdaptor { paragraphs: updatedInputParagraphs, dateModified: new Date().toISOString(), }; - const opensearchClientResponse = await this.updateNote(client, params.noteId, updateNotebook); + await this.updateNote(client, params.noteId, updateNotebook); let resultParagraph = {}; updatedInputParagraphs.map((paragraph: DefaultParagraph) => { @@ -518,7 +514,7 @@ export class DefaultBackend implements NotebookAdaptor { addFetchNewParagraph = async function ( client: ILegacyScopedClusterClient, params: { noteId: string; paragraphIndex: number; paragraphInput: string; inputType: string }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const opensearchClientGetResponse = await this.getNote(client, params.noteId); @@ -529,7 +525,7 @@ export class DefaultBackend implements NotebookAdaptor { paragraphs, dateModified: new Date().toISOString(), }; - const opensearchClientResponse = await this.updateNote(client, params.noteId, updateNotebook); + await this.updateNote(client, params.noteId, updateNotebook); return newParagraph; } catch (error) { @@ -546,26 +542,24 @@ export class DefaultBackend implements NotebookAdaptor { deleteFetchParagraphs = async function ( client: ILegacyScopedClusterClient, params: { noteId: string; paragraphId: string | undefined }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const opensearchClientGetResponse = await this.getNote(client, params.noteId); const updatedparagraphs: DefaultParagraph[] = []; if (params.paragraphId !== undefined) { - opensearchClientGetResponse.notebook.paragraphs.map( - (paragraph: DefaultParagraph, index: number) => { - if (paragraph.id !== params.paragraphId) { - updatedparagraphs.push(paragraph); - } + opensearchClientGetResponse.notebook.paragraphs.map((paragraph: DefaultParagraph) => { + if (paragraph.id !== params.paragraphId) { + updatedparagraphs.push(paragraph); } - ); + }); } const updateNotebook = { paragraphs: updatedparagraphs, dateModified: new Date().toISOString(), }; - const opensearchClientResponse = await this.updateNote(client, params.noteId, updateNotebook); + await this.updateNote(client, params.noteId, updateNotebook); return { paragraphs: updatedparagraphs }; } catch (error) { @@ -582,24 +576,22 @@ export class DefaultBackend implements NotebookAdaptor { clearAllFetchParagraphs = async function ( client: ILegacyScopedClusterClient, params: { noteId: string }, - _wreckOptions: optionsType + _wreckOptions: OptionsType ) { try { const opensearchClientGetResponse = await this.getNote(client, params.noteId); const updatedparagraphs: DefaultParagraph[] = []; - opensearchClientGetResponse.notebook.paragraphs.map( - (paragraph: DefaultParagraph, index: number) => { - const updatedParagraph = { ...paragraph }; - updatedParagraph.output = []; - updatedparagraphs.push(updatedParagraph); - } - ); + opensearchClientGetResponse.notebook.paragraphs.map((paragraph: DefaultParagraph) => { + const updatedParagraph = { ...paragraph }; + updatedParagraph.output = []; + updatedparagraphs.push(updatedParagraph); + }); const updateNotebook = { paragraphs: updatedparagraphs, dateModified: new Date().toISOString(), }; - const opensearchClientResponse = await this.updateNote(client, params.noteId, updateNotebook); + await this.updateNote(client, params.noteId, updateNotebook); return { paragraphs: updatedparagraphs }; } catch (error) { diff --git a/server/adaptors/notebooks/notebook_adaptor.ts b/server/adaptors/notebooks/notebook_adaptor.ts index 0ede5ffb2..f121a375c 100644 --- a/server/adaptors/notebooks/notebook_adaptor.ts +++ b/server/adaptors/notebooks/notebook_adaptor.ts @@ -4,13 +4,13 @@ */ import { ILegacyScopedClusterClient } from '../../../../../src/core/server'; -import { optionsType } from '../../../common/types/notebooks'; +import { OptionsType } from '../../../common/types/notebooks'; export interface NotebookAdaptor { backend: string; // Gets all the notebooks available - viewNotes: (client: ILegacyScopedClusterClient, wreckOptions: optionsType) => Promise; + viewNotes: (client: ILegacyScopedClusterClient, wreckOptions: OptionsType) => Promise; /* Fetches a notebook by id * Param: noteId -> Id of notebook to be fetched @@ -18,7 +18,7 @@ export interface NotebookAdaptor { fetchNote: ( client: ILegacyScopedClusterClient, noteId: string, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* Adds a notebook to storage @@ -27,7 +27,7 @@ export interface NotebookAdaptor { addNote: ( client: ILegacyScopedClusterClient, params: { name: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* Renames a notebook @@ -37,7 +37,7 @@ export interface NotebookAdaptor { renameNote: ( client: ILegacyScopedClusterClient, params: { name: string; noteId: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* Clone a notebook @@ -47,7 +47,7 @@ export interface NotebookAdaptor { cloneNote: ( client: ILegacyScopedClusterClient, params: { name: string; noteId: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* Delete a notebook @@ -56,7 +56,7 @@ export interface NotebookAdaptor { deleteNote: ( client: ILegacyScopedClusterClient, noteId: string, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* Export a notebook @@ -65,7 +65,7 @@ export interface NotebookAdaptor { exportNote: ( client: ILegacyScopedClusterClient, noteId: string, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* Import a notebook @@ -74,7 +74,7 @@ export interface NotebookAdaptor { importNote: ( client: ILegacyScopedClusterClient, noteObj: any, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* --> Updates a Paragraph with input content @@ -87,7 +87,7 @@ export interface NotebookAdaptor { updateRunFetchParagraph: ( client: ILegacyScopedClusterClient, request: any, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* --> Updates a Paragraph with input content @@ -99,7 +99,7 @@ export interface NotebookAdaptor { updateFetchParagraph: ( client: ILegacyScopedClusterClient, params: { noteId: string; paragraphId: string; paragraphInput: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* --> Adds a Paragraph with input content @@ -110,7 +110,7 @@ export interface NotebookAdaptor { addFetchNewParagraph: ( client: ILegacyScopedClusterClient, params: { noteId: string; paragraphIndex: number; paragraphInput: string; inputType: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise; /* --> Deletes a Paragraph with id @@ -121,7 +121,7 @@ export interface NotebookAdaptor { deleteFetchParagraphs: ( client: ILegacyScopedClusterClient, params: { noteId: string; paragraphId: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise<{ paragraphs: any }>; /* --> Clears output for all the paragraphs @@ -131,6 +131,6 @@ export interface NotebookAdaptor { clearAllFetchParagraphs: ( client: ILegacyScopedClusterClient, params: { noteId: string }, - wreckOptions: optionsType + wreckOptions: OptionsType ) => Promise<{ paragraphs: any }>; } diff --git a/server/adaptors/notebooks/saved_objects_notebooks_router.tsx b/server/adaptors/notebooks/saved_objects_notebooks_router.tsx new file mode 100644 index 000000000..6a941fbf9 --- /dev/null +++ b/server/adaptors/notebooks/saved_objects_notebooks_router.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsClientContract } from '../../../../../src/core/server/types'; +import { NOTEBOOK_SAVED_OBJECT } from '../../../common/types/observability_saved_object_attributes'; +import { DefaultNotebooks } from '../../../server/common/helpers/notebooks/default_notebook_schema'; +import { getSampleNotebooks } from '../../../server/common/helpers/notebooks/sample_notebooks'; + +export function fetchNotebooks(savedObjectNotebooks: []) { + const notebooks = []; + savedObjectNotebooks.map((savedObject) => { + if (savedObject.type === 'observability-notebook' && savedObject.attributes.savedNotebook) { + notebooks.push({ + dateCreated: savedObject.attributes.savedNotebook.dateCreated, + dateModified: savedObject.attributes.savedNotebook.dateModified, + path: savedObject.attributes.savedNotebook.name, + id: savedObject.id, + }); + } + }); + + return notebooks; +} + +export function createNotebook(notebookName: { name: string }) { + const noteObject = { + dateCreated: new Date().toISOString(), + name: notebookName.name, + dateModified: new Date().toISOString(), + backend: '.kibana_1.0', + paragraphs: [], + path: notebookName.name, + }; + + return { + savedNotebook: noteObject, + }; +} + +export function cloneNotebook(fetchedNotebook: DefaultNotebooks, name: string) { + const noteObject = { + dateCreated: new Date().toISOString(), + name, + dateModified: new Date().toISOString(), + backend: 'kibana_1.0', + paragraphs: fetchedNotebook.paragraphs, + path: name, + }; + + return { + savedNotebook: noteObject, + }; +} + +export function renameNotebook(noteBookObj: { name: string; noteId: string }) { + const noteObject = { + name: noteBookObj.name, + dateModified: new Date().toISOString(), + path: noteBookObj.name, + }; + + return { + savedNotebook: noteObject, + }; +} + +export async function addSampleNotes( + opensearchNotebooksClient: SavedObjectsClientContract, + visIds: string[] +) { + const notebooks = getSampleNotebooks(visIds); + const sampleNotebooks = []; + try { + for (const item of notebooks) { + const createdNotebooks = await opensearchNotebooksClient.create(NOTEBOOK_SAVED_OBJECT, item); + sampleNotebooks.push({ + dateCreated: createdNotebooks.attributes.savedNotebook.dateCreated, + dateModified: createdNotebooks.attributes.savedNotebook.dateModified, + name: createdNotebooks.attributes.savedNotebook.name, + id: createdNotebooks.id, + path: createdNotebooks.attributes.savedNotebook.name, + }); + } + + return { status: 'OK', message: '', body: sampleNotebooks }; + } catch (error) { + console.log('error', error); + throw new Error('Update Sample Notebook error' + error); + } +} diff --git a/server/adaptors/notebooks/saved_objects_paragraphs_router.tsx b/server/adaptors/notebooks/saved_objects_paragraphs_router.tsx new file mode 100644 index 000000000..d3d27748f --- /dev/null +++ b/server/adaptors/notebooks/saved_objects_paragraphs_router.tsx @@ -0,0 +1,306 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import now from 'performance-now'; +import { v4 as uuid } from 'uuid'; +import { SavedObjectsClientContract } from '../../../../../src/core/server/types'; +import { NOTEBOOK_SAVED_OBJECT } from '../../../common/types/observability_saved_object_attributes'; +import { + DefaultOutput, + DefaultParagraph, +} from '../../common/helpers/notebooks/default_notebook_schema'; +import { formatNotRecognized, inputIsQuery } from '../../common/helpers/notebooks/query_helpers'; + +export function createNotebook(paragraphInput: string, inputType: string) { + try { + let paragraphType = 'MARKDOWN'; + if (inputType === 'VISUALIZATION') { + paragraphType = 'VISUALIZATION'; + } + if (inputType === 'OBSERVABILITY_VISUALIZATION') { + paragraphType = 'OBSERVABILITY_VISUALIZATION'; + } + if (paragraphInput.substring(0, 3) === '%sql' || paragraphInput.substring(0, 3) === '%ppl') { + paragraphType = 'QUERY'; + } + const inputObject = { + inputType: paragraphType, + inputText: paragraphInput, + }; + const outputObjects: DefaultOutput[] = [ + { + outputType: paragraphType, + result: '', + execution_time: '0s', + }, + ]; + const newParagraph = { + id: 'paragraph_' + uuid(), + dateCreated: new Date().toISOString(), + dateModified: new Date().toISOString(), + input: inputObject, + output: outputObjects, + }; + + return newParagraph; + } catch (error) { + throw new Error('Create Paragraph Error:' + error); + } +} + +export async function fetchNotebook( + noteId: string, + opensearchNotebooksClient: SavedObjectsClientContract +) { + try { + const notebook = await opensearchNotebooksClient.get(NOTEBOOK_SAVED_OBJECT, noteId); + return notebook; + } catch (error) { + throw new Error('update Paragraph Error:' + error); + } +} + +export async function createParagraphs( + params: { noteId: string; paragraphIndex: number; paragraphInput: string; inputType: string }, + opensearchNotebooksClient: SavedObjectsClientContract +) { + const notebookinfo = await fetchNotebook(params.noteId, opensearchNotebooksClient); + const paragraphs = notebookinfo.attributes.savedNotebook.paragraphs; + const newParagraph = createNotebook(params.paragraphInput, params.inputType); + paragraphs.splice(params.paragraphIndex, 0, newParagraph); + const updateNotebook = { + paragraphs, + dateModified: new Date().toISOString(), + }; + await opensearchNotebooksClient.update(NOTEBOOK_SAVED_OBJECT, params.noteId, { + savedNotebook: updateNotebook, + }); + await fetchNotebook(params.noteId, opensearchNotebooksClient); + return newParagraph; +} + +export async function clearParagraphs( + params: { noteId: string }, + opensearchNotebooksClient: SavedObjectsClientContract +) { + const notebookinfo = await fetchNotebook(params.noteId, opensearchNotebooksClient); + const updatedparagraphs: DefaultParagraph[] = []; + notebookinfo.attributes.savedNotebook.paragraphs.map((paragraph: DefaultParagraph) => { + const updatedParagraph = { ...paragraph }; + updatedParagraph.output = []; + updatedparagraphs.push(updatedParagraph); + }); + const updateNotebook = { + paragraphs: updatedparagraphs, + dateModified: new Date().toISOString(), + }; + try { + await opensearchNotebooksClient.update(NOTEBOOK_SAVED_OBJECT, params.noteId, { + savedNotebook: updateNotebook, + }); + return { paragraphs: updatedparagraphs }; + } catch (error) { + throw new Error('Clear Paragraph Error:' + error); + } +} + +export async function deleteParagraphs( + params: { noteId: string; paragraphId: string | undefined }, + opensearchNotebooksClient: SavedObjectsClientContract +) { + const notebookinfo = await fetchNotebook(params.noteId, opensearchNotebooksClient); + const updatedparagraphs: DefaultParagraph[] = []; + if (params.paragraphId !== undefined) { + notebookinfo.attributes.savedNotebook.paragraphs.map((paragraph: DefaultParagraph) => { + if (paragraph.id !== params.paragraphId) { + updatedparagraphs.push(paragraph); + } + }); + } + + const updateNotebook = { + paragraphs: updatedparagraphs, + dateModified: new Date().toISOString(), + }; + try { + await opensearchNotebooksClient.update(NOTEBOOK_SAVED_OBJECT, params.noteId, { + savedNotebook: updateNotebook, + }); + return { paragraphs: updatedparagraphs }; + } catch (error) { + throw new Error('update Paragraph Error:' + error); + } +} + +export async function updateRunFetchParagraph( + params: { + noteId: string; + paragraphId: string; + paragraphInput: string; + paragraphType: string; + dataSourceMDSId: string | undefined; + dataSourceMDSLabel: string | undefined; + }, + opensearchNotebooksClient: SavedObjectsClientContract +) { + try { + const notebookinfo = await fetchNotebook(params.noteId, opensearchNotebooksClient); + const updatedInputParagraphs = updateParagraphs( + notebookinfo.attributes.savedNotebook.paragraphs, + params.paragraphId, + params.paragraphInput, + params.paragraphType, + params.dataSourceMDSId, + params.dataSourceMDSLabel + ); + const updatedOutputParagraphs = await runParagraph(updatedInputParagraphs, params.paragraphId); + + const updateNotebook = { + paragraphs: updatedOutputParagraphs, + dateModified: new Date().toISOString(), + }; + await opensearchNotebooksClient.update(NOTEBOOK_SAVED_OBJECT, params.noteId, { + savedNotebook: updateNotebook, + }); + let resultParagraph = {}; + let index = 0; + + for (index = 0; index < updatedOutputParagraphs.length; ++index) { + if (params.paragraphId === updatedOutputParagraphs[index].id) { + resultParagraph = updatedOutputParagraphs[index]; + } + } + return resultParagraph; + } catch (error) { + throw new Error('Update/Run Paragraph Error:' + error); + } +} + +export function runParagraph(paragraphs: DefaultParagraph[], paragraphId: string) { + try { + const updatedParagraphs = []; + let index = 0; + for (index = 0; index < paragraphs.length; ++index) { + const startTime = now(); + const updatedParagraph = { ...paragraphs[index] }; + if (paragraphs[index].id === paragraphId) { + updatedParagraph.dateModified = new Date().toISOString(); + if (inputIsQuery(paragraphs[index].input.inputText)) { + updatedParagraph.output = [ + { + outputType: 'QUERY', + result: paragraphs[index].input.inputText.substring( + 4, + paragraphs[index].input.inputText.length + ), + execution_time: `${(now() - startTime).toFixed(3)} ms`, + }, + ]; + } else if (paragraphs[index].input.inputText.substring(0, 3) === '%md') { + updatedParagraph.output = [ + { + outputType: 'MARKDOWN', + result: paragraphs[index].input.inputText.substring( + 4, + paragraphs[index].input.inputText.length + ), + execution_time: `${(now() - startTime).toFixed(3)} ms`, + }, + ]; + } else if (paragraphs[index].input.inputType === 'VISUALIZATION') { + updatedParagraph.dateModified = new Date().toISOString(); + updatedParagraph.output = [ + { + outputType: 'VISUALIZATION', + result: '', + execution_time: `${(now() - startTime).toFixed(3)} ms`, + }, + ]; + } else if (paragraphs[index].input.inputType === 'OBSERVABILITY_VISUALIZATION') { + updatedParagraph.dateModified = new Date().toISOString(); + updatedParagraph.output = [ + { + outputType: 'OBSERVABILITY_VISUALIZATION', + result: '', + execution_time: `${(now() - startTime).toFixed(3)} ms`, + }, + ]; + } else if (formatNotRecognized(paragraphs[index].input.inputText)) { + updatedParagraph.output = [ + { + outputType: 'MARKDOWN', + result: 'Please select an input type (%sql, %ppl, or %md)', + execution_time: `${(now() - startTime).toFixed(3)} ms`, + }, + ]; + } + } + updatedParagraphs.push(updatedParagraph); + } + return updatedParagraphs; + } catch (error) { + throw new Error('Running Paragraph Error:' + error); + } +} + +export function updateParagraphs( + paragraphs: DefaultParagraph[], + paragraphId: string, + paragraphInput: string, + paragraphType?: string, + dataSourceMDSId?: string, + dataSourceMDSLabel?: string +) { + try { + const updatedParagraphs: DefaultParagraph[] = []; + paragraphs.map((paragraph: DefaultParagraph) => { + const updatedParagraph = { ...paragraph }; + if (paragraph.id === paragraphId) { + updatedParagraph.dataSourceMDSId = dataSourceMDSId; + updatedParagraph.dataSourceMDSLabel = dataSourceMDSLabel; + updatedParagraph.dateModified = new Date().toISOString(); + updatedParagraph.input.inputText = paragraphInput; + if (paragraphType.length > 0) { + updatedParagraph.input.inputType = paragraphType; + } + } + updatedParagraphs.push(updatedParagraph); + }); + return updatedParagraphs; + } catch (error) { + throw new Error('Update Paragraph Error:' + error); + } +} + +export async function updateFetchParagraph( + params: { noteId: string; paragraphId: string; paragraphInput: string }, + opensearchNotebooksClient: SavedObjectsClientContract +) { + try { + const notebookinfo = await fetchNotebook(params.noteId, opensearchNotebooksClient); + const updatedInputParagraphs = updateParagraphs( + notebookinfo.attributes.savedNotebook.paragraphs, + params.paragraphId, + params.paragraphInput + ); + + const updateNotebook = { + paragraphs: updatedInputParagraphs, + dateModified: new Date().toISOString(), + }; + await opensearchNotebooksClient.update(NOTEBOOK_SAVED_OBJECT, params.noteId, { + savedNotebook: updateNotebook, + }); + let resultParagraph = {}; + updatedInputParagraphs.map((paragraph: DefaultParagraph) => { + if (params.paragraphId === paragraph.id) { + resultParagraph = paragraph; + } + }); + return resultParagraph; + } catch (error) { + throw new Error('update Paragraph Error:' + error); + } +} diff --git a/server/common/helpers/notebooks/default_notebook_schema.ts b/server/common/helpers/notebooks/default_notebook_schema.ts index eb2a763fc..603be3d00 100644 --- a/server/common/helpers/notebooks/default_notebook_schema.ts +++ b/server/common/helpers/notebooks/default_notebook_schema.ts @@ -21,6 +21,8 @@ export interface DefaultParagraph { dateModified: string; input: DefaultInput; output: DefaultOutput[]; + dataSourceMDSId?: string; + dataSourceMDSLabel?: string; } export interface DefaultNotebooks { name: string; diff --git a/server/common/helpers/notebooks/sample_notebooks.ts b/server/common/helpers/notebooks/sample_notebooks.ts index 3124c8215..eeef29ea1 100644 --- a/server/common/helpers/notebooks/sample_notebooks.ts +++ b/server/common/helpers/notebooks/sample_notebooks.ts @@ -11,9 +11,10 @@ const getDemoNotebook = (dateString: string, visId: string) => { oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); return { name: 'OpenSearch Notebooks Quick Start Guide', + path: 'OpenSearch Notebooks Quick Start Guide', dateCreated: dateString, dateModified: dateString, - backend: 'Default', + backend: 'kibana_1.0', paragraphs: [ { output: [ @@ -189,9 +190,10 @@ const getRootCauseNotebook = (dateString: string, visIds: string[]) => { oneWeekAgo.setDate(oneWeekAgo.getDate() - 7); return { name: '[Logs] Sample Root Cause Event Analysis', + path: '[Logs] Sample Root Cause Event Analysis', dateCreated: dateString, dateModified: dateString, - backend: 'Default', + backend: 'kibana_1.0', paragraphs: [ { output: [ @@ -285,7 +287,7 @@ Let's take a look at the source data by the selected fields (search and fields). response, bytes | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.008 ms', }, ], @@ -324,7 +326,7 @@ Check for any error log with response code 404 or 503 (filter).`, result: ` source=opensearch_dashboards_sample_data_logs | fields host, clientip, response, bytes | where response='503' or response='404' | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.007 ms', }, ], @@ -362,7 +364,7 @@ We see too many events. Let's quickly check which host has the issue (dedup).`, source=opensearch_dashboards_sample_data_logs | fields host, clientip, response, bytes | where response='503' or response='404' | dedup host | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.010 ms', }, ], @@ -402,7 +404,7 @@ We get too few events. Let's dedup in consecutive mode (dedup).`, bytes | where response='503' or response='404' | dedup host consecutive=true | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.007 ms', }, ], @@ -443,7 +445,7 @@ How many IP addresses for each response (stats).`, bytes | where response='503' or response='404' | stats count() as ip_count by response | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.008 ms', }, ], @@ -485,7 +487,7 @@ To dive deep, let's group by host and response, count, and sum (stats).`, stats count() as ip_count, sum(bytes) as sum_bytes by host, response | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.007 ms', }, ], @@ -530,7 +532,7 @@ We don't see a meaningful response. Let's change to resp_code (rename).`, rename response as resp_code | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.009 ms', }, ], @@ -578,7 +580,7 @@ The data looks better now. Let's sort by \`DESC count\` and \`ASC sum_bytes\` (s rename response as resp_code | sort - ip_count, + sum_bytes | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.006 ms', }, ], @@ -626,7 +628,7 @@ Let's check if we can perform aggregations after stats (eval).`, sort - ip_count, + sum_bytes | eval per_ip_bytes=sum_bytes/ip_count | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.006 ms', }, ], @@ -676,7 +678,7 @@ Wait, what's meant by an evaluation. Can we really perform an evaluation?`, eval per_ip_bytes=sum_bytes/ip_count, double_per_ip_bytes = 2 * per_ip_bytes | head 20 `, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.010 ms', }, ], @@ -704,9 +706,10 @@ Wait, what's meant by an evaluation. Can we really perform an evaluation?`, const getSQLNotebook = (dateString: string) => { return { name: '[Flights] OpenSearch SQL Quick Start Guide', + path: '[Flights] OpenSearch SQL Quick Start Guide', dateCreated: dateString, dateModified: dateString, - backend: 'Default', + backend: 'kibana_1.0', paragraphs: [ { output: [ @@ -769,7 +772,7 @@ To use SQL, add a code paragraph, type %sql on the first line, and then add SQL output: [ { result: 'Select * from opensearch_dashboards_sample_data_flights limit 20;', - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.007 ms', }, ], @@ -804,7 +807,7 @@ You can specify fields in the \`SELECT\` clause and use the \`WHERE\` clause to output: [ { result: `SELECT FlightNum,OriginCountry,OriginCityName,DestCountry,DestCityName,DistanceMiles FROM opensearch_dashboards_sample_data_flights WHERE DistanceMiles > 5000 AND DestCountry LIKE 'A%' LIMIT 20;`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.006 ms', }, ], @@ -838,7 +841,7 @@ OpenSearch SQL also supports subqueries:`, output: [ { result: `SELECT opensearch_dashboards_sample_data_flights.FlightNum,opensearch_dashboards_sample_data_flights.OriginCountry,opensearch_dashboards_sample_data_flights.OriginCityName,opensearch_dashboards_sample_data_flights.DestCountry,opensearch_dashboards_sample_data_flights.DestCityName,opensearch_dashboards_sample_data_flights.DistanceMiles FROM opensearch_dashboards_sample_data_flights WHERE FlightNum IN (SELECT FlightNum FROM opensearch_dashboards_sample_data_flights WHERE DistanceMiles > 5000 AND DestCountry = 'AU') LIMIT 20;`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.009 ms', }, ], @@ -874,7 +877,7 @@ OpenSearch SQL supports inner joins, cross joins, and left outer joins. The foll { result: ` SELECT a.FlightNum,a.OriginCountry,a.OriginCityName,a.DestCountry,a.DestCityName,a.DistanceMiles FROM opensearch_dashboards_sample_data_flights a JOIN opensearch_dashboards_sample_data_flights b on a.OriginCountry = b.DestCountry LIMIT 20`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.006 ms', }, ], @@ -909,7 +912,7 @@ For aggregations, use the \`GROUP BY\` clause. The following query finds the cou output: [ { result: `SELECT OriginCountry,COUNT(1) FROM opensearch_dashboards_sample_data_flights GROUP BY OriginCountry HAVING COUNT(1) > 500 LIMIT 20;`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.006 ms', }, ], @@ -943,7 +946,7 @@ OpenSearch SQL supports expressions.`, output: [ { result: `SELECT abs(-1.234), abs(-1 * abs(-5)), dayofmonth(DATE '2021-07-07');`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.005 ms', }, ], @@ -963,9 +966,10 @@ SELECT abs(-1.234), abs(-1 * abs(-5)), dayofmonth(DATE '2021-07-07');`, const getPPLNotebook = (dateString: string) => { return { name: '[Logs] OpenSearch Piped Processing Language (PPL) Quick Start Guide', + path: '[Logs] OpenSearch Piped Processing Language (PPL) Quick Start Guide', dateCreated: dateString, dateModified: dateString, - backend: 'Default', + backend: 'kibana_1.0', paragraphs: [ { output: [ @@ -1062,7 +1066,7 @@ To use PPL, add a code paragraph, type \`%ppl\` on the first line, and add your { result: ` source=opensearch_dashboards_sample_data_logs | head 20`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.026 ms', }, ], @@ -1098,7 +1102,7 @@ To specify fields to include and filter results, use the \`field\` and \`where\` { result: ` source=opensearch_dashboards_sample_data_logs | fields host, clientip, response, bytes | where response='503' or response='404'`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.006 ms', }, ], @@ -1133,7 +1137,7 @@ To see most common hosts from the previous result, use the \`top\` command.`, { result: ` source=opensearch_dashboards_sample_data_logs | where response='503' or response='404' | top host`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.008 ms', }, ], @@ -1168,7 +1172,7 @@ To perform aggregations on search results, use the \`stats\` command.`, { result: ` source=opensearch_dashboards_sample_data_logs | where response='503' or response='404' | stats count(1) by host`, - outputType: 'QUERY', + // outputType: 'QUERY', execution_time: '0.011 ms', }, ], @@ -1210,22 +1214,22 @@ export const getSampleNotebooks = (visIds: string[]) => { const dateString = new Date().toISOString(); return [ { - notebook: getDemoNotebook(dateString, visIds[2]), + savedNotebook: getDemoNotebook(dateString, visIds[2]), dateModified: dateString, dateCreated: dateString, }, { - notebook: getSQLNotebook(dateString), + savedNotebook: getSQLNotebook(dateString), dateModified: dateString, dateCreated: dateString, }, { - notebook: getPPLNotebook(dateString), + savedNotebook: getPPLNotebook(dateString), dateModified: dateString, dateCreated: dateString, }, { - notebook: getRootCauseNotebook(dateString, visIds), + savedNotebook: getRootCauseNotebook(dateString, visIds), dateModified: dateString, dateCreated: dateString, }, diff --git a/server/common/helpers/notebooks/wreck_requests.ts b/server/common/helpers/notebooks/wreck_requests.ts index 97290cae4..7e396728f 100644 --- a/server/common/helpers/notebooks/wreck_requests.ts +++ b/server/common/helpers/notebooks/wreck_requests.ts @@ -4,12 +4,12 @@ */ import Wreck from '@hapi/wreck'; -import { optionsType } from '../../../../common/types/notebooks'; +import { OptionsType } from '../../../../common/types/notebooks'; export const requestor = async function ( requestType: string, url: string, - wreckOptions: optionsType + wreckOptions: OptionsType ) { const promise = Wreck.request(requestType, url, wreckOptions); const res = await promise; diff --git a/server/plugin.ts b/server/plugin.ts index 7270a1141..d0b5530da 100644 --- a/server/plugin.ts +++ b/server/plugin.ts @@ -21,6 +21,7 @@ import { PPLPlugin } from './adaptors/ppl_plugin'; import { PPLParsers } from './parsers/ppl_parser'; import { setupRoutes } from './routes/index'; import { + notebookSavedObject, searchSavedObject, visualizationSavedObject, } from './saved_objects/observability_saved_object'; @@ -218,6 +219,7 @@ export class ObservabilityPlugin core.savedObjects.registerType(visualizationSavedObject); core.savedObjects.registerType(searchSavedObject); + core.savedObjects.registerType(notebookSavedObject); core.capabilities.registerProvider(() => ({ observability: { show: true, diff --git a/server/routes/index.ts b/server/routes/index.ts index d8a695233..383c4a381 100644 --- a/server/routes/index.ts +++ b/server/routes/index.ts @@ -47,9 +47,9 @@ export function setupRoutes({ // notebooks routes registerParaRoute(router); registerNoteRoute(router); - registerVizRoute(router); + registerVizRoute(router, dataSourceEnabled); const queryService = new QueryService(client); - registerSqlRoute(router, queryService); + registerSqlRoute(router, queryService, dataSourceEnabled); registerMetricsRoute(router, dataSourceEnabled); registerIntegrationsRoute(router); diff --git a/server/routes/notebooks/noteRouter.ts b/server/routes/notebooks/noteRouter.ts index b8bcc503f..6b09ee466 100644 --- a/server/routes/notebooks/noteRouter.ts +++ b/server/routes/notebooks/noteRouter.ts @@ -10,8 +10,17 @@ import { IRouter, ResponseError, } from '../../../../../src/core/server'; +import { SavedObjectsClientContract } from '../../../../../src/core/server/types'; import { NOTEBOOKS_API_PREFIX, wreckOptions } from '../../../common/constants/notebooks'; +import { NOTEBOOK_SAVED_OBJECT } from '../../../common/types/observability_saved_object_attributes'; import { BACKEND } from '../../adaptors/notebooks'; +import { + addSampleNotes, + cloneNotebook, + createNotebook, + fetchNotebooks, + renameNotebook, +} from '../../adaptors/notebooks/saved_objects_notebooks_router'; export function registerNoteRoute(router: IRouter) { // Fetch all the notebooks available @@ -263,4 +272,288 @@ export function registerNoteRoute(router: IRouter) { } } ); + + router.get( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook`, + validate: {}, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + try { + const notebooksData = await opensearchNotebooksClient.find({ + type: NOTEBOOK_SAVED_OBJECT, + perPage: 1000, + }); + const fetchedNotebooks = fetchNotebooks(notebooksData.saved_objects); + return response.ok({ + body: { + data: fetchedNotebooks, + }, + }); + } catch (error) { + console.log('Notebook:', error); + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/note/savedNotebook`, + validate: { + body: schema.object({ + name: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + let notebooksData; + try { + const newNotebookObject = createNotebook(request.body); + notebooksData = await opensearchNotebooksClient.create( + NOTEBOOK_SAVED_OBJECT, + newNotebookObject + ); + return response.ok({ + body: `${notebooksData.id}`, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.get( + { + path: `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/{noteId}`, + validate: { + params: schema.object({ + noteId: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + try { + const notebookinfo = await opensearchNotebooksClient.get( + NOTEBOOK_SAVED_OBJECT, + request.params.noteId + ); + return response.ok({ + body: notebookinfo.attributes.savedNotebook, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/clone`, + validate: { + body: schema.object({ + name: schema.string(), + noteId: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + try { + const getNotebook = await opensearchNotebooksClient.get( + NOTEBOOK_SAVED_OBJECT, + request.body.noteId + ); + const createCloneNotebook = cloneNotebook( + getNotebook.attributes.savedNotebook, + request.body.name + ); + const createdNotebook = await opensearchNotebooksClient.create( + NOTEBOOK_SAVED_OBJECT, + createCloneNotebook + ); + return response.ok({ + body: createdNotebook, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.put( + { + path: `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/rename`, + validate: { + body: schema.object({ + name: schema.string(), + noteId: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + try { + const renamedNotebook = renameNotebook(request.body); + const updatedNotebook = await opensearchNotebooksClient.update( + NOTEBOOK_SAVED_OBJECT, + request.body.noteId, + renamedNotebook + ); + return response.ok({ + body: updatedNotebook, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.delete( + { + path: `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/{noteId}`, + validate: { + params: schema.object({ + noteId: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + + try { + const deletedNotebooks = await opensearchNotebooksClient.delete( + NOTEBOOK_SAVED_OBJECT, + request.params.noteId + ); + return response.ok({ + body: deletedNotebooks, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/note/savedNotebook/addSampleNotebooks`, + validate: { + body: schema.object({ + visIds: schema.arrayOf(schema.string()), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + try { + const sampleNotebooks = await addSampleNotes( + opensearchNotebooksClient, + request.body.visIds + ); + return response.ok({ + body: sampleNotebooks, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/note/migrate`, + validate: { + body: schema.object({ + name: schema.string(), + noteId: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( + request + ); + try { + const getNotebook = await BACKEND.fetchNote( + opensearchNotebooksClient, + request.body.noteId, + wreckOptions + ); + const createCloneNotebook = cloneNotebook(getNotebook, request.body.name); + const createdNotebook = await context.core.savedObjects.client.create( + NOTEBOOK_SAVED_OBJECT, + createCloneNotebook + ); + return response.ok({ + body: createdNotebook, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); } diff --git a/server/routes/notebooks/paraRouter.ts b/server/routes/notebooks/paraRouter.ts index 51d5c1673..16a3e3678 100644 --- a/server/routes/notebooks/paraRouter.ts +++ b/server/routes/notebooks/paraRouter.ts @@ -10,13 +10,21 @@ import { IRouter, ResponseError, } from '../../../../../src/core/server'; +import { SavedObjectsClientContract } from '../../../../../src/core/server/types'; import { NOTEBOOKS_API_PREFIX, wreckOptions } from '../../../common/constants/notebooks'; +import { NOTEBOOK_SAVED_OBJECT } from '../../../common/types/observability_saved_object_attributes'; import { BACKEND } from '../../adaptors/notebooks'; +import { + clearParagraphs, + createParagraphs, + deleteParagraphs, + updateFetchParagraph, + updateRunFetchParagraph, +} from '../../adaptors/notebooks/saved_objects_paragraphs_router'; import { DefaultNotebooks, DefaultParagraph, } from '../../common/helpers/notebooks/default_notebook_schema'; - export function registerParaRoute(router: IRouter) { /* --> Updates the input content in a paragraph * --> Runs the paragraph @@ -39,9 +47,6 @@ export function registerParaRoute(router: IRouter) { request, response ): Promise> => { - const opensearchNotebooksClient: ILegacyScopedClusterClient = context.observability_plugin.observabilityClient.asScoped( - request - ); try { const runResponse = await BACKEND.updateRunFetchParagraph( context.observability_plugin.observabilityClient, @@ -276,4 +281,217 @@ export function registerParaRoute(router: IRouter) { } } ); + + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/`, + validate: { + body: schema.object({ + noteId: schema.string(), + paragraphIndex: schema.number(), + paragraphInput: schema.string(), + inputType: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const saveResponse = await createParagraphs(request.body, context.core.savedObjects.client); + return response.ok({ + body: saveResponse, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.put( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/clearall/`, + validate: { + body: schema.object({ + noteId: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const clearParaResponse = await clearParagraphs( + request.body, + context.core.savedObjects.client + ); + return response.ok({ + body: clearParaResponse, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.delete( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph`, + validate: { + query: schema.object({ + noteId: schema.string(), + paragraphId: schema.maybe(schema.string()), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const params = { + noteId: request.query.noteId, + paragraphId: request.query.paragraphId, + }; + try { + const deleteResponse = await deleteParagraphs(params, context.core.savedObjects.client); + return response.ok({ + body: deleteResponse, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/update/run/`, + validate: { + body: schema.object({ + noteId: schema.string(), + paragraphId: schema.string(), + paragraphInput: schema.string(), + paragraphType: schema.string(), + dataSourceMDSId: schema.maybe(schema.string({ defaultValue: '' })), + dataSourceMDSLabel: schema.maybe(schema.string({ defaultValue: '' })), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const runResponse = await updateRunFetchParagraph( + request.body, + context.core.savedObjects.client + ); + return response.ok({ + body: runResponse, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + + router.put( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook/paragraph/`, + validate: { + body: schema.object({ + noteId: schema.string(), + paragraphId: schema.string(), + paragraphInput: schema.string(), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + try { + const saveResponse = await updateFetchParagraph( + request.body, + context.core.savedObjects.client + ); + return response.ok({ + body: saveResponse, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); + router.post( + { + path: `${NOTEBOOKS_API_PREFIX}/savedNotebook/set_paragraphs/`, + validate: { + body: schema.object({ + noteId: schema.string(), + paragraphs: schema.arrayOf( + schema.object({ + output: schema.maybe(schema.arrayOf(schema.object({}, { unknowns: 'allow' }))), + input: schema.object({ + inputText: schema.string(), + inputType: schema.string(), + }), + dateCreated: schema.string(), + dateModified: schema.string(), + id: schema.string(), + dataSourceMDSId: schema.maybe(schema.string({ defaultValue: '' })), + dataSourceMDSLabel: schema.maybe(schema.string({ defaultValue: '' })), + }) + ), + }), + }, + }, + async ( + context, + request, + response + ): Promise> => { + const opensearchNotebooksClient: SavedObjectsClientContract = + context.core.savedObjects.client; + try { + const updateNotebook: Partial = { + paragraphs: request.body.paragraphs as DefaultParagraph[], + dateModified: new Date().toISOString(), + }; + const updateResponse = await opensearchNotebooksClient.update( + NOTEBOOK_SAVED_OBJECT, + request.body.noteId, + updateNotebook + ); + return response.ok({ + body: updateResponse, + }); + } catch (error) { + return response.custom({ + statusCode: error.statusCode || 500, + body: error.message, + }); + } + } + ); } diff --git a/server/routes/notebooks/sqlRouter.ts b/server/routes/notebooks/sqlRouter.ts index 5d9dde660..892076da3 100644 --- a/server/routes/notebooks/sqlRouter.ts +++ b/server/routes/notebooks/sqlRouter.ts @@ -11,12 +11,19 @@ import { } from '../../../../../src/core/server'; import { QueryService } from '../../services/queryService'; -export function registerSqlRoute(server: IRouter, service: QueryService) { +export function registerSqlRoute( + server: IRouter, + service: QueryService, + _dataSourceEnabled: boolean +) { server.post( { path: '/api/sql/sqlquery', validate: { body: schema.any(), + query: schema.object({ + dataSourceMDSId: schema.maybe(schema.string({ defaultValue: '' })), + }), }, }, async ( @@ -24,7 +31,7 @@ export function registerSqlRoute(server: IRouter, service: QueryService) { request, response ): Promise> => { - const retVal = await service.describeSQLQuery(request); + const retVal = await service.describeSQLQuery(context, request); return response.ok({ body: retVal, }); @@ -36,6 +43,9 @@ export function registerSqlRoute(server: IRouter, service: QueryService) { path: '/api/sql/pplquery', validate: { body: schema.any(), + query: schema.object({ + dataSourceMDSId: schema.maybe(schema.string({ defaultValue: '' })), + }), }, }, async ( @@ -43,7 +53,7 @@ export function registerSqlRoute(server: IRouter, service: QueryService) { request, response ): Promise> => { - const retVal = await service.describePPLQuery(request); + const retVal = await service.describePPLQuery(context, request); return response.ok({ body: retVal, }); diff --git a/server/routes/notebooks/vizRouter.ts b/server/routes/notebooks/vizRouter.ts index 8d70ff644..481caa063 100644 --- a/server/routes/notebooks/vizRouter.ts +++ b/server/routes/notebooks/vizRouter.ts @@ -4,6 +4,7 @@ */ import { RequestParams } from '@elastic/elasticsearch'; +import { schema } from '@osd/config-schema'; import { IOpenSearchDashboardsResponse, IRouter, @@ -11,12 +12,16 @@ import { } from '../../../../../src/core/server'; import { NOTEBOOKS_API_PREFIX, NOTEBOOKS_FETCH_SIZE } from '../../../common/constants/notebooks'; -export function registerVizRoute(router: IRouter) { +export function registerVizRoute(router: IRouter, dataSourceEnabled: boolean) { // Fetches available saved visualizations for current user router.get( { - path: `${NOTEBOOKS_API_PREFIX}/visualizations`, - validate: {}, + path: `${NOTEBOOKS_API_PREFIX}/visualizations/{dataSourceMDSId?}`, + validate: { + params: schema.object({ + dataSourceMDSId: schema.maybe(schema.string({ defaultValue: '' })), + }), + }, }, async ( context, @@ -29,10 +34,18 @@ export function registerVizRoute(router: IRouter) { q: 'type:visualization', }; try { - const opensearchClientResponse = await context.core.opensearch.legacy.client.callAsCurrentUser( - 'search', - params - ); + let opensearchClientResponse; + if (dataSourceEnabled && request.params.dataSourceMDSId) { + const client = await context.dataSource.opensearch.legacy.getClient( + request.params.dataSourceMDSId + ); + opensearchClientResponse = await client.callAPI('search', params); + } else { + opensearchClientResponse = await context.core.opensearch.legacy.client.callAsCurrentUser( + 'search', + params + ); + } const savedVisualizations = opensearchClientResponse.hits.hits; const vizResponse = savedVisualizations.map((vizDocument) => ({ label: vizDocument._source.visualization.title, diff --git a/server/saved_objects/observability_saved_object.ts b/server/saved_objects/observability_saved_object.ts index 2054a4329..eb3700301 100644 --- a/server/saved_objects/observability_saved_object.ts +++ b/server/saved_objects/observability_saved_object.ts @@ -4,8 +4,10 @@ */ import { SavedObjectsType } from '../../../../src/core/server'; -import { observabilityID, observabilityLogsID } from '../../common/constants/shared'; +import { NOTEBOOKS_API_PREFIX } from '../../common/constants/notebooks'; +import { observabilityLogsID } from '../../common/constants/shared'; import { + NOTEBOOK_SAVED_OBJECT, SEARCH_SAVED_OBJECT, VISUALIZATION_SAVED_OBJECT, } from '../../common/types/observability_saved_object_attributes'; @@ -79,3 +81,37 @@ export const searchSavedObject: SavedObjectsType = { }, migrations: {}, }; + +export const notebookSavedObject: SavedObjectsType = { + name: NOTEBOOK_SAVED_OBJECT, + hidden: false, + namespaceType: 'single', + management: { + defaultSearchField: 'title', + importableAndExportable: true, + icon: 'notebookApp', + getTitle(obj) { + return obj.attributes.title; + }, + getInAppUrl(obj) { + const editUrl = `/app/${NOTEBOOKS_API_PREFIX}#/${obj.id}?view=view_both`; + return { + path: editUrl, + uiCapabilitiesPath: 'observability.show', + }; + }, + }, + mappings: { + dynamic: false, + properties: { + title: { + type: 'text', + }, + description: { + type: 'text', + }, + version: { type: 'integer' }, + }, + }, + migrations: {}, +}; diff --git a/server/services/queryService.ts b/server/services/queryService.ts index acc86bbf3..d11e66621 100644 --- a/server/services/queryService.ts +++ b/server/services/queryService.ts @@ -4,8 +4,8 @@ */ import 'core-js/stable'; -import 'regenerator-runtime/runtime'; import _ from 'lodash'; +import 'regenerator-runtime/runtime'; export class QueryService { private client: any; @@ -13,7 +13,12 @@ export class QueryService { this.client = client; } - describeQueryInternal = async (request: any, format: string, responseFormat: string) => { + describeQueryInternal = async ( + request: any, + format: string, + responseFormat: string, + context: any + ) => { try { const queryRequest = { query: request.body, @@ -21,7 +26,17 @@ export class QueryService { const params = { body: JSON.stringify(queryRequest), }; - const queryResponse = await this.client.asScoped(request).callAsCurrentUser(format, params); + + let client = this.client; + let queryResponse; + + const { dataSourceMDSId } = request.query; + if (dataSourceMDSId) { + client = context.dataSource.opensearch.legacy.getClient(dataSourceMDSId); + queryResponse = await client.callAPI(format, params); + } else { + queryResponse = await this.client.asScoped(request).callAsCurrentUser(format, params); + } return { data: { ok: true, @@ -29,22 +44,24 @@ export class QueryService { }, }; } catch (err) { - console.error(err); + this.logger.info('error describeQueryInternal'); + this.logger.info(err); + return { data: { ok: false, - resp: err.response, + resp: err.message, body: err.body, }, }; } }; - describeSQLQuery = async (request: any) => { - return this.describeQueryInternal(request, 'ppl.sqlQuery', 'json'); + describeSQLQuery = async (context: any, request: any) => { + return this.describeQueryInternal(request, 'ppl.sqlQuery', 'json', context); }; - describePPLQuery = async (request: any) => { - return this.describeQueryInternal(request, 'ppl.pplQuery', 'json'); + describePPLQuery = async (context: any, request: any) => { + return this.describeQueryInternal(request, 'ppl.pplQuery', 'json', context); }; } diff --git a/test/notebooks_constants.ts b/test/notebooks_constants.ts index a936b41ac..75de7a3ad 100644 --- a/test/notebooks_constants.ts +++ b/test/notebooks_constants.ts @@ -36,6 +36,22 @@ export const codeBlockNotebook = { ], }; +export const migrateBlockNotebook = { + path: 'sample-notebook-1', + dateCreated: '2023-12-14T18:49:43.375Z', + dateModified: '2023-12-18T23:40:59.500Z', + name: 'sample-notebook-1', + paragraphs: [ + { + output: [{ result: 'hello', outputType: 'MARKDOWN', execution_time: '0.018 ms' }], + input: { inputText: '%md\nhello', inputType: 'MARKDOWN' }, + dateCreated: '2023-12-18T23:38:50.848Z', + dateModified: '2023-12-18T23:39:12.265Z', + id: 'paragraph_de00ea2d-a8fb-45d1-8085-698f51c6b6be', + }, + ], +}; + export const clearOutputNotebook = { paragraphs: [ {