Skip to content

Commit

Permalink
feat: Collapse children in UI Workflow viewer (argoproj#3526)
Browse files Browse the repository at this point in the history
  • Loading branch information
simster7 authored Jul 22, 2020
1 parent 7536982 commit 7e4a780
Show file tree
Hide file tree
Showing 7 changed files with 201 additions and 18 deletions.
24 changes: 24 additions & 0 deletions test/e2e/ui/ui-render-many-nodex.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Test to ensure parameter aggregation works when every item is filtered
apiVersion: argoproj.io/v1alpha1
kind: Workflow
metadata:
generateName: ui-parameter-aggregation-empty-
spec:
entrypoint: parameter-aggregation
templates:
- name: parameter-aggregation
steps:
- - name: generate
template: echo
withSequence:
start: "1"
end: "500"

# echo prints a message
- name: echo
script:
image: argoproj/argosay:v1
command: [sh, -x]
source: |
#!/bin/sh
echo hello
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function getCollapsedNodeName(parent: string, numHidden: number): string {
return '@collapsed/' + parent + '/' + numHidden;
}

export function isCollapsedNode(id: string): boolean {
return id.startsWith('@collapsed/');
}

export function getCollapsedNodeParent(id: string): string {
const split = id.split('/');
return split[1];
}

export function getCollapsedNumHidden(id: string): number {
const split = id.split('/');
return Number(split[2]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export class DfsSorter {
}

public sort() {
// Pre-order DFS sort
this.graph.nodes.forEach(n => this.visit(n));
return this.sorted.reverse();
}
Expand Down
43 changes: 43 additions & 0 deletions ui/src/app/workflows/components/workflow-dag/graph/shifter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// This shifter is used to shift the index of the first relevant item one index to the right, and subsequent one index to the left.
//
// A collapsed node always gets ordered before the other children: C 1 2, C is the collapsed node and 1, 2 are child nodes.
// What we want is 1 C 2. Since we must "stream" the ordering due to the way the graph code works at the moment, this class
// serves to shift the index of C and 1 when so desired. It will essentially map 0 -> 1, 1 -> 0, 2 -> 2 for ordering purposes.
//
// Example:
//
// Shift: 0 1 2 3 4 ... to 0 1 3 2 4 ...
//
// shifter.get(0) -> 0
// shifter.get(1) -> 1
// shifter.start()
// shifter.get(2) -> 3
// shifter.get(3) -> 2
// shifter.get(4) -> 4
// ...
export class Shifter {
private shifts: number;
constructor() {
this.shifts = -1;
}

public start(): void {
if (this.shifts !== -1) {
return;
}
this.shifts = 0;
}

public get(i: number): number {
if (this.shifts === -1) {
return i;
} else if (this.shifts === 0) {
this.shifts++;
return i + 1;
} else if (this.shifts === 1) {
this.shifts = -1;
return i - 1;
}
return i;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,26 @@ export class WorkflowDagRenderOptionsPanel extends React.Component<WorkflowDagRe
title='Zoom out from the timeline'>
<i className='fa fa-search-minus' />
</a>
<a
onClick={() =>
this.props.onChange({
...this.workflowDagRenderOptions,
expandNodes: new Set()
})
}
title='Collapse all nodes'>
<i className='fa fa-compress' data-fa-transform='rotate-45' />
</a>
<a
onClick={() =>
this.props.onChange({
...this.workflowDagRenderOptions,
expandNodes: new Set(['*'])
})
}
title='Expand all nodes'>
<i className='fa fa-expand' data-fa-transform='rotate-45' />
</a>
</div>
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,5 +78,13 @@
&--suspended {
fill: $argo-color-gray-4;
}

&--collapsed-horizontal {
fill: $argo-color-gray-6;
}

&--collapsed-vertical {
fill: $argo-color-gray-6;
}
}
}
106 changes: 88 additions & 18 deletions ui/src/app/workflows/components/workflow-dag/workflow-dag.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,16 @@ import {NODE_PHASE, NodePhase, NodeStatus} from '../../../../models';
import {Loading} from '../../../shared/components/loading';
import {Utils} from '../../../shared/utils';
import {CoffmanGrahamSorter} from './graph/coffman-graham-sorter';
import {getCollapsedNodeName, getCollapsedNodeParent, getCollapsedNumHidden, isCollapsedNode} from './graph/collapsible-node';
import {Graph} from './graph/graph';
import {Shifter} from './graph/shifter';
import {WorkflowDagRenderOptionsPanel} from './workflow-dag-render-options-panel';

export interface WorkflowDagRenderOptions {
horizontal: boolean;
scale: number;
nodesToDisplay: string[];
expandNodes: Set<string>;
}

export interface WorkflowDagProps {
Expand All @@ -23,7 +26,7 @@ export interface WorkflowDagProps {

require('./workflow-dag.scss');

type DagPhase = NodePhase | 'Suspended';
type DagPhase = NodePhase | 'Suspended' | 'Collapsed-Horizontal' | 'Collapsed-Vertical';

const LOCAL_STORAGE_KEY = 'DagOptions';

Expand Down Expand Up @@ -113,6 +116,23 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
d='M144 479H48c-26.5 0-48-21.5-48-48V79c0-26.5 21.5-48 48-48h96c26.5 0 48 21.5 48 48v352c0 26.5-21.5 48-48 48zm304-48V79c0-26.5-21.5-48-48-48h-96c-26.5 0-48 21.5-48 48v352c0 26.5 21.5 48 48 48h96c26.5 0 48-21.5 48-48z'
/>
);
case 'Collapsed-Horizontal':
return (
<path
fill='currentColor'
// tslint:disable-next-line
d='M328 256c0 39.8-32.2 72-72 72s-72-32.2-72-72 32.2-72 72-72 72 32.2 72 72zm104-72c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72zm-352 0c-39.8 0-72 32.2-72 72s32.2 72 72 72 72-32.2 72-72-32.2-72-72-72z'
/>
);
case 'Collapsed-Vertical':
return (
<path
fill='currentColor'
transform='translate(150,0)'
// tslint:disable-next-line
d='M96 184c39.8 0 72 32.2 72 72s-32.2 72-72 72-72-32.2-72-72 32.2-72 72-72zM24 80c0 39.8 32.2 72 72 72s72-32.2 72-72S135.8 8 96 8 24 40.2 24 80zm0 352c0 39.8 32.2 72 72 72s72-32.2 72-72-32.2-72-72-72-72 32.2-72 72z'
/>
);
}
}

Expand Down Expand Up @@ -144,6 +164,7 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
</>
);
}

