From ae53f00678f9e9259fa489da018a82160e635167 Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Wed, 10 Jul 2024 14:26:40 -0700 Subject: [PATCH] Support importing local workflow templates (#208) Signed-off-by: Tyler Ohlsen --- common/interfaces.ts | 7 +- .../workflow_detail/resizable_workspace.tsx | 52 +++--- .../ingest_inputs/source_data.tsx | 2 +- .../import_workflow/import_workflow_modal.tsx | 163 +++++++++++++++++ .../pages/workflows/import_workflow/index.ts | 6 + .../workflows/workflow_list/workflow_list.tsx | 11 +- public/pages/workflows/workflows.tsx | 170 ++++++++++++------ public/utils/utils.ts | 29 +++ 8 files changed, 351 insertions(+), 89 deletions(-) create mode 100644 public/pages/workflows/import_workflow/import_workflow_modal.tsx create mode 100644 public/pages/workflows/import_workflow/index.ts diff --git a/common/interfaces.ts b/common/interfaces.ts index 5d7744c1..37d0c108 100644 --- a/common/interfaces.ts +++ b/common/interfaces.ts @@ -287,12 +287,13 @@ export type TemplateFlows = { // A stateless template of a workflow export type WorkflowTemplate = { + // Name is the only required field: see https://opensearch.org/docs/latest/automating-configurations/api/create-workflow/#request-fields name: string; - description: string; + description?: string; // TODO: finalize on version type when that is implemented // https://github.com/opensearch-project/flow-framework/issues/526 - version: any; - workflows: TemplateFlows; + version?: any; + workflows?: TemplateFlows; use_case?: USE_CASE; // UI state and any ReactFlow state may not exist if a workflow is created via API/backend-only. ui_metadata?: UIState; diff --git a/public/pages/workflow_detail/resizable_workspace.tsx b/public/pages/workflow_detail/resizable_workspace.tsx index 801a4ebf..d78dc6d0 100644 --- a/public/pages/workflow_detail/resizable_workspace.tsx +++ b/public/pages/workflow_detail/resizable_workspace.tsx @@ -23,6 +23,7 @@ import { WorkflowSchema, } from '../../../common'; import { + isValidUiWorkflow, reduceToTemplate, uiConfigToFormik, uiConfigToSchema, @@ -110,8 +111,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { // Hook to check if the workflow is valid or not useEffect(() => { - const missingUiFlow = - props.workflow && !props.workflow?.ui_metadata?.config; + const missingUiFlow = props.workflow && !isValidUiWorkflow(props.workflow); if (missingUiFlow) { setIsValidWorkflow(false); } else { @@ -286,27 +286,31 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { )} ) : ( - <> - Unable to view workflow details} - titleSize="s" - body={ - <> - - Only valid workflows created from this OpenSearch Dashboards - application are editable and viewable. - - - } - /> - - {JSON.stringify( - reduceToTemplate(props.workflow as Workflow), - undefined, - 2 - )} - - + + + Unable to view workflow details} + titleSize="s" + body={ + <> + + Only valid workflows created from this OpenSearch Dashboards + application are editable and viewable. + + + } + /> + + + + {JSON.stringify( + reduceToTemplate(props.workflow as Workflow), + undefined, + 2 + )} + + + ); } diff --git a/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data.tsx b/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data.tsx index 30cbd120..982577b3 100644 --- a/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data.tsx +++ b/public/pages/workflow_detail/workflow_inputs/ingest_inputs/source_data.tsx @@ -12,7 +12,7 @@ import { EuiTitle, } from '@elastic/eui'; import { JsonField } from '../input_fields'; -import { IConfigField, WorkspaceFormValues } from '../../../../../common'; +import { WorkspaceFormValues } from '../../../../../common'; interface SourceDataProps { setIngestDocs: (docs: string) => void; diff --git a/public/pages/workflows/import_workflow/import_workflow_modal.tsx b/public/pages/workflows/import_workflow/import_workflow_modal.tsx new file mode 100644 index 00000000..a223d4dc --- /dev/null +++ b/public/pages/workflows/import_workflow/import_workflow_modal.tsx @@ -0,0 +1,163 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { + EuiSpacer, + EuiFlexGroup, + EuiButtonEmpty, + EuiButton, + EuiText, + EuiModal, + EuiModalHeader, + EuiModalHeaderTitle, + EuiModalBody, + EuiModalFooter, + EuiFilePicker, + EuiCallOut, + EuiFlexItem, +} from '@elastic/eui'; +import { + getObjFromJsonOrYamlString, + isValidUiWorkflow, + isValidWorkflow, +} from '../../../utils'; +import { getCore } from '../../../services'; +import { + createWorkflow, + searchWorkflows, + useAppDispatch, +} from '../../../store'; +import { FETCH_ALL_QUERY_BODY, Workflow } from '../../../../common'; +import { WORKFLOWS_TAB } from '../workflows'; + +interface ImportWorkflowModalProps { + isImportModalOpen: boolean; + setIsImportModalOpen(isOpen: boolean): void; + setSelectedTabId(tabId: WORKFLOWS_TAB): void; +} + +/** + * The import workflow modal. Allows uploading local JSON or YAML files to be uploaded, parsed, and + * created as new workflows. Automatic validation is handled to: + * 1/ allow upload (valid workflow with UI data), + * 2/ warn and allow upload (valid workflow but missing/no UI data), and + * 3/ prevent upload (invalid workflow). + */ +export function ImportWorkflowModal(props: ImportWorkflowModalProps) { + const dispatch = useAppDispatch(); + + // file contents & file obj state + const [fileContents, setFileContents] = useState( + undefined + ); + const [fileObj, setFileObj] = useState(undefined); + useEffect(() => { + setFileObj(getObjFromJsonOrYamlString(fileContents)); + }, [fileContents]); + + // file reader to read the file and set the fileContents var + const fileReader = new FileReader(); + fileReader.onload = (e) => { + if (e.target) { + setFileContents(e.target.result as string); + } + }; + + function onModalClose(): void { + props.setIsImportModalOpen(false); + setFileContents(undefined); + setFileObj(undefined); + } + + return ( + onModalClose()} style={{ width: '40vw' }}> + + +

{`Import a workflow (JSON/YAML)`}

+
+
+ + + {fileContents !== undefined && !isValidWorkflow(fileObj) && ( + <> + + + + + + )} + {isValidWorkflow(fileObj) && !isValidUiWorkflow(fileObj) && ( + <> + + + + + + )} + + { + if (files && files.length > 0) { + fileReader.readAsText(files[0]); + } + }} + display="large" + /> + + + + Must be in JSON or YAML format. + + + + + + onModalClose()}>Cancel + { + dispatch(createWorkflow(fileObj as Workflow)) + .unwrap() + .then((result) => { + const { workflow } = result; + dispatch(searchWorkflows(FETCH_ALL_QUERY_BODY)); + props.setSelectedTabId(WORKFLOWS_TAB.MANAGE); + getCore().notifications.toasts.addSuccess( + `Successfully imported ${workflow.name}` + ); + }) + .catch((error: any) => { + getCore().notifications.toasts.addDanger(error); + }) + .finally(() => { + onModalClose(); + }); + }} + fill={true} + color="primary" + > + {isValidWorkflow(fileObj) && !isValidUiWorkflow(fileObj) + ? 'Import anyway' + : 'Import'} + + +
+ ); +} diff --git a/public/pages/workflows/import_workflow/index.ts b/public/pages/workflows/import_workflow/index.ts new file mode 100644 index 00000000..415380a2 --- /dev/null +++ b/public/pages/workflows/import_workflow/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { ImportWorkflowModal } from './import_workflow_modal'; diff --git a/public/pages/workflows/workflow_list/workflow_list.tsx b/public/pages/workflows/workflow_list/workflow_list.tsx index 0fd58c0e..a196722b 100644 --- a/public/pages/workflows/workflow_list/workflow_list.tsx +++ b/public/pages/workflows/workflow_list/workflow_list.tsx @@ -19,7 +19,6 @@ import { EuiTitle, EuiFlyoutBody, EuiText, - EuiLink, } from '@elastic/eui'; import { AppState, deleteWorkflow, useAppDispatch } from '../../../store'; import { UIState, WORKFLOW_TYPE, Workflow } from '../../../../common'; @@ -181,15 +180,7 @@ export function WorkflowList(props: WorkflowListProps) { - {`Manage existing workflows or`} -   - - props.setSelectedTabId(WORKFLOWS_TAB.CREATE)} - > - create a new workflow - - + {`Manage existing workflows`} diff --git a/public/pages/workflows/workflows.tsx b/public/pages/workflows/workflows.tsx index fd2ba6a9..8922b5b9 100644 --- a/public/pages/workflows/workflows.tsx +++ b/public/pages/workflows/workflows.tsx @@ -12,6 +12,9 @@ import { EuiPageBody, EuiPageContent, EuiSpacer, + EuiFlexGroup, + EuiButton, + EuiText, } from '@elastic/eui'; import queryString from 'query-string'; import { useSelector } from 'react-redux'; @@ -22,6 +25,7 @@ import { NewWorkflow } from './new_workflow'; import { AppState, searchWorkflows, useAppDispatch } from '../../store'; import { EmptyListMessage } from './empty_list_message'; import { FETCH_ALL_QUERY_BODY } from '../../../common'; +import { ImportWorkflowModal } from './import_workflow'; export interface WorkflowsRouterProps {} @@ -45,8 +49,8 @@ function replaceActiveTab(activeTab: string, props: WorkflowsProps) { /** * The base workflows page. From here, users can toggle between views to access - * existing created workflows, or explore the library of workflow templates - * to get started on a new workflow. + * existing created workflows, explore the library of workflow templates + * to get started on a new workflow, or import local workflow templates. */ export function Workflows(props: WorkflowsProps) { const dispatch = useAppDispatch(); @@ -54,6 +58,10 @@ export function Workflows(props: WorkflowsProps) { (state: AppState) => state.workflows ); + // import modal state + const [isImportModalOpen, setIsImportModalOpen] = useState(false); + + // tab state const tabFromUrl = queryString.parse(useLocation().search)[ ACTIVE_TAB_PARAM ] as WORKFLOWS_TAB; @@ -98,58 +106,118 @@ export function Workflows(props: WorkflowsProps) { }, []); return ( - - - { - setSelectedTabId(WORKFLOWS_TAB.MANAGE); - replaceActiveTab(WORKFLOWS_TAB.MANAGE, props); - }, - }, - { - id: WORKFLOWS_TAB.CREATE, - label: 'New workflow', - isSelected: selectedTabId === WORKFLOWS_TAB.CREATE, - onClick: () => { - setSelectedTabId(WORKFLOWS_TAB.CREATE); - replaceActiveTab(WORKFLOWS_TAB.CREATE, props); - }, - }, - ]} - bottomBorder={true} + <> + {isImportModalOpen && ( + - - - -

- {selectedTabId === WORKFLOWS_TAB.MANAGE && 'Workflows'} - {selectedTabId === WORKFLOWS_TAB.CREATE && - 'Create a new workflow'} -

-
- - {selectedTabId === WORKFLOWS_TAB.MANAGE && ( - - )} - {selectedTabId === WORKFLOWS_TAB.CREATE && } - {selectedTabId === WORKFLOWS_TAB.MANAGE && - Object.keys(workflows || {}).length === 0 && - !loading && ( - { + )} + + + + +

Search Studio

+
+ + Design, experiment, and prototype your solutions with + workflows. Build your search and last mile ingestion flows. + +
+ } + tabs={[ + { + id: WORKFLOWS_TAB.MANAGE, + label: 'Manage workflows', + isSelected: selectedTabId === WORKFLOWS_TAB.MANAGE, + onClick: () => { + setSelectedTabId(WORKFLOWS_TAB.MANAGE); + replaceActiveTab(WORKFLOWS_TAB.MANAGE, props); + }, + }, + { + id: WORKFLOWS_TAB.CREATE, + label: 'New workflow', + isSelected: selectedTabId === WORKFLOWS_TAB.CREATE, + onClick: () => { setSelectedTabId(WORKFLOWS_TAB.CREATE); replaceActiveTab(WORKFLOWS_TAB.CREATE, props); - }} - /> + }, + }, + ]} + bottomBorder={true} + /> + + + +

