Repository: argoproj/argo (go)

Issue:
Retain layout changes after page reloads in UI
# Summary

Argo UI currently provides a few neat ways to modify the visual layout of the workflow(s) and filter steps/tasks. But changes are not retained following a page reload.

# Motivation

Especially for very large and complex workflows, UI customization actions become quite repetitive.

# Proposal

A simple save/load process using local storage may be sufficient.

---
<!-- Issue Author: Don't delete this message to encourage other users to support your issue! -->
**Message from the maintainers**:

If you wish to see this enhancement implemented please add a 👍 reaction to this issue! We often sort issues this way to know what to prioritize.


Pull request:
feat(ui): Persist DAG rendering options in local storage. Fixes #3120
Closes #3120

Patch:
diff --git a/docs/README.md b/docs/README.md
index 9ec3ff425..251633ea2 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -23,6 +23,7 @@ Some use-case specific documentation is available:
 * [Cluster Workflow Templates](cluster-workflow-templates.md)
 * [Configuring Your Artifact Repository](configure-artifact-repository.md)
 * [Core Concepts](core-concepts.md)
+* [Cost Optimisation](cost-optimisation.md)
 * [Cron Backfill](cron-backfill.md)
 * [Cron Workflows](cron-workflows.md)
 * [Default Workflow Spec](default-workflow-specs.md)
