diff --git a/.cypress/integration/9_integrations.spec.js b/.cypress/integration/9_integrations.spec.js new file mode 100644 index 0000000000..2c0f39c39a --- /dev/null +++ b/.cypress/integration/9_integrations.spec.js @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/// + +import { + TEST_INTEGRATION_INSTANCE, TEST_SAMPLE_INSTANCE, +} from '../utils/constants'; + +let testInstanceSuffix = (Math.random() + 1).toString(36).substring(7); +let testInstance = `${TEST_INTEGRATION_INSTANCE}_${testInstanceSuffix}`; + +const moveToIntegrationsHome = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/integrations#/available`); +}; + +const moveToAvailableNginxIntegration = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/integrations#/available/nginx`); +}; + +const moveToAddedIntegrations = () => { + cy.visit(`${Cypress.env('opensearchDashboards')}/app/integrations#/installed`); +}; + + +describe('Basic sanity test for integrations plugin', () => { + it('Navigates to integrations plugin and expects the correct header', () => { + moveToIntegrationsHome(); + cy.get('[data-test-subj="integrations-header"]').should('exist'); + }); + + it('Navigates to integrations plugin and tests that clicking the nginx cards navigates to the nginx page', () => { + moveToIntegrationsHome(); + cy.get('[data-test-subj="integration_card_nginx"]').click(); + cy.url().should('include', '/available/nginx') + }) + + it('Navigates to nginx page and asserts the page to be as expected', () => { + moveToAvailableNginxIntegration(); + cy.get('[data-test-subj="nginx-overview"]').should('exist') + cy.get('[data-test-subj="nginx-details"]').should('exist') + cy.get('[data-test-subj="nginx-screenshots"]').should('exist') + cy.get('[data-test-subj="nginx-assets"]').should('exist') + cy.get('[data-test-subj="fields"]').click(); + cy.get('[data-test-subj="nginx-fields"]').should('exist') + }) +}); + +describe('Tests the add nginx integration instance flow', () => { + it('Navigates to nginx page and triggers the adds the instance flow', () => { + moveToAvailableNginxIntegration(); + cy.get('[data-test-subj="add-integration-button"]').click(); + cy.get('[data-test-subj="new-instance-name"]').should('have.value', 'nginx'); + cy.get('[data-test-subj="createInstanceButton"]').should('be.disabled') + cy.get('[data-test-subj="addIntegrationFlyoutTitle"]').should('exist') + cy.get('[data-test-subj="data-source-name"]').type('test'); + cy.get('[data-test-subj="new-instance-name"]').type(testInstance.substring(5)); + cy.get('[data-test-subj="createInstanceButton"]').click(); + cy.get('.euiToastHeader__title').should('contain', 'successfully'); + }) + + it('Navigates to installed integrations page and verifies that nginx-test exists', () => { + moveToAddedIntegrations(); + cy.contains(testInstance).should('exist'); + cy.get(`[data-test-subj="${testInstance}IntegrationLink"]`).click(); + }) + + it('Navigates to added integrations page and verifies that nginx-test exists and linked asset works as expected', () => { + moveToAddedIntegrations(); + cy.contains(TEST_INTEGRATION_INSTANCE).should('exist'); + cy.get(`[data-test-subj="${testInstance}IntegrationLink"]`).click(); + cy.get(`[data-test-subj="IntegrationAssetLink"]`).click(); + cy.url().should('include', '/dashboards#/') + }) + + it('Navigates to installed nginx-test instance page and deletes it', () => { + moveToAddedIntegrations(); + cy.contains(testInstance).should('exist'); + cy.get(`[data-test-subj="${testInstance}IntegrationLink"]`).click(); + cy.get('[data-test-subj="deleteInstanceButton"]').click(); + + cy.get('button[data-test-subj="popoverModal__deleteButton"]').should('be.disabled'); + + cy.get('input.euiFieldText[placeholder="delete"]').focus().type('delete', { + delay: 50, + }); + cy.get('button[data-test-subj="popoverModal__deleteButton"]').should('not.be.disabled'); + cy.get('button[data-test-subj="popoverModal__deleteButton"]').click(); + cy.get('.euiToastHeader__title').should('contain', 'successfully'); + }) +}); + +describe('Tests the add nginx integration instance flow', () => { + it('Navigates to nginx page and triggers the try it flow', () => { + moveToAvailableNginxIntegration(); + cy.get('[data-test-subj="try-it-button"]').click(); + cy.get('.euiToastHeader__title').should('contain', 'successfully'); + moveToAddedIntegrations(); + cy.contains(TEST_SAMPLE_INSTANCE).should('exist'); + }) +}); + + diff --git a/.cypress/utils/constants.js b/.cypress/utils/constants.js index 534035f633..2244ac1787 100644 --- a/.cypress/utils/constants.js +++ b/.cypress/utils/constants.js @@ -73,6 +73,8 @@ export const setTimeFilter = (setEndTime = false, refresh = true) => { // notebooks export const TEST_NOTEBOOK = 'Test Notebook'; +export const TEST_INTEGRATION_INSTANCE = 'nginx-test'; +export const TEST_SAMPLE_INSTANCE = 'nginx-sample'; export const SAMPLE_URL = 'https://github.com/opensearch-project/sql/tree/main/sql-jdbc'; export const NOTEBOOK_TEXT = 'Use Notebooks to interactively and collaboratively develop rich reports backed by live data. Common use cases for notebooks includes creating postmortem reports, designing run books, building live infrastructure reports, or even documentation.'; export const OPENSEARCH_URL = 'https://opensearch.org/docs/latest/observability-plugin/notebooks/'; diff --git a/common/constants/custom_panels.ts b/common/constants/custom_panels.ts index 555f778b19..ae228c1790 100644 --- a/common/constants/custom_panels.ts +++ b/common/constants/custom_panels.ts @@ -8,7 +8,8 @@ import { v4 as uuidv4 } from 'uuid'; export const CUSTOM_PANELS_API_PREFIX = '/api/observability/operational_panels'; export const CUSTOM_PANELS_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/observability-plugin/operational-panels/'; -export const CREATE_PANEL_MESSAGE = 'Enter a name to describe the purpose of this Observability Dashboard.'; +export const CREATE_PANEL_MESSAGE = + 'Enter a name to describe the purpose of this Observability Dashboard.'; export const CUSTOM_PANELS_SAVED_OBJECT_TYPE = 'observability-panel'; diff --git a/common/constants/integrations.ts b/common/constants/integrations.ts new file mode 100644 index 0000000000..fb0b804067 --- /dev/null +++ b/common/constants/integrations.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const OPENSEARCH_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest'; +export const ASSET_FILTER_OPTIONS = ['index-pattern', 'search', 'visualization', 'dashboard']; +export const INTEGRATION_TEMPLATE_OPTIONS = ['nginx', 'aws_elb']; +export const INTEGRATION_CATEOGRY_OPTIONS = [ + 'communication', + 'http', + 'cloud', + 'aws_elb', + 'container', + 'logs', +]; diff --git a/common/constants/shared.ts b/common/constants/shared.ts index d38ba45e71..f3a3cb35cb 100644 --- a/common/constants/shared.ts +++ b/common/constants/shared.ts @@ -52,9 +52,9 @@ export const observabilityPanelsID = 'observability-dashboards'; export const observabilityPanelsTitle = 'Dashboards'; export const observabilityPanelsPluginOrder = 5095; -export const observabilityIntegrationsID = 'observability-integrations'; -export const observabilityIntegrationsTitle = 'Integrations'; -export const observabilityIntegrationsPluginOrder = 5096; +export const integrationsID = 'integrations'; +export const integrationsTitle = 'Integrations'; +export const integrationsPluginOrder = 9020; // Shared Constants export const SQL_DOCUMENTATION_URL = 'https://opensearch.org/docs/latest/search-plugins/sql/index/'; diff --git a/public/components/app.tsx b/public/components/app.tsx index 18066f3281..ac3fa5772c 100644 --- a/public/components/app.tsx +++ b/public/components/app.tsx @@ -12,6 +12,7 @@ import { observabilityID, observabilityTitle } from '../../common/constants/shar import { store } from '../framework/redux/store'; import { AppPluginStartDependencies } from '../types'; import { Home as ApplicationAnalyticsHome } from './application_analytics/home'; +import { Home as IntegrationsHome } from './integrations/home'; import { MetricsListener } from './common/metrics_listener'; import { Home as CustomPanelsHome } from './custom_panels/home'; import { EventAnalytics } from './event_analytics'; @@ -42,6 +43,7 @@ const pages = { traces: TraceAnalyticsHome, notebooks: NotebooksHome, dashboards: CustomPanelsHome, + integrations: IntegrationsHome, }; export const App = ({ diff --git a/public/components/integrations/components/__tests__/__snapshots__/added_integration.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/added_integration.test.tsx.snap new file mode 100644 index 0000000000..eae624891e --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/added_integration.test.tsx.snap @@ -0,0 +1,1085 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Added Integration View Test Renders added integration view using dummy data 1`] = ` + + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ +
+ +
+ +

+ +

+
+ +
+ +
+ +
+ +
+ + + + + +
+
+ +
+ Critical +
+
+
+
+
+
+
+
+
+
+ +
+ + + + + +
+
+
+
+
+
+ +
+
+ +
+ + +
+ + +
+ + Assets List + +
+
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+ + +
+ + + Type + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="field_value_selection_0" + isOpen={false} + ownFocus={true} + panelClassName="euiFilterGroup__popoverPanel" + panelPaddingSize="none" + > +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + Name + + + + + + + + + + + + Type + + + + + +
+
+ + No items found + +
+
+
+
+
+ +
+ +
+ + +
+ +
+ +
+ + +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap new file mode 100644 index 0000000000..fd2395b109 --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/added_integration_flyout.test.tsx.snap @@ -0,0 +1,1808 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Add Integration Flyout Test Renders add integration flyout with dummy integration name 1`] = ` + + + + + + + } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + sideCar={ + Object { + "assignMedium": [Function], + "assignSyncMedium": [Function], + "options": Object { + "async": true, + "ssr": false, + }, + "read": [Function], + "useMedium": [Function], + } + } + > + + +
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + > + + +
+ } + onActivation={[Function]} + onDeactivation={[Function]} + persistentFocus={false} + returnFocus={[Function]} + shards={Array []} + /> + + + + +
+ +
+ + + + + + +
+ +

