Skip to content

Commit

Permalink
Onboard update workflow API; add more guardrails and fine-grained sta…
Browse files Browse the repository at this point in the history
…te management (#127)

Signed-off-by: Tyler Ohlsen <[email protected]>
  • Loading branch information
ohltyler authored Apr 5, 2024
1 parent ce28197 commit 2a9f3a7
Show file tree
Hide file tree
Showing 11 changed files with 205 additions and 12 deletions.
1 change: 1 addition & 0 deletions common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand All @@ -25,14 +28,16 @@ interface ComponentDetailsProps {
export function ComponentDetails(props: ComponentDetailsProps) {
return (
<EuiPanel paddingSize="m">
{props.selectedComponent ? (
{props.isDeprovisionable ? (
<ProvisionedComponentInputs />
) : props.selectedComponent ? (
<ComponentInputs
selectedComponent={props.selectedComponent}
onFormChange={props.onFormChange}
/>
) : (
) : props.workflow ? (
<EmptyComponentInputs />
)}
) : undefined}
</EuiPanel>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<EuiEmptyPrompt
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import React from 'react';
import { EuiEmptyPrompt, EuiText } from '@elastic/eui';

// Simple prompt to display when the workflow is provisioned.
export function ProvisionedComponentInputs() {
return (
<EuiEmptyPrompt
iconType="bell"
title={<h2>The workflow has been provisioned</h2>}
titleSize="s"
body={
<>
<EuiText>Please deprovision first to continue editing.</EuiText>
</>
}
/>
);
}
30 changes: 26 additions & 4 deletions public/pages/workflow_detail/workspace/resizable_workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
USE_CASE,
WORKFLOW_STATE,
processNodes,
reduceToTemplate,
} from '../../../../common';
import {
AppState,
Expand All @@ -44,6 +45,7 @@ import {
provisionWorkflow,
removeDirty,
setDirty,
updateWorkflow,
useAppDispatch,
} from '../../../store';
import { Workspace } from './workspace';
Expand Down Expand Up @@ -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<boolean>(false);
Expand Down Expand Up @@ -376,7 +382,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
</EuiButton>,
<EuiButton
fill={false}
disabled={!isSaveable || isLoadingGlobal}
disabled={!isSaveable || isLoadingGlobal || isDeprovisionable}
isLoading={isSaving}
// TODO: if props.isNewWorkflow is true, clear the workflow cache if saving is successful.
onClick={() => {
Expand All @@ -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()
Expand Down Expand Up @@ -444,6 +463,7 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
<Workspace
id="ingest"
workflow={workflow}
readonly={readonly}
onNodesChange={onNodesChange}
onSelectionChange={onSelectionChange}
/>
Expand All @@ -467,7 +487,9 @@ export function ResizableWorkspace(props: ResizableWorkspaceProps) {
>
<EuiFlexItem>
<ComponentDetails
workflow={props.workflow}
selectedComponent={selectedComponent}
isDeprovisionable={isDeprovisionable}
onFormChange={onFormChange}
/>
</EuiFlexItem>
Expand Down
9 changes: 9 additions & 0 deletions public/pages/workflow_detail/workspace/workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}
>
<Controls
showFitView={false}
Expand Down
25 changes: 22 additions & 3 deletions public/route_service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
SEARCH_MODELS_NODE_API_PATH,
PROVISION_WORKFLOW_NODE_API_PATH,
DEPROVISION_WORKFLOW_NODE_API_PATH,
UPDATE_WORKFLOW_NODE_API_PATH,
WorkflowTemplate,
} from '../common';

/**
Expand All @@ -28,9 +30,10 @@ export interface RouteService {
getWorkflow: (workflowId: string) => Promise<any | HttpFetchError>;
searchWorkflows: (body: {}) => Promise<any | HttpFetchError>;
getWorkflowState: (workflowId: string) => Promise<any | HttpFetchError>;
createWorkflow: (
body: {},
provision?: boolean
createWorkflow: (body: {}) => Promise<any | HttpFetchError>;
updateWorkflow: (
workflowId: string,
workflowTemplate: WorkflowTemplate
) => Promise<any | HttpFetchError>;
provisionWorkflow: (workflowId: string) => Promise<any | HttpFetchError>;
deprovisionWorkflow: (workflowId: string) => Promise<any | HttpFetchError>;
Expand Down Expand Up @@ -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 }>(
Expand Down
50 changes: 49 additions & 1 deletion public/store/reducers/workflows_reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -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`;
Expand Down Expand Up @@ -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 }) => {
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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;
Expand Down
15 changes: 15 additions & 0 deletions public/utils/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import {
WorkspaceFormValues,
WORKFLOW_STATE,
ReactFlowComponent,
Workflow,
WorkflowTemplate,
} from '../../common';

// Append 16 random characters
Expand Down Expand Up @@ -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) {
Expand Down
14 changes: 14 additions & 0 deletions server/cluster/flow_framework_plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading

0 comments on commit 2a9f3a7

Please sign in to comment.