Repository: argoproj/argo
Pull request #2319 (2020-02-27 03:13:46)

feat(ui): Adds ability zoom and hide successful steps. POC
Checklist:

* [ ] Either (a) I've created an [enhancement proposal](https://github.com/argoproj/argo/issues/new/choose) and discussed it with the community, (b) this is a bug fix, or (c) this is a chore.
* [x] The title of the PR is (a) [conventional](https://www.conventionalcommits.org/en/v1.0.0/), (b) states what changed, and (c) suffixes the related issues number. E.g. `"fix(controller): Updates such and such. Fixes #1234"`.  
* x ] I have written unit and/or e2e tests for my change. PRs without these are unlike to be merged.
* [x] Optional. I've added My organization is added to the README.
* [ ] I've signed the CLA and required builds are green. 

## Changes

This attempts to address the common of use case of "I need to find why my very large workflow failed" by:

* Adding a "horizontal layout" control"
* Adding a "zoom" control.
* Adding a "hide successful steps" control.

## Screenshots

![default](https://user-images.githubusercontent.com/1142830/75577368-b7580200-5a16-11ea-8624-889424a79945.png)
![horiz](https://user-images.githubusercontent.com/1142830/75577372-b921c580-5a16-11ea-8278-f38c46f26975.png)
![zoom+horiz](https://user-images.githubusercontent.com/1142830/75577374-b9ba5c00-5a16-11ea-871d-b4eb7466c169.png)
![zoom+horiz+not-succ](https://user-images.githubusercontent.com/1142830/75577376-b9ba5c00-5a16-11ea-8887-1faa17e6afb7.png)



codecov[bot]: # [Codecov](https://codecov.io/gh/argoproj/argo/pull/2319?src=pr&el=h1) Report
> :exclamation: No coverage uploaded for pull request base (`master@aa05234`). [Click here to learn what that means](https://docs.codecov.io/docs/error-reference#section-missing-base-commit).
> The diff coverage is `n/a`.

[![Impacted file tree graph](https://codecov.io/gh/argoproj/argo/pull/2319/graphs/tree.svg?width=650&token=4c3DPSTq8o&height=150&src=pr)](https://codecov.io/gh/argoproj/argo/pull/2319?src=pr&el=tree)

```diff
@@            Coverage Diff            @@
##             master    #2319   +/-   ##
=========================================
  Coverage          ?   13.38%           
=========================================
  Files             ?       70           
  Lines             ?    24232           
  Branches          ?        0           
=========================================
  Hits              ?     3244           
  Misses            ?    20579           
  Partials          ?      409
```



------

[Continue to review full report at Codecov](https://codecov.io/gh/argoproj/argo/pull/2319?src=pr&el=continue).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/argoproj/argo/pull/2319?src=pr&el=footer). Last update [aa05234...54fb2c3](https://codecov.io/gh/argoproj/argo/pull/2319?src=pr&el=lastupdated). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments).


Patch:
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index efbfa5124..919b4a331 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -19,5 +19,5 @@ Be patient
 
 # Relevant References
 * https://www.djangoproject.com/conduct/
-* http://contributor-covenant.org/version/1/4/code_of_conduct.md
+* https://www.contributor-covenant.org/version/1/4/code-of-conduct/
 * http://contributor-covenant.org/
diff --git a/api/openapi-spec/swagger.json b/api/openapi-spec/swagger.json
index 71fe0b6d5..fae466187 100644
--- a/api/openapi-spec/swagger.json
+++ b/api/openapi-spec/swagger.json
@@ -10,6 +10,7 @@
   ],
   "swagger": "2.0",
   "info": {
+    "description": "Workflow Service API performs CRUD actions against application resources",
     "title": "Argo",
     "version": "latest"
   },
@@ -847,6 +848,12 @@
             "description": "The continue option should be set when retrieving more results from the server. Since this value is\nserver defined, clients may only use the continue value from a previous query result with identical\nquery parameters (except for the value of continue) and the server may reject a continue value it\ndoes not recognize. If the specified continue value is no longer valid whether due to expiration\n(generally five to fifteen minutes) or a configuration change on the server, the server will\nrespond with a 410 ResourceExpired error together with a continue token. If the client needs a\nconsistent list, it must restart their list without the continue field. Otherwise, the client may\nsend another list request with the token received with the 410 error, the server will respond with\na list starting from the next key, but from the latest snapshot, which is inconsistent from the\nprevious list results - objects that are created, modified, or deleted after the first list request\nwill be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last\nresourceVersion value returned by the server and not miss any modifications.",
             "name": "listOptions.continue",
             "in": "query"
+          },
+          {
+            "type": "string",
+            "description": "Fields to be included or excluded in the response. e.g. \"items.spec,items.status.phase\", \"-items.status.nodes\".",
+            "name": "fields",
+            "in": "query"
           }
         ],
         "responses": {
@@ -945,6 +952,12 @@
             "description": "When specified:\n- if unset, then the result is returned from remote storage based on quorum-read flag;\n- if it's 0, then we simply return what we currently have in cache, no guarantee;\n- if set to non zero, then the result is at least as fresh as given rv.",
             "name": "getOptions.resourceVersion",
             "in": "query"
+          },
+          {
+            "type": "string",
+            "description": "Fields to be included or excluded in the response. e.g. \"spec,status.phase\", \"-status.nodes\".",
+            "name": "fields",
+            "in": "query"
           }
         ],
         "responses": {
@@ -5429,6 +5442,9 @@
       "properties": {
         "content": {
           "type": "string"
+        },
+        "podName": {
+          "type": "string"
         }
       }
     },
@@ -5599,5 +5615,31 @@
       "description": "HTTP Basic authentication",
       "type": "basic"
     }
+  },
+  "x-stream-definitions": {
+    "io.argoproj.workflow.v1alpha1.LogEntry": {
+      "properties": {
+        "error": {
+          "$ref": "#/definitions/grpc.gateway.runtime.StreamError"
+        },
+        "result": {
+          "$ref": "#/definitions/io.argoproj.workflow.v1alpha1.LogEntry"
+        }
+      },
+      "title": "Stream result of io.argoproj.workflow.v1alpha1.LogEntry",
+      "type": "object"
+    },
+    "io.argoproj.workflow.v1alpha1.WorkflowWatchEvent": {
+      "properties": {
+        "error": {
+          "$ref": "#/definitions/grpc.gateway.runtime.StreamError"
+        },
+        "result": {
+          "$ref": "#/definitions/io.argoproj.workflow.v1alpha1.WorkflowWatchEvent"
+        }
+      },
+      "title": "Stream result of io.argoproj.workflow.v1alpha1.WorkflowWatchEvent",
+      "type": "object"
+    }
   }
 }
diff --git a/go.mod b/go.mod
index f4b609a76..5e890611c 100644
--- a/go.mod
+++ b/go.mod
@@ -6,7 +6,7 @@ require (
 	cloud.google.com/go v0.51.0 // indirect
 	github.com/Knetic/govaluate v3.0.1-0.20171022003610-9aa49832a739+incompatible
 	github.com/ajg/form v1.5.1 // indirect
-	github.com/argoproj/pkg v0.0.0-20200102163130-2dd1f3f6b4de
+	github.com/argoproj/pkg v0.0.0-20200226231057-d69f0b04da65
 	github.com/aws/aws-sdk-go v1.27.1 // indirect
 	github.com/beorn7/perks v1.0.1 // indirect
 	github.com/colinmarc/hdfs v1.1.4-0.20180805212432-9746310a4d31
diff --git a/go.sum b/go.sum
index ba4baacd5..de0c6872c 100644
--- a/go.sum
+++ b/go.sum
@@ -35,6 +35,8 @@ github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYU
 github.com/antihax/optional v0.0.0-20180407024304-ca021399b1a6/go.mod h1:V8iCPQYkqmusNa815XgQio277wI47sdRh1dUOLdyC6Q=
 github.com/argoproj/pkg v0.0.0-20200102163130-2dd1f3f6b4de h1:i19Yd0ERYAHtR84jnB8vmTuizk2q4Qw4NuayfRja5/A=
 github.com/argoproj/pkg v0.0.0-20200102163130-2dd1f3f6b4de/go.mod h1:2EZ44RG/CcgtPTwrRR0apOc7oU6UIw8GjCUJWZ8X3bM=
+github.com/argoproj/pkg v0.0.0-20200226231057-d69f0b04da65 h1:bBC9exkuWvqhyDDhoNr66BpE6B4f+vxxKIXKxg6E6us=
+github.com/argoproj/pkg v0.0.0-20200226231057-d69f0b04da65/go.mod h1:2EZ44RG/CcgtPTwrRR0apOc7oU6UIw8GjCUJWZ8X3bM=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/aws/aws-sdk-go v1.27.1 h1:MXnqY6SlWySaZAqNnXThOvjRFdiiOuKtC6i7baFdNdU=
diff --git a/pkg/apiclient/workflow/forwarder_overwrite.go b/pkg/apiclient/workflow/forwarder_overwrite.go
index 98750c4c7..b9d097522 100644
--- a/pkg/apiclient/workflow/forwarder_overwrite.go
+++ b/pkg/apiclient/workflow/forwarder_overwrite.go
@@ -1,10 +1,12 @@
 package workflow
 
 import (
-	"github.com/argoproj/argo/util/http"
+	"github.com/argoproj/pkg/grpc/http"
 )
 
 func init() {
 	forward_WorkflowService_WatchWorkflows_0 = http.StreamForwarder
 	forward_WorkflowService_PodLogs_0 = http.StreamForwarder
+	forward_WorkflowService_ListWorkflows_0 = http.UnaryForwarder
+	forward_WorkflowService_GetWorkflow_0 = http.UnaryForwarder
 }
diff --git a/pkg/apiclient/workflow/workflow.proto b/pkg/apiclient/workflow/workflow.proto
index 781f795e3..c7c4d0001 100644
--- a/pkg/apiclient/workflow/workflow.proto
+++ b/pkg/apiclient/workflow/workflow.proto
@@ -24,11 +24,15 @@ message WorkflowGetRequest {
     string name = 1;
     string namespace = 2;
     k8s.io.apimachinery.pkg.apis.meta.v1.GetOptions getOptions = 3;
+    // Fields to be included or excluded in the response. e.g. "spec,status.phase", "-status.nodes"
+    string fields = 4;
 }
 
 message WorkflowListRequest {
     string namespace = 1;
     k8s.io.apimachinery.pkg.apis.meta.v1.ListOptions listOptions = 2;
+    // Fields to be included or excluded in the response. e.g. "items.spec,items.status.phase", "-items.status.nodes"
+    string fields = 3;
 }
 
 message WorkflowResubmitRequest {
diff --git a/pkg/apiclient/workflow/workflow.swagger.json b/pkg/apiclient/workflow/workflow.swagger.json
index 4f4861ae5..dc917dd94 100644
--- a/pkg/apiclient/workflow/workflow.swagger.json
+++ b/pkg/apiclient/workflow/workflow.swagger.json
@@ -173,6 +173,13 @@
             "in": "query",
             "required": false,
             "type": "string"
+          },
+          {
+            "name": "fields",
+            "description": "Fields to be included or excluded in the response. e.g. \"items.spec,items.status.phase\", \"-items.status.nodes\".",
+            "in": "query",
+            "required": false,
+            "type": "string"
           }
         ],
         "tags": [
@@ -272,6 +279,13 @@
             "in": "query",
             "required": false,
             "type": "string"
+          },
+          {
+            "name": "fields",
+            "description": "Fields to be included or excluded in the response. e.g. \"spec,status.phase\", \"-status.nodes\".",
+            "in": "query",
+            "required": false,
+            "type": "string"
           }
         ],
         "tags": [
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 bc9b5bfe5..d2fb941fa 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
@@ -9,8 +9,10 @@ import {Loading} from '../../../shared/components/loading';
 import {YamlEditor} from '../../../shared/components/yaml/yaml-editor';
 import {services} from '../../../shared/services';
 import {
+    defaultWorkflowDagRenderOptions,
     WorkflowArtifacts,
     WorkflowDag,
+    WorkflowDagRenderOptions,
     WorkflowLogsViewer,
     WorkflowNodeInfo,
     WorkflowParametersPanel,
@@ -18,10 +20,12 @@ import {
     WorkflowTimeline,
     WorkflowYamlViewer
 } from '../../../workflows/components';
+import {WorkflowDagRenderOptionsPanel} from '../../../workflows/components/workflow-dag/workflow-dag-render-options-panel';
 
 require('../../../workflows/components/workflow-details/workflow-details.scss');
 
 interface State {
+    workflowDagRenderOptions: WorkflowDagRenderOptions;
     workflow?: Workflow;
     error?: Error;
 }
@@ -61,7 +65,7 @@ export class ArchivedWorkflowDetails extends BasePage<RouteComponentProps<any>,
 
     constructor(props: RouteComponentProps<any>, context: any) {
         super(props, context);
-        this.state = {};
+        this.state = {workflowDagRenderOptions: defaultWorkflowDagRenderOptions};
     }
 
     public componentDidMount(): void {
@@ -102,6 +106,12 @@ export class ArchivedWorkflowDetails extends BasePage<RouteComponentProps<any>,
                     ],
                     tools: (
                         <div className='workflow-details__topbar-buttons'>
+                            {this.tab === 'workflow' && (
+                                <WorkflowDagRenderOptionsPanel
+                                    {...this.state.workflowDagRenderOptions}
+                                    onChange={workflowDagRenderOptions => this.setState({workflowDagRenderOptions})}
+                                />
+                            )}
                             <a className={classNames({active: this.tab === 'summary'})} onClick={() => (this.tab = 'summary')}>
                                 <i className='fa fa-columns' />
                             </a>
@@ -143,7 +153,12 @@ export class ArchivedWorkflowDetails extends BasePage<RouteComponentProps<any>,
                     <div>
                         <div className='workflow-details__graph-container'>
                             {this.tab === 'workflow' ? (
-                                <WorkflowDag workflow={this.state.workflow} selectedNodeId={this.nodeId} nodeClicked={node => (this.nodeId = node.id)} />
+                                <WorkflowDag
+                                    renderOptions={this.state.workflowDagRenderOptions}
+                                    workflow={this.state.workflow}
+                                    selectedNodeId={this.nodeId}
+                                    nodeClicked={node => (this.nodeId = node.id)}
+                                />
                             ) : (
                                 <WorkflowTimeline workflow={this.state.workflow} selectedNodeId={this.nodeId} nodeClicked={node => (this.nodeId = node.id)} />
                             )}
diff --git a/ui/src/app/workflows/components/workflow-dag/workflow-dag-render-options-panel.tsx b/ui/src/app/workflows/components/workflow-dag/workflow-dag-render-options-panel.tsx
new file mode 100644
index 000000000..215d862a4
--- /dev/null
+++ b/ui/src/app/workflows/components/workflow-dag/workflow-dag-render-options-panel.tsx
@@ -0,0 +1,49 @@
+import * as classNames from 'classnames';
+import * as React from 'react';
+import {WorkflowDagRenderOptions} from './workflow-dag';
+
+export class WorkflowDagRenderOptionsPanel extends React.Component<WorkflowDagRenderOptions & {onChange: (changed: WorkflowDagRenderOptions) => void}> {
+    private get workflowDagRenderOptions() {
+        return this.props as WorkflowDagRenderOptions;
+    }
+
+    public render() {
+        return (
+            <>
+                <a
+                    className={classNames({active: this.props.horizontal})}
+                    onClick={() =>
+                        this.props.onChange({
+                            ...this.workflowDagRenderOptions,
+                            horizontal: !this.props.horizontal
+                        })
+                    }
+                    title='Horizontal layout'>
+                    <i className='fa fa-project-diagram' />
+                </a>
+                <a
+                    className={classNames({active: this.props.zoom > 1})}
+                    onClick={() =>
+                        this.props.onChange({
+                            ...this.workflowDagRenderOptions,
+                            zoom: this.props.zoom === 1 ? 2 : 1
+                        })
+                    }
+                    title='Zoom into the timeline'>
+                    2x
+                </a>
+                <a
+                    className={classNames({active: !this.props.hideSucceeded})}
+                    onClick={() =>
+                        this.props.onChange({
+                            ...this.workflowDagRenderOptions,
+                            hideSucceeded: !this.props.hideSucceeded
+                        })
+                    }
+                    title='Hide succeeded'>
+                    <i className='fa fa-check' />
+                </a>
+            </>
+        );
+    }
+}
diff --git a/ui/src/app/workflows/components/workflow-dag/workflow-dag.scss b/ui/src/app/workflows/components/workflow-dag/workflow-dag.scss
index b68d30399..38b847aa8 100644
--- a/ui/src/app/workflows/components/workflow-dag/workflow-dag.scss
+++ b/ui/src/app/workflows/components/workflow-dag/workflow-dag.scss
@@ -28,7 +28,6 @@
 
     &__node {
         position: absolute;
-        padding-left: 3.5em;
         box-shadow: 1px 1px 1px $argo-color-gray-4;
         background-color: white;
         border-radius: 4px;
@@ -39,7 +38,7 @@
             border-color: $argo-color-teal-6;
         }
 
-        &.virtual {
+        &.small {
             background-color: transparent;
             box-shadow: none;
             border: none;
@@ -49,7 +48,6 @@
                 content: '';
                 position: absolute;
                 display: block;
-                border-radius: 10px;
                 width: 20px;
                 height: 20px;
                 left: -10px;
@@ -57,11 +55,17 @@
                 border: 1px dashed $argo-color-gray-5;
             }
 
-            .workflow-dag__node-status, .workflow-dag__node-title {
+            .workflow-dag__node-title, .workflow-dag__node-status {
                 display: none;
             }
         }
-        &.virtual.active {
+        &.virtual {
+            &:after {
+                /* give virtual nodes a circle */
+                border-radius: 10px;
+            }
+        }
+        &.small.active {
             &:after {
                 border-color: $argo-color-teal-6;
             }
@@ -78,7 +82,6 @@
         left: -1px;
         bottom: -1px;
         top: -1px;
-        width: 3em;
         border-top-left-radius: 4px;
         border-bottom-left-radius: 4px;
         text-align: center;
@@ -121,7 +124,7 @@
             from {transform:rotate(0deg);}
             to {transform:rotate(360deg);}
         }
-    
+
         &--succeeded {
             background-color: $argo-success-color;
             &::after {
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 7d232c96f..f76b81669 100644
--- a/ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx
+++ b/ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx
@@ -3,12 +3,26 @@ import * as dagre from 'dagre';
 import * as React from 'react';
 
 import * as models from '../../../../models';
+import {NODE_PHASE} from '../../../../models';
 import {Utils} from '../../../shared/utils';
 
+export const defaultWorkflowDagRenderOptions: WorkflowDagRenderOptions = {
+    hideSucceeded: false,
+    horizontal: false,
+    zoom: 1
+};
+
+export interface WorkflowDagRenderOptions {
+    horizontal: boolean;
+    zoom: number;
+    hideSucceeded: boolean;
+}
+
 export interface WorkflowDagProps {
     workflow: models.Workflow;
     selectedNodeId?: string;
     nodeClicked?: (node: models.NodeStatus) => any;
+    renderOptions: WorkflowDagRenderOptions;
 }
 
 interface Line {
@@ -21,21 +35,36 @@ interface Line {
 
 require('./workflow-dag.scss');
 
-const NODE_WIDTH = 182;
-const NODE_HEIGHT = 52;
-
 export class WorkflowDag extends React.Component<WorkflowDagProps> {
+    private get zoom() {
+        return this.props.renderOptions.zoom || 1;
+    }
+
+    private get nodeWidth() {
+        return 140 / this.zoom;
+    }
+
+    private get nodeHeight() {
+        return 52 / this.zoom;
+    }
+
     public render() {
         const graph = new dagre.graphlib.Graph();
-        graph.setGraph({});
+        // https://github.com/dagrejs/dagre/wiki
+        graph.setGraph({
+            edgesep: 20 / this.zoom,
+            nodesep: 50 / this.zoom,
+            rankdir: this.props.renderOptions.horizontal ? 'LR' : 'TB',
+            ranksep: 50 / this.zoom
+        });
         graph.setDefaultEdgeLabel(() => ({}));
         const nodes = (this.props.workflow.status && this.props.workflow.status.nodes) || {};
-        Object.keys(nodes).forEach(nodeId => {
-            const node = nodes[nodeId];
-            if (this.isVirtual(node)) {
-                graph.setNode(nodeId, {width: 1, height: 1, ...nodes[nodeId]});
+        Object.values(nodes).forEach(node => {
+            const label = Utils.shortNodeName(node);
+            if (this.isSmall(node)) {
+                graph.setNode(node.id, {label, width: 1, height: 1, ...nodes[node.id]});
             } else {
-                graph.setNode(nodeId, {width: NODE_WIDTH, height: NODE_HEIGHT, ...nodes[nodeId]});
+                graph.setNode(node.id, {label, width: this.nodeWidth, height: this.nodeHeight, ...nodes[node.id]});
             }
         });
         Object.keys(nodes).forEach(nodeId => {
@@ -60,7 +89,13 @@ export class WorkflowDag extends React.Component<WorkflowDagProps> {
             if (edge.points.length > 1) {
                 for (let i = 1; i < edge.points.length; i++) {
                     const toNode = nodes[edgeInfo.w];
-                    lines.push({x1: edge.points[i - 1].x, y1: edge.points[i - 1].y, x2: edge.points[i].x, y2: edge.points[i].y, noArrow: this.isVirtual(toNode)});
+                    lines.push({
+                        x1: edge.points[i - 1].x,
+                        y1: edge.points[i - 1].y,
+                        x2: edge.points[i].x,
+                        y2: edge.points[i].y,
+                        noArrow: this.isSmall(toNode)
+                    });
                 }
             }
             edges.push({from: edgeInfo.v, to: edgeInfo.w, lines});
@@ -70,19 +105,30 @@ export class WorkflowDag extends React.Component<WorkflowDagProps> {
             <div className='workflow-dag' style={{width: size.width, height: size.height}}>
                 {graph.nodes().map(id => {
                     const node = graph.node(id) as models.NodeStatus & dagre.Node;
-                    const shortName = Utils.shortNodeName(node);
+                    const statusWidth = 3 / this.zoom;
                     return (
                         <div
                             key={id}
-                            className={classNames('workflow-dag__node', {active: node.id === this.props.selectedNodeId, virtual: this.isVirtual(node)})}
-                            style={{left: node.x - node.width / 2, top: node.y - node.height / 2, width: node.width, height: node.height}}
+                            title={node.label}
+                            className={classNames('workflow-dag__node', {
+                                active: node.id === this.props.selectedNodeId,
+                                virtual: this.isVirtual(node),
+                                small: this.isSmall(node)
+                            })}
+                            style={{
+                                left: node.x - node.width / 2,
+                                top: node.y - node.height / 2,
+                                width: node.width,
+                                height: node.height,
+                                paddingLeft: 0.5 + statusWidth + 'em'
+                            }}
                             onClick={() => this.props.nodeClicked && this.props.nodeClicked(node)}>
                             <div
                                 className={`fas workflow-dag__node-status workflow-dag__node-status--${Utils.isNodeSuspended(node) ? 'suspended' : node.phase.toLocaleLowerCase()}`}
-                                style={{lineHeight: NODE_HEIGHT + 'px'}}
+                                style={{width: statusWidth + 'em', lineHeight: this.nodeHeight + 'px'}}
                             />
-                            <div className='workflow-dag__node-title' style={{lineHeight: NODE_HEIGHT + 'px'}}>
-                                {shortName}
+                            <div className='workflow-dag__node-title' style={{lineHeight: this.nodeHeight + 'px', fontSize: 1 / this.zoom + 'em'}}>
+                                {node.label}
                             </div>
                         </div>
                     );
@@ -98,7 +144,12 @@ export class WorkflowDag extends React.Component<WorkflowDagProps> {
                                 <div
                                     className={classNames('workflow-dag__line', {'workflow-dag__line--no-arrow': line.noArrow})}
                                     key={i}
-                                    style={{width: distance, left: xMid - distance / 2, top: yMid, transform: ` rotate(${angle}deg)`}}
+                                    style={{
+                                        width: distance,
+                                        left: xMid - distance / 2,
+                                        top: yMid,
+                                        transform: ` rotate(${angle}deg)`
+                                    }}
                                 />
                             );
                         })}
@@ -108,6 +159,10 @@ export class WorkflowDag extends React.Component<WorkflowDagProps> {
         );
     }
 
+    private isSmall(node: models.NodeStatus) {
+        return this.isVirtual(node) || (this.props.renderOptions.hideSucceeded && node.phase === NODE_PHASE.SUCCEEDED);
+    }
+
     private getOutboundNodes(nodeID: string): string[] {
         const node = this.props.workflow.status.nodes[nodeID];
         if (node.type === 'Pod' || node.type === 'Skipped') {
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 a717b0068..a18fc99bd 100644
--- a/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
+++ b/ui/src/app/workflows/components/workflow-details/workflow-details.tsx
@@ -10,9 +10,10 @@ import {NodePhase} from '../../../../models';
 import {uiUrl} from '../../../shared/base';
 import {services} from '../../../shared/services';
 
-import {WorkflowArtifacts, WorkflowDag, WorkflowLogsViewer, WorkflowNodeInfo, WorkflowSummaryPanel, WorkflowTimeline, WorkflowYamlViewer} from '..';
+import {WorkflowArtifacts, WorkflowDag, WorkflowDagRenderOptions, WorkflowLogsViewer, WorkflowNodeInfo, WorkflowSummaryPanel, WorkflowTimeline, WorkflowYamlViewer} from '..';
 import {Consumer, ContextApis} from '../../../shared/context';
 import {Utils} from '../../../shared/utils';
+import {WorkflowDagRenderOptionsPanel} from '../workflow-dag/workflow-dag-render-options-panel';
 import {WorkflowParametersPanel} from '../workflow-parameters-panel';
 
 require('./workflow-details.scss');
@@ -25,7 +26,7 @@ function parseSidePanelParam(param: string) {
     return null;
 }
 
-export class WorkflowDetails extends React.Component<RouteComponentProps<any>, {workflow: models.Workflow}> {
+export class WorkflowDetails extends React.Component<RouteComponentProps<any>, {workflowDagRenderOptions: WorkflowDagRenderOptions; workflow: models.Workflow}> {
     public static contextTypes = {
         router: PropTypes.object,
         apis: PropTypes.object
@@ -48,7 +49,7 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, {
 
     constructor(props: RouteComponentProps<any>) {
         super(props);
-        this.state = {workflow: null};
+        this.state = {workflowDagRenderOptions: {horizontal: false, zoom: 1, hideSucceeded: false}, workflow: null};
     }
 
     public componentDidMount() {
@@ -131,6 +132,12 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, {
                             },
                             tools: (
                                 <div className='workflow-details__topbar-buttons'>
+                                    {this.selectedTabKey === 'workflow' && (
+                                        <WorkflowDagRenderOptionsPanel
+                                            {...this.state.workflowDagRenderOptions}
+                                            onChange={workflowDagRenderOptions => this.setState({workflowDagRenderOptions})}
+                                        />
+                                    )}
                                     <a className={classNames({active: this.selectedTabKey === 'summary'})} onClick={() => this.selectTab('summary')}>
                                         <i className='fa fa-columns' />
                                     </a>
@@ -149,7 +156,12 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, {
                                     <div>
                                         <div className='workflow-details__graph-container'>
                                             {(this.selectedTabKey === 'workflow' && (
-                                                <WorkflowDag workflow={this.state.workflow} selectedNodeId={this.selectedNodeId} nodeClicked={node => this.selectNode(node.id)} />
+                                                <WorkflowDag
+                                                    renderOptions={this.state.workflowDagRenderOptions}
+                                                    workflow={this.state.workflow}
+                                                    selectedNodeId={this.selectedNodeId}
+                                                    nodeClicked={node => this.selectNode(node.id)}
+                                                />
                                             )) || (
                                                 <WorkflowTimeline
                                                     workflow={this.state.workflow}
@@ -196,7 +208,7 @@ export class WorkflowDetails extends React.Component<RouteComponentProps<any>, {
         }
         services.workflows
             .delete(this.props.match.params.name, this.props.match.params.namespace)
-            .then(wfDeleteRes => ctx.navigation.goto(uiUrl(`workflows/`)))
+            .then(() => ctx.navigation.goto(uiUrl(`workflows/`)))
             .catch(error => {
                 this.appContext.apis.notifications.show({
                     content: 'Unable to delete workflow',
diff --git a/util/http/forwarders.go b/util/http/forwarders.go
deleted file mode 100644
index 0ab0b3e47..000000000
--- a/util/http/forwarders.go
+++ /dev/null
@@ -1,48 +0,0 @@
-package http
-
-import (
-	"encoding/json"
-	"fmt"
-	"net/http"
-
-	"github.com/golang/protobuf/proto"
-	"github.com/grpc-ecosystem/grpc-gateway/runtime"
-	"golang.org/x/net/context"
-)
-
-type sseMarshaller struct {
-	runtime.Marshaler
-}
-
-func (m *sseMarshaller) ContentType() string {
-	return "text/event-stream"
-}
-
-func (m *sseMarshaller) Marshal(v interface{}) ([]byte, error) {
-	dataBytes, err := json.Marshal(v)
-	if err != nil {
-		return nil, err
-	}
-	return []byte(fmt.Sprintf("data: %s \n\n", string(dataBytes))), nil
-}
-
-var (
-	StreamForwarder = func(
-		ctx context.Context,
-		mux *runtime.ServeMux,
-		marshaler runtime.Marshaler,
-		w http.ResponseWriter,
-		req *http.Request,
-		recv func() (proto.Message, error),
-		opts ...func(context.Context, http.ResponseWriter, proto.Message) error,
-	) {
-		if req.Header.Get("Accept") == "text/event-stream" {
-			w.Header().Set("Content-Type", "text/event-stream")
-			w.Header().Set("Transfer-Encoding", "chunked")
-			w.Header().Set("X-Content-Type-Options", "nosniff")
-			runtime.ForwardResponseStream(ctx, mux, &sseMarshaller{marshaler}, w, req, recv, opts...)
-		} else {
-			runtime.ForwardResponseStream(ctx, mux, marshaler, w, req, recv, opts...)
-		}
-	}
-)
diff --git a/workflow/controller/operator.go b/workflow/controller/operator.go
index fcba59605..ca7aacd3c 100644
--- a/workflow/controller/operator.go
+++ b/workflow/controller/operator.go
@@ -624,9 +624,8 @@ func (woc *wfOperationCtx) processNodeRetries(node *wfv1.NodeStatus, retryStrate
 		if time.Now().Before(waitingDeadline) {
 			retryMessage := fmt.Sprintf("Retrying in %s", humanize.Duration(time.Until(waitingDeadline)))
 			return woc.markNodePhase(node.Name, node.Phase, retryMessage), false, nil
-		} else {
-			node = woc.markNodePhase(node.Name, node.Phase, "")
 		}
+		node = woc.markNodePhase(node.Name, node.Phase, "")
 	}
 
 	var retryOnFailed bool
@@ -1312,7 +1311,7 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 	// the container. The status of this node should be "Success" if any
 	// of the retries succeed. Otherwise, it is "Failed".
 	retryNodeName := ""
-	if processedTmpl.IsLeaf() && processedTmpl.RetryStrategy != nil {
+	if processedTmpl.RetryStrategy != nil {
 		retryNodeName = nodeName
 		retryParentNode := node
 		if retryParentNode == nil {
@@ -1337,19 +1336,20 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 		}
 		if lastChildNode != nil && !lastChildNode.Completed() {
 			// Last child node is still running.
-			return retryParentNode, nil
-		}
-
-		// Create a new child node and append it to the retry node.
-		nodeName = fmt.Sprintf("%s(%d)", retryNodeName, len(retryParentNode.Children))
-		woc.addChildNode(retryNodeName, nodeName)
-		node = nil
-
-		// Change the `pod.name` variable to the new retry node name
-		if processedTmpl.IsPodType() {
-			processedTmpl, err = common.SubstituteParams(processedTmpl, map[string]string{}, map[string]string{common.LocalVarPodName: woc.wf.NodeID(nodeName)})
-			if err != nil {
-				return woc.initializeNodeOrMarkError(node, nodeName, wfv1.NodeTypeSkipped, templateScope, orgTmpl, boundaryID, err), err
+			nodeName = lastChildNode.Name
+			node = lastChildNode
+		} else {
+			// Create a new child node and append it to the retry node.
+			nodeName = fmt.Sprintf("%s(%d)", retryNodeName, len(retryParentNode.Children))
+			woc.addChildNode(retryNodeName, nodeName)
+			node = nil
+
+			// Change the `pod.name` variable to the new retry node name
+			if processedTmpl.IsPodType() {
+				processedTmpl, err = common.SubstituteParams(processedTmpl, map[string]string{}, map[string]string{common.LocalVarPodName: woc.wf.NodeID(nodeName)})
+				if err != nil {
+					return woc.initializeNodeOrMarkError(node, nodeName, wfv1.NodeTypeSkipped, templateScope, orgTmpl, boundaryID, err), err
+				}
 			}
 		}
 	}
@@ -1383,9 +1383,16 @@ func (woc *wfOperationCtx) executeTemplate(nodeName string, orgTmpl wfv1.Templat
 	}
 	node = woc.getNodeByName(node.Name)
 
-	// Swap the node back to retry node.
+	// Swap the node back to retry node
 	if retryNodeName != "" {
-		node = woc.getNodeByName(retryNodeName)
+		retryNode := woc.getNodeByName(retryNodeName)
+		if !retryNode.Completed() && node.Completed() { //if the retry child has completed we need to update outself
+			node, err = woc.executeTemplate(retryNodeName, orgTmpl, tmplCtx, args, boundaryID)
+			if err != nil {
+				return woc.markNodeError(node.Name, err), err
+			}
+		}
+		node = retryNode
 	}
 
 	return node, nil
diff --git a/workflow/validate/validate.go b/workflow/validate/validate.go
index 5295fc218..475717111 100644
--- a/workflow/validate/validate.go
+++ b/workflow/validate/validate.go
@@ -457,9 +457,6 @@ func validateNonLeaf(tmpl *wfv1.Template) error {
 	if tmpl.ActiveDeadlineSeconds != nil {
 		return errors.Errorf(errors.CodeBadRequest, "templates.%s.activeDeadlineSeconds is only valid for leaf templates", tmpl.Name)
 	}
-	if tmpl.RetryStrategy != nil {
-		return errors.Errorf(errors.CodeBadRequest, "templates.%s.retryStrategy is only valid for container templates", tmpl.Name)
-	}
 	return nil
 }
 


Test patch:
diff --git a/test/e2e/argo_server_test.go b/test/e2e/argo_server_test.go
index 241bc6e53..a5dd9f6c0 100644
--- a/test/e2e/argo_server_test.go
+++ b/test/e2e/argo_server_test.go
@@ -462,6 +462,21 @@ func (s *ArgoServerSuite) TestWorkflowService() {
 			NotNull()
 	})
 
+	s.Run("ListWithFields", func() {
+		j := s.e(s.T()).GET("/api/v1/workflows/argo").
+			WithQuery("listOptions.labelSelector", "argo-e2e=subject").
+			WithQuery("fields", "-items.status.nodes,items.status.finishedAt,items.status.startedAt").
+			Expect().
+			Status(200).
+			JSON()
+		j.
+			Path("$.items").
+			Array().
+			Length().
+			Equal(1)
+		j.Path("$.items[0].status").Object().ContainsKey("phase").NotContainsKey("nodes")
+	})
+
 	s.Run("Get", func() {
 		j := s.e(s.T()).GET("/api/v1/workflows/argo/test").
 			Expect().
@@ -480,6 +495,15 @@ func (s *ArgoServerSuite) TestWorkflowService() {
 			Status(404)
 	})
 
+	s.Run("GetWithFields", func() {
+		j := s.e(s.T()).GET("/api/v1/workflows/argo/test").
+			WithQuery("fields", "status.phase").
+			Expect().
+			Status(200).
+			JSON()
+		j.Path("$.status").Object().ContainsKey("phase").NotContainsKey("nodes")
+	})
+
 	s.Run("Suspend", func() {
 		s.e(s.T()).PUT("/api/v1/workflows/argo/test/suspend").
 			Expect().
diff --git a/workflow/controller/controller_test.go b/workflow/controller/controller_test.go
index a26e73fdf..d981f60b4 100644
--- a/workflow/controller/controller_test.go
+++ b/workflow/controller/controller_test.go
@@ -83,13 +83,18 @@ func unmarshalWFTmpl(yamlStr string) *wfv1.WorkflowTemplate {
 	return &wftmpl
 }
 
-// makePodsRunning acts like a pod controller and simulates the transition of pods transitioning into a running state
-func makePodsRunning(t *testing.T, kubeclientset kubernetes.Interface, namespace string) {
+// makePodsPhase acts like a pod controller and simulates the transition of pods transitioning into a specified state
+func makePodsPhase(t *testing.T, phase apiv1.PodPhase, kubeclientset kubernetes.Interface, namespace string) {
 	podcs := kubeclientset.CoreV1().Pods(namespace)
 	pods, err := podcs.List(metav1.ListOptions{})
 	assert.NoError(t, err)
 	for _, pod := range pods.Items {
-		pod.Status.Phase = apiv1.PodRunning
-		_, _ = podcs.Update(&pod)
+		if pod.Status.Phase == "" {
+			pod.Status.Phase = phase
+			if phase == apiv1.PodFailed {
+				pod.Status.Message = "Pod failed"
+			}
+			_, _ = podcs.Update(&pod)
+		}
 	}
 }
diff --git a/workflow/controller/operator_test.go b/workflow/controller/operator_test.go
index 797e261ad..17e5bc45c 100644
--- a/workflow/controller/operator_test.go
+++ b/workflow/controller/operator_test.go
@@ -405,6 +405,80 @@ func TestAssessNodeStatus(t *testing.T) {
 	}
 }
 
+var workflowStepRetry = `
+apiVersion: argoproj.io/v1alpha1
+kind: Workflow
+metadata:
+  name: step-retry
+spec:
+  entrypoint: step-retry
+  templates:
+  - name: step-retry
+    retryStrategy:
+      limit: 1
+    steps:
+      - - name: whalesay-success
+          arguments:
+            parameters:
+            - name: message
+              value: success
+          template: whalesay
+      - - name: whalesay-failure
+          arguments:
+            parameters:
+            - name: message
+              value: failure
+          template: whalesay
+
+  - name: whalesay
+    inputs:
+      parameters:
+        - name: message
+    container:
+      image: docker/whalesay:latest
+      command: [sh, -c]
+      args: ["cowsay {{inputs.parameters.message}}"]
+`
+
+// TestWorkflowParallelismLimit verifies parallelism at a workflow level is honored.
+func TestWorkflowStepRetry(t *testing.T) {
+	controller := newController()
+	wfcset := controller.wfclientset.ArgoprojV1alpha1().Workflows("")
+	wf := unmarshalWF(workflowStepRetry)
+	wf, err := wfcset.Create(wf)
+	assert.Nil(t, err)
+	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
+	assert.Nil(t, err)
+	woc := newWorkflowOperationCtx(wf, controller)
+	woc.operate()
+	pods, err := controller.kubeclientset.CoreV1().Pods("").List(metav1.ListOptions{})
+	assert.Nil(t, err)
+	assert.Equal(t, 1, len(pods.Items))
+
+	//complete the first pod
+	makePodsPhase(t, apiv1.PodSucceeded, controller.kubeclientset, wf.ObjectMeta.Namespace)
+	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
+	assert.Nil(t, err)
+	woc = newWorkflowOperationCtx(wf, controller)
+	woc.operate()
+
+	// fail the second pod
+	makePodsPhase(t, apiv1.PodFailed, controller.kubeclientset, wf.ObjectMeta.Namespace)
+	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
+	assert.Nil(t, err)
+	woc = newWorkflowOperationCtx(wf, controller)
+	woc.operate()
+	pods, err = controller.kubeclientset.CoreV1().Pods("").List(metav1.ListOptions{})
+	assert.Nil(t, err)
+	assert.Equal(t, 3, len(pods.Items))
+	assert.Equal(t, "cowsay success", pods.Items[0].Spec.Containers[1].Args[0])
+	assert.Equal(t, "cowsay failure", pods.Items[1].Spec.Containers[1].Args[0])
+
+	//verify that after the cowsay failure pod failed, we are retrying cowsay success
+	assert.Equal(t, "cowsay success", pods.Items[2].Spec.Containers[1].Args[0])
+
+}
+
 var workflowParallelismLimit = `
 apiVersion: argoproj.io/v1alpha1
 kind: Workflow
@@ -451,7 +525,7 @@ func TestWorkflowParallelismLimit(t *testing.T) {
 	assert.NoError(t, err)
 	assert.Equal(t, 2, len(pods.Items))
 	// operate again and make sure we don't schedule any more pods
-	makePodsRunning(t, controller.kubeclientset, wf.ObjectMeta.Namespace)
+	makePodsPhase(t, apiv1.PodRunning, controller.kubeclientset, wf.ObjectMeta.Namespace)
 	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
 	assert.NoError(t, err)
 	// wfBytes, _ := json.MarshalIndent(wf, "", "  ")
@@ -510,7 +584,7 @@ func TestStepsTemplateParallelismLimit(t *testing.T) {
 	assert.Equal(t, 2, len(pods.Items))
 
 	// operate again and make sure we don't schedule any more pods
-	makePodsRunning(t, controller.kubeclientset, wf.ObjectMeta.Namespace)
+	makePodsPhase(t, apiv1.PodRunning, controller.kubeclientset, wf.ObjectMeta.Namespace)
 	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
 	assert.NoError(t, err)
 	// wfBytes, _ := json.MarshalIndent(wf, "", "  ")
@@ -566,7 +640,7 @@ func TestDAGTemplateParallelismLimit(t *testing.T) {
 	assert.Equal(t, 2, len(pods.Items))
 
 	// operate again and make sure we don't schedule any more pods
-	makePodsRunning(t, controller.kubeclientset, wf.ObjectMeta.Namespace)
+	makePodsPhase(t, apiv1.PodRunning, controller.kubeclientset, wf.ObjectMeta.Namespace)
 	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
 	assert.NoError(t, err)
 	// wfBytes, _ := json.MarshalIndent(wf, "", "  ")
@@ -2135,7 +2209,7 @@ func TestEventTimeout(t *testing.T) {
 	assert.NoError(t, err)
 	woc := newWorkflowOperationCtx(wf, controller)
 	woc.operate()
-	makePodsRunning(t, controller.kubeclientset, wf.ObjectMeta.Namespace)
+	makePodsPhase(t, apiv1.PodRunning, controller.kubeclientset, wf.ObjectMeta.Namespace)
 	wf, err = wfcset.Get(wf.ObjectMeta.Name, metav1.GetOptions{})
 	assert.NoError(t, err)
 	woc = newWorkflowOperationCtx(wf, controller)
diff --git a/workflow/validate/validate_test.go b/workflow/validate/validate_test.go
index c76fd0bf2..4e2ae0ab6 100644
--- a/workflow/validate/validate_test.go
+++ b/workflow/validate/validate_test.go
@@ -1066,34 +1066,6 @@ func TestLeafWithParallelism(t *testing.T) {
 	}
 }
 
-var nonLeafWithRetryStrategy = `
-apiVersion: argoproj.io/v1alpha1
-kind: Workflow
-metadata:
-  name: non-leaf-with-retry-strategy
-spec:
-  entrypoint: non-leaf-with-retry-strategy
-  templates:
-  - name: non-leaf-with-retry-strategy
-    retryStrategy:
-      limit: 4
-    steps:
-    - - name: try
-        template: try
-  - name: try
-    container:
-      image: debian:9.4
-      command: [sh, -c]
-      args: ["kubectl version"]
-`
-
-func TestNonLeafWithRetryStrategy(t *testing.T) {
-	err := validate(nonLeafWithRetryStrategy)
-	if assert.NotNil(t, err) {
-		assert.Contains(t, err.Error(), "is only valid")
-	}
-}
-
 var invalidStepsArgumentNoFromOrLocation = `
 apiVersion: argoproj.io/v1alpha1
 kind: Workflow