+ Add Integration +

+
+
+
+ +
+
+
+ +
+
+ +
+
+ + + +
+
+ + Validate + + } + aria-describedby="random_html_id-help-0" + data-test-subj="data-source-name" + id="random_html_id" + isInvalid={false} + name="first" + onBlur={[Function]} + onChange={[Function]} + onFocus={[Function]} + value="" + > + + Validate + + } + fullWidth={false} + inputId="random_html_id" + > +
+
+ + + + +
+ + + + + +
+
+
+ +
+ Input an index name or wildcard pattern that your integration will query. +
+
+
+
+
+ +
+
+ + + +
+
+ + +
+
+ + + + +
+
+
+
+ +
+ This will be used to label the newly added integration. +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+ + + + + +
+
+ +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + + + + + + +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/added_integration_table.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/added_integration_table.test.tsx.snap new file mode 100644 index 0000000000..9cb3e5126d --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/added_integration_table.test.tsx.snap @@ -0,0 +1,1631 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Added Integration Table View Test Renders added integration table view using dummy data 1`] = ` + + + +
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+ + +
+ + + Type + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="field_value_selection_0" + isOpen={false} + ownFocus={true} + panelClassName="euiFilterGroup__popoverPanel" + panelPaddingSize="none" + > +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + Asset name + + + + + + + + + + + + Source + + + + + + + + + + + + Date Added + + + + + + + + + + + + Actions + + + + + +
+
+ Asset name +
+
+ + + nginx + + +
+
+
+ Source +
+
+ + + nginx + + +
+
+
+ Date Added +
+
+ +
+ 2023-06-15T16:28:36.370Z +
+
+
+
+
+ Actions +
+
+ + + + + +
+
+
+
+ +
+ +
+ + + +
+ +
+ + + : + 10 + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > +
+
+ + + +
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+ +
+ +
+ +
+ + + +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/available_integration_card_view.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/available_integration_card_view.test.tsx.snap new file mode 100644 index 0000000000..9f6372b915 --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/available_integration_card_view.test.tsx.snap @@ -0,0 +1,438 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Available Integration Card View Test Renders nginx integration card view using dummy data 1`] = ` + + +
+ +
+ +
+ + +
+
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+ +
+ + +
+ +
+ + + Text align + + +
+ + + + + + + + + + +
+
+
+
+
+
+ + +
+ +
+ + +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/available_integration_table_view.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/available_integration_table_view.test.tsx.snap new file mode 100644 index 0000000000..2406957a3c --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/available_integration_table_view.test.tsx.snap @@ -0,0 +1,1578 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Available Integration Table View Test Renders nginx integration table view using dummy data 1`] = ` + + + +
+ +
+ + + + + + + , + } + } + tableLayout="auto" + > +
+ + + + + + + } + > + +
+ +
+ + + +
+
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+ +
+ +
+ +
+ + +
+ +
+ + + Text align + + +
+ + + + + + + + + + +
+
+
+
+
+
+ +
+ +
+ + + +
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + Name + + + + + + + + + + + + Description + + + + + + + + + + + + Categories + + + + + +
+
+ Name +
+ +
+
+ Description +
+
+ +
+ Nginx HTTP server collector +
+
+
+
+
+ Categories +
+
+ +
+ + + + + + + communication + + + + + + + + + + + + + http + + + + + + + + + + + + + logs + + + + + + +
+
+
+
+
+
+ +
+ +
+ + + +
+ +
+ + + : + 10 + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > +
+
+ + + +
+
+
+
+
+ +
+ + + +
+
+
+
+
+
+ +
+ +
+ +
+ + + +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/integration_details.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/integration_details.test.tsx.snap new file mode 100644 index 0000000000..c54266f2a0 --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/integration_details.test.tsx.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Available Integration Table View Test Renders nginx integration table view using dummy data 1`] = ` + +
+ + +
+ + Details + +
+
+
+ +
+ + +
+ Nginx HTTP server collector +
+
+
+ +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/integration_fields.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/integration_fields.test.tsx.snap new file mode 100644 index 0000000000..f54fb131cd --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/integration_fields.test.tsx.snap @@ -0,0 +1,502 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Available Integration Table View Test Renders nginx integration table view using dummy data 1`] = ` + +
+ + +
+ + Fields + +
+
+
+ +
+ + +
+ + +
+ +
+ + + +
+
+ + + + +
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+ +
+ + +
+
+ +
+ +
+ +
+ + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + Name + + + + + + + + + + + + Type + + + + + + + + + + + + Category + + + + + +
+
+ + No items found + +
+
+
+
+
+ +
+ +
+ +`; diff --git a/public/components/integrations/components/__tests__/__snapshots__/integration_header.test.tsx.snap b/public/components/integrations/components/__tests__/__snapshots__/integration_header.test.tsx.snap new file mode 100644 index 0000000000..0c71537130 --- /dev/null +++ b/public/components/integrations/components/__tests__/__snapshots__/integration_header.test.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Integration Header Test Renders integration header as expected 1`] = ` + +
+ +
+ +
+ +

+ Integrations +

+
+
+
+
+
+ +
+ + +
+ +
+ View or add available integrations to use pre-canned assets immediately in your OpenSearch setup. + + + + Learn more + + + + + + + +
+
+
+
+ +
+ + +
+ + + + + + +
+
+ +
+ +
+ +`; diff --git a/public/components/integrations/components/__tests__/added_integration.test.tsx b/public/components/integrations/components/__tests__/added_integration.test.tsx new file mode 100644 index 0000000000..e644cb7d21 --- /dev/null +++ b/public/components/integrations/components/__tests__/added_integration.test.tsx @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { AddedIntegration } from '../added_integration'; +import { addedIntegrationData, testIntegrationInstanceData } from './testing_constants'; +import React from 'react'; + +describe('Added Integration View Test', () => { + configure({ adapter: new Adapter() }); + let mockChrome: any; + let mockHttp: any; + const instanceId: string = '6b3b8010-015a-11ee-8bf8-9f447e9961b0'; + + beforeEach(() => { + // Create mock instances for each test + mockChrome = { + setBreadcrumbs: jest.fn(), + }; + mockHttp = { + get: jest.fn().mockResolvedValue({ data: testIntegrationInstanceData }), + }; + }); + + it('Renders added integration view using dummy data', async () => { + const wrapper = mount( + + ); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx b/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx new file mode 100644 index 0000000000..7f5280652a --- /dev/null +++ b/public/components/integrations/components/__tests__/added_integration_flyout.test.tsx @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { AddIntegrationFlyout } from '../add_integration_flyout'; +import React from 'react'; + +describe('Add Integration Flyout Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders add integration flyout with dummy integration name', async () => { + const wrapper = mount( + + ); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/added_integration_table.test.tsx b/public/components/integrations/components/__tests__/added_integration_table.test.tsx new file mode 100644 index 0000000000..95e9c2bd11 --- /dev/null +++ b/public/components/integrations/components/__tests__/added_integration_table.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { AddedIntegrationsTable } from '../added_integration_table'; +import { addedIntegrationData } from './testing_constants'; +import React from 'react'; + +describe('Added Integration Table View Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders added integration table view using dummy data', async () => { + const wrapper = mount(); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/available_integration_card_view.test.tsx b/public/components/integrations/components/__tests__/available_integration_card_view.test.tsx new file mode 100644 index 0000000000..0617528956 --- /dev/null +++ b/public/components/integrations/components/__tests__/available_integration_card_view.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { AvailableIntegrationsCardView } from '../available_integration_card_view'; +import { availableCardViewData } from './testing_constants'; +import React from 'react'; + +describe('Available Integration Card View Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders nginx integration card view using dummy data', async () => { + const wrapper = mount(); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/available_integration_table_view.test.tsx b/public/components/integrations/components/__tests__/available_integration_table_view.test.tsx new file mode 100644 index 0000000000..e865beb766 --- /dev/null +++ b/public/components/integrations/components/__tests__/available_integration_table_view.test.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { AvailableIntegrationsTable } from '../available_integration_table'; +import { availableTableViewData } from './testing_constants'; +import React from 'react'; + +describe('Available Integration Table View Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders nginx integration table view using dummy data', async () => { + const wrapper = mount(); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/integration_details.test.tsx b/public/components/integrations/components/__tests__/integration_details.test.tsx new file mode 100644 index 0000000000..91808953dc --- /dev/null +++ b/public/components/integrations/components/__tests__/integration_details.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { IntegrationDetails } from '../integration_details_panel'; +import { nginxIntegrationData } from './testing_constants'; + +describe('Available Integration Table View Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders nginx integration table view using dummy data', async () => { + const wrapper = mount(IntegrationDetails(nginxIntegrationData)); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/integration_fields.test.tsx b/public/components/integrations/components/__tests__/integration_fields.test.tsx new file mode 100644 index 0000000000..93582d9635 --- /dev/null +++ b/public/components/integrations/components/__tests__/integration_fields.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import { waitFor } from '@testing-library/react'; +import { IntegrationFields } from '../integration_fields_panel'; +import { nginxIntegrationData } from './testing_constants'; + +describe('Available Integration Table View Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders nginx integration table view using dummy data', async () => { + const wrapper = mount(IntegrationFields(nginxIntegrationData)); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/integration_header.test.tsx b/public/components/integrations/components/__tests__/integration_header.test.tsx new file mode 100644 index 0000000000..6bbd44e4fe --- /dev/null +++ b/public/components/integrations/components/__tests__/integration_header.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import { IntegrationHeader } from '../integration_header'; + +describe('Integration Header Test', () => { + configure({ adapter: new Adapter() }); + + it('Renders integration header as expected', async () => { + const wrapper = mount(); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/mapping_validation.test.ts b/public/components/integrations/components/__tests__/mapping_validation.test.ts new file mode 100644 index 0000000000..4a02058cf4 --- /dev/null +++ b/public/components/integrations/components/__tests__/mapping_validation.test.ts @@ -0,0 +1,176 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + doTypeValidation, + doNestedPropertyValidation, + doPropertyValidation, +} from '../add_integration_flyout'; + +describe('Validation', () => { + describe('doTypeValidation', () => { + it('should return true if required type is not specified', () => { + const toCheck = { type: 'string' }; + const required = {}; + + const result = doTypeValidation(toCheck, required); + + expect(result).toBe(true); + }); + + it('should return true if types match', () => { + const toCheck = { type: 'string' }; + const required = { type: 'string' }; + + const result = doTypeValidation(toCheck, required); + + expect(result).toBe(true); + }); + + it('should return true if object has properties', () => { + const toCheck = { properties: { prop1: { type: 'string' } } }; + const required = { type: 'object' }; + + const result = doTypeValidation(toCheck, required); + + expect(result).toBe(true); + }); + + it('should return false if types do not match', () => { + const toCheck = { type: 'string' }; + const required = { type: 'number' }; + + const result = doTypeValidation(toCheck, required); + + expect(result).toBe(false); + }); + }); + + describe('doNestedPropertyValidation', () => { + it('should return true if type validation passes and no properties are required', () => { + const toCheck = { type: 'string' }; + const required = { type: 'string' }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result).toBe(true); + }); + + it('should return false if type validation fails', () => { + const toCheck = { type: 'string' }; + const required = { type: 'number' }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result).toBe(false); + }); + + it('should return false if a required property is missing', () => { + const toCheck = { type: 'object', properties: { prop1: { type: 'string' } } }; + const required = { + type: 'object', + properties: { prop1: { type: 'string' }, prop2: { type: 'number' } }, + }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result).toBe(false); + }); + + it('should return true if all required properties pass validation', () => { + const toCheck = { + type: 'object', + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }; + const required = { + type: 'object', + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }; + + const result = doNestedPropertyValidation(toCheck, required); + + expect(result).toBe(true); + }); + }); + + describe('doPropertyValidation', () => { + it('should return true if all properties pass validation', () => { + const rootType = 'root'; + const dataSourceProps = { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }; + const requiredMappings = { + root: { + template: { + mappings: { + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }, + }, + }, + }; + + const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); + + expect(result).toBe(true); + }); + + it('should return false if a property fails validation', () => { + const rootType = 'root'; + const dataSourceProps = { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }; + const requiredMappings = { + root: { + template: { + mappings: { + properties: { + prop1: { type: 'string' }, + prop2: { type: 'boolean' }, + }, + }, + }, + }, + }; + + const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); + + expect(result).toBe(false); + }); + + it('should return false if a required nested property is missing', () => { + const rootType = 'root'; + const dataSourceProps = { + prop1: { type: 'string' }, + }; + const requiredMappings = { + root: { + template: { + mappings: { + properties: { + prop1: { type: 'string' }, + prop2: { type: 'number' }, + }, + }, + }, + }, + }; + + const result = doPropertyValidation(rootType, dataSourceProps as any, requiredMappings); + + expect(result).toBe(false); + }); + }); +}); diff --git a/public/components/integrations/components/__tests__/testing_constants.ts b/public/components/integrations/components/__tests__/testing_constants.ts new file mode 100644 index 0000000000..32cf5b4e61 --- /dev/null +++ b/public/components/integrations/components/__tests__/testing_constants.ts @@ -0,0 +1,261 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AddedIntegrationsTableProps } from '../added_integration_overview_page'; +import { + AvailableIntegrationsCardViewProps, + AvailableIntegrationsTableProps, +} from '../available_integration_overview_page'; + +export const availableCardViewData: AvailableIntegrationsCardViewProps = { + data: { + hits: [ + { + name: 'nginx', + version: '1.0.1', + displayName: 'NginX Dashboard', + integrationType: 'logs', + description: 'Nginx HTTP server collector', + license: 'Apache-2.0', + type: 'logs', + author: 'John Doe', + sourceUrl: 'https://github.com/', + statics: { + logo: { annotation: 'NginX Logo', path: 'logo.svg' }, + gallery: [ + { annotation: 'NginX Dashboard', path: 'dashboard1.png' }, + { annotation: 'NginX Logo', path: 'logo.svg' }, + ], + }, + components: [ + { name: 'communication', version: '1.0.0' }, + { name: 'http', version: '1.0.0' }, + { name: 'logs', version: '1.0.0' }, + ], + assets: { savedObjects: { name: 'nginx', version: '1.0.1' } }, + }, + ], + }, + showModal: () => {}, + renderCateogryFilters: () => null as any, +}; + +export const availableTableViewData: AvailableIntegrationsTableProps = { + data: { + hits: [ + { + name: 'nginx', + version: '1.0.1', + displayName: 'NginX Dashboard', + integrationType: 'logs', + description: 'Nginx HTTP server collector', + license: 'Apache-2.0', + type: 'logs', + author: 'John Doe', + sourceUrl: 'https://github.com/', + statics: { + logo: { annotation: 'NginX Logo', path: 'logo.svg' }, + gallery: [ + { annotation: 'NginX Dashboard', path: 'dashboard1.png' }, + { annotation: 'NginX Logo', path: 'logo.svg' }, + ], + }, + components: [ + { name: 'communication', version: '1.0.0' }, + { name: 'http', version: '1.0.0' }, + { name: 'logs', version: '1.0.0' }, + ], + assets: { savedObjects: { name: 'nginx', version: '1.0.1' } }, + }, + ], + }, + showModal: () => {}, + loading: false, + renderCateogryFilters: () => null as any, +}; + +export const addedIntegrationData: AddedIntegrationsTableProps = { + data: { + total: 1, + hits: [ + { + name: 'nginx', + templateName: 'nginx', + dataSource: { sourceType: 'logs', dataset: 'nginx', namespace: 'prod' }, + creationDate: '2023-06-15T16:28:36.370Z', + assets: [ + { + assetType: 'index-pattern', + assetId: '3fc41705-8a23-49f4-926c-2819e0d7306d', + status: 'available', + isDefaultAsset: false, + description: 'ss4o_logs-nginx-prod', + }, + { + assetType: 'search', + assetId: 'a0415ddd-047d-4c02-8769-d14bfb70f525', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Nginx Access Logs', + }, + { + assetType: 'visualization', + assetId: 'a17cd453-fb2f-4c24-81db-aedfc8682829', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Response codes over time', + }, + { + assetType: 'search', + assetId: '3e47dfed-d9ff-4c1b-b425-04ffc8ed3fa9', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Nginx Error Logs', + }, + { + assetType: 'visualization', + assetId: '641c2a03-eead-4900-94ee-e12d2fef8383', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Errors over time', + }, + { + assetType: 'visualization', + assetId: 'ce61594d-8307-4358-9b7e-71101b3ed722', + status: 'available', + isDefaultAsset: false, + description: 'Data Volume', + }, + { + assetType: 'visualization', + assetId: '452bd6e3-3b50-407f-88f2-c35a29c56051', + status: 'available', + isDefaultAsset: false, + description: 'Top Paths', + }, + { + assetType: 'visualization', + assetId: '14a1ddab-08c1-4aba-ba3b-88bae36f7e50', + status: 'available', + isDefaultAsset: false, + description: 'Requests per Minute', + }, + { + assetType: 'dashboard', + assetId: '179bad58-c840-4c6c-9fd8-1667c14bd03a', + status: 'available', + isDefaultAsset: true, + description: '[NGINX Core Logs 1.0] Overview', + }, + ], + id: 'ad7e6e30-0b99-11ee-b27c-c9863222e9bf', + }, + ], + }, + loading: false, +}; + +export const testIntegrationInstanceData = { + data: { + id: 'ad7e6e30-0b99-11ee-b27c-c9863222e9bf', + status: 'unknown', + name: 'nginx', + templateName: 'nginx', + dataSource: { sourceType: 'logs', dataset: 'nginx', namespace: 'prod' }, + creationDate: '2023-06-15T16:28:36.370Z', + assets: [ + { + assetType: 'index-pattern', + assetId: '3fc41705-8a23-49f4-926c-2819e0d7306d', + status: 'available', + isDefaultAsset: false, + description: 'ss4o_logs-nginx-prod', + }, + { + assetType: 'search', + assetId: 'a0415ddd-047d-4c02-8769-d14bfb70f525', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Nginx Access Logs', + }, + { + assetType: 'visualization', + assetId: 'a17cd453-fb2f-4c24-81db-aedfc8682829', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Response codes over time', + }, + { + assetType: 'search', + assetId: '3e47dfed-d9ff-4c1b-b425-04ffc8ed3fa9', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Nginx Error Logs', + }, + { + assetType: 'visualization', + assetId: '641c2a03-eead-4900-94ee-e12d2fef8383', + status: 'available', + isDefaultAsset: false, + description: '[NGINX Core Logs 1.0] Errors over time', + }, + { + assetType: 'visualization', + assetId: 'ce61594d-8307-4358-9b7e-71101b3ed722', + status: 'available', + isDefaultAsset: false, + description: 'Data Volume', + }, + { + assetType: 'visualization', + assetId: '452bd6e3-3b50-407f-88f2-c35a29c56051', + status: 'available', + isDefaultAsset: false, + description: 'Top Paths', + }, + { + assetType: 'visualization', + assetId: '14a1ddab-08c1-4aba-ba3b-88bae36f7e50', + status: 'available', + isDefaultAsset: false, + description: 'Requests per Minute', + }, + { + assetType: 'dashboard', + assetId: '179bad58-c840-4c6c-9fd8-1667c14bd03a', + status: 'available', + isDefaultAsset: true, + description: '[NGINX Core Logs 1.0] Overview', + }, + ], + }, +}; + +export const nginxIntegrationData = { + integration: { + name: 'nginx', + version: '1.0.1', + displayName: 'NginX Dashboard', + integrationType: 'logs', + description: 'Nginx HTTP server collector', + license: 'Apache-2.0', + type: 'logs', + author: 'John Doe', + sourceUrl: 'https://github.com/', + statics: { + logo: { annotation: 'NginX Logo', path: 'logo.svg' }, + gallery: [ + { annotation: 'NginX Dashboard', path: 'dashboard1.png' }, + { annotation: 'NginX Logo', path: 'logo.svg' }, + ], + }, + components: [ + { name: 'communication', version: '1.0.0' }, + { name: 'http', version: '1.0.0' }, + { name: 'logs', version: '1.0.0' }, + ], + assets: { savedObjects: { name: 'nginx', version: '1.0.1' } }, + }, +}; diff --git a/public/components/integrations/components/add_integration_flyout.tsx b/public/components/integrations/components/add_integration_flyout.tsx new file mode 100644 index 0000000000..b021570473 --- /dev/null +++ b/public/components/integrations/components/add_integration_flyout.tsx @@ -0,0 +1,287 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import _ from 'lodash'; +import { + EuiButton, + EuiFieldText, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiTitle, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { HttpStart } from '../../../../../../src/core/public'; +import { useToast } from '../../../../public/components/common/toast'; + +interface IntegrationFlyoutProps { + onClose: () => void; + onCreate: (name: string, dataSource: string) => void; + integrationName: string; + integrationType: string; + http: HttpStart; +} + +export const doTypeValidation = (toCheck: any, required: any): boolean => { + if (!required.type) { + return true; + } + if (required.type === 'object') { + return Boolean(toCheck.properties); + } + return required.type === toCheck.type; +}; + +export const doNestedPropertyValidation = ( + toCheck: { type?: string; properties?: any }, + required: { type?: string; properties?: any } +): boolean => { + if (!doTypeValidation(toCheck, required)) { + return false; + } + if (required.properties) { + return Object.keys(required.properties).every((property: string) => { + if (!toCheck.properties[property]) { + return false; + } + return doNestedPropertyValidation( + toCheck.properties[property], + required.properties[property] + ); + }); + } + return true; +}; + +export const doPropertyValidation = ( + rootType: string, + dataSourceProps: { [key: string]: { properties?: any } }, + requiredMappings: { [key: string]: { template: { mappings: { properties?: any } } } } +): boolean => { + // Check root object type (without dependencies) + for (const [key, value] of Object.entries( + requiredMappings[rootType].template.mappings.properties + )) { + if (!dataSourceProps[key] || !doNestedPropertyValidation(dataSourceProps[key], value as any)) { + return false; + } + } + // Check nested dependencies + for (const [key, value] of Object.entries(requiredMappings)) { + if (key === rootType) { + continue; + } + if ( + !dataSourceProps[key] || + !doNestedPropertyValidation(dataSourceProps[key], value.template.mappings.properties) + ) { + return false; + } + } + return true; +}; + +export function AddIntegrationFlyout(props: IntegrationFlyoutProps) { + const { onClose, onCreate, integrationName, integrationType } = props; + + const [isDataSourceValid, setDataSourceValid] = useState(null); + + const { setToast } = useToast(); + + const [name, setName] = useState(integrationName || ''); // sets input value + const [dataSource, setDataSource] = useState(''); + + const onDatasourceChange = (e: React.ChangeEvent) => { + setDataSource(e.target.value); + }; + + const [errors, setErrors] = useState([]); + + const onNameChange = (e: React.ChangeEvent) => { + setName(e.target.value); + }; + + // Returns true if the data stream is a legal name. + // Appends any additional validation errors to the provided errors array. + const checkDataSourceName = (targetDataSource: string, validationErrors: string[]): boolean => { + if (!Boolean(targetDataSource.match(/^[a-z\d\.][a-z\d\._\-\*]*$/))) { + validationErrors.push('This is not a valid index name.'); + setErrors(validationErrors); + return false; + } + const nameValidity: boolean = Boolean( + targetDataSource.match(new RegExp(`^ss4o_${integrationType}-[^\\-]+-[^\\-]+`)) + ); + if (!nameValidity) { + validationErrors.push('This index does not match the suggested naming convention.'); + setErrors(validationErrors); + } + return true; + }; + + const fetchDataSourceMappings = async ( + targetDataSource: string + ): Promise<{ [key: string]: { properties: any } } | null> => { + return fetch(`/api/console/proxy?path=${targetDataSource}/_mapping&method=GET`, { + method: 'POST', + headers: [['osd-xsrf', 'true']], + }) + .then((response) => response.json()) + .then((response) => { + // Un-nest properties by a level for caller convenience + Object.keys(response).forEach((key) => { + response[key].properties = response[key].mappings.properties; + }); + return response; + }) + .catch((err: any) => { + console.error(err); + return null; + }); + }; + + const fetchIntegrationMappings = async ( + targetName: string + ): Promise<{ [key: string]: { template: { mappings: { properties?: any } } } } | null> => { + return fetch(`/api/integrations/repository/${targetName}/schema`) + .then((response) => response.json()) + .then((response) => { + if (response.statusCode && response.statusCode !== 200) { + throw new Error('Failed to retrieve Integration schema', { cause: response }); + } + return response.data.mappings; + }) + .catch((err: any) => { + console.error(err); + return null; + }); + }; + + const doExistingDataSourceValidation = async (targetDataSource: string): Promise => { + const validationErrors: string[] = []; + if (!checkDataSourceName(targetDataSource, validationErrors)) { + return false; + } + const [dataSourceMappings, integrationMappings] = await Promise.all([ + fetchDataSourceMappings(targetDataSource), + fetchIntegrationMappings(name), + ]); + if (!dataSourceMappings) { + validationErrors.push('Provided data stream could not be retrieved'); + setErrors(validationErrors); + return false; + } + if (!integrationMappings) { + validationErrors.push('Failed to retrieve integration schema information'); + setErrors(validationErrors); + return false; + } + const validationResult = Object.values(dataSourceMappings).every((value) => + doPropertyValidation(integrationType, value.properties, integrationMappings) + ); + if (!validationResult) { + validationErrors.push('The provided index does not match the schema'); + setErrors(validationErrors); + } + return validationResult; + }; + + const formContent = () => { + return ( +
+ + onDatasourceChange(e)} + value={dataSource} + isInvalid={isDataSourceValid === false} + append={ + { + const validationResult = await doExistingDataSourceValidation(dataSource); + if (validationResult) { + setToast('Index name or wildcard pattern is valid', 'success'); + } + setDataSourceValid(validationResult); + }} + disabled={dataSource.length === 0} + > + Validate + + } + /> + + + onNameChange(e)} + value={name} + /> + +
+ ); + }; + + const renderContent = () => { + return ( + <> + {formContent()} + + ); + }; + + return ( + + + +

Add Integration

+
+
+ {renderContent()} + + + + onClose()} color="danger"> + Cancel + + + + { + onCreate(name, dataSource); + onClose(); + }} + fill + disabled={ + dataSource.length < 1 || + dataSource.length > 50 || + name.length < 1 || + name.length > 50 || + isDataSourceValid !== true + } + data-test-subj="createInstanceButton" + data-click-metric-element="integrations.create_from_setup" + > + Add Integration + + + + +
+ ); +} diff --git a/public/components/integrations/components/added_integration.tsx b/public/components/integrations/components/added_integration.tsx new file mode 100644 index 0000000000..4723dfdccf --- /dev/null +++ b/public/components/integrations/components/added_integration.tsx @@ -0,0 +1,297 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { + EuiBadge, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiGlobalToastList, + EuiHealth, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiOverlayMask, + EuiPage, + EuiPageBody, + EuiPageContentHeaderSection, + EuiPageHeader, + EuiPageHeaderSection, + EuiPanel, + EuiSpacer, + EuiTableFieldDataColumnType, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { useEffect, useState } from 'react'; +import _ from 'lodash'; +import { PanelTitle } from '../../trace_analytics/components/common/helper_functions'; +import { ASSET_FILTER_OPTIONS } from '../../../../common/constants/integrations'; +import { INTEGRATIONS_BASE, OBSERVABILITY_BASE } from '../../../../common/constants/shared'; +import { DeleteModal } from '../../common/helpers/delete_modal'; +import { AddedIntegrationProps } from './integration_types'; +import { useToast } from '../../../../public/components/common/toast'; + +export function AddedIntegration(props: AddedIntegrationProps) { + const { http, integrationInstanceId, chrome } = props; + + const { setToast } = useToast(); + + const [stateData, setData] = useState({ data: {} }); + + useEffect(() => { + chrome.setBreadcrumbs([ + { + text: 'Integrations', + href: '#/', + }, + { + text: 'Installed Integrations', + href: '#/installed', + }, + { + text: `${stateData.data?.name}`, + href: `#/installed/${stateData.data?.id}`, + }, + ]); + handleDataRequest(); + }, [integrationInstanceId, stateData.data?.name]); + + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalLayout, setModalLayout] = useState(); + + const badge = (status) => { + switch (status) { + case 'available': + return Active; + case 'partially-available': + return Partially Available; + default: + return Critical; + } + }; + + const getModal = () => { + setModalLayout( + { + setIsModalVisible(false); + deleteAddedIntegration(integrationInstanceId); + }} + onCancel={() => { + setIsModalVisible(false); + }} + title={`Delete Assets`} + message={`Are you sure you want to delete the selected asset(s)?`} + /> + ); + setIsModalVisible(true); + }; + + async function deleteAddedIntegration(integrationInstance: string) { + http + .delete(`${INTEGRATIONS_BASE}/store/${integrationInstance}`) + .then(() => { + setToast(`${stateData.data?.name} integration successfully deleted!`, 'success'); + }) + .catch((err) => { + setToast(`Error deleting ${stateData.data?.name} or its assets`, 'danger'); + }) + .finally(() => { + window.location.hash = '#/installed'; + }); + } + + async function handleDataRequest() { + http + .get(`${INTEGRATIONS_BASE}/store/${integrationInstanceId}`) + .then((exists) => setData(exists)); + } + + function AddedOverview(overviewProps: any) { + const { data } = overviewProps.data; + + return ( + + + + + + + + +

{data?.name}

+
+
+ + {badge(data?.status)} + +
+ + + { + getModal(); + }} + data-test-subj="deleteInstanceButton" + /> + +
+
+ + + + +

Template

+
+ + {data?.templateName} +
+ + +

Date Added

+
+ + {data?.creationDate?.split('T')[0]} +
+
+
+
+ ); + } + + function AddedAssets(assetProps: any) { + const { data } = assetProps.data; + + const assets = data?.assets || []; + + const renderAsset = (record) => { + switch (record.assetType) { + case 'dashboard': + return ( + window.location.assign(`dashboards#/view/${record.assetId}`)} + > + {_.truncate(record.description, { length: 100 })} + + ); + case 'index-pattern': + return ( + + window.location.assign( + `management/opensearch-dashboards/indexPatterns/patterns/${record.assetId}` + ) + } + > + {_.truncate(record.description, { length: 100 })} + + ); + case 'search': + return ( + window.location.assign(`discover#/view/${record.assetId}`)} + > + {_.truncate(record.description, { length: 100 })} + + ); + case 'visualization': + return ( + window.location.assign(`visualize#/edit/${record.assetId}`)} + > + {_.truncate(record.description, { length: 100 })} + + ); + default: + return ( + + {_.truncate(record.description, { length: 100 })} + + ); + } + }; + + const search = { + box: { + incremental: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'assetType', + name: 'Type', + multiSelect: false, + options: ASSET_FILTER_OPTIONS.map((i) => ({ + value: i, + name: i, + view: i, + })), + }, + ], + }; + + const tableColumns = [ + { + field: 'name', + name: 'Name', + sortable: true, + truncateText: true, + render: (value, record) => { + return renderAsset(record); + }, + }, + { + field: 'type', + name: 'Type', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.assetType, { length: 100 })} + + ), + }, + ] as Array>; + + return ( + + + + + + ); + } + + return ( + + + + {AddedOverview({ data: stateData })} + + {AddedAssets({ data: stateData })} + + + {isModalVisible && modalLayout} + + ); +} diff --git a/public/components/integrations/components/added_integration_overview_page.tsx b/public/components/integrations/components/added_integration_overview_page.tsx new file mode 100644 index 0000000000..89026239c4 --- /dev/null +++ b/public/components/integrations/components/added_integration_overview_page.tsx @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { IntegrationHeader } from './integration_header'; +import { AddedIntegrationsTable } from './added_integration_table'; +import { INTEGRATIONS_BASE } from '../../../../common/constants/shared'; +import { AddedIntegrationOverviewPageProps } from './integration_types'; +import { HttpStart } from '../../../../../../src/core/public'; + +export interface AddedIntegrationsTableProps { + loading: boolean; + data: AddedIntegrationsList; + http: HttpStart; +} + +export interface AddedIntegrationsList { + hits: AddedIntegrationType[]; +} + +export interface AddedIntegrationType { + name: string; + templateName: string; + dataSource: any; + creationDate: string; + status: string; + assets: any[]; + addedBy: string; + id: string; +} + +export function AddedIntegrationOverviewPage(props: AddedIntegrationOverviewPageProps) { + const { chrome, http } = props; + + const [data, setData] = useState({ hits: [] }); + + useEffect(() => { + chrome.setBreadcrumbs([ + { + text: 'Integrations', + href: '#/', + }, + ]); + handleDataRequest(); + }, []); + + async function handleDataRequest() { + http.get(`${INTEGRATIONS_BASE}/store`).then((exists) => setData(exists.data)); + } + + return ( + + + {IntegrationHeader()} + {AddedIntegrationsTable({ data, loading: false, http })} + + + ); +} diff --git a/public/components/integrations/components/added_integration_table.tsx b/public/components/integrations/components/added_integration_table.tsx new file mode 100644 index 0000000000..022882ff0e --- /dev/null +++ b/public/components/integrations/components/added_integration_table.tsx @@ -0,0 +1,180 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiOverlayMask, + EuiPageContent, + EuiSpacer, + EuiTableFieldDataColumnType, + EuiText, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useState } from 'react'; +import { AddedIntegrationsTableProps } from './added_integration_overview_page'; +import { + ASSET_FILTER_OPTIONS, + INTEGRATION_TEMPLATE_OPTIONS, +} from '../../../../common/constants/integrations'; +import { DeleteModal } from '../../../../public/components/common/helpers/delete_modal'; +import { INTEGRATIONS_BASE } from '../../../../common/constants/shared'; +import { useToast } from '../../../../public/components/common/toast'; + +export function AddedIntegrationsTable(props: AddedIntegrationsTableProps) { + const integrations = props.data.hits; + + const { http } = props; + + const { setToast } = useToast(); + + const [isModalVisible, setIsModalVisible] = useState(false); + const [modalLayout, setModalLayout] = useState(); + + const tableColumns = [ + { + field: 'name', + name: 'Asset name', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.name, { length: 100 })} + + ), + }, + { + field: 'source', + name: 'Source', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.templateName, { length: 100 })} + + ), + }, + { + field: 'dateAdded', + name: 'Date Added', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.creationDate, { length: 100 })} + + ), + }, + { + field: 'actions', + name: 'Actions', + sortable: true, + truncateText: true, + render: (value, record) => ( + { + getModal(record.id, record.templateName); + }} + /> + ), + }, + ] as Array>; + + async function deleteAddedIntegration(integrationInstance: string, name: string) { + http + .delete(`${INTEGRATIONS_BASE}/store/${integrationInstance}`) + .then(() => { + setToast(`${name} integration successfully deleted!`, 'success'); + }) + .catch((err) => { + setToast(`Error deleting ${name} or its assets`, 'danger'); + }) + .finally(() => { + window.location.hash = '#/installed'; + }); + } + + const getModal = (integrationInstanceId, name) => { + setModalLayout( + { + setIsModalVisible(false); + deleteAddedIntegration(integrationInstanceId, name); + }} + onCancel={() => { + setIsModalVisible(false); + }} + title={`Delete Assets`} + message={`Are you sure you want to delete the selected asset(s)?`} + /> + ); + setIsModalVisible(true); + }; + + const search = { + box: { + incremental: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'templateName', + name: 'Type', + multiSelect: false, + options: INTEGRATION_TEMPLATE_OPTIONS.map((i) => ({ + value: i, + name: i, + view: i, + })), + }, + ], + }; + + return ( + + + {integrations && integrations.length > 0 ? ( + + ) : ( + <> + + + + + + + +

+ There are currently no added integrations. Add them{' '} + here to start using pre-canned assets! +

+
+ + + )} + {isModalVisible && modalLayout} +
+ ); +} diff --git a/public/components/integrations/components/available_integration_card_view.tsx b/public/components/integrations/components/available_integration_card_view.tsx new file mode 100644 index 0000000000..ebcf6f1aa0 --- /dev/null +++ b/public/components/integrations/components/available_integration_card_view.tsx @@ -0,0 +1,122 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiPanel, + EuiCard, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiSearchBar, + EuiButton, + EuiFieldSearch, + EuiSwitch, + EuiButtonGroup, + EuiBadgeGroup, + EuiBadge, + EuiToolTip, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useRef, useState } from 'react'; +import { + AvailableIntegrationsCardViewProps, + AvailableIntegrationType, +} from './available_integration_overview_page'; +import { INTEGRATIONS_BASE } from '../../../../common/constants/shared'; +import { badges } from './integration_category_badge_group'; + +export function AvailableIntegrationsCardView(props: AvailableIntegrationsCardViewProps) { + const [toggleIconIdSelected, setToggleIconIdSelected] = useState('1'); + + const getImage = (url?: string) => { + let optionalImg; + if (url) { + optionalImg = ( + + ); + } + return optionalImg; + }; + + const toggleButtonsIcons = [ + { + id: '0', + label: 'list', + iconType: 'list', + }, + { + id: '1', + label: 'grid', + iconType: 'grid', + }, + ]; + + const onChangeIcons = (optionId) => { + setToggleIconIdSelected(optionId); + if (optionId === '0') { + props.setCardView(false); + } else { + props.setCardView(true); + } + }; + + const renderRows = (integrations: AvailableIntegrationType[]) => { + if (!integrations || !integrations.length) return null; + return ( + <> + + {integrations.map((i, v) => { + return ( + + (window.location.hash = `#/available/${i.name}`)} + footer={badges(i.components)} + /> + + ); + })} + + + + ); + }; + + return ( + + + + { + props.setQuery(e.target.value); + }} + /> + + {props.renderCateogryFilters()} + + onChangeIcons(id)} + isIconOnly + /> + + + + {renderRows(props.data.hits.filter((x) => x.name.includes(props.query)))} + + ); +} diff --git a/public/components/integrations/components/available_integration_overview_page.tsx b/public/components/integrations/components/available_integration_overview_page.tsx new file mode 100644 index 0000000000..f533d38cb0 --- /dev/null +++ b/public/components/integrations/components/available_integration_overview_page.tsx @@ -0,0 +1,212 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { + EuiFieldSearch, + EuiFilterButton, + EuiFilterGroup, + EuiFilterSelectItem, + EuiPage, + EuiPageBody, + EuiPopover, + EuiPopoverTitle, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { IntegrationHeader } from './integration_header'; +import { AvailableIntegrationsTable } from './available_integration_table'; +import { AvailableIntegrationsCardView } from './available_integration_card_view'; +import { INTEGRATIONS_BASE } from '../../../../common/constants/shared'; +import { AvailableIntegrationOverviewPageProps } from './integration_types'; +import { useToast } from '../../../../public/components/common/toast'; + +export interface AvailableIntegrationType { + name: string; + description: string; + assetUrl?: string | undefined; + version?: string | undefined; + displayName?: string; + integrationType: string; + statics: any; + components: any[]; + displayAssets: any[]; +} + +export interface AvailableIntegrationsTableProps { + loading: boolean; + data: AvailableIntegrationsList; + isCardView: boolean; + setCardView: (input: boolean) => void; + renderCateogryFilters: () => React.JSX.Element; +} + +export interface AvailableIntegrationsList { + hits: AvailableIntegrationType[]; +} + +export interface AvailableIntegrationsCardViewProps { + data: AvailableIntegrationsList; + isCardView: boolean; + setCardView: (input: boolean) => void; + query: string; + setQuery: (input: string) => void; + renderCateogryFilters: () => React.JSX.Element; +} + +export function AvailableIntegrationOverviewPage(props: AvailableIntegrationOverviewPageProps) { + const { chrome, http } = props; + + const [query, setQuery] = useState(''); + const [isCardView, setCardView] = useState(true); + const { setToast } = useToast(); + const [data, setData] = useState({ hits: [] }); + + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const onButtonClick = () => { + setIsPopoverOpen(!isPopoverOpen); + }; + + const closePopover = () => { + setIsPopoverOpen(false); + }; + + const [items, setItems] = useState([ + { name: 'http' }, + { name: 'logs' }, + { name: 'communication' }, + { name: 'cloud' }, + { name: 'aws_elb' }, + { name: 'container' }, + ]); + + function updateItem(index) { + if (!items[index]) { + return; + } + + const newItems = [...items]; + + switch (newItems[index].checked) { + case 'on': + newItems[index].checked = undefined; + break; + + default: + newItems[index].checked = 'on'; + } + + setItems(newItems); + } + + const helper = items.filter((item) => item.checked === 'on').map((x) => x.name); + + const button = ( + item.checked === 'on')} + numActiveFilters={items.filter((item) => item.checked === 'on').length} + > + Categories + + ); + + useEffect(() => { + chrome.setBreadcrumbs([ + { + text: 'Integrations', + href: '#/', + }, + ]); + handleDataRequest(); + }, []); + + async function handleDataRequest() { + http.get(`${INTEGRATIONS_BASE}/repository`).then((exists) => setData(exists.data)); + } + + async function addIntegrationRequest(name: string) { + http + .post(`${INTEGRATIONS_BASE}/store`) + .then((res) => { + setToast( + `${name} integration successfully added!`, + 'success', + `View the added assets from ${name} in the Added Integrations list` + ); + }) + .catch((err) => + setToast( + 'Failed to load integration. Check Added Integrations table for more details', + 'danger' + ) + ); + } + + const renderCateogryFilters = () => { + return ( + + + + + +
+ {items.map((item, index) => ( + updateItem(index)} + > + {item.name} + + ))} +
+
+
+ ); + }; + + return ( + + + {IntegrationHeader()} + {isCardView + ? AvailableIntegrationsCardView({ + data: { + hits: data.hits.filter((hit) => + helper.every((compon) => hit.components.map((x) => x.name).includes(compon)) + ), + }, + isCardView, + setCardView, + query, + setQuery, + renderCateogryFilters, + }) + : AvailableIntegrationsTable({ + loading: false, + data: { + hits: data.hits.filter((hit) => + helper.every((compon) => hit.components.map((x) => x.name).includes(compon)) + ), + }, + isCardView, + setCardView, + renderCateogryFilters, + })} + + + ); +} diff --git a/public/components/integrations/components/available_integration_table.tsx b/public/components/integrations/components/available_integration_table.tsx new file mode 100644 index 0000000000..1d336b8bb8 --- /dev/null +++ b/public/components/integrations/components/available_integration_table.tsx @@ -0,0 +1,144 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonGroup, + EuiFieldSearch, + EuiFilterButton, + EuiFilterGroup, + EuiFilterSelectItem, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiInMemoryTable, + EuiLink, + EuiPageContent, + EuiPopover, + EuiPopoverTitle, + EuiSpacer, + EuiTableFieldDataColumnType, + EuiText, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useState } from 'react'; +import { AvailableIntegrationsTableProps } from './available_integration_overview_page'; +import { badges } from './integration_category_badge_group'; + +export function AvailableIntegrationsTable(props: AvailableIntegrationsTableProps) { + const integrations = props.data.hits; + + const toggleButtonsIcons = [ + { + id: '0', + label: 'list', + iconType: 'list', + }, + { + id: '1', + label: 'grid', + iconType: 'grid', + }, + ]; + + const [toggleIconIdSelected, setToggleIconIdSelected] = useState('0'); + + const onChangeIcons = (optionId) => { + setToggleIconIdSelected(optionId); + if (optionId === '0') { + props.setCardView(false); + } else { + props.setCardView(true); + } + }; + + const tableColumns = [ + { + field: 'name', + name: 'Name', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.displayName || record.name, { length: 100 })} + + ), + }, + { + field: 'description', + name: 'Description', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.description, { length: 100 })} + + ), + }, + { + field: 'categories', + name: 'Categories', + sortable: true, + truncateText: true, + render: (value, record) => badges(record.components), + }, + ] as Array>; + + const renderToggle = () => { + return ( + + {props.renderCateogryFilters()} + + onChangeIcons(id)} + isIconOnly + /> + + + ); + }; + + const search = { + toolsRight: renderToggle(), + box: { + incremental: true, + }, + }; + + return ( + + + {integrations.length > 0 ? ( + + ) : ( + <> + + +

No Integrations Available

+
+ + + )} +
+ ); +} diff --git a/public/components/integrations/components/integration.tsx b/public/components/integrations/components/integration.tsx new file mode 100644 index 0000000000..ad1a1dfe65 --- /dev/null +++ b/public/components/integrations/components/integration.tsx @@ -0,0 +1,325 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +/* eslint-disable react-hooks/exhaustive-deps */ + +import { + EuiGlobalToastList, + EuiLoadingSpinner, + EuiOverlayMask, + EuiPage, + EuiPageBody, + EuiSpacer, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import React, { ReactChild, useEffect, useState } from 'react'; +import { last } from 'lodash'; +import { IntegrationOverview } from './integration_overview_panel'; +import { IntegrationDetails } from './integration_details_panel'; +import { IntegrationFields } from './integration_fields_panel'; +import { IntegrationAssets } from './integration_assets_panel'; +import { AvailableIntegrationProps } from './integration_types'; +import { INTEGRATIONS_BASE } from '../../../../common/constants/shared'; +import { IntegrationScreenshots } from './integration_screenshots_panel'; +import { AddIntegrationFlyout } from './add_integration_flyout'; +import { useToast } from '../../../../public/components/common/toast'; + +export function Integration(props: AvailableIntegrationProps) { + const { http, integrationTemplateId, chrome } = props; + + const [isFlyoutVisible, setIsFlyoutVisible] = useState(false); + const { setToast } = useToast(); + const [integration, setIntegration] = useState({}); + + const [integrationMapping, setMapping] = useState(null); + const [integrationAssets, setAssets] = useState([]); + const [loading, setLoading] = useState(false); + + const createMappings = async ( + componentName: string, + payload: { + template: { mappings: { _meta: { version: string } } }; + composed_of: string[]; + index_patterns: string[]; + }, + dataSourceName: string + ): Promise<{ [key: string]: { properties: any } } | null> => { + const version = payload.template.mappings._meta.version; + if (componentName !== integration.type) { + return fetch( + `/api/console/proxy?path=_component_template/ss4o_${componentName}_${version}_template&method=POST`, + { + method: 'POST', + headers: [ + ['osd-xsrf', 'true'], + ['Content-Type', 'application/json'], + ], + body: JSON.stringify(payload), + } + ) + .then((response) => response.json()) + .catch((err: any) => { + console.error(err); + return err; + }); + } else { + payload.index_patterns = [dataSourceName]; + return fetch( + `/api/console/proxy?path=_index_template/${componentName}_${version}&method=POST`, + { + method: 'POST', + headers: [ + ['osd-xsrf', 'true'], + ['Content-Type', 'application/json'], + ], + body: JSON.stringify(payload), + } + ) + .then((response) => response.json()) + .catch((err: any) => { + console.error(err); + return err; + }); + } + }; + + const createDataSourceMappings = async (targetDataSource: string): Promise => { + const data = await fetch( + `${INTEGRATIONS_BASE}/repository/${integrationTemplateId}/schema` + ).then((response) => { + return response.json(); + }); + let error = null; + const mappings = data.data.mappings; + mappings[integration.type].composed_of = mappings[integration.type].composed_of.map( + (templateName: string) => { + const version = mappings[templateName].template.mappings._meta.version; + return `ss4o_${templateName}_${version}_template`; + } + ); + Object.entries(mappings).forEach(async ([key, mapping]) => { + if (key === integration.type) { + return; + } + await createMappings(key, mapping as any, targetDataSource); + }); + await createMappings(integration.type, mappings[integration.type], targetDataSource); + + for (const [key, mapping] of Object.entries(data.data.mappings)) { + const result = await createMappings(key, mapping as any, targetDataSource); + + if (result && result.error) { + error = (result.error as any).reason; + } + } + + if (error !== null) { + setToast('Failure creating index template', 'danger', error); + } else { + setToast(`Successfully created index template`); + } + }; + + useEffect(() => { + chrome.setBreadcrumbs([ + { + text: 'Integrations', + href: '#/', + }, + { + text: integrationTemplateId, + href: `#/available/${integrationTemplateId}`, + }, + ]); + handleDataRequest(); + }, [integrationTemplateId]); + + async function handleDataRequest() { + // TODO fill in ID request here + http.get(`${INTEGRATIONS_BASE}/repository/${integrationTemplateId}`).then((exists) => { + setIntegration(exists.data); + }); + } + + useEffect(() => { + if (Object.keys(integration).length === 0) { + return; + } + fetch(`${INTEGRATIONS_BASE}/repository/${integration.name}/schema`) + .then((response) => response.json()) + .then((parsedResponse) => { + if (parsedResponse.statusCode && parsedResponse.statusCode !== 200) { + throw new Error('Request for schema failed: ' + parsedResponse.message); + } + return parsedResponse.data.mappings[integration.type]; + }) + .then((mapping) => setMapping(mapping)) + .catch((err: any) => { + console.error(err.message); + }); + }, [integration]); + + useEffect(() => { + if (Object.keys(integration).length === 0) { + return; + } + fetch(`${INTEGRATIONS_BASE}/repository/${integration.name}/assets`) + .then((response) => response.json()) + .then((parsedResponse) => { + if (parsedResponse.statusCode && parsedResponse.statusCode !== 200) { + throw new Error('Request for assets failed: ' + parsedResponse.message); + } + return parsedResponse.data; + }) + .then((assets) => setAssets(assets)) + .catch((err: any) => { + console.error(err.message); + }); + }, [integration]); + + async function addIntegrationRequest( + addSample: boolean, + templateName: string, + name?: string, + dataSource?: string + ) { + setLoading(true); + if (addSample) { + createDataSourceMappings(`ss4o_${integration.type}-${integrationTemplateId}-*-sample`); + name = `${integrationTemplateId}-sample`; + dataSource = `ss4o_${integration.type}-${integrationTemplateId}-sample-sample`; + } + + const response: boolean = await http + .post(`${INTEGRATIONS_BASE}/store/${templateName}`, { + body: JSON.stringify({ name, dataSource }), + }) + .then((_res) => { + setToast(`${name} integration successfully added!`, 'success'); + window.location.hash = `#/installed/${_res.data?.id}`; + return true; + }) + .catch((_err) => { + setToast( + 'Failed to load integration. Check Added Integrations table for more details', + 'danger' + ); + return false; + }); + if (!addSample || !response) { + setLoading(false); + return; + } + const data: { sampleData: unknown[] } = await http + .get(`${INTEGRATIONS_BASE}/repository/${templateName}/data`) + .then((res) => res.data) + .catch((err) => { + console.error(err); + setToast('The sample data could not be retrieved', 'danger'); + return { sampleData: [] }; + }); + const requestBody = + data.sampleData + .map((record) => `{"create": { "_index": "${dataSource}" } }\n${JSON.stringify(record)}`) + .join('\n') + '\n'; + fetch(`/api/console/proxy?path=${dataSource}/_bulk&method=POST`, { + method: 'POST', + body: requestBody, + headers: [ + ['osd-xsrf', 'true'], + ['Content-Type', 'application/json; charset=utf-8'], + ], + }) + .catch((err) => { + console.error(err); + setToast('Failed to load sample data', 'danger'); + }) + .finally(() => { + setLoading(false); + }); + } + + const tabs = [ + { + id: 'assets', + name: 'Asset List', + disabled: false, + }, + { + id: 'fields', + name: 'Integration Fields', + disabled: false, + }, + ]; + + const [selectedTabId, setSelectedTabId] = useState('assets'); + + const onSelectedTabChanged = (id) => { + setSelectedTabId(id); + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + key={index} + data-test-subj={tab.id} + > + {tab.name} + + )); + }; + + if (Object.keys(integration).length === 0) { + return ( + + + + ); + } + return ( + + + + {IntegrationOverview({ + integration, + showFlyout: () => { + setIsFlyoutVisible(true); + }, + setUpSample: () => { + addIntegrationRequest(true, integrationTemplateId); + }, + loading, + })} + + {IntegrationDetails({ integration })} + + {IntegrationScreenshots({ integration })} + + {renderTabs()} + + {selectedTabId === 'assets' + ? IntegrationAssets({ integration, integrationAssets }) + : IntegrationFields({ integration, integrationMapping })} + + + {isFlyoutVisible && ( + { + setIsFlyoutVisible(false); + }} + onCreate={(name, dataSource) => { + addIntegrationRequest(false, integrationTemplateId, name, dataSource); + }} + integrationName={integrationTemplateId} + integrationType={integration.type} + http={http} + /> + )} + + ); +} diff --git a/public/components/integrations/components/integration_assets_panel.tsx b/public/components/integrations/components/integration_assets_panel.tsx new file mode 100644 index 0000000000..3b74d498af --- /dev/null +++ b/public/components/integrations/components/integration_assets_panel.tsx @@ -0,0 +1,86 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiInMemoryTable, + EuiPanel, + EuiSpacer, + EuiTableFieldDataColumnType, + EuiText, +} from '@elastic/eui'; +import React from 'react'; +import _ from 'lodash'; +import { PanelTitle } from '../../trace_analytics/components/common/helper_functions'; +import { ASSET_FILTER_OPTIONS } from '../../../../common/constants/integrations'; + +export function IntegrationAssets(props: any) { + const [config, assets] = [props.integration, props.integrationAssets]; + + const search = { + box: { + incremental: true, + }, + filters: [ + { + type: 'field_value_selection', + field: 'type', + name: 'Type', + multiSelect: false, + options: ASSET_FILTER_OPTIONS.map((i) => ({ + value: i, + name: i, + view: i, + })), + }, + ], + }; + + const tableColumns = [ + { + field: 'name', + name: 'Name', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.attributes.title ? record.attributes.title : '(Unnamed)', { + length: 100, + })} + + ), + }, + { + field: 'type', + name: 'Type', + sortable: true, + truncateText: true, + render: (_value, record) => ( + + {_.truncate(record.type, { length: 100 })} + + ), + }, + ] as Array>; + + return ( + + + + x.type !== undefined) : [] + } + columns={tableColumns} + pagination={{ + initialPageSize: 10, + pageSizeOptions: [5, 10, 15], + }} + search={search} + /> + + ); +} diff --git a/public/components/integrations/components/integration_category_badge_group.tsx b/public/components/integrations/components/integration_category_badge_group.tsx new file mode 100644 index 0000000000..8635c6270e --- /dev/null +++ b/public/components/integrations/components/integration_category_badge_group.tsx @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiBadge, EuiBadgeGroup, EuiToolTip } from '@elastic/eui'; +import React from 'react'; + +export const badges = (categories) => { + if (categories.length <= 3) { + return ( + + {categories.map((cateogry) => { + return {cateogry.name}; + })} + + ); + } else { + const tooltip = `+${categories.length - 2} more`; + return ( + + {categories[0].name} + {categories[1].name} + + (index ? ', ' : '') + item.name)} + > +

{tooltip}

+
+
+
+ ); + } +}; diff --git a/public/components/integrations/components/integration_details_panel.tsx b/public/components/integrations/components/integration_details_panel.tsx new file mode 100644 index 0000000000..64c7f4533d --- /dev/null +++ b/public/components/integrations/components/integration_details_panel.tsx @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPanel, EuiSpacer, EuiText } from '@elastic/eui'; +import React from 'react'; +import { PanelTitle } from '../../trace_analytics/components/common/helper_functions'; + +export function IntegrationDetails(props: any) { + const config = props.integration; + let screenshots; + if (config.statics.gallery) { + screenshots = config.statics.gallery; + } + + return ( + + + + {config.description} + + ); +} diff --git a/public/components/integrations/components/integration_fields_panel.tsx b/public/components/integrations/components/integration_fields_panel.tsx new file mode 100644 index 0000000000..36fe8b4e1c --- /dev/null +++ b/public/components/integrations/components/integration_fields_panel.tsx @@ -0,0 +1,111 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiInMemoryTable, + EuiPanel, + EuiSpacer, + EuiTableFieldDataColumnType, + EuiText, +} from '@elastic/eui'; +import React from 'react'; +import _ from 'lodash'; +import { PanelTitle } from '../../trace_analytics/components/common/helper_functions'; + +export function IntegrationFields(props: any) { + const config = props.integration; + const mapping = props.integrationMapping; + + const search = { + box: { + incremental: true, + }, + }; + + const tableColumns = [ + { + field: 'name', + name: 'Name', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.name, { length: 100 })} + + ), + }, + { + field: 'type', + name: 'Type', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.type, { length: 100 })} + + ), + }, + { + field: 'category', + name: 'Category', + sortable: true, + truncateText: true, + render: (value, record) => ( + + {_.truncate(record.category, { length: 100 })} + + ), + }, + ] as Array>; + + const traverseTypes = ( + properties: any, + category?: string, + prefix?: string + ): Array<{ + name: string; + type: string; + category: string; + }> => { + const result: any[] = []; + for (const p of Object.keys(properties)) { + if (properties[p].type) { + result.push({ + name: prefix ? prefix + '.' + p : p, + type: properties[p].type, + category: category ? category : 'None', + }); + } else if (properties[p].properties) { + result.push({ + name: prefix ? prefix + '.' + p : p, + type: 'nested', + category: category ? category : 'None', + }); + result.push( + ...traverseTypes(properties[p].properties, (prefix = prefix ? prefix + '.' + p : p)) + ); + } + } + return result; + }; + + return ( + + + + + + ); +} diff --git a/public/components/integrations/components/integration_header.tsx b/public/components/integrations/components/integration_header.tsx new file mode 100644 index 0000000000..2b8ad7f177 --- /dev/null +++ b/public/components/integrations/components/integration_header.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiLink, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiTab, + EuiTabs, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import _ from 'lodash'; +import React, { useEffect, useState } from 'react'; +import { OPENSEARCH_DOCUMENTATION_URL } from '../../../../common/constants/integrations'; + +export function IntegrationHeader() { + const tabs = [ + { + id: 'installed', + name: 'Installed', + disabled: false, + }, + { + id: 'available', + name: 'Available', + disabled: false, + }, + ]; + + const [selectedTabId, setSelectedTabId] = useState( + window.location.hash.substring(2) ? window.location.hash.substring(2) : 'installed' + ); + + const onSelectedTabChanged = (id) => { + setSelectedTabId(id); + window.location.hash = id; + }; + + const renderTabs = () => { + return tabs.map((tab, index) => ( + onSelectedTabChanged(tab.id)} + isSelected={tab.id === selectedTabId} + disabled={tab.disabled} + key={index} + > + {tab.name} + + )); + }; + return ( +
+ + + +

Integrations

+
+
+
+ + + View or add available integrations to use pre-canned assets immediately in your OpenSearch + setup.{' '} + + Learn more + + + + {renderTabs()} + +
+ ); +} diff --git a/public/components/integrations/components/integration_overview_panel.tsx b/public/components/integrations/components/integration_overview_panel.tsx new file mode 100644 index 0000000000..ce4168c85a --- /dev/null +++ b/public/components/integrations/components/integration_overview_panel.tsx @@ -0,0 +1,113 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiFlexGroup, + EuiLink, + EuiPageHeader, + EuiPageHeaderSection, + EuiSpacer, + EuiTitle, + EuiFlexItem, + EuiText, + EuiPageContentHeaderSection, + EuiBadge, + EuiBadgeGroup, +} from '@elastic/eui'; +import React from 'react'; + +const pageStyles: CSS.Properties = { + width: '100%', + justifyContent: 'spaceBetween', +}; + +export function IntegrationOverview(props: any) { + const config = props.integration; + return ( + + + + + + + +

{config.displayName || config.name}

+
+
+ + { + props.showFlyout(config.name); + }} + fill + disabled={props.loading} + data-test-subj="add-integration-button" + data-click-metric-element="integrations.set_up" + > + Set Up + + + + { + props.setUpSample(); + }} + fill + disabled={props.loading} + data-test-subj="try-it-button" + data-click-metric-element="integrations.create_from_try_it" + > + Try It + + +
+
+ + + + +