+ {selectedTabId === WORKFLOWS_TAB.MANAGE + ? 'Workflows' + : 'Create from a template'} +

+ + } + rightSideItems={ + selectedTabId === WORKFLOWS_TAB.MANAGE + ? [ + { + setSelectedTabId(WORKFLOWS_TAB.CREATE); + }} + > + Create workflow + , + { + setIsImportModalOpen(true); + }} + > + Import workflow + , + ] + : [ + { + setIsImportModalOpen(true); + }} + > + Import workflow + , + ] + } + bottomBorder={false} + /> + {selectedTabId === WORKFLOWS_TAB.MANAGE ? ( + + ) : ( + <> + + + )} -
- - + {selectedTabId === WORKFLOWS_TAB.MANAGE && + Object.keys(workflows || {}).length === 0 && + !loading && ( + { + setSelectedTabId(WORKFLOWS_TAB.CREATE); + replaceActiveTab(WORKFLOWS_TAB.CREATE, props); + }} + /> + )} + + + + ); } diff --git a/public/utils/utils.ts b/public/utils/utils.ts index 947e84ca..d38347a5 100644 --- a/public/utils/utils.ts +++ b/public/utils/utils.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import yaml from 'js-yaml'; import { WORKFLOW_STEP_TYPE, Workflow } from '../../common'; // Append 16 random characters @@ -43,3 +44,31 @@ export function hasProvisionedSearchResources( }); return result; } + +export function getObjFromJsonOrYamlString( + fileContents: string | undefined +): object | undefined { + try { + const jsonObj = JSON.parse(fileContents); + return jsonObj; + } catch (e) {} + try { + const yamlObj = yaml.load(fileContents) as object; + return yamlObj; + } catch (e) {} + return undefined; +} + +// Based off of https://opensearch.org/docs/latest/automating-configurations/api/create-workflow/#request-fields +// Only "name" field is required +export function isValidWorkflow(workflowObj: object | undefined): boolean { + return workflowObj?.name !== undefined; +} + +export function isValidUiWorkflow(workflowObj: object | undefined): boolean { + return ( + isValidWorkflow(workflowObj) && + workflowObj?.ui_metadata?.config !== undefined && + workflowObj?.ui_metadata?.type !== undefined + ); +}