From ec3add8b3747c8e2e9d7e2ebd498434c1ad5351a Mon Sep 17 00:00:00 2001 From: Tyler Ohlsen Date: Fri, 5 Apr 2024 16:31:57 -0700 Subject: [PATCH] Onboard update workflow API; add more guardrails and fine-grained state management (#127) Signed-off-by: Tyler Ohlsen (cherry picked from commit 2a9f3a752c72481ee4e752f081771ad0ee8e0b43) --- common/constants.ts | 1 + .../component_details/component_details.tsx | 13 +++-- .../empty_component_inputs.tsx | 1 + .../provisioned_component_inputs.tsx | 23 +++++++++ .../workspace/resizable_workspace.tsx | 30 +++++++++-- .../workflow_detail/workspace/workspace.tsx | 9 ++++ public/route_service.ts | 25 ++++++++-- public/store/reducers/workflows_reducer.ts | 50 ++++++++++++++++++- public/utils/utils.ts | 15 ++++++ server/cluster/flow_framework_plugin.ts | 14 ++++++ .../routes/flow_framework_routes_service.ts | 36 +++++++++++++ 11 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 public/pages/workflow_detail/component_details/provisioned_component_inputs.tsx diff --git a/common/constants.ts b/common/constants.ts index a7f442cc..fc186934 100644 --- a/common/constants.ts +++ b/common/constants.ts @@ -37,6 +37,7 @@ export const GET_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}`; export const SEARCH_WORKFLOWS_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/search`; export const GET_WORKFLOW_STATE_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/state`; export const CREATE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/create`; +export const UPDATE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/update`; export const PROVISION_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/provision`; export const DEPROVISION_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/deprovision`; export const DELETE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/delete`; diff --git a/public/pages/workflow_detail/component_details/component_details.tsx b/public/pages/workflow_detail/component_details/component_details.tsx index 242ae81d..41ed85d0 100644 --- a/public/pages/workflow_detail/component_details/component_details.tsx +++ b/public/pages/workflow_detail/component_details/component_details.tsx @@ -5,15 +5,18 @@ import React from 'react'; import { EuiPanel } from '@elastic/eui'; -import { ReactFlowComponent } from '../../../../common'; +import { ReactFlowComponent, Workflow } from '../../../../common'; import { ComponentInputs } from './component_inputs'; import { EmptyComponentInputs } from './empty_component_inputs'; +import { ProvisionedComponentInputs } from './provisioned_component_inputs'; // styling import '../workspace/workspace-styles.scss'; interface ComponentDetailsProps { + workflow: Workflow | undefined; onFormChange: () => void; + isDeprovisionable: boolean; selectedComponent?: ReactFlowComponent; } @@ -25,14 +28,16 @@ interface ComponentDetailsProps { export function ComponentDetails(props: ComponentDetailsProps) { return ( - {props.selectedComponent ? ( + {props.isDeprovisionable ? ( + + ) : props.selectedComponent ? ( - ) : ( + ) : props.workflow ? ( - )} + ) : undefined} ); } diff --git a/public/pages/workflow_detail/component_details/empty_component_inputs.tsx b/public/pages/workflow_detail/component_details/empty_component_inputs.tsx index 18a19880..19ce0754 100644 --- a/public/pages/workflow_detail/component_details/empty_component_inputs.tsx +++ b/public/pages/workflow_detail/component_details/empty_component_inputs.tsx @@ -6,6 +6,7 @@ import React from 'react'; import { EuiEmptyPrompt, EuiText } from '@elastic/eui'; +// Simple prompt to display when no components are selected. export function EmptyComponentInputs() { return ( The workflow has been provisioned} + titleSize="s" + body={ + <> + Please deprovision first to continue editing. + + } + /> + ); +} diff --git a/public/pages/workflow_detail/workspace/resizable_workspace.tsx b/public/pages/workflow_detail/workspace/resizable_workspace.tsx index 041ebf01..d0d1e7bb 100644 --- a/public/pages/workflow_detail/workspace/resizable_workspace.tsx +++ b/public/pages/workflow_detail/workspace/resizable_workspace.tsx @@ -35,6 +35,7 @@ import { USE_CASE, WORKFLOW_STATE, processNodes, + reduceToTemplate, } from '../../../../common'; import { AppState, @@ -44,6 +45,7 @@ import { provisionWorkflow, removeDirty, setDirty, + updateWorkflow, useAppDispatch, } from '../../../store'; import { Workspace } from './workspace'; @@ -103,16 +105,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { >(); // Save/provision/deprovision button state - const isSaveable = isFirstSave ? true : isDirty; + const isSaveable = + props.workflow !== undefined && (isFirstSave ? true : isDirty); const isProvisionable = + props.workflow !== undefined && !isDirty && !props.isNewWorkflow && formValidOnSubmit && flowValidOnSubmit && props.workflow?.state === WORKFLOW_STATE.NOT_STARTED; const isDeprovisionable = + props.workflow !== undefined && !props.isNewWorkflow && props.workflow?.state !== WORKFLOW_STATE.NOT_STARTED; + const readonly = props.workflow === undefined || isDeprovisionable; // Loading state const [isProvisioning, setIsProvisioning] = useState(false); @@ -376,7 +382,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { , { @@ -390,8 +396,21 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { // The callback fn to run if everything is valid. (updatedWorkflow) => { if (updatedWorkflow.id) { - // TODO: add update workflow API - // make sure to set isSaving to false in catch block + dispatch( + updateWorkflow({ + workflowId: updatedWorkflow.id, + workflowTemplate: reduceToTemplate(updatedWorkflow), + }) + ) + .unwrap() + .then((result) => { + setIsSaving(false); + }) + .catch((error: any) => { + // TODO: process error (toast msg?) + console.log('error: ', error); + setIsSaving(false); + }); } else { dispatch(createWorkflow(updatedWorkflow)) .unwrap() @@ -444,6 +463,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { @@ -467,7 +487,9 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) { > diff --git a/public/pages/workflow_detail/workspace/workspace.tsx b/public/pages/workflow_detail/workspace/workspace.tsx index 234e3518..b85ea94d 100644 --- a/public/pages/workflow_detail/workspace/workspace.tsx +++ b/public/pages/workflow_detail/workspace/workspace.tsx @@ -38,6 +38,7 @@ import './workspace_edge/deletable-edge-styles.scss'; interface WorkspaceProps { workflow?: Workflow; + readonly: boolean; onNodesChange: (nodes: ReactFlowComponent[]) => void; id: string; // TODO: make more typesafe @@ -134,6 +135,14 @@ export function Workspace(props: WorkspaceProps) { onConnect={onConnect} className="reactflow-workspace" fitView + edgesUpdatable={!props.readonly} + edgesFocusable={!props.readonly} + nodesDraggable={!props.readonly} + nodesConnectable={!props.readonly} + nodesFocusable={!props.readonly} + draggable={!props.readonly} + panOnDrag={!props.readonly} + elementsSelectable={!props.readonly} > Promise; searchWorkflows: (body: {}) => Promise; getWorkflowState: (workflowId: string) => Promise; - createWorkflow: ( - body: {}, - provision?: boolean + createWorkflow: (body: {}) => Promise; + updateWorkflow: ( + workflowId: string, + workflowTemplate: WorkflowTemplate ) => Promise; provisionWorkflow: (workflowId: string) => Promise; deprovisionWorkflow: (workflowId: string) => Promise; @@ -88,6 +91,22 @@ export function configureRoutes(core: CoreStart): RouteService { return e as HttpFetchError; } }, + updateWorkflow: async ( + workflowId: string, + workflowTemplate: WorkflowTemplate + ) => { + try { + const response = await core.http.put<{ respString: string }>( + `${UPDATE_WORKFLOW_NODE_API_PATH}/${workflowId}`, + { + body: JSON.stringify(workflowTemplate), + } + ); + return response; + } catch (e: any) { + return e as HttpFetchError; + } + }, provisionWorkflow: async (workflowId: string) => { try { const response = await core.http.post<{ respString: string }>( diff --git a/public/store/reducers/workflows_reducer.ts b/public/store/reducers/workflows_reducer.ts index f8ac6b9d..d8e6e18b 100644 --- a/public/store/reducers/workflows_reducer.ts +++ b/public/store/reducers/workflows_reducer.ts @@ -4,7 +4,7 @@ */ import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; -import { Workflow, WorkflowDict } from '../../../common'; +import { Workflow, WorkflowDict, WorkflowTemplate } from '../../../common'; import { HttpFetchError } from '../../../../../src/core/public'; import { getRouteService } from '../../services'; @@ -20,6 +20,7 @@ const GET_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/get`; const SEARCH_WORKFLOWS_ACTION = `${WORKFLOWS_ACTION_PREFIX}/search`; const GET_WORKFLOW_STATE_ACTION = `${WORKFLOWS_ACTION_PREFIX}/getState`; const CREATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/create`; +const UPDATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/update`; const PROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/provision`; const DEPROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/deprovision`; const DELETE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/delete`; @@ -90,6 +91,29 @@ export const createWorkflow = createAsyncThunk( } ); +export const updateWorkflow = createAsyncThunk( + UPDATE_WORKFLOW_ACTION, + async ( + workflowInfo: { workflowId: string; workflowTemplate: WorkflowTemplate }, + { rejectWithValue } + ) => { + const { workflowId, workflowTemplate } = workflowInfo; + const response: + | any + | HttpFetchError = await getRouteService().updateWorkflow( + workflowId, + workflowTemplate + ); + if (response instanceof HttpFetchError) { + return rejectWithValue( + 'Error updating workflow: ' + response.body.message + ); + } else { + return response; + } + } +); + export const provisionWorkflow = createAsyncThunk( PROVISION_WORKFLOW_ACTION, async (workflowId: string, { rejectWithValue }) => { @@ -173,6 +197,10 @@ const workflowsSlice = createSlice({ state.loading = true; state.errorMessage = ''; }) + .addCase(updateWorkflow.pending, (state, action) => { + state.loading = true; + state.errorMessage = ''; + }) .addCase(provisionWorkflow.pending, (state, action) => { state.loading = true; state.errorMessage = ''; @@ -228,6 +256,22 @@ const workflowsSlice = createSlice({ state.loading = false; state.errorMessage = ''; }) + .addCase(updateWorkflow.fulfilled, (state, action) => { + const { workflowId, workflowTemplate } = action.payload as { + workflowId: string; + workflowTemplate: WorkflowTemplate; + }; + state.workflows = { + ...state.workflows, + [workflowId]: { + // only overwrite the stateless / template fields. persist any existing state (e.g., lastUpdated, lastProvisioned) + ...state.workflows[workflowId], + ...workflowTemplate, + }, + }; + state.loading = false; + state.errorMessage = ''; + }) .addCase(provisionWorkflow.fulfilled, (state, action) => { state.loading = false; state.errorMessage = ''; @@ -266,6 +310,10 @@ const workflowsSlice = createSlice({ state.errorMessage = action.payload as string; state.loading = false; }) + .addCase(updateWorkflow.rejected, (state, action) => { + state.errorMessage = action.payload as string; + state.loading = false; + }) .addCase(provisionWorkflow.rejected, (state, action) => { state.errorMessage = action.payload as string; state.loading = false; diff --git a/public/utils/utils.ts b/public/utils/utils.ts index 75ea61a3..daca9c23 100644 --- a/public/utils/utils.ts +++ b/public/utils/utils.ts @@ -16,6 +16,8 @@ import { WorkspaceFormValues, WORKFLOW_STATE, ReactFlowComponent, + Workflow, + WorkflowTemplate, } from '../../common'; // Append 16 random characters @@ -72,6 +74,19 @@ export function formikToComponentData( } as IComponentData; } +// Helper fn to remove state-related fields from a workflow and have a stateless template +// to export and/or pass around, use when updating, etc. +export function reduceToTemplate(workflow: Workflow): WorkflowTemplate { + const { + id, + lastUpdated, + lastLaunched, + state, + ...workflowTemplate + } = workflow; + return workflowTemplate; +} + // Helper fn to get an initial value based on the field type export function getInitialValue(fieldType: FieldType): FieldValue { switch (fieldType) { diff --git a/server/cluster/flow_framework_plugin.ts b/server/cluster/flow_framework_plugin.ts index 690047a2..4f9b0990 100644 --- a/server/cluster/flow_framework_plugin.ts +++ b/server/cluster/flow_framework_plugin.ts @@ -73,6 +73,20 @@ export function flowFrameworkPlugin(Client: any, config: any, components: any) { method: 'POST', }); + flowFramework.updateWorkflow = ca({ + url: { + fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>`, + req: { + workflow_id: { + type: 'string', + required: true, + }, + }, + }, + needBody: true, + method: 'PUT', + }); + flowFramework.provisionWorkflow = ca({ url: { fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>/_provision`, diff --git a/server/routes/flow_framework_routes_service.ts b/server/routes/flow_framework_routes_service.ts index 83fe7580..d9ee6190 100644 --- a/server/routes/flow_framework_routes_service.ts +++ b/server/routes/flow_framework_routes_service.ts @@ -22,6 +22,7 @@ import { GET_WORKFLOW_STATE_NODE_API_PATH, PROVISION_WORKFLOW_NODE_API_PATH, SEARCH_WORKFLOWS_NODE_API_PATH, + UPDATE_WORKFLOW_NODE_API_PATH, WORKFLOW_STATE, Workflow, WorkflowTemplate, @@ -85,6 +86,19 @@ export function registerFlowFrameworkRoutes( flowFrameworkRoutesService.createWorkflow ); + router.put( + { + path: `${UPDATE_WORKFLOW_NODE_API_PATH}/{workflow_id}`, + validate: { + params: schema.object({ + workflow_id: schema.string(), + }), + body: schema.any(), + }, + }, + flowFrameworkRoutesService.updateWorkflow + ); + router.post( { path: `${PROVISION_WORKFLOW_NODE_API_PATH}/{workflow_id}`, @@ -227,6 +241,28 @@ export class FlowFrameworkRoutesService { } }; + updateWorkflow = async ( + context: RequestHandlerContext, + req: OpenSearchDashboardsRequest, + res: OpenSearchDashboardsResponseFactory + ): Promise> => { + const { workflow_id } = req.params as { workflow_id: string }; + const workflowTemplate = req.body as WorkflowTemplate; + + try { + await this.client + .asScoped(req) + .callAsCurrentUser('flowFramework.updateWorkflow', { + workflow_id, + body: workflowTemplate, + }); + + return res.ok({ body: { workflowId: workflow_id, workflowTemplate } }); + } catch (err: any) { + return generateCustomError(res, err); + } + }; + provisionWorkflow = async ( context: RequestHandlerContext, req: OpenSearchDashboardsRequest,