diff --git a/ui/src/app/applications/components/application-details/application-details.scss b/ui/src/app/applications/components/application-details/application-details.scss index f0bdd024d5b74..cfb94ca8e176b 100644 --- a/ui/src/app/applications/components/application-details/application-details.scss +++ b/ui/src/app/applications/components/application-details/application-details.scss @@ -255,6 +255,12 @@ $header: 120px; } } + .separator { + border-right: 1px solid $argo-color-gray-4; + padding-top: 6px; + padding-bottom: 6px; + } + .zoom-value { user-select: none; margin-top: 3px; diff --git a/ui/src/app/applications/components/application-details/application-details.tsx b/ui/src/app/applications/components/application-details/application-details.tsx index 308ce94663462..38e7878e5ca6a 100644 --- a/ui/src/app/applications/components/application-details/application-details.tsx +++ b/ui/src/app/applications/components/application-details/application-details.tsx @@ -36,6 +36,7 @@ interface ApplicationDetailsState { slidingPanelPage?: number; filteredGraph?: any[]; truncateNameOnRight?: boolean; + collapsedNodes?: string[]; } interface FilterInput { @@ -70,13 +71,30 @@ export class ApplicationDetails extends React.Component) { super(props); - this.state = {page: 0, groupedResources: [], slidingPanelPage: 0, filteredGraph: [], truncateNameOnRight: false}; + this.state = {page: 0, groupedResources: [], slidingPanelPage: 0, filteredGraph: [], truncateNameOnRight: false, collapsedNodes: []}; } private get showOperationState() { return new URLSearchParams(this.props.history.location.search).get('operation') === 'true'; } + private setNodeExpansion(node: string, isExpanded: boolean) { + const index = this.state.collapsedNodes.indexOf(node); + if (isExpanded && index >= 0) { + this.state.collapsedNodes.splice(index, 1); + const updatedNodes = this.state.collapsedNodes.slice(); + this.setState({collapsedNodes: updatedNodes}); + } else if (!isExpanded && index < 0) { + const updatedNodes = this.state.collapsedNodes.slice(); + updatedNodes.push(node); + this.setState({collapsedNodes: updatedNodes}); + } + } + + private getNodeExpansion(node: string): boolean { + return this.state.collapsedNodes.indexOf(node) < 0; + } + private get showConditions() { return new URLSearchParams(this.props.history.location.search).get('conditions') === 'true'; } @@ -229,6 +247,44 @@ export class ApplicationDetails extends React.Component { this.setState({truncateNameOnRight: !this.state.truncateNameOnRight}); }; + const expandAll = () => { + this.setState({collapsedNodes: []}); + }; + const collapseAll = () => { + const nodes = new Array(); + tree.nodes + .map(node => ({...node, orphaned: false})) + .concat((tree.orphanedNodes || []).map(node => ({...node, orphaned: true}))) + .forEach(node => { + const resourceNode: ResourceTreeNode = {...node}; + nodes.push(resourceNode); + }); + const collapsedNodesList = this.state.collapsedNodes.slice(); + if (pref.view === 'network') { + const networkNodes = nodes.filter(node => node.networkingInfo); + networkNodes.forEach(parent => { + const parentId = parent.uid; + if (collapsedNodesList.indexOf(parentId) < 0) { + collapsedNodesList.push(parentId); + } + }); + this.setState({collapsedNodes: collapsedNodesList}); + } else { + const managedKeys = new Set(application.status.resources.map(AppUtils.nodeKey)); + nodes.forEach(node => { + if (!((node.parentRefs || []).length === 0 || managedKeys.has(AppUtils.nodeKey(node)))) { + node.parentRefs.forEach(parent => { + const parentId = parent.uid; + if (collapsedNodesList.indexOf(parentId) < 0) { + collapsedNodesList.push(parentId); + } + }); + } + }); + collapsedNodesList.push(application.kind + '-' + application.metadata.namespace + '-' + application.metadata.name); + this.setState({collapsedNodes: collapsedNodesList}); + } + }; return (
)} + + expandAll()} title='Expand all child nodes of all parent nodes'> + + + collapseAll()} title='Collapse all child nodes of all parent nodes'> + + + setZoom(0.1)} title='Zoom in'> @@ -343,6 +407,8 @@ export class ApplicationDetails extends React.Component this.setNodeExpansion(node, isExpanded)} + getNodeExpansion={node => this.getNodeExpansion(node)} /> )) || diff --git a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss index 3824d24093f82..645ad2ae5f623 100644 --- a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss +++ b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.scss @@ -49,8 +49,7 @@ .application-resource-tree__line { &:last-child { &:after { - color: $argo-color-teal-6; - top: -8px; + content: none; } } } @@ -93,6 +92,33 @@ background-color: $argo-color-teal-2; } + &--expansion { + position: absolute; + flex-shrink: 0px; + z-index: 10; + font-size: 0.5em; + padding: 2px; + box-shadow: 1px 1px 1px $argo-color-gray-4; + background-color: white; + margin-top: 9px; + margin-left: 215px; + } + + &--podgroup--expansion { + position: absolute; + flex-shrink: 0px; + z-index: 10; + font-size: 0.5em; + padding: 2px; + box-shadow: 1px 1px 1px $argo-color-gray-4; + background-color: white; + margin-left: 215px; + } + + &--pod { + background-color: lightcyan; + } + &--lower-section { left: 8px; right: 10px; diff --git a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx index fef9af16e518d..ff9499ab2d28f 100644 --- a/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx +++ b/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx @@ -26,6 +26,7 @@ import { } from '../utils'; import {NodeUpdateAnimation} from './node-update-animation'; import {PodGroup} from '../application-pod-view/pod-view'; +import {ArrowConnector} from './arrow-connector'; function treeNodeKey(node: NodeId & {uid?: string}) { return node.uid || nodeKey(node); @@ -43,6 +44,7 @@ export interface ResourceTreeNode extends models.ResourceNode { requiresPruning?: boolean; orphaned?: boolean; podGroup?: PodGroup; + isExpanded?: boolean; } export interface ApplicationResourceTreeProps { @@ -62,6 +64,8 @@ export interface ApplicationResourceTreeProps { filters?: string[]; setTreeFilterGraph?: (filterGraph: any[]) => void; nameDirection: boolean; + setNodeExpansion: (node: string, isExpanded: boolean) => any; + getNodeExpansion: (node: string) => boolean; } interface Line { @@ -363,7 +367,7 @@ function processPodGroup(targetPodGroup: ResourceTreeNode, child: ResourceTreeNo } } -function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: ResourceTreeNode & dagre.Node) { +function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: ResourceTreeNode & dagre.Node, childMap: Map) { const fullName = nodeKey(node); let comparisonStatus: models.SyncStatusCode = null; let healthState: models.HealthStatus = null; @@ -377,6 +381,14 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R if (rootNode) { extLinks = getExternalUrls(props.app.metadata.annotations, props.app.status.summary.externalURLs); } + const podGroupChildren = childMap.get(treeNodeKey(node)); + const nonPodChildren = podGroupChildren?.reduce((acc, child) => { + if (child.kind !== 'Pod') { + acc.push(child); + } + return acc; + }, []); + const childCount = nonPodChildren?.length; const margin = 8; let topExtra = 0; const podGroup = node.podGroup; @@ -411,7 +423,6 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R })}> {node.name} -
+ {childCount > 0 && ( + <> +
+
{ + expandCollapse(node, props); + event.stopPropagation(); + }}> + {props.getNodeExpansion(node.uid) ?
:
} +
+ + )}
{node.createdAt || rootNode ? ( @@ -565,7 +590,13 @@ function renderPodGroup(props: ApplicationResourceTreeProps, id: string, node: R ); } -function renderResourceNode(props: ApplicationResourceTreeProps, id: string, node: ResourceTreeNode & dagre.Node) { +function expandCollapse(node: ResourceTreeNode, props: ApplicationResourceTreeProps) { + const isExpanded = !props.getNodeExpansion(node.uid); + node.isExpanded = isExpanded; + props.setNodeExpansion(node.uid, isExpanded); +} + +function renderResourceNode(props: ApplicationResourceTreeProps, id: string, node: ResourceTreeNode & dagre.Node, nodesHavingChildren: Map) { const fullName = nodeKey(node); let comparisonStatus: models.SyncStatusCode = null; let healthState: models.HealthStatus = null; @@ -576,13 +607,14 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod const appNode = isAppNode(node); const rootNode = !node.root; let extLinks: string[] = props.app.status.summary.externalURLs; + const childCount = nodesHavingChildren.get(node.uid); if (rootNode) { extLinks = getExternalUrls(props.app.metadata.annotations, props.app.status.summary.externalURLs); } return (
props.onNodeClick && props.onNodeClick(fullName)} - className={classNames('application-resource-tree__node', { + className={classNames('application-resource-tree__node', 'application-resource-tree__node--' + node.kind.toLowerCase(), { 'active': fullName === props.selectedNodeFullName, 'application-resource-tree__node--orphaned': node.orphaned })} @@ -628,6 +660,16 @@ function renderResourceNode(props: ApplicationResourceTreeProps, id: string, nod )}
+ {childCount > 0 && ( +
{ + expandCollapse(node, props); + event.stopPropagation(); + }}> + {props.getNodeExpansion(node.uid) ?
:
} +
+ )}
{node.createdAt || rootNode ? ( @@ -692,7 +734,7 @@ function findNetworkTargets(nodes: ResourceTreeNode[], networkingInfo: models.Re } export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => { const graph = new dagre.graphlib.Graph(); - graph.setGraph({nodesep: 15, rankdir: 'LR', marginy: 45, marginx: -100}); + graph.setGraph({nodesep: 25, rankdir: 'LR', marginy: 45, marginx: -100, ranksep: 80}); graph.setDefaultEdgeLabel(() => ({})); const overridesCount = getAppOverridesCount(props.app); const appNode = { @@ -705,6 +747,7 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => children: Array(), status: props.app.status.sync.status, health: props.app.status.health, + uid: props.app.kind + '-' + props.app.metadata.namespace + '-' + props.app.metadata.name, info: overridesCount > 0 ? [ @@ -736,7 +779,8 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => const nodes = Array.from(nodeByKey.values()); let roots: ResourceTreeNode[] = []; const childrenByParentKey = new Map(); - + const nodesHavingChildren = new Map(); + const childrenMap = new Map(); const [filters, setFilters] = React.useState(props.filters); const [filteredGraph, setFilteredGraph] = React.useState([]); const filteredNodes: any[] = []; @@ -777,19 +821,37 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => // Network view const hasParents = new Set(); const networkNodes = nodes.filter(node => node.networkingInfo); + const hiddenNodes: ResourceTreeNode[] = []; networkNodes.forEach(parent => { findNetworkTargets(networkNodes, parent.networkingInfo).forEach(child => { const children = childrenByParentKey.get(treeNodeKey(parent)) || []; hasParents.add(treeNodeKey(child)); + const parentId = parent.uid; + if (nodesHavingChildren.has(parentId)) { + nodesHavingChildren.set(parentId, nodesHavingChildren.get(parentId) + children.length); + } else { + nodesHavingChildren.set(parentId, 1); + } if (child.kind !== 'Pod' || !props.showCompactNodes) { - children.push(child); - childrenByParentKey.set(treeNodeKey(parent), children); + if (props.getNodeExpansion(parentId)) { + hasParents.add(treeNodeKey(child)); + children.push(child); + childrenByParentKey.set(treeNodeKey(parent), children); + } else { + hiddenNodes.push(child); + } } else { processPodGroup(parent, child, props); } }); }); roots = networkNodes.filter(node => !hasParents.has(treeNodeKey(node))); + roots = roots.reduce((acc, curr) => { + if (hiddenNodes.indexOf(curr) < 0) { + acc.push(curr); + } + return acc; + }, []); const externalRoots = roots.filter(root => (root.networkingInfo.ingress || []).length > 0).sort(compareNodes); const internalRoots = roots.filter(root => (root.networkingInfo.ingress || []).length === 0).sort(compareNodes); const colorsBySource = new Map(); @@ -852,25 +914,46 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => const managedKeys = new Set(props.app.status.resources.map(nodeKey)); const orphanedKeys = new Set(props.tree.orphanedNodes?.map(nodeKey)); const orphans: ResourceTreeNode[] = []; - nodes.forEach(node => { - if ((node.parentRefs || []).length === 0 || managedKeys.has(nodeKey(node))) { - roots.push(node); - } else { - if (orphanedKeys.has(nodeKey(node))) { - orphans.push(node); - } - node.parentRefs.forEach(parent => { - const children = childrenByParentKey.get(treeNodeKey(parent)) || []; - if (node.kind !== 'Pod' || !props.showCompactNodes) { - children.push(node); - childrenByParentKey.set(treeNodeKey(parent), children); - } else { - const parentTreeNode = nodeByKey.get(treeNodeKey(parent)); - processPodGroup(parentTreeNode, node, props); + let allChildNodes: ResourceTreeNode[] = []; + nodesHavingChildren.set(appNode.uid, 1); + if (props.getNodeExpansion(appNode.uid)) { + nodes.forEach(node => { + allChildNodes = []; + if ((node.parentRefs || []).length === 0 || managedKeys.has(nodeKey(node))) { + roots.push(node); + } else { + if (orphanedKeys.has(nodeKey(node))) { + orphans.push(node); } - }); - } - }); + node.parentRefs.forEach(parent => { + const parentId = treeNodeKey(parent); + const children = childrenByParentKey.get(parentId) || []; + if (nodesHavingChildren.has(parentId)) { + nodesHavingChildren.set(parentId, nodesHavingChildren.get(parentId) + children.length); + } else { + nodesHavingChildren.set(parentId, 1); + } + allChildNodes.push(node); + if (node.kind !== 'Pod' || !props.showCompactNodes) { + if (props.getNodeExpansion(parentId)) { + children.push(node); + childrenByParentKey.set(parentId, children); + } + } else { + const parentTreeNode = nodeByKey.get(parentId); + processPodGroup(parentTreeNode, node, props); + } + if (props.showCompactNodes) { + if (childrenMap.has(parentId)) { + childrenMap.set(parentId, childrenMap.get(parentId).concat(allChildNodes)); + } else { + childrenMap.set(parentId, allChildNodes); + } + } + }); + } + }); + } roots.sort(compareNodes).forEach(node => { processNode(node, node); graph.setEdge(appNodeKey(props.app), treeNodeKey(node)); @@ -906,7 +989,22 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => } dagre.layout(graph); - const edges: {from: string; to: string; lines: Line[]; backgroundImage?: string}[] = []; + const edges: {from: string; to: string; lines: Line[]; backgroundImage?: string; color?: string; colors?: string | {[key: string]: any}}[] = []; + const nodeOffset = new Map(); + const reverseEdge = new Map(); + graph.edges().forEach(edgeInfo => { + const edge = graph.edge(edgeInfo); + if (edge.points.length > 1) { + if (!reverseEdge.has(edgeInfo.w)) { + reverseEdge.set(edgeInfo.w, 1); + } else { + reverseEdge.set(edgeInfo.w, reverseEdge.get(edgeInfo.w) + 1); + } + if (!nodeOffset.has(edgeInfo.v)) { + nodeOffset.set(edgeInfo.v, reverseEdge.get(edgeInfo.w) - 1); + } + } + }); graph.edges().forEach(edgeInfo => { const edge = graph.edge(edgeInfo); const colors = (edge.colors as string[]) || []; @@ -925,11 +1023,29 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => return; } if (edge.points.length > 1) { - for (let i = 1; i < edge.points.length; i++) { - lines.push({x1: edge.points[i - 1].x, y1: edge.points[i - 1].y, x2: edge.points[i].x, y2: edge.points[i].y}); + const startNode = graph.node(edgeInfo.v); + const endNode = graph.node(edgeInfo.w); + const offset = nodeOffset.get(edgeInfo.v); + const startNodeRight = props.useNetworkingHierarchy ? 162 : 142; + const endNodeLeft = 140; + if (edgeInfo.v.startsWith(EXTERNAL_TRAFFIC_NODE)) { + lines.push({x1: startNode.x, y1: startNode.y, x2: endNode.x - endNodeLeft, y2: endNode.y}); + } else { + const len = reverseEdge.get(edgeInfo.w) + 1; + const yEnd = endNode.y - endNode.height / 2 + (endNode.height / len + (endNode.height / len) * offset); + const firstBend = + startNode.x + + startNodeRight + + (endNode.x - startNode.x - startNodeRight - endNodeLeft) / len + + ((endNode.x - startNode.x - startNodeRight - endNodeLeft) / len) * offset; + lines.push({x1: startNode.x + startNodeRight, y1: startNode.y, x2: firstBend, y2: startNode.y}); + if (startNode.y - yEnd >= 1 || yEnd - startNode.y >= 1) { + lines.push({x1: firstBend, y1: startNode.y, x2: firstBend, y2: yEnd}); + } + lines.push({x1: firstBend, y1: yEnd, x2: endNode.x - endNodeLeft, y2: yEnd}); } } - edges.push({from: edgeInfo.v, to: edgeInfo.w, lines, backgroundImage}); + edges.push({from: edgeInfo.v, to: edgeInfo.w, lines, backgroundImage, colors: [{colors}]}); }); const graphNodes = graph.nodes(); const size = getGraphSize(graphNodes.map(id => graph.node(id))); @@ -958,9 +1074,9 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => case NODE_TYPES.groupedNodes: return {renderGroupedNodes(props, node as any)}; case NODE_TYPES.podGroup: - return {renderPodGroup(props, key, node as ResourceTreeNode & dagre.Node)}; + return {renderPodGroup(props, key, node as ResourceTreeNode & dagre.Node, childrenMap)}; default: - return {renderResourceNode(props, key, node as ResourceTreeNode & dagre.Node)}; + return {renderResourceNode(props, key, node as ResourceTreeNode & dagre.Node, nodesHavingChildren)}; } })} {edges.map(edge => ( @@ -970,6 +1086,16 @@ export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => const xMid = (line.x1 + line.x2) / 2; const yMid = (line.y1 + line.y2) / 2; const angle = (Math.atan2(line.y1 - line.y2, line.x1 - line.x2) * 180) / Math.PI; + const lastLine = i === edge.lines.length - 1 ? line : null; + let arrowColor = null; + if (edge.colors) { + if (Array.isArray(edge.colors)) { + const firstColor = edge.colors[0]; + if (firstColor.colors) { + arrowColor = firstColor.colors; + } + } + } return (
left: xMid - distance / 2, top: yMid, backgroundImage: edge.backgroundImage, - transform: `translate(150px, 35px) rotate(${angle}deg)` - }} - /> + transform: props.useNetworkingHierarchy ? `translate(140px, 35px) rotate(${angle}deg)` : `translate(150px, 35px) rotate(${angle}deg)` + }}> + {lastLine && props.useNetworkingHierarchy && } +
); })}
diff --git a/ui/src/app/applications/components/application-resource-tree/arrow-connector.tsx b/ui/src/app/applications/components/application-resource-tree/arrow-connector.tsx new file mode 100644 index 0000000000000..b6c04b6cd5542 --- /dev/null +++ b/ui/src/app/applications/components/application-resource-tree/arrow-connector.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; + +export interface ArrowConnectorProps { + color: string; + left: number; + top: number; + angle: number; +} + +export const ArrowConnector = (props: ArrowConnectorProps) => { + const {color, left, top, angle} = props; + return ( + + + + ); +};