github.com/argoproj/argo-cd@v1.8.7/ui/src/app/applications/components/application-resource-tree/application-resource-tree.tsx (about) 1 import {DropDown} from 'argo-ui'; 2 import * as classNames from 'classnames'; 3 import * as dagre from 'dagre'; 4 import * as React from 'react'; 5 import Moment from 'react-moment'; 6 7 import * as models from '../../../shared/models'; 8 9 import {EmptyState} from '../../../shared/components'; 10 import {Consumer} from '../../../shared/context'; 11 import {ApplicationURLs} from '../application-urls'; 12 import {ResourceIcon} from '../resource-icon'; 13 import {ResourceLabel} from '../resource-label'; 14 import {ComparisonStatusIcon, getAppOverridesCount, HealthStatusIcon, isAppNode, NodeId, nodeKey} from '../utils'; 15 import {NodeUpdateAnimation} from './node-update-animation'; 16 17 function treeNodeKey(node: NodeId & {uid?: string}) { 18 return node.uid || nodeKey(node); 19 } 20 21 const color = require('color'); 22 23 require('./application-resource-tree.scss'); 24 25 export interface ResourceTreeNode extends models.ResourceNode { 26 status?: models.SyncStatusCode; 27 health?: models.HealthStatus; 28 hook?: boolean; 29 root?: ResourceTreeNode; 30 requiresPruning?: boolean; 31 orphaned?: boolean; 32 } 33 34 export interface ApplicationResourceTreeProps { 35 app: models.Application; 36 tree: models.ApplicationTree; 37 useNetworkingHierarchy: boolean; 38 nodeFilter: (node: ResourceTreeNode) => boolean; 39 selectedNodeFullName?: string; 40 onNodeClick?: (fullName: string) => any; 41 nodeMenu?: (node: models.ResourceNode) => React.ReactNode; 42 onClearFilter: () => any; 43 showOrphanedResources: boolean; 44 } 45 46 interface Line { 47 x1: number; 48 y1: number; 49 x2: number; 50 y2: number; 51 } 52 53 const NODE_WIDTH = 282; 54 const NODE_HEIGHT = 52; 55 const FILTERED_INDICATOR_NODE = '__filtered_indicator__'; 56 const EXTERNAL_TRAFFIC_NODE = '__external_traffic__'; 57 const INTERNAL_TRAFFIC_NODE = '__internal_traffic__'; 58 const NODE_TYPES = { 59 filteredIndicator: 'filtered_indicator', 60 externalTraffic: 'external_traffic', 61 externalLoadBalancer: 'external_load_balancer', 62 internalTraffic: 'internal_traffic' 63 }; 64 65 const BASE_COLORS = [ 66 '#0DADEA', // blue 67 '#95D58F', // green 68 '#F4C030', // orange 69 '#FF6262', // red 70 '#4B0082', // purple 71 '#964B00' // brown 72 ]; 73 74 // generate lots of colors with different darkness 75 const TRAFFIC_COLORS = [0, 0.25, 0.4, 0.6] 76 .map(darken => 77 BASE_COLORS.map(item => 78 color(item) 79 .darken(darken) 80 .hex() 81 ) 82 ) 83 .reduce((first, second) => first.concat(second), []); 84 85 function getGraphSize(nodes: dagre.Node[]): {width: number; height: number} { 86 let width = 0; 87 let height = 0; 88 nodes.forEach(node => { 89 width = Math.max(node.x + node.width, width); 90 height = Math.max(node.y + node.height, height); 91 }); 92 return {width, height}; 93 } 94 95 function filterGraph(app: models.Application, filteredIndicatorParent: string, graph: dagre.graphlib.Graph, predicate: (node: ResourceTreeNode) => boolean) { 96 const appKey = appNodeKey(app); 97 let filtered = 0; 98 graph.nodes().forEach(nodeId => { 99 const node: ResourceTreeNode = graph.node(nodeId) as any; 100 const parentIds = graph.predecessors(nodeId); 101 if (node.root != null && !predicate(node) && appKey !== nodeId) { 102 const childIds = graph.successors(nodeId); 103 graph.removeNode(nodeId); 104 filtered++; 105 childIds.forEach((childId: any) => { 106 parentIds.forEach((parentId: any) => { 107 graph.setEdge(parentId, childId); 108 }); 109 }); 110 } 111 }); 112 if (filtered) { 113 graph.setNode(FILTERED_INDICATOR_NODE, {height: NODE_HEIGHT, width: NODE_WIDTH, count: filtered, type: NODE_TYPES.filteredIndicator}); 114 graph.setEdge(filteredIndicatorParent, FILTERED_INDICATOR_NODE); 115 } 116 } 117 118 function compareNodes(first: ResourceTreeNode, second: ResourceTreeNode) { 119 return `${(first.orphaned && '1') || '0'}/${nodeKey(first)}`.localeCompare(`${(second.orphaned && '1') || '0'}/${nodeKey(second)}`); 120 } 121 122 function appNodeKey(app: models.Application) { 123 return nodeKey({group: 'argoproj.io', kind: app.kind, name: app.metadata.name, namespace: app.metadata.namespace}); 124 } 125 126 function renderFilteredNode(node: {count: number} & dagre.Node, onClearFilter: () => any) { 127 const indicators = new Array<number>(); 128 let count = Math.min(node.count - 1, 3); 129 while (count > 0) { 130 indicators.push(count--); 131 } 132 return ( 133 <React.Fragment> 134 <div className='application-resource-tree__node' style={{left: node.x, top: node.y, width: node.width, height: node.height}}> 135 <div className='application-resource-tree__node-kind-icon '> 136 <i className='icon fa fa-filter' /> 137 </div> 138 <div className='application-resource-tree__node-content-wrap-overflow'> 139 <a className='application-resource-tree__node-title' onClick={onClearFilter}> 140 clear filters to show {node.count} additional resource{node.count > 1 && 's'} 141 </a> 142 </div> 143 </div> 144 {indicators.map(i => ( 145 <div 146 key={i} 147 className='application-resource-tree__node application-resource-tree__filtered-indicator' 148 style={{left: node.x + i * 2, top: node.y + i * 2, width: node.width, height: node.height}} 149 /> 150 ))} 151 </React.Fragment> 152 ); 153 } 154 155 function renderTrafficNode(node: dagre.Node) { 156 return ( 157 <div style={{position: 'absolute', left: 0, top: node.y, width: node.width, height: node.height}}> 158 <div className='application-resource-tree__node-kind-icon' style={{fontSize: '2em'}}> 159 <i className='icon fa fa-cloud' /> 160 </div> 161 </div> 162 ); 163 } 164 165 function renderLoadBalancerNode(node: dagre.Node & {label: string; color: string}) { 166 return ( 167 <div 168 className='application-resource-tree__node application-resource-tree__node--load-balancer' 169 style={{ 170 left: node.x, 171 top: node.y, 172 width: node.width, 173 height: node.height 174 }}> 175 <div className='application-resource-tree__node-kind-icon'> 176 <i title={node.kind} className={`icon fa fa-network-wired`} style={{color: node.color}} /> 177 </div> 178 <div className='application-resource-tree__node-content'> 179 <span className='application-resource-tree__node-title'>{node.label}</span> 180 </div> 181 </div> 182 ); 183 } 184 185 export const describeNode = (node: ResourceTreeNode) => { 186 const lines = [`Kind: ${node.kind}`, `Namespace: ${node.namespace}`, `Name: ${node.name}`]; 187 if (node.images) { 188 lines.push('Images:'); 189 node.images.forEach(i => lines.push(`- ${i}`)); 190 } 191 return lines.join('\n'); 192 }; 193 194 function renderResourceNode(props: ApplicationResourceTreeProps, id: string, node: (ResourceTreeNode) & dagre.Node) { 195 const fullName = nodeKey(node); 196 let comparisonStatus: models.SyncStatusCode = null; 197 let healthState: models.HealthStatus = null; 198 if (node.status || node.health) { 199 comparisonStatus = node.status; 200 healthState = node.health; 201 } 202 const appNode = isAppNode(node); 203 const rootNode = !node.root; 204 return ( 205 <div 206 onClick={() => props.onNodeClick && props.onNodeClick(fullName)} 207 className={classNames('application-resource-tree__node', { 208 'active': fullName === props.selectedNodeFullName, 209 'application-resource-tree__node--orphaned': node.orphaned 210 })} 211 title={describeNode(node)} 212 style={{left: node.x, top: node.y, width: node.width, height: node.height}}> 213 {!appNode && <NodeUpdateAnimation resourceVersion={node.resourceVersion} />} 214 <div 215 className={classNames('application-resource-tree__node-kind-icon', { 216 'application-resource-tree__node-kind-icon--big': rootNode 217 })}> 218 <ResourceIcon kind={node.kind} /> 219 <br /> 220 {!rootNode && <div className='application-resource-tree__node-kind'>{ResourceLabel({kind: node.kind})}</div>} 221 </div> 222 <div className='application-resource-tree__node-content'> 223 <span className='application-resource-tree__node-title'>{node.name}</span> 224 <br /> 225 <span 226 className={classNames('application-resource-tree__node-status-icon', { 227 'application-resource-tree__node-status-icon--offset': rootNode 228 })}> 229 {node.hook && <i title='Resource lifecycle hook' className='fa fa-anchor' />} 230 {healthState != null && <HealthStatusIcon state={healthState} />} 231 {comparisonStatus != null && <ComparisonStatusIcon status={comparisonStatus} resource={!rootNode && node} />} 232 {appNode && !rootNode && ( 233 <Consumer> 234 {ctx => ( 235 <a href={ctx.baseHref + 'applications/' + node.name} title='Open application'> 236 <i className='fa fa-external-link-alt' /> 237 </a> 238 )} 239 </Consumer> 240 )} 241 <ApplicationURLs urls={rootNode ? props.app.status.summary.externalURLs : node.networkingInfo && node.networkingInfo.externalURLs} /> 242 </span> 243 </div> 244 <div className='application-resource-tree__node-labels'> 245 {node.createdAt ? ( 246 <Moment className='application-resource-tree__node-label' fromNow={true} ago={true}> 247 {node.createdAt} 248 </Moment> 249 ) : null} 250 {(node.info || []).map((tag, i) => ( 251 <span className='application-resource-tree__node-label' title={`${tag.name}:${tag.value}`} key={i}> 252 {tag.value} 253 </span> 254 ))} 255 </div> 256 {props.nodeMenu && ( 257 <div className='application-resource-tree__node-menu'> 258 <DropDown 259 isMenu={true} 260 anchor={() => ( 261 <button className='argo-button argo-button--light argo-button--lg argo-button--short'> 262 <i className='fa fa-ellipsis-v' /> 263 </button> 264 )}> 265 {() => props.nodeMenu(node)} 266 </DropDown> 267 </div> 268 )} 269 </div> 270 ); 271 } 272 273 function findNetworkTargets(nodes: ResourceTreeNode[], networkingInfo: models.ResourceNetworkingInfo): ResourceTreeNode[] { 274 let result = new Array<ResourceTreeNode>(); 275 const refs = new Set((networkingInfo.targetRefs || []).map(nodeKey)); 276 result = result.concat(nodes.filter(target => refs.has(nodeKey(target)))); 277 if (networkingInfo.targetLabels) { 278 result = result.concat( 279 nodes.filter(target => { 280 if (target.networkingInfo && target.networkingInfo.labels) { 281 return Object.keys(networkingInfo.targetLabels).every(key => networkingInfo.targetLabels[key] === target.networkingInfo.labels[key]); 282 } 283 return false; 284 }) 285 ); 286 } 287 return result; 288 } 289 export const ApplicationResourceTree = (props: ApplicationResourceTreeProps) => { 290 const graph = new dagre.graphlib.Graph(); 291 graph.setGraph({nodesep: 15, rankdir: 'LR', marginx: -100}); 292 graph.setDefaultEdgeLabel(() => ({})); 293 const overridesCount = getAppOverridesCount(props.app); 294 const appNode = { 295 kind: props.app.kind, 296 name: props.app.metadata.name, 297 namespace: props.app.metadata.namespace, 298 resourceVersion: props.app.metadata.resourceVersion, 299 group: 'argoproj.io', 300 version: '', 301 children: Array(), 302 status: props.app.status.sync.status, 303 health: props.app.status.health, 304 info: 305 overridesCount > 0 306 ? [ 307 { 308 name: 'Parameter overrides', 309 value: `${overridesCount} parameter override(s)` 310 } 311 ] 312 : [] 313 }; 314 315 const statusByKey = new Map<string, models.ResourceStatus>(); 316 props.app.status.resources.forEach(res => statusByKey.set(nodeKey(res), res)); 317 const nodeByKey = new Map<string, ResourceTreeNode>(); 318 props.tree.nodes 319 .map(node => ({...node, orphaned: false})) 320 .concat(((props.showOrphanedResources && props.tree.orphanedNodes) || []).map(node => ({...node, orphaned: true}))) 321 .forEach(node => { 322 const status = statusByKey.get(nodeKey(node)); 323 const resourceNode: ResourceTreeNode = {...node}; 324 if (status) { 325 resourceNode.health = status.health; 326 resourceNode.status = status.status; 327 resourceNode.hook = status.hook; 328 resourceNode.requiresPruning = status.requiresPruning; 329 } 330 nodeByKey.set(treeNodeKey(node), resourceNode); 331 }); 332 const nodes = Array.from(nodeByKey.values()); 333 let roots: ResourceTreeNode[] = []; 334 const childrenByParentKey = new Map<string, ResourceTreeNode[]>(); 335 if (props.useNetworkingHierarchy) { 336 // Network view 337 const hasParents = new Set<string>(); 338 const networkNodes = nodes.filter(node => node.networkingInfo); 339 networkNodes.forEach(parent => { 340 findNetworkTargets(networkNodes, parent.networkingInfo).forEach(child => { 341 const children = childrenByParentKey.get(treeNodeKey(parent)) || []; 342 hasParents.add(treeNodeKey(child)); 343 children.push(child); 344 childrenByParentKey.set(treeNodeKey(parent), children); 345 }); 346 }); 347 roots = networkNodes.filter(node => !hasParents.has(treeNodeKey(node))); 348 const externalRoots = roots.filter(root => (root.networkingInfo.ingress || []).length > 0).sort(compareNodes); 349 const internalRoots = roots.filter(root => (root.networkingInfo.ingress || []).length === 0).sort(compareNodes); 350 const colorsBySource = new Map<string, string>(); 351 // sources are root internal services and external ingress/service IPs 352 const sources = Array.from( 353 new Set( 354 internalRoots 355 .map(root => treeNodeKey(root)) 356 .concat( 357 externalRoots.map(root => root.networkingInfo.ingress.map(ingress => ingress.hostname || ingress.ip)).reduce((first, second) => first.concat(second), []) 358 ) 359 ) 360 ); 361 // assign unique color to each traffic source 362 sources.forEach((key, i) => colorsBySource.set(key, TRAFFIC_COLORS[i % TRAFFIC_COLORS.length])); 363 364 if (externalRoots.length > 0) { 365 graph.setNode(EXTERNAL_TRAFFIC_NODE, {height: NODE_HEIGHT, width: 30, type: NODE_TYPES.externalTraffic}); 366 externalRoots.sort(compareNodes).forEach(root => { 367 const loadBalancers = root.networkingInfo.ingress.map(ingress => ingress.hostname || ingress.ip); 368 processNode(root, root, loadBalancers.map(lb => colorsBySource.get(lb))); 369 loadBalancers.forEach(key => { 370 const loadBalancerNodeKey = `${EXTERNAL_TRAFFIC_NODE}:${key}`; 371 graph.setNode(loadBalancerNodeKey, { 372 height: NODE_HEIGHT, 373 width: NODE_WIDTH, 374 type: NODE_TYPES.externalLoadBalancer, 375 label: key, 376 color: colorsBySource.get(key) 377 }); 378 graph.setEdge(loadBalancerNodeKey, treeNodeKey(root), {colors: [colorsBySource.get(key)]}); 379 graph.setEdge(EXTERNAL_TRAFFIC_NODE, loadBalancerNodeKey, {colors: [colorsBySource.get(key)]}); 380 }); 381 }); 382 } 383 384 if (internalRoots.length > 0) { 385 graph.setNode(INTERNAL_TRAFFIC_NODE, {height: NODE_HEIGHT, width: 30, type: NODE_TYPES.internalTraffic}); 386 internalRoots.forEach(root => { 387 processNode(root, root, [colorsBySource.get(treeNodeKey(root))]); 388 graph.setEdge(INTERNAL_TRAFFIC_NODE, treeNodeKey(root)); 389 }); 390 } 391 if (props.nodeFilter) { 392 // show filtered indicator next to external traffic node is app has it otherwise next to internal traffic node 393 filterGraph(props.app, externalRoots.length > 0 ? EXTERNAL_TRAFFIC_NODE : INTERNAL_TRAFFIC_NODE, graph, props.nodeFilter); 394 } 395 } else { 396 // Tree view 397 const managedKeys = new Set(props.app.status.resources.map(nodeKey)); 398 const orphans: ResourceTreeNode[] = []; 399 nodes.forEach(node => { 400 if ((node.parentRefs || []).length === 0 || managedKeys.has(nodeKey(node))) { 401 roots.push(node); 402 } else { 403 orphans.push(node); 404 node.parentRefs.forEach(parent => { 405 const children = childrenByParentKey.get(treeNodeKey(parent)) || []; 406 children.push(node); 407 childrenByParentKey.set(treeNodeKey(parent), children); 408 }); 409 } 410 }); 411 roots.sort(compareNodes).forEach(node => { 412 processNode(node, node); 413 graph.setEdge(appNodeKey(props.app), treeNodeKey(node)); 414 }); 415 orphans.sort(compareNodes).forEach(node => { 416 processNode(node, node); 417 }); 418 graph.setNode(appNodeKey(props.app), {...appNode, width: NODE_WIDTH, height: NODE_HEIGHT}); 419 if (props.nodeFilter) { 420 filterGraph(props.app, appNodeKey(props.app), graph, props.nodeFilter); 421 } 422 } 423 424 function processNode(node: ResourceTreeNode, root: ResourceTreeNode, colors?: string[]) { 425 graph.setNode(treeNodeKey(node), {...node, width: NODE_WIDTH, height: NODE_HEIGHT, root}); 426 (childrenByParentKey.get(treeNodeKey(node)) || []).sort(compareNodes).forEach(child => { 427 if (treeNodeKey(child) === treeNodeKey(root)) { 428 return; 429 } 430 graph.setEdge(treeNodeKey(node), treeNodeKey(child), {colors}); 431 processNode(child, root, colors); 432 }); 433 } 434 dagre.layout(graph); 435 436 const edges: {from: string; to: string; lines: Line[]; backgroundImage?: string}[] = []; 437 graph.edges().forEach(edgeInfo => { 438 const edge = graph.edge(edgeInfo); 439 const colors = (edge.colors as string[]) || []; 440 let backgroundImage: string; 441 if (colors.length > 0) { 442 const step = 100 / colors.length; 443 const gradient = colors.map((lineColor, i) => { 444 return `${lineColor} ${step * i}%, ${lineColor} ${step * i + step / 2}%, transparent ${step * i + step / 2}%, transparent ${step * (i + 1)}%`; 445 }); 446 backgroundImage = `linear-gradient(90deg, ${gradient})`; 447 } 448 449 const lines: Line[] = []; 450 // don't render connections from hidden node representing internal traffic 451 if (edgeInfo.v === INTERNAL_TRAFFIC_NODE || edgeInfo.w === INTERNAL_TRAFFIC_NODE) { 452 return; 453 } 454 if (edge.points.length > 1) { 455 for (let i = 1; i < edge.points.length; i++) { 456 lines.push({x1: edge.points[i - 1].x, y1: edge.points[i - 1].y, x2: edge.points[i].x, y2: edge.points[i].y}); 457 } 458 } 459 edges.push({from: edgeInfo.v, to: edgeInfo.w, lines, backgroundImage}); 460 }); 461 const graphNodes = graph.nodes(); 462 const size = getGraphSize(graphNodes.map(id => graph.node(id))); 463 return ( 464 (graphNodes.length === 0 && ( 465 <EmptyState icon=' fa fa-network-wired'> 466 <h4>Your application has no network resources</h4> 467 <h5>Try switching to tree or list view</h5> 468 </EmptyState> 469 )) || ( 470 <div 471 className={classNames('application-resource-tree', {'application-resource-tree--network': props.useNetworkingHierarchy})} 472 style={{width: size.width + 150, height: size.height + 250}}> 473 {graphNodes.map(key => { 474 const node = graph.node(key); 475 const nodeType = node.type; 476 switch (nodeType) { 477 case NODE_TYPES.filteredIndicator: 478 return <React.Fragment key={key}>{renderFilteredNode(node as any, props.onClearFilter)}</React.Fragment>; 479 case NODE_TYPES.externalTraffic: 480 return <React.Fragment key={key}>{renderTrafficNode(node)}</React.Fragment>; 481 case NODE_TYPES.internalTraffic: 482 return null; 483 case NODE_TYPES.externalLoadBalancer: 484 return <React.Fragment key={key}>{renderLoadBalancerNode(node as any)}</React.Fragment>; 485 default: 486 return <React.Fragment key={key}>{renderResourceNode(props, key, node as (ResourceTreeNode) & dagre.Node)}</React.Fragment>; 487 } 488 })} 489 {edges.map(edge => ( 490 <div key={`${edge.from}-${edge.to}`} className='application-resource-tree__edge'> 491 {edge.lines.map((line, i) => { 492 const distance = Math.sqrt(Math.pow(line.x1 - line.x2, 2) + Math.pow(line.y1 - line.y2, 2)); 493 const xMid = (line.x1 + line.x2) / 2; 494 const yMid = (line.y1 + line.y2) / 2; 495 const angle = (Math.atan2(line.y1 - line.y2, line.x1 - line.x2) * 180) / Math.PI; 496 return ( 497 <div 498 className='application-resource-tree__line' 499 key={i} 500 style={{ 501 width: distance, 502 left: xMid - distance / 2, 503 top: yMid, 504 backgroundImage: edge.backgroundImage, 505 transform: `translate(150px, 35px) rotate(${angle}deg)` 506 }} 507 /> 508 ); 509 })} 510 </div> 511 ))} 512 </div> 513 ) 514 ); 515 };