private hash: {scale: number; nodeCount: number; nodesToDisplay: string[]};
private graph: {
width: number;
Expand All @@ -154,7 +175,10 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe

constructor(props: Readonly<WorkflowDagProps>) {
super(props);
this.state = this.getOptions();
this.state = {
...this.getOptions(),
expandNodes: new Set()
};
}

public render() {
Expand Down Expand Up @@ -191,9 +215,19 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
return <path key={`line/${edge.v}-${edge.w}`} d={points} className='line' markerEnd={this.hiddenNode(edge.w) ? '' : 'url(#arrow)'} />;
})}
{Array.from(this.graph.nodes).map(([nodeId, v]) => {
const node = this.props.nodes[nodeId];
const phase: DagPhase = node.type === 'Suspend' && node.phase === 'Running' ? 'Suspended' : node.phase;
const hidden = this.hiddenNode(nodeId);
let phase: DagPhase;
let label: string;
let hidden: boolean;
if (isCollapsedNode(nodeId)) {
phase = this.state.horizontal ? 'Collapsed-Vertical' : 'Collapsed-Horizontal';
label = getCollapsedNumHidden(nodeId) + ' hidden nodes';
hidden = this.hiddenNode(getCollapsedNodeParent(nodeId));
} else {
const node = this.props.nodes[nodeId];
phase = node.type === 'Suspend' && node.phase === 'Running' ? 'Suspended' : node.phase;
label = Utils.shortNodeName(node);
hidden = this.hiddenNode(nodeId);
}
return (
<g key={`node/${nodeId}`} transform={`translate(${v.x},${v.y})`} onClick={() => this.selectNode(nodeId)} className='node'>
<circle
Expand All @@ -208,7 +242,7 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
{this.icon(phase)}
<g transform={`translate(0,${this.nodeSize})`}>
<text className='label' fontSize={12 / this.scale}>
{WorkflowDag.formatLabel(Utils.shortNodeName(node))}
{WorkflowDag.formatLabel(label)}
</text>
</g>
</>
Expand Down Expand Up @@ -253,25 +287,48 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
}

private prepareGraph() {
const nodes = Object.values(this.props.nodes)
.filter(node => !!node)
.filter(node => node.phase !== NODE_PHASE.OMITTED)
.map(node => node.id);
const collapsedNodes: Set<string> = new Set();
const nodesToAdd: string[] = [];
const edges = Object.values(this.props.nodes)
.filter(node => !!node)
.filter(node => node.phase !== NODE_PHASE.OMITTED)
.map(node =>
(node.children || [])
// we can get outbound nodes, but no node
.filter(childId => this.props.nodes[childId])
.filter(childId => this.props.nodes[childId].phase !== NODE_PHASE.OMITTED)
.map(childId => ({v: node.id, w: childId}))
)
.map(node => {
if (!node.children || node.children.length === 0) {
return [];
} else if (node.children.length > 3 && !this.state.expandNodes.has('*') && !this.state.expandNodes.has(node.id)) {
node.children.slice(1, node.children.length - 1).map(collapsedNode => collapsedNodes.add(collapsedNode));
const collapsedNodeName = getCollapsedNodeName(node.id, node.children.length - 2);
nodesToAdd.push(collapsedNodeName);
const out = [0, node.children.length - 1]
.map(i => node.children[i])
.filter(childId => this.props.nodes[childId])
.filter(childId => this.props.nodes[childId].phase !== NODE_PHASE.OMITTED)
.map(childId => ({v: node.id, w: childId}));
out.push({v: node.id, w: collapsedNodeName});
if (this.props.nodes[node.children[0]] && this.props.nodes[node.children[0]].children && this.props.nodes[this.props.nodes[node.children[0]].children[0]]) {
out.push({v: collapsedNodeName, w: this.props.nodes[this.props.nodes[node.children[0]].children[0]].id});
}
return out;
}
return (
(node.children || [])
// we can get outbound nodes, but no node
.filter(childId => this.props.nodes[childId])
.filter(childId => this.props.nodes[childId].phase !== NODE_PHASE.OMITTED)
.map(childId => ({v: node.id, w: childId}))
);
})
.reduce((a, b) => a.concat(b));
const nodes = Object.values(this.props.nodes)
.filter(node => !!node)
.filter(node => node.phase !== NODE_PHASE.OMITTED)
.filter(node => !collapsedNodes.has(node.id))
.map(node => node.id);
const onExitHandlerNodeId = nodes.find(nodeId => this.props.nodes[nodeId].name === `${this.props.workflowName}.onExit`);
if (onExitHandlerNodeId) {
this.getOutboundNodes(this.props.workflowName).forEach(v => edges.push({v, w: onExitHandlerNodeId}));
}
nodes.push(...nodesToAdd);
return {nodes, edges};
}

Expand Down Expand Up @@ -308,8 +365,14 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
this.graph.width = Math.max(this.graph.width, level.length * this.hgap * 2);
}
});
// Shifter is used to shift the location of a collapsed node to the center of the children nodes.
const shifter = new Shifter();
layers.forEach((level, i) => {
level.forEach((node, j) => {
if (isCollapsedNode(node)) {
shifter.start();
}
j = shifter.get(j);
const l = this.state.horizontal ? 0 : this.graph.width / 2 - level.length * this.hgap;
const t = !this.state.horizontal ? 0 : this.graph.height / 2 - level.length * this.vgap;
this.graph.nodes.set(node, {
Expand Down Expand Up @@ -341,6 +404,9 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
}

private selectNode(nodeId: string) {
if (isCollapsedNode(nodeId)) {
this.setState({expandNodes: new Set(this.state.expandNodes).add(getCollapsedNodeParent(nodeId))});
}
return this.props.nodeClicked && this.props.nodeClicked(nodeId);
}

Expand Down Expand Up @@ -374,7 +440,11 @@ export class WorkflowDag extends React.Component<WorkflowDagProps, WorkflowDagRe
return outbound;
}

private hiddenNode(id: string) {
private hiddenNode(id: string): boolean {
if (isCollapsedNode(id)) {
return this.hiddenNode(getCollapsedNodeParent(id));
}

const node = this.props.nodes[id];
// Filter the node if it is a virtual node or a Retry node with one child
return (
Expand Down

0 comments on commit 7e4a780

Please sign in to comment.