Skip to content

Commit 34a58ea

Browse files
Onboard update workflow API; add more guardrails and fine-grained state management (#127) (#128)
Signed-off-by: Tyler Ohlsen <ohltyler@amazon.com> (cherry picked from commit 2a9f3a7) Co-authored-by: Tyler Ohlsen <ohltyler@amazon.com>
1 parent c2bcd95 commit 34a58ea

11 files changed

+205
-12
lines changed

common/constants.ts

+1
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const GET_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}`;
3737
export const SEARCH_WORKFLOWS_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/search`;
3838
export const GET_WORKFLOW_STATE_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/state`;
3939
export const CREATE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/create`;
40+
export const UPDATE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/update`;
4041
export const PROVISION_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/provision`;
4142
export const DEPROVISION_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/deprovision`;
4243
export const DELETE_WORKFLOW_NODE_API_PATH = `${BASE_WORKFLOW_NODE_API_PATH}/delete`;

public/pages/workflow_detail/component_details/component_details.tsx

+9-4
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,18 @@
55

66
import React from 'react';
77
import { EuiPanel } from '@elastic/eui';
8-
import { ReactFlowComponent } from '../../../../common';
8+
import { ReactFlowComponent, Workflow } from '../../../../common';
99
import { ComponentInputs } from './component_inputs';
1010
import { EmptyComponentInputs } from './empty_component_inputs';
11+
import { ProvisionedComponentInputs } from './provisioned_component_inputs';
1112

1213
// styling
1314
import '../workspace/workspace-styles.scss';
1415

1516
interface ComponentDetailsProps {
17+
workflow: Workflow | undefined;
1618
onFormChange: () => void;
19+
isDeprovisionable: boolean;
1720
selectedComponent?: ReactFlowComponent;
1821
}
1922

@@ -25,14 +28,16 @@ interface ComponentDetailsProps {
2528
export function ComponentDetails(props: ComponentDetailsProps) {
2629
return (
2730
<EuiPanel paddingSize="m">
28-
{props.selectedComponent ? (
31+
{props.isDeprovisionable ? (
32+
<ProvisionedComponentInputs />
33+
) : props.selectedComponent ? (
2934
<ComponentInputs
3035
selectedComponent={props.selectedComponent}
3136
onFormChange={props.onFormChange}
3237
/>
33-
) : (
38+
) : props.workflow ? (
3439
<EmptyComponentInputs />
35-
)}
40+
) : undefined}
3641
</EuiPanel>
3742
);
3843
}

public/pages/workflow_detail/component_details/empty_component_inputs.tsx

+1
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import React from 'react';
77
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
88

9+
// Simple prompt to display when no components are selected.
910
export function EmptyComponentInputs() {
1011
return (
1112
<EuiEmptyPrompt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
/*
2+
* Copyright OpenSearch Contributors
3+
* SPDX-License-Identifier: Apache-2.0
4+
*/
5+
6+
import React from 'react';
7+
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';
8+
9+
// Simple prompt to display when the workflow is provisioned.
10+
export function ProvisionedComponentInputs() {
11+
return (
12+
<EuiEmptyPrompt
13+
iconType="bell"
14+
title={<h2>The workflow has been provisioned</h2>}
15+
titleSize="s"
16+
body={
17+
<>
18+
<EuiText>Please deprovision first to continue editing.</EuiText>
19+
</>
20+
}
21+
/>
22+
);
23+
}

public/pages/workflow_detail/workspace/resizable_workspace.tsx

+26-4
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
USE_CASE,
3636
WORKFLOW_STATE,
3737
processNodes,
38+
reduceToTemplate,
3839
} from '../../../../common';
3940
import {
4041
AppState,
@@ -44,6 +45,7 @@ import {
4445
provisionWorkflow,
4546
removeDirty,
4647
setDirty,
48+
updateWorkflow,
4749
useAppDispatch,
4850
} from '../../../store';
4951
import { Workspace } from './workspace';
@@ -103,16 +105,20 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
103105
>();
104106

105107
// Save/provision/deprovision button state
106-
const isSaveable = isFirstSave ? true : isDirty;
108+
const isSaveable =
109+
props.workflow !== undefined && (isFirstSave ? true : isDirty);
107110
const isProvisionable =
111+
props.workflow !== undefined &&
108112
!isDirty &&
109113
!props.isNewWorkflow &&
110114
formValidOnSubmit &&
111115
flowValidOnSubmit &&
112116
props.workflow?.state === WORKFLOW_STATE.NOT_STARTED;
113117
const isDeprovisionable =
118+
props.workflow !== undefined &&
114119
!props.isNewWorkflow &&
115120
props.workflow?.state !== WORKFLOW_STATE.NOT_STARTED;
121+
const readonly = props.workflow === undefined || isDeprovisionable;
116122

117123
// Loading state
118124
const [isProvisioning, setIsProvisioning] = useState<boolean>(false);
@@ -376,7 +382,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
376382
</EuiButton>,
377383
<EuiButton
378384
fill={false}
379-
disabled={!isSaveable || isLoadingGlobal}
385+
disabled={!isSaveable || isLoadingGlobal || isDeprovisionable}
380386
isLoading={isSaving}
381387
// TODO: if props.isNewWorkflow is true, clear the workflow cache if saving is successful.
382388
onClick={() => {
@@ -390,8 +396,21 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
390396
// The callback fn to run if everything is valid.
391397
(updatedWorkflow) => {
392398
if (updatedWorkflow.id) {
393-
// TODO: add update workflow API
394-
// make sure to set isSaving to false in catch block
399+
dispatch(
400+
updateWorkflow({
401+
workflowId: updatedWorkflow.id,
402+
workflowTemplate: reduceToTemplate(updatedWorkflow),
403+
})
404+
)
405+
.unwrap()
406+
.then((result) => {
407+
setIsSaving(false);
408+
})
409+
.catch((error: any) => {
410+
// TODO: process error (toast msg?)
411+
console.log('error: ', error);
412+
setIsSaving(false);
413+
});
395414
} else {
396415
dispatch(createWorkflow(updatedWorkflow))
397416
.unwrap()
@@ -444,6 +463,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
444463
<Workspace
445464
id="ingest"
446465
workflow={workflow}
466+
readonly={readonly}
447467
onNodesChange={onNodesChange}
448468
onSelectionChange={onSelectionChange}
449469
/>
@@ -467,7 +487,9 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
467487
>
468488
<EuiFlexItem>
469489
<ComponentDetails
490+
workflow={props.workflow}
470491
selectedComponent={selectedComponent}
492+
isDeprovisionable={isDeprovisionable}
471493
onFormChange={onFormChange}
472494
/>
473495
</EuiFlexItem>

public/pages/workflow_detail/workspace/workspace.tsx

+9
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import './workspace_edge/deletable-edge-styles.scss';
3838

3939
interface WorkspaceProps {
4040
workflow?: Workflow;
41+
readonly: boolean;
4142
onNodesChange: (nodes: ReactFlowComponent[]) => void;
4243
id: string;
4344
// TODO: make more typesafe
@@ -134,6 +135,14 @@ export function Workspace(props: WorkspaceProps) {
134135
onConnect={onConnect}
135136
className="reactflow-workspace"
136137
fitView
138+
edgesUpdatable={!props.readonly}
139+
edgesFocusable={!props.readonly}
140+
nodesDraggable={!props.readonly}
141+
nodesConnectable={!props.readonly}
142+
nodesFocusable={!props.readonly}
143+
draggable={!props.readonly}
144+
panOnDrag={!props.readonly}
145+
elementsSelectable={!props.readonly}
137146
>
138147
<Controls
139148
showFitView={false}

public/route_service.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
SEARCH_MODELS_NODE_API_PATH,
1616
PROVISION_WORKFLOW_NODE_API_PATH,
1717
DEPROVISION_WORKFLOW_NODE_API_PATH,
18+
UPDATE_WORKFLOW_NODE_API_PATH,
19+
WorkflowTemplate,
1820
} from '../common';
1921

2022
/**
@@ -28,9 +30,10 @@ export interface RouteService {
2830
getWorkflow: (workflowId: string) => Promise<any | HttpFetchError>;
2931
searchWorkflows: (body: {}) => Promise<any | HttpFetchError>;
3032
getWorkflowState: (workflowId: string) => Promise<any | HttpFetchError>;
31-
createWorkflow: (
32-
body: {},
33-
provision?: boolean
33+
createWorkflow: (body: {}) => Promise<any | HttpFetchError>;
34+
updateWorkflow: (
35+
workflowId: string,
36+
workflowTemplate: WorkflowTemplate
3437
) => Promise<any | HttpFetchError>;
3538
provisionWorkflow: (workflowId: string) => Promise<any | HttpFetchError>;
3639
deprovisionWorkflow: (workflowId: string) => Promise<any | HttpFetchError>;
@@ -88,6 +91,22 @@ export function configureRoutes(core: CoreStart): RouteService {
8891
return e as HttpFetchError;
8992
}
9093
},
94+
updateWorkflow: async (
95+
workflowId: string,
96+
workflowTemplate: WorkflowTemplate
97+
) => {
98+
try {
99+
const response = await core.http.put<{ respString: string }>(
100+
`${UPDATE_WORKFLOW_NODE_API_PATH}/${workflowId}`,
101+
{
102+
body: JSON.stringify(workflowTemplate),
103+
}
104+
);
105+
return response;
106+
} catch (e: any) {
107+
return e as HttpFetchError;
108+
}
109+
},
91110
provisionWorkflow: async (workflowId: string) => {
92111
try {
93112
const response = await core.http.post<{ respString: string }>(

public/store/reducers/workflows_reducer.ts

+49-1
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
*/
55

66
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit';
7-
import { Workflow, WorkflowDict } from '../../../common';
7+
import { Workflow, WorkflowDict, WorkflowTemplate } from '../../../common';
88
import { HttpFetchError } from '../../../../../src/core/public';
99
import { getRouteService } from '../../services';
1010

@@ -20,6 +20,7 @@ const GET_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/get`;
2020
const SEARCH_WORKFLOWS_ACTION = `${WORKFLOWS_ACTION_PREFIX}/search`;
2121
const GET_WORKFLOW_STATE_ACTION = `${WORKFLOWS_ACTION_PREFIX}/getState`;
2222
const CREATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/create`;
23+
const UPDATE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/update`;
2324
const PROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/provision`;
2425
const DEPROVISION_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/deprovision`;
2526
const DELETE_WORKFLOW_ACTION = `${WORKFLOWS_ACTION_PREFIX}/delete`;
@@ -90,6 +91,29 @@ export const createWorkflow = createAsyncThunk(
9091
}
9192
);
9293

