diff --git a/common/constants/integrations.ts b/common/constants/integrations.ts index 327d3b5b7b..aaf19eb60c 100644 --- a/common/constants/integrations.ts +++ b/common/constants/integrations.ts @@ -12,6 +12,8 @@ export const ASSET_FILTER_OPTIONS = [ 'observability-search', ]; export const VALID_INDEX_NAME = /^[a-z\d\.][a-z\d\._\-\*]*$/; +export const OPENSEARCH_CATALOG_URL = + 'https://github.com/opensearch-project/opensearch-catalog/releases'; // Upstream doesn't export this, so we need to redeclare it for our use. export type Color = 'success' | 'primary' | 'warning' | 'danger' | undefined; 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 index 41591c2408..574594d3a5 100644 --- a/public/components/integrations/components/__tests__/__snapshots__/integration_header.test.tsx.snap +++ b/public/components/integrations/components/__tests__/__snapshots__/integration_header.test.tsx.snap @@ -24,6 +24,124 @@ exports[`Integration Header Test Renders integration header as expected 1`] = ` + +
+ + + Catalog + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="integHeaderActionsPanel" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > +
+
+ + + + + +
+
+
+
+
+
+ + Upload Integrations + + + + + + + + + Cancel + + + + + Upload + + + + + +`; + +exports[`Integration Upload Flyout Renders the clean integration picker as expected 1`] = ` + + + + + +`; diff --git a/public/components/integrations/components/__tests__/upload_flyout.test.tsx b/public/components/integrations/components/__tests__/upload_flyout.test.tsx new file mode 100644 index 0000000000..82037a8fd9 --- /dev/null +++ b/public/components/integrations/components/__tests__/upload_flyout.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { configure, shallow } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import { IntegrationUploadFlyout, IntegrationUploadPicker } from '../upload_flyout'; + +describe('Integration Upload Flyout', () => { + configure({ adapter: new Adapter() }); + + it('Renders integration upload flyout as expected', async () => { + const wrapper = shallow( {}} />); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); + + it('Renders the clean integration picker as expected', async () => { + const wrapper = shallow( + {}} + onFileSelected={(_) => {}} + /> + ); + + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/integrations/components/integration_details_panel.tsx b/public/components/integrations/components/integration_details_panel.tsx index a69ffca76e..49a36e2ca1 100644 --- a/public/components/integrations/components/integration_details_panel.tsx +++ b/public/components/integrations/components/integration_details_panel.tsx @@ -15,6 +15,7 @@ import { EuiTitle, } from '@elastic/eui'; import React from 'react'; +import { OPENSEARCH_CATALOG_URL } from '../../../../common/constants/integrations'; export function IntegrationDetails(props: { integration: IntegrationConfig }) { const config = props.integration; @@ -48,9 +49,7 @@ export function IntegrationDetails(props: { integration: IntegrationConfig }) { - - Check for new versions - + Check for new versions diff --git a/public/components/integrations/components/integration_header.tsx b/public/components/integrations/components/integration_header.tsx index 72868134cd..fc823b95bb 100644 --- a/public/components/integrations/components/integration_header.tsx +++ b/public/components/integrations/components/integration_header.tsx @@ -4,9 +4,13 @@ */ import { + EuiButton, + EuiContextMenuItem, + EuiContextMenuPanel, EuiLink, EuiPageHeader, EuiPageHeaderSection, + EuiPopover, EuiSpacer, EuiTab, EuiTabs, @@ -14,9 +18,54 @@ import { EuiTitle, } from '@elastic/eui'; import React, { useState } from 'react'; -import { OPENSEARCH_DOCUMENTATION_URL } from '../../../../common/constants/integrations'; +import { + OPENSEARCH_CATALOG_URL, + OPENSEARCH_DOCUMENTATION_URL, +} from '../../../../common/constants/integrations'; +import { IntegrationUploadFlyout } from './upload_flyout'; + +export const IntegrationHeaderActions = ({ onShowUpload }: { onShowUpload: () => void }) => { + const [isPopoverOpen, setPopover] = useState(false); + + const closePopover = () => { + setPopover(false); + }; + + const onButtonClick = () => { + setPopover((isOpen) => !isOpen); + }; + + const items = [ + { + closePopover(); // If the popover isn't closed, it overlays over the flyout + onShowUpload(); + }} + > + Upload Integrations + , + View Catalog, + ]; + const button = ( + + Catalog + + ); + return ( + + + + ); +}; -export function IntegrationHeader() { +export const IntegrationHeader = () => { const tabs = [ { id: 'installed', @@ -33,8 +82,9 @@ export function IntegrationHeader() { const [selectedTabId, setSelectedTabId] = useState( window.location.hash.substring(2) ? window.location.hash.substring(2) : 'installed' ); + const [showUploadFlyout, setShowUploadFlyout] = useState(false); - const onSelectedTabChanged = (id) => { + const onSelectedTabChanged = (id: string) => { setSelectedTabId(id); window.location.hash = id; }; @@ -51,6 +101,7 @@ export function IntegrationHeader() { )); }; + return (
@@ -59,6 +110,9 @@ export function IntegrationHeader() {

Integrations

+ + setShowUploadFlyout(true)} /> +
@@ -70,6 +124,9 @@ export function IntegrationHeader() { {renderTabs()} + {showUploadFlyout ? ( + setShowUploadFlyout(false)} /> + ) : null}
); -} +}; diff --git a/public/components/integrations/components/upload_flyout.tsx b/public/components/integrations/components/upload_flyout.tsx new file mode 100644 index 0000000000..e9d3a15da5 --- /dev/null +++ b/public/components/integrations/components/upload_flyout.tsx @@ -0,0 +1,174 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiButton, + EuiFilePicker, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, +} from '@elastic/eui'; +import { useEffect } from 'react'; +import { coreRefs } from '../../../../public/framework/core_refs'; +import { useToast } from '../../../../public/components/common/toast'; + +const uploadBundle = async (bundle: File | null): Promise => { + if (!bundle) { + return new Error('No bundle selected'); + } + + const formData = new FormData(); + formData.append('file', bundle); + try { + const response = await coreRefs.http?.post('/api/saved_objects/_import', { + headers: { + // Important to leave undefined, it forces proper headers to be set for FormData + 'Content-Type': undefined, + }, + body: formData, + query: { + overwrite: true, + // If we don't disable this, we'll get 413 Content Too Large errors for many bundles + dataSourceEnabled: false, + }, + }); + + console.log(response); + if (response.success) { + return null; + } else { + return new Error(response.body.message); + } + } catch (err) { + return err; + } +}; + +const checkBundle = async (bundle: File | null): Promise => { + if (bundle == null) { + return false; + } + if (!/.*\.ndjson&/i.test(bundle.name) && bundle.type !== 'application/x-ndjson') { + return false; + } + + try { + const contents = await bundle.text(); + const objects = contents + .trim() + .split('\n') + .map((s) => JSON.parse(s)); + return objects.every((obj) => !obj.type || obj.type === 'integration-template'); + } catch (err) { + return false; + } +}; + +export const IntegrationUploadPicker = ({ + onFileSelected, + isInvalid, + setIsInvalid, +}: { + onFileSelected: (file: File | null) => void; + isInvalid: boolean; + setIsInvalid: (value: boolean) => void; +}) => { + const [blurred, setBlurred] = useState(false); + const [checkCompleted, setCheckCompleted] = useState(false); + const [bundle, setBundle] = useState(null as File | null); + + useEffect(() => { + const refreshValidity = async () => { + if (!blurred) { + return; + } + const valid = await checkBundle(bundle); + setIsInvalid(!valid); + setCheckCompleted(true); + }; + setCheckCompleted(false); + refreshValidity(); + // It's important that we update *only* on the bundle changing here, so we don't report errors + // early when the file picker is still open and get error flickering on the UI in the happy path. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [bundle]); + + const handleFileChange = (files: FileList | null) => { + if (files && files.length > 0) { + setBundle(files[0]); + onFileSelected(files[0]); + } else { + setBundle(null); + onFileSelected(null); + } + }; + + return ( + + + setBlurred(true)} + /> + + + ); +}; + +export const IntegrationUploadFlyout = ({ onClose }: { onClose: () => void }) => { + const [bundle, setBundle] = useState(null as File | null); + const { setToast } = useToast(); + const [isInvalid, setIsInvalid] = useState(true); + + return ( + + Upload Integrations + + + + + + + Cancel + + + { + const err = await uploadBundle(bundle); + if (err == null) { + setToast('Successfully uploaded bundle', 'success'); + onClose(); + } else { + setToast('Error uploading bundle', 'danger', err.message ?? ''); + } + }} + > + Upload + + + + + + ); +};