Version

+
+ + {config.version} +
+ + +

Category

+
+ + + {config.components.map((cateogry) => { + return {cateogry.name}; + })} + +
+ + +

Contributer

+
+ + + {config.author} + +
+ + +

License

+
+ + {config.license} +
+
+
+
+ ); +} diff --git a/public/components/integrations/components/integration_screenshots_panel.tsx b/public/components/integrations/components/integration_screenshots_panel.tsx new file mode 100644 index 0000000000..4c993a895c --- /dev/null +++ b/public/components/integrations/components/integration_screenshots_panel.tsx @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlexGroup, EuiPanel, EuiFlexItem, EuiImage } from '@elastic/eui'; +import React from 'react'; +import { PanelTitle } from '../../trace_analytics/components/common/helper_functions'; +import { INTEGRATIONS_BASE } from '../../../../common/constants/shared'; + +export function IntegrationScreenshots(props: any) { + const config = props.integration; + let screenshots; + if (config.statics.gallery) { + screenshots = config.statics.gallery; + } + + return ( + + + + {screenshots?.map((screenshot: { path: string; annotation?: string }) => { + return ( + + + + ); + })} + + + ); +} diff --git a/public/components/integrations/components/integration_types.ts b/public/components/integrations/components/integration_types.ts new file mode 100644 index 0000000000..441678f382 --- /dev/null +++ b/public/components/integrations/components/integration_types.ts @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChromeBreadcrumb, ChromeStart, HttpStart } from '../../../../../../src/core/public'; + +export interface AvailableIntegrationOverviewPageProps { + http: HttpStart; + chrome: ChromeStart; +} + +export interface AddedIntegrationOverviewPageProps { + http: HttpStart; + chrome: ChromeStart; +} + +export interface AvailableIntegrationProps { + http: HttpStart; + chrome: ChromeStart; +} + +export interface AddedIntegrationProps { + http: HttpStart; + chrome: ChromeStart; + integrationInstanceId: string; +} + +export interface AvailableIntegrationProps { + http: HttpStart; + chrome: ChromeStart; + integrationTemplateId: string; +} diff --git a/public/components/integrations/home.tsx b/public/components/integrations/home.tsx new file mode 100644 index 0000000000..3fa85b98dc --- /dev/null +++ b/public/components/integrations/home.tsx @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { HashRouter, Route, RouteComponentProps, Switch } from 'react-router-dom'; +import { EuiGlobalToastList } from '@elastic/eui'; +import { Toast } from '@elastic/eui/src/components/toast/global_toast_list'; +import { Integration } from './components/integration'; +import { TraceAnalyticsCoreDeps } from '../trace_analytics/home'; +import { ChromeBreadcrumb } from '../../../../../src/core/public'; +import { AvailableIntegrationOverviewPage } from './components/available_integration_overview_page'; +import { AddedIntegrationOverviewPage } from './components/added_integration_overview_page'; +import { AddedIntegration } from './components/added_integration'; + +export type AppAnalyticsCoreDeps = TraceAnalyticsCoreDeps; + +interface HomeProps extends RouteComponentProps, AppAnalyticsCoreDeps { + parentBreadcrumbs: ChromeBreadcrumb[]; +} + +export const Home = (props: HomeProps) => { + const { http, chrome } = props; + + const commonProps = { + http, + chrome, + }; + + return ( +
+ + + } + /> + } + /> + ( + + )} + /> + ( + + )} + /> + + +
+ ); +}; diff --git a/public/plugin.ts b/public/plugin.ts index 0a94c6273c..021a9f56ad 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -11,6 +11,7 @@ import { AppMountParameters, CoreSetup, CoreStart, + DEFAULT_APP_CATEGORIES, Plugin, } from '../../../src/core/public'; import { CREATE_TAB_PARAM, CREATE_TAB_PARAM_KEY, TAB_CHART_ID } from '../common/constants/explorer'; @@ -34,6 +35,9 @@ import { observabilityLogsID, observabilityLogsTitle, observabilityLogsPluginOrder, + observabilityIntegrationsID, + observabilityIntegrationsTitle, + observabilityIntegrationsPluginOrder, observabilityPluginOrder, } from '../common/constants/shared'; import { QueryManager } from '../common/query_manager'; @@ -185,6 +189,14 @@ export class ObservabilityPlugin mount: appMountWithStartPage('dashboards'), }); + core.application.register({ + id: observabilityIntegrationsID, + title: observabilityIntegrationsTitle, + category: DEFAULT_APP_CATEGORIES.management, + order: observabilityIntegrationsPluginOrder, + mount: appMountWithStartPage('integrations'), + }); + const embeddableFactory = new ObservabilityEmbeddableFactoryDefinition(async () => ({ getAttributeService: (await core.getStartServices())[1].dashboard.getAttributeService, savedObjectsClient: (await core.getStartServices())[0].savedObjects.client, diff --git a/server/adaptors/integrations/__data__/repository/aws_elb/aws_elb-1.0.0.json b/server/adaptors/integrations/__data__/repository/aws_elb/aws_elb-1.0.0.json index 9eea541007..cf4d90c674 100644 --- a/server/adaptors/integrations/__data__/repository/aws_elb/aws_elb-1.0.0.json +++ b/server/adaptors/integrations/__data__/repository/aws_elb/aws_elb-1.0.0.json @@ -6,7 +6,7 @@ "license": "Apache-2.0", "type": "logs", "author": "OpenSearch", - "sourceUrl": "https://github.com/YANG-DB/observability/tree/aws_alb_integration", + "sourceUrl": "https://github.com/opensearch-project/opensearch-catalog/blob/main/schema/observability/logs/aws_elb.mapping", "statics": { "logo": { "annotation": "ELB Logo", diff --git a/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.0.json b/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.0.json index e0cbee9855..fd046e4b18 100644 --- a/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.0.json +++ b/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.0.json @@ -7,7 +7,7 @@ "type": "logs", "link": "https://www.nginx.com/", "author": "OpenSearch", - "sourceUrl": "https://github.com/opensearch-project/observability/pull/1493/files", + "sourceUrl": "https://github.com/opensearch-project/observability/tree/2.x/integrations/nginx", "statics": { "logo": { "annotation": "NginX Logo", diff --git a/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.1.json b/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.1.json index dc708838d5..eb4b642df5 100644 --- a/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.1.json +++ b/server/adaptors/integrations/__data__/repository/nginx/nginx-1.0.1.json @@ -6,7 +6,7 @@ "license": "Apache-2.0", "type": "logs", "author": "OpenSearch", - "sourceUrl": "https://github.com/opensearch-project/observability/pull/1493/files", + "sourceUrl": "https://github.com/opensearch-project/observability/tree/2.x/integrations/nginx", "statics": { "logo": { "annotation": "NginX Logo", diff --git a/server/adaptors/integrations/integrations_kibana_backend.ts b/server/adaptors/integrations/integrations_kibana_backend.ts index b41f75a4d7..f28c883ecf 100644 --- a/server/adaptors/integrations/integrations_kibana_backend.ts +++ b/server/adaptors/integrations/integrations_kibana_backend.ts @@ -4,6 +4,7 @@ */ import path from 'path'; +import { addRequestToMetric } from '../../../server/common/metrics/metrics_helper'; import { IntegrationsAdaptor } from './integrations_adaptor'; import { SavedObject, SavedObjectsClientContract } from '../../../../../src/core/server/types'; import { IntegrationInstanceBuilder } from './integrations_builder'; @@ -42,11 +43,13 @@ export class IntegrationsKibanaBackend implements IntegrationsAdaptor { await this.client.delete(asset.type, asset.id); return Promise.resolve(asset.id); } catch (err: any) { + addRequestToMetric('integrations', 'delete', err); return err.output?.statusCode === 404 ? Promise.resolve(asset.id) : Promise.reject(err); } } ) ); + addRequestToMetric('integrations', 'delete', 'count'); return result; }; @@ -66,6 +69,7 @@ export class IntegrationsKibanaBackend implements IntegrationsAdaptor { getIntegrationInstances = async ( _query?: IntegrationInstanceQuery ): Promise => { + addRequestToMetric('integrations', 'get', 'count'); const result = await this.client.find({ type: 'integration-instance' }); return Promise.resolve({ total: result.total, @@ -79,6 +83,7 @@ export class IntegrationsKibanaBackend implements IntegrationsAdaptor { getIntegrationInstance = async ( query?: IntegrationInstanceQuery ): Promise => { + addRequestToMetric('integrations', 'get', 'count'); const result = await this.client.get('integration-instance', `${query!.id}`); return Promise.resolve(this.buildInstanceResponse(result)); }; @@ -137,6 +142,7 @@ export class IntegrationsKibanaBackend implements IntegrationsAdaptor { }); } try { + addRequestToMetric('integrations', 'create', 'count'); const result = await this.instanceBuilder.build(template, { name, dataSource, @@ -144,6 +150,7 @@ export class IntegrationsKibanaBackend implements IntegrationsAdaptor { const test = await this.client.create('integration-instance', result); return Promise.resolve({ ...result, id: test.id }); } catch (err: any) { + addRequestToMetric('integrations', 'create', err); return Promise.reject({ message: err.message, statusCode: 500, diff --git a/server/adaptors/opensearch_observability_plugin.ts b/server/adaptors/opensearch_observability_plugin.ts index d7042f76f8..fbdbac72be 100644 --- a/server/adaptors/opensearch_observability_plugin.ts +++ b/server/adaptors/opensearch_observability_plugin.ts @@ -9,7 +9,6 @@ export function OpenSearchObservabilityPlugin(Client: any, config: any, componen const clientAction = components.clientAction.factory; Client.prototype.observability = components.clientAction.namespaceFactory(); - Client.prototype.integrations = components.clientAction.namespaceFactory(); const observability = Client.prototype.observability.prototype; // Get Object