94+
export const updateWorkflow = createAsyncThunk(
95+
UPDATE_WORKFLOW_ACTION,
96+
async (
97+
workflowInfo: { workflowId: string; workflowTemplate: WorkflowTemplate },
98+
{ rejectWithValue }
99+
) => {
100+
const { workflowId, workflowTemplate } = workflowInfo;
101+
const response:
102+
| any
103+
| HttpFetchError = await getRouteService().updateWorkflow(
104+
workflowId,
105+
workflowTemplate
106+
);
107+
if (response instanceof HttpFetchError) {
108+
return rejectWithValue(
109+
'Error updating workflow: ' + response.body.message
110+
);
111+
} else {
112+
return response;
113+
}
114+
}
115+
);
116+
93117
export const provisionWorkflow = createAsyncThunk(
94118
PROVISION_WORKFLOW_ACTION,
95119
async (workflowId: string, { rejectWithValue }) => {
@@ -173,6 +197,10 @@ const workflowsSlice = createSlice({
173197
state.loading = true;
174198
state.errorMessage = '';
175199
})
200+
.addCase(updateWorkflow.pending, (state, action) => {
201+
state.loading = true;
202+
state.errorMessage = '';
203+
})
176204
.addCase(provisionWorkflow.pending, (state, action) => {
177205
state.loading = true;
178206
state.errorMessage = '';
@@ -228,6 +256,22 @@ const workflowsSlice = createSlice({
228256
state.loading = false;
229257
state.errorMessage = '';
230258
})
259+
.addCase(updateWorkflow.fulfilled, (state, action) => {
260+
const { workflowId, workflowTemplate } = action.payload as {
261+
workflowId: string;
262+
workflowTemplate: WorkflowTemplate;
263+
};
264+
state.workflows = {
265+
...state.workflows,
266+
[workflowId]: {
267+
// only overwrite the stateless / template fields. persist any existing state (e.g., lastUpdated, lastProvisioned)
268+
...state.workflows[workflowId],
269+
...workflowTemplate,
270+
},
271+
};
272+
state.loading = false;
273+
state.errorMessage = '';
274+
})
231275
.addCase(provisionWorkflow.fulfilled, (state, action) => {
232276
state.loading = false;
233277
state.errorMessage = '';
@@ -266,6 +310,10 @@ const workflowsSlice = createSlice({
266310
state.errorMessage = action.payload as string;
267311
state.loading = false;
268312
})
313+
.addCase(updateWorkflow.rejected, (state, action) => {
314+
state.errorMessage = action.payload as string;
315+
state.loading = false;
316+
})
269317
.addCase(provisionWorkflow.rejected, (state, action) => {
270318
state.errorMessage = action.payload as string;
271319
state.loading = false;

public/utils/utils.ts

+15
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import {
1616
WorkspaceFormValues,
1717
WORKFLOW_STATE,
1818
ReactFlowComponent,
19+
Workflow,
20+
WorkflowTemplate,
1921
} from '../../common';
2022

2123
// Append 16 random characters
@@ -72,6 +74,19 @@ export function formikToComponentData(
7274
} as IComponentData;
7375
}
7476

77+
// Helper fn to remove state-related fields from a workflow and have a stateless template
78+
// to export and/or pass around, use when updating, etc.
79+
export function reduceToTemplate(workflow: Workflow): WorkflowTemplate {
80+
const {
81+
id,
82+
lastUpdated,
83+
lastLaunched,
84+
state,
85+
...workflowTemplate
86+
} = workflow;
87+
return workflowTemplate;
88+
}
89+
7590
// Helper fn to get an initial value based on the field type
7691
export function getInitialValue(fieldType: FieldType): FieldValue {
7792
switch (fieldType) {

server/cluster/flow_framework_plugin.ts

+14
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,20 @@ export function flowFrameworkPlugin(Client: any, config: any, components: any) {
7373
method: 'POST',
7474
});
7575

76+
flowFramework.updateWorkflow = ca({
77+
url: {
78+
fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>`,
79+
req: {
80+
workflow_id: {
81+
type: 'string',
82+
required: true,
83+
},
84+
},
85+
},
86+
needBody: true,
87+
method: 'PUT',
88+
});
89+
7690
flowFramework.provisionWorkflow = ca({
7791
url: {
7892
fmt: `${FLOW_FRAMEWORK_WORKFLOW_ROUTE_PREFIX}/<%=workflow_id%>/_provision`,

0 commit comments

Comments
 (0)