diff --git a/docs/cost-optimisation.md b/docs/cost-optimisation.md
new file mode 100644
index 000000000..e61249909
--- /dev/null
+++ b/docs/cost-optimisation.md
@@ -0,0 +1,122 @@
+# Cost Optimisation
+
+## User Cost Optimisations
+
+Suggestions for users running workflows.
+
+### Set The Workflows Pod Resource Requests 
+
+> Suitable if you are running a workflow with many homogenous pods.
+
+[Resource duration](resource-duration.md) shows the amount of CPU and memory requested by a pod and is indicative of the cost. You can use this to find costly steps within your workflow.
+
+Smaller requests can be set in the pod spec patch's [resource requirements](fields.md#resourcerequirements). 
+
+## Use A Node Selector To Use Cheaper Instances
+
+You can use a [node selector](fields.md#nodeselector) for cheaper instances, e.g. spot instances:
+
+```yaml
+nodeSelector:
+  "node-role.kubernetes.io/argo-spot-worker": "true"
+```
+
+### Consider trying Volume Claim Templates or Volumes instead of Artifacts
+
+> Suitable if you have a workflow that passes a lot of artifacts within itself.
+
+Copying artifacts to and from storage outside of a cluster can be expensive. The correct choice is dependent on your artifact storage provider is vs. what volume they are using. For example, we believe it may be more expensive to allocate and delete new EBS volumes every workflow using the PVC feature, than it is to upload and download some small files to S3.
+
+On the other hand if they are using a NFS volume shared between all their workflows with large artifacts, that might be cheaper than the data transfer and storage costs of S3.
+
+Consider:
+
+* Data transfer costs (upload/download vs. copying)
+* Data storage costs (s3 vs. volume)
+* Requirement for parallel access to data (NFS vs. EBS vs. artifact)
+
+### Limit The Total Number Of Workflows And Pods
+
+> Suitable for all.
+
+A workflow (and for that matter, any Kubernetes resource) will incur a cost as long as they exist in your cluster. 
+
+The workflow controller memory and CPU needs increase linearly with the number of pods and workflows you are currently running. 
+
+Limit the total number of workflows using:
+
+* Active Deadline Seconds - terminate running workflows that do not complete in a set time. This will make sure workflows do not run forever.
+* [Workflow TTL Strategy](fields.md#ttlstrategy) - delete completed workflows after a time
+* [Pod GC](fields.md#podgc) - delete completed pods after a time
+
+Example
+
+```yaml
+spec:
+  # must complete in 8h (28,800 seconds)
+  activeDeadlineSeconds: 28800
+  # keep workflows for 1d (86,400 seconds)
+  ttlStrategy:
+    secondsAfterCompletion: 86400
+  # delete all pods as soon as they complete
+  podGC:
+    strategy: OnPodCompletion
+```
+
+You can set these configurations globally using [Default Workflow Spec](default-workflow-specs.md).
+
+If you need to keep records historically, use the [Workflow Archive](workflow-archive.md).
+
+Changing these settings will not delete workflows that have already run. To list old workflows:
+
+```
+argo list --completed --since 7d
+```
+
+> v2.9 and after
+
+To list/delete workflows completed over 7 days ago:
+
+```
+argo list --older 7d
+argo delete --older 7d
+```
+
+## Operator Cost Optimisations
+
+Suggestions for operators who installed Argo Workflows.
+
+### Set Resources Requests and Limits
+
+> Suitable if you have many instances, e.g. on dozens of clusters or namespaces.
+
+Set a resource requests and limits for the `workflow-controller` and `argo-server`, e.g. 
+
+```yaml
+requests:
+  cpu: 100m
+  memory: 64Mi
+limits:
+  cpu: 500m
+  memory: 128Mi
+```
+
+This above limit is suitable for the Argo Server, as this is stateless. The Workflow Controller is stateful and will scale to the number of live workflows - so you are likely to need higher values.
+
+### Configure Executor Resource Requests
+
+> Suitable for all - unless you have large artifacts.
+
+Configure [workflow-controller-configmap.yaml](workflow-controller-configmap.yaml) to set the `executorResources`
+
+```yaml
+executorResources:
+  requests:
+    cpu: 100m
+    memory: 64Mi
+  limits:
+    cpu: 500m
+    memory: 512Mi
+```
+
+The correct values depend on the size of artifacts your workflows download. For artifacts > 10GB, memory usage maybe large - [#1322](https://github.com/argoproj/argo/issues/1322).
diff --git a/pkg/apis/workflow/v1alpha1/item.go b/pkg/apis/workflow/v1alpha1/item.go
index 386ece4a5..d0fd1d695 100644
--- a/pkg/apis/workflow/v1alpha1/item.go
+++ b/pkg/apis/workflow/v1alpha1/item.go
@@ -17,6 +17,10 @@ const (
 	List
 )
 
+type Typer interface {
+	GetType() Type
+}
+
 // Item expands a single workflow step into multiple parallel steps
 // The value of Item can be a map, string, bool, or number
 //
@@ -98,6 +102,10 @@ func (i Item) MarshalJSON() ([]byte, error) {
 	}
 }
 
+func (i *Item) GetType() Type {
+	return i.Type
+}
+
 // +protobuf=true
 // +protobuf.options.(gogoproto.goproto_stringer)=false
 // +k8s:openapi-gen=true
@@ -159,6 +167,10 @@ func (iv ItemValue) Format(s fmt.State, verb rune) {
 	fmt.Fprintf(s, iv.String()) //nolint
 }
 
+func (i *ItemValue) GetType() Type {
+	return i.Type
+}
+
 // MarshalJSON implements the json.Marshaller interface.
 func (iv ItemValue) MarshalJSON() ([]byte, error) {
 	switch iv.Type {
@@ -170,7 +182,9 @@ func (iv ItemValue) MarshalJSON() ([]byte, error) {
 		return json.Marshal(iv.NumVal)
 	case Map:
 		return json.Marshal(iv.MapVal)
+	case List:
+		return json.Marshal(iv.ListVal)
 	default:
-		return []byte{}, fmt.Errorf("impossible ItemValue.Type")
+		return []byte{}, fmt.Errorf("impossible ItemValue.Type %v", iv.Type)
 	}
 }
diff --git a/ui/src/app/archived-workflows/components/archived-workflow-details/archived-workflow-details.tsx b/ui/src/app/archived-workflows/components/archived-workflow-details/archived-workflow-details.tsx
index eebf178f0..e4079cd93 100644
--- a/ui/src/app/archived-workflows/components/archived-workflow-details/archived-workflow-details.tsx
+++ b/ui/src/app/archived-workflows/components/archived-workflow-details/archived-workflow-details.tsx
@@ -10,9 +10,9 @@ import {YamlEditor} from '../../../shared/components/yaml/yaml-editor';
 import {services} from '../../../shared/services';
 import {
     WorkflowArtifacts,
-    WorkflowDag,
     WorkflowLogsViewer,
     WorkflowNodeInfo,
+    WorkflowPanel,
     WorkflowParametersPanel,
     WorkflowSummaryPanel,
     WorkflowTimeline,
@@ -159,9 +159,9 @@ export class ArchivedWorkflowDetails extends BasePage<RouteComponentProps<any>,
                     <div>
                         <div className='workflow-details__graph-container'>
                             {this.tab === 'workflow' ? (
-                                <WorkflowDag
-                                    nodes={this.state.workflow.status.nodes}
-                                    workflowName={this.state.workflow.metadata.name}
+                                <WorkflowPanel
+                                    workflowMetadata={this.state.workflow.metadata}
+                                    workflowStatus={this.state.workflow.status}
                                     selectedNodeId={this.nodeId}
                                     nodeClicked={nodeId => (this.nodeId = nodeId)}
                                 />
diff --git a/ui/src/app/archived-workflows/components/archived-workflow-list/archived-workflow-list.tsx b/ui/src/app/archived-workflows/components/archived-workflow-list/archived-workflow-list.tsx
index c7d023f6d..559e26a5b 100644
--- a/ui/src/app/archived-workflows/components/archived-workflow-list/archived-workflow-list.tsx
+++ b/ui/src/app/archived-workflows/components/archived-workflow-list/archived-workflow-list.tsx
@@ -1,6 +1,5 @@
 import {Page, SlidingPanel} from 'argo-ui';
 
-import * as classNames from 'classnames';
 import * as React from 'react';
 import {Link, RouteComponentProps} from 'react-router-dom';
 import * as models from '../../../../models';
@@ -9,6 +8,7 @@ import {uiUrl} from '../../../shared/base';
 import {BasePage} from '../../../shared/components/base-page';
 import {Loading} from '../../../shared/components/loading';
 import {PaginationPanel} from '../../../shared/components/pagination-panel';
+import {PhaseIcon} from '../../../shared/components/phase-icon';
 import {ResourceSubmit} from '../../../shared/components/resource-submit';
 import {Timestamp} from '../../../shared/components/timestamp';
 import {ZeroState} from '../../../shared/components/zero-state';
@@ -235,7 +235,7 @@ export class ArchivedWorkflowList extends BasePage<RouteComponentProps<any>, Sta
                     {this.state.workflows.map(w => (
                         <Link className='row argo-table-list__row' key={`${w.metadata.uid}`} to={uiUrl(`archived-workflows/${w.metadata.namespace}/${w.metadata.uid}`)}>
                             <div className='columns small-1'>
-                                <i className={classNames('fa', Utils.statusIconClasses(w.status.phase))} />
+                                <PhaseIcon value={w.status.phase} />
                             </div>
                             <div className='columns small-3'>{w.metadata.name}</div>
                             <div className='columns small-2'>{w.metadata.namespace}</div>
diff --git a/ui/src/app/shared/components/phase-icon.tsx b/ui/src/app/shared/components/phase-icon.tsx
new file mode 100644
index 000000000..c20e9058a
--- /dev/null
+++ b/ui/src/app/shared/components/phase-icon.tsx
@@ -0,0 +1,8 @@
+import * as classNames from 'classnames';
+import * as React from 'react';
+import {NodePhase} from '../../../models';
+import {Utils} from '../utils';
+
+export const PhaseIcon = ({value}: {value: NodePhase}) => {
+    return <i className={classNames('fa', Utils.statusIconClasses(value))} aria-hidden='true' />;
+};
diff --git a/ui/src/app/shared/components/phase.tsx b/ui/src/app/shared/components/phase.tsx
new file mode 100644
index 000000000..c8ad243bc
--- /dev/null
+++ b/ui/src/app/shared/components/phase.tsx
@@ -0,0 +1,11 @@
+import * as React from 'react';
+import {NodePhase} from '../../../models';
+import {PhaseIcon} from './phase-icon';
+
+export const Phase = ({value}: {value: NodePhase}) => {
+    return (
+        <>
+            <PhaseIcon value={value} /> {value}
+        </>
+    );
+};
diff --git a/ui/src/app/workflows/components/index.ts b/ui/src/app/workflows/components/index.ts
index fd4af830f..4ee537ec0 100644
--- a/ui/src/app/workflows/components/index.ts
+++ b/ui/src/app/workflows/components/index.ts
@@ -5,5 +5,6 @@ export * from './workflow-timeline/workflow-timeline';
 export * from './workflow-yaml-viewer/workflow-yaml-viewer';
 export * from './workflows-list/workflows-list';
 export * from './workflow-artifacts';
+export * from './workflow-panel/workflow-panel';
 export * from './workflow-parameters-panel';
 export * from './workflow-summary-panel';
diff --git a/ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx b/ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx
index 9415291f2..d056cef37 100644
--- a/ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx
+++ b/ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx
@@ -25,6 +25,8 @@ require('./workflow-dag.scss');
 
 type DagPhase = NodePhase | 'Suspended';
 
+const LOCAL_STORAGE_KEY = 'DagOptions';
+
 export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRenderOptions> {
     private get scale() {
         return this.state.scale;
@@ -144,24 +146,7 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
 
     constructor(props: Readonly<WorkflowDagProps>) {
         super(props);
-        this.state = {
-            horizontal: false,
-            scale: 1,
-            nodesToDisplay: [
-                'phase:Pending',
-                'phase:Running',
-                'phase:Succeeded',
-                'phase:Skipped',
-                'phase:Failed',
-                'phase:Error',
-                'type:Pod',
-                'type:Steps',
-                'type:DAG',
-                'type:Retry',
-                'type:Skipped',
-                'type:Suspend'
-            ]
-        };
+        this.state = this.getOptions();
     }
 
     public render() {
@@ -173,7 +158,7 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
 
         return (
             <>
-                <WorkflowDagRenderOptionsPanel {...this.state} onChange={workflowDagRenderOptions => this.setState(workflowDagRenderOptions)} />
+                <WorkflowDagRenderOptionsPanel {...this.state} onChange={workflowDagRenderOptions => this.saveOptions(workflowDagRenderOptions)} />
                 <div className='workflow-dag'>
                     <svg
                         style={{
@@ -230,6 +215,35 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
         );
     }
 
+    private saveOptions(newChanges: WorkflowDagRenderOptions) {
+        localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(newChanges));
+        this.setState(newChanges);
+    }
+
+    private getOptions(): WorkflowDagRenderOptions {
+        if (localStorage.getItem(LOCAL_STORAGE_KEY) !== null) {
+            return JSON.parse(localStorage.getItem(LOCAL_STORAGE_KEY)) as WorkflowDagRenderOptions;
+        }
+        return {
+            horizontal: false,
+            scale: 1,
+            nodesToDisplay: [
+                'phase:Pending',
+                'phase:Running',
+                'phase:Succeeded',
+                'phase:Skipped',
+                'phase:Failed',
+                'phase:Error',
+                'type:Pod',
+                'type:Steps',
+                'type:DAG',
+                'type:Retry',
+                'type:Skipped',
+                'type:Suspend'
+            ]
+        } as WorkflowDagRenderOptions;
+    }
+
     private prepareGraph() {
         const nodes = Object.values(this.props.nodes)
             .filter(node => !!node)
diff --git a/ui/src/app/workflows/components/workflow-details/workflow-details.tsx b/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
index f7f8d79f7..1b78ae2c9 100644
--- a/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
+++ b/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
@@ -9,8 +9,9 @@ import {Link, NodePhase, Workflow} from '../../../../models';
 import {uiUrl} from '../../../shared/base';
 import {services} from '../../../shared/services';
 
-import {WorkflowArtifacts, WorkflowDag, WorkflowLogsViewer, WorkflowNodeInfo, WorkflowSummaryPanel, WorkflowTimeline, WorkflowYamlViewer} from '..';
+import {WorkflowArtifacts, WorkflowLogsViewer, WorkflowNodeInfo, WorkflowPanel, WorkflowSummaryPanel, WorkflowTimeline, WorkflowYamlViewer} from '..';
 import {CostOptimisationNudge} from '../../../shared/components/cost-optimisation-nudge';
+import {Loading} from '../../../shared/components/loading';
 import {hasWarningConditionBadge} from '../../../shared/conditions-panel';
 import {Consumer, ContextApis} from '../../../shared/context';
 import {Utils} from '../../../shared/utils';
@@ -129,9 +130,9 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, W
                                     <div>
                                         <div className='workflow-details__graph-container'>
                                             {(this.selectedTabKey === 'workflow' && (
-                                                <WorkflowDag
-                                                    nodes={this.state.workflow.status.nodes}
-                                                    workflowName={this.state.workflow.metadata.name}
+                                                <WorkflowPanel
+                                                    workflowMetadata={this.state.workflow.metadata}
+                                                    workflowStatus={this.state.workflow.status}
                                                     selectedNodeId={this.selectedNodeId}
                                                     nodeClicked={nodeId => this.selectNode(nodeId)}
                                                 />
@@ -384,7 +385,7 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, W
 
     private renderSummaryTab() {
         if (!this.state.workflow) {
-            return <div>Loading...</div>;
+            return <Loading />;
         }
         return (
             <div className='argo-container'>
diff --git a/ui/src/app/workflows/components/workflow-node-info/workflow-node-info.tsx b/ui/src/app/workflows/components/workflow-node-info/workflow-node-info.tsx
index 65da0a686..082aef302 100644
--- a/ui/src/app/workflows/components/workflow-node-info/workflow-node-info.tsx
+++ b/ui/src/app/workflows/components/workflow-node-info/workflow-node-info.tsx
@@ -1,14 +1,13 @@
 import {Duration, Tabs, Ticker} from 'argo-ui';
-import * as classNames from 'classnames';
 import * as moment from 'moment';
 import * as React from 'react';
 
 import * as models from '../../../../models';
+import {Phase} from '../../../shared/components/phase';
 import {Timestamp} from '../../../shared/components/timestamp';
 import {ResourcesDuration} from '../../../shared/resources-duration';
 import {services} from '../../../shared/services';
 import {getResolvedTemplates} from '../../../shared/template-resolution';
-import {Utils} from '../../../shared/utils';
 
 require('./workflow-node-info.scss');
 
@@ -46,11 +45,7 @@ export const WorkflowNodeSummary = (props: Props) => {
         {title: 'TYPE', value: props.node.type},
         {
             title: 'PHASE',
-            value: (
-                <span>
-                    <i className={classNames('fa', Utils.statusIconClasses(props.node.phase))} aria-hidden='true' /> {props.node.phase}
-                </span>
-            )
+            value: <Phase value={props.node.phase} />
         },
         ...(props.node.message
             ? [
diff --git a/ui/src/app/workflows/components/workflow-panel/workflow-panel.tsx b/ui/src/app/workflows/components/workflow-panel/workflow-panel.tsx
new file mode 100644
index 000000000..b5b75883e
--- /dev/null
+++ b/ui/src/app/workflows/components/workflow-panel/workflow-panel.tsx
@@ -0,0 +1,36 @@
+import {ObjectMeta} from 'argo-ui/src/models/kubernetes';
+import * as React from 'react';
+import {WorkflowDag} from '..';
+import {labels, WorkflowStatus} from '../../../../models';
+import {Notice} from '../../../shared/components/notice';
+import {Phase} from '../../../shared/components/phase';
+
+interface Props {
+    workflowMetadata: ObjectMeta;
+    workflowStatus: WorkflowStatus;
+    selectedNodeId: string;
+    nodeClicked: (nodedId: string) => void;
+}
+
+export class WorkflowPanel extends React.Component<Props> {
+    public render() {
+        if (this.props.workflowMetadata.labels && this.props.workflowMetadata.labels[labels.completed] === 'true' && !this.props.workflowStatus.nodes) {
+            return (
+                <div className='argo-container'>
+                    <Notice>
+                        <Phase value={this.props.workflowStatus.phase} />: {this.props.workflowStatus.message}
+                    </Notice>
+                </div>
+            );
+        }
+
+        return (
+            <WorkflowDag
+                workflowName={this.props.workflowMetadata.name}
+                nodes={this.props.workflowStatus.nodes}
+                selectedNodeId={this.props.selectedNodeId}
+                nodeClicked={this.props.nodeClicked}
+            />
+        );
+    }
+}
diff --git a/ui/src/app/workflows/components/workflow-summary-panel.tsx b/ui/src/app/workflows/components/workflow-summary-panel.tsx
index 8ba1612e1..a6e2e4829 100644
--- a/ui/src/app/workflows/components/workflow-summary-panel.tsx
+++ b/ui/src/app/workflows/components/workflow-summary-panel.tsx
@@ -2,6 +2,8 @@ import {Ticker} from 'argo-ui';
 import * as React from 'react';
 
 import {NODE_PHASE, Workflow} from '../../../models';
+import {Phase} from '../../shared/components/phase';
+import {Timestamp} from '../../shared/components/timestamp';
 import {ConditionsPanel} from '../../shared/conditions-panel';
 import {formatDuration, wfDuration} from '../../shared/duration';
 import {ResourcesDuration} from '../../shared/resources-duration';
@@ -10,12 +12,12 @@ export const WorkflowSummaryPanel = (props: {workflow: Workflow}) => (
     <Ticker disabled={props.workflow && props.workflow.status.phase !== NODE_PHASE.RUNNING}>
         {() => {
             const attributes: {title: string; value: any}[] = [
-                {title: 'Status', value: props.workflow.status.phase},
+                {title: 'Status', value: <Phase value={props.workflow.status.phase} />},
                 {title: 'Message', value: props.workflow.status.message},
                 {title: 'Name', value: props.workflow.metadata.name},
                 {title: 'Namespace', value: props.workflow.metadata.namespace},
-                {title: 'Started At', value: props.workflow.status.startedAt},
-                {title: 'Finished At', value: props.workflow.status.finishedAt || '-'},
+                {title: 'Started', value: <Timestamp date={props.workflow.status.startedAt} />},
+                {title: 'Finished ', value: <Timestamp date={props.workflow.status.finishedAt} />},
                 {title: 'Duration', value: formatDuration(wfDuration(props.workflow.status))}
             ];
             if (props.workflow.status.resourcesDuration) {
diff --git a/ui/src/app/workflows/components/workflows-list/workflows-list.tsx b/ui/src/app/workflows/components/workflows-list/workflows-list.tsx
index 85afec478..056a38caa 100644
--- a/ui/src/app/workflows/components/workflows-list/workflows-list.tsx
+++ b/ui/src/app/workflows/components/workflows-list/workflows-list.tsx
@@ -18,9 +18,9 @@ import {exampleWorkflow} from '../../../shared/examples';
 import {Utils} from '../../../shared/utils';
 
 import {Ticker} from 'argo-ui/src/index';
-import * as classNames from 'classnames';
 import {CostOptimisationNudge} from '../../../shared/components/cost-optimisation-nudge';
 import {PaginationPanel} from '../../../shared/components/pagination-panel';
+import {PhaseIcon} from '../../../shared/components/phase-icon';
 import {Timestamp} from '../../../shared/components/timestamp';
 import {formatDuration, wfDuration} from '../../../shared/duration';
 import {Pagination, parseLimit} from '../../../shared/pagination';
@@ -275,7 +275,7 @@ export class WorkflowsList extends BasePage<RouteComponentProps<any>, State> {
                             key={`${w.metadata.namespace}-${w.metadata.name}`}
                             to={uiUrl(`workflows/${w.metadata.namespace}/${w.metadata.name}`)}>
                             <div className='columns small-1'>
-                                <i className={classNames('fa', Utils.statusIconClasses(w.status.phase))} />
+                                <PhaseIcon value={w.status.phase} />
                             </div>
                             <div className='columns small-3'>{w.metadata.name}</div>
                             <div className='columns small-2'>{w.metadata.namespace}</div>
diff --git a/workflow/controller/operator.go b/workflow/controller/operator.go
index edb28a2f5..4d5b58e10 100644
--- a/workflow/controller/operator.go
+++ b/workflow/controller/operator.go
@@ -2683,8 +2683,8 @@ func (woc *wfOperationCtx) loadExecutionSpec() (wfv1.TemplateReferenceHolder, wf
 
 	executionParameters := woc.wf.Spec.Arguments
 
+	woc.wfSpec = &woc.wf.Spec
 	if woc.wf.Spec.WorkflowTemplateRef == nil {
-		woc.wfSpec = &woc.wf.Spec
 		tmplRef := &wfv1.WorkflowStep{Template: woc.wfSpec.Entrypoint}
 		return tmplRef, executionParameters, nil
 	}


Test patch:
diff --git a/pkg/apis/workflow/v1alpha1/item_test.go b/pkg/apis/workflow/v1alpha1/item_test.go
index 896f7359b..ec4d8466c 100644
--- a/pkg/apis/workflow/v1alpha1/item_test.go
+++ b/pkg/apis/workflow/v1alpha1/item_test.go
@@ -9,29 +9,37 @@ import (
 )
 
 func TestItem(t *testing.T) {
-	testData := map[string]Type{
+	for data, expectedType := range map[string]Type{
 		"0":                               Number,
 		"3.141":                           Number,
 		"true":                            Bool,
 		"\"hello\"":                       String,
 		"{\"val\":\"123\"}":               Map,
 		"[\"1\",\"2\",\"3\",\"4\",\"5\"]": List,
+	} {
+		t.Run(string(expectedType), func(t *testing.T) {
+			t.Run("Item", func(t *testing.T) {
+				runItemTest(t, data, &Item{}, expectedType)
+			})
+			t.Run("ItemValue", func(t *testing.T) {
+				runItemTest(t, data, &ItemValue{}, expectedType)
+			})
+		})
 	}
+}
 
-	for data, expectedType := range testData {
-		var itm Item
-		err := json.Unmarshal([]byte(data), &itm)
-		assert.NoError(t, err)
-		assert.Equal(t, itm.Type, expectedType)
-		jsonBytes, err := json.Marshal(itm)
-		assert.NoError(t, err)
-		assert.Equal(t, data, string(jsonBytes))
-		if itm.Type == String {
-			assert.Equal(t, data, fmt.Sprintf("\"%v\"", itm))
-			assert.Equal(t, data, fmt.Sprintf("\"%s\"", itm))
-		} else {
-			assert.Equal(t, data, fmt.Sprintf("%v", itm))
-			assert.Equal(t, data, fmt.Sprintf("%s", itm))
-		}
+func runItemTest(t *testing.T, data string, itm Typer, expectedType Type) {
+	err := json.Unmarshal([]byte(data), itm)
+	assert.NoError(t, err)
+	assert.Equal(t, itm.GetType(), expectedType)
+	jsonBytes, err := json.Marshal(itm)
+	assert.NoError(t, err)
+	assert.Equal(t, data, string(jsonBytes))
+	if itm.GetType() == String {
+		assert.Equal(t, data, fmt.Sprintf("\"%v\"", itm))
+		assert.Equal(t, data, fmt.Sprintf("\"%s\"", itm))
+	} else {
+		assert.Equal(t, data, fmt.Sprintf("%v", itm))
+		assert.Equal(t, data, fmt.Sprintf("%s", itm))
 	}
 }
diff --git a/test/e2e/ui/ui-workflow-error.yaml b/test/e2e/ui/ui-workflow-error.yaml
new file mode 100644
index 000000000..716197136
--- /dev/null
+++ b/test/e2e/ui/ui-workflow-error.yaml
@@ -0,0 +1,11 @@
+apiVersion: argoproj.io/v1alpha1
+kind: Workflow
+metadata:
+  name: ui-workflow-error
+  namespace: argo
+spec:
+  entrypoint: main
+  templates:
+    - name: noop
+  workflowTemplateRef:
+    name: not-exists
diff --git a/workflow/controller/operator_workflow_template_ref_test.go b/workflow/controller/operator_workflow_template_ref_test.go
index 3bbc89783..9c5375932 100644
--- a/workflow/controller/operator_workflow_template_ref_test.go
+++ b/workflow/controller/operator_workflow_template_ref_test.go
@@ -130,3 +130,27 @@ func TestWorkflowTemplateRefGetFromStored(t *testing.T) {
 		assert.Equal(t, "hello", *execArgs.Parameters[1].Value)
 	})
 }
+
+const invalidWF = `
+apiVersion: argoproj.io/v1alpha1
+kind: Workflow
+metadata:
+  name: ui-workflow-error
+  namespace: argo
+spec:
+  entrypoint: main
+  workflowTemplateRef:
+    name: not-exists
+`
+
+func TestWorkflowTemplateRefInvalidWF(t *testing.T) {
+	wf := unmarshalWF(invalidWF)
+	t.Run("ProcessWFWithStoredWFT", func(t *testing.T) {
+		_, controller := newController(wf)
+		woc := newWorkflowOperationCtx(wf, controller)
+		_, _, err := woc.loadExecutionSpec()
+		assert.Error(t, err)
+		woc.operate()
+		assert.Equal(t, wfv1.NodeError, woc.wf.Status.Phase)
+	})
+}