github.com/hernad/nomad@v1.6.112/ui/app/components/topo-viz.js (about) 1 /** 2 * Copyright (c) HashiCorp, Inc. 3 * SPDX-License-Identifier: MPL-2.0 4 */ 5 6 import Component from '@glimmer/component'; 7 import { tracked } from '@glimmer/tracking'; 8 import { action, set } from '@ember/object'; 9 import { inject as service } from '@ember/service'; 10 import { next } from '@ember/runloop'; 11 import { scaleLinear } from 'd3-scale'; 12 import { extent, deviation, mean } from 'd3-array'; 13 import { line, curveBasis } from 'd3-shape'; 14 import styleStringProperty from '../utils/properties/style-string'; 15 16 export default class TopoViz extends Component { 17 @service system; 18 19 @tracked element = null; 20 @tracked topology = { datacenters: [] }; 21 22 @tracked activeNode = null; 23 @tracked activeAllocation = null; 24 @tracked activeEdges = []; 25 @tracked edgeOffset = { x: 0, y: 0 }; 26 @tracked viewportColumns = 2; 27 28 @tracked highlightAllocation = null; 29 @tracked tooltipProps = {}; 30 31 @styleStringProperty('tooltipProps') tooltipStyle; 32 33 get isSingleColumn() { 34 if (this.topology.datacenters.length <= 1 || this.viewportColumns === 1) 35 return true; 36 37 // Compute the coefficient of variance to determine if it would be 38 // better to stack datacenters or place them in columns 39 const nodeCounts = this.topology.datacenters.map( 40 (datacenter) => datacenter.nodes.length 41 ); 42 const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts); 43 44 // The point at which the varation is too extreme for a two column layout 45 const threshold = 0.5; 46 if (variationCoefficient > threshold) return true; 47 return false; 48 } 49 50 get datacenterIsSingleColumn() { 51 // If there are enough nodes, use two columns of nodes within 52 // a single column layout of datacenters to increase density. 53 if (this.viewportColumns === 1) return true; 54 return ( 55 !this.isSingleColumn || 56 (this.isSingleColumn && this.args.nodes.length <= 20) 57 ); 58 } 59 60 // Once a cluster is large enough, the exact details of a node are 61 // typically irrelevant and a waste of space. 62 get isDense() { 63 return this.args.nodes.length > 50; 64 } 65 66 dataForNode(node) { 67 return { 68 node, 69 datacenter: node.datacenter, 70 memory: node.resources.memory, 71 cpu: node.resources.cpu, 72 allocations: [], 73 isSelected: false, 74 }; 75 } 76 77 dataForAllocation(allocation, node) { 78 const jobId = allocation.belongsTo('job').id(); 79 return { 80 allocation, 81 node, 82 jobId, 83 groupKey: JSON.stringify([jobId, allocation.taskGroupName]), 84 memory: allocation.allocatedResources.memory, 85 cpu: allocation.allocatedResources.cpu, 86 memoryPercent: allocation.allocatedResources.memory / node.memory, 87 cpuPercent: allocation.allocatedResources.cpu / node.cpu, 88 isSelected: false, 89 }; 90 } 91 92 @action 93 buildTopology() { 94 const nodes = this.args.nodes; 95 const allocations = this.args.allocations; 96 97 // Nodes may not have a resources property due to having an old Nomad agent version. 98 const badNodes = []; 99 100 // Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment 101 const nodeContainers = []; 102 const nodeIndex = {}; 103 nodes.forEach((node) => { 104 if (!node.resources) { 105 badNodes.push(node); 106 return; 107 } 108 109 const container = this.dataForNode(node); 110 nodeContainers.push(container); 111 nodeIndex[node.id] = container; 112 }); 113 114 // Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation 115 // index keyed off of job and task group 116 const allocationIndex = {}; 117 allocations.forEach((allocation) => { 118 const nodeId = allocation.belongsTo('node').id(); 119 const nodeContainer = nodeIndex[nodeId]; 120 121 // Ignore orphaned allocations and allocations on nodes with an old Nomad agent version. 122 if (!nodeContainer) return; 123 124 const allocationContainer = this.dataForAllocation( 125 allocation, 126 nodeContainer 127 ); 128 nodeContainer.allocations.push(allocationContainer); 129 130 const key = allocationContainer.groupKey; 131 if (!allocationIndex[key]) allocationIndex[key] = []; 132 allocationIndex[key].push(allocationContainer); 133 }); 134 135 // Group nodes into datacenters 136 const datacentersMap = nodeContainers.reduce( 137 (datacenters, nodeContainer) => { 138 if (!datacenters[nodeContainer.datacenter]) 139 datacenters[nodeContainer.datacenter] = []; 140 datacenters[nodeContainer.datacenter].push(nodeContainer); 141 return datacenters; 142 }, 143 {} 144 ); 145 146 // Turn hash of datacenters into a sorted array 147 const datacenters = Object.keys(datacentersMap) 148 .map((key) => ({ name: key, nodes: datacentersMap[key] })) 149 .sortBy('name'); 150 151 const topology = { 152 datacenters, 153 allocationIndex, 154 selectedKey: null, 155 heightScale: scaleLinear() 156 .range([15, 40]) 157 .domain(extent(nodeContainers.mapBy('memory'))), 158 }; 159 this.topology = topology; 160 161 if (badNodes.length && this.args.onDataError) { 162 this.args.onDataError([ 163 { 164 type: 'filtered-nodes', 165 context: badNodes, 166 }, 167 ]); 168 } 169 } 170 171 @action 172 captureElement(element) { 173 this.element = element; 174 this.determineViewportColumns(); 175 } 176 177 @action 178 showNodeDetails(node) { 179 if (this.activeNode) { 180 set(this.activeNode, 'isSelected', false); 181 } 182 183 this.activeNode = this.activeNode === node ? null : node; 184 185 if (this.activeNode) { 186 set(this.activeNode, 'isSelected', true); 187 } 188 189 if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode); 190 } 191 192 @action showTooltip(allocation, element) { 193 const bbox = element.getBoundingClientRect(); 194 this.highlightAllocation = allocation; 195 this.tooltipProps = { 196 left: window.scrollX + bbox.left + bbox.width / 2, 197 top: window.scrollY + bbox.top, 198 }; 199 } 200 201 @action hideTooltip() { 202 this.highlightAllocation = null; 203 } 204 205 @action 206 associateAllocations(allocation) { 207 if (this.activeAllocation === allocation) { 208 this.activeAllocation = null; 209 this.activeEdges = []; 210 211 if (this.topology.selectedKey) { 212 const selectedAllocations = 213 this.topology.allocationIndex[this.topology.selectedKey]; 214 if (selectedAllocations) { 215 selectedAllocations.forEach((allocation) => { 216 set(allocation, 'isSelected', false); 217 }); 218 } 219 set(this.topology, 'selectedKey', null); 220 } 221 } else { 222 if (this.activeNode) { 223 set(this.activeNode, 'isSelected', false); 224 } 225 this.activeNode = null; 226 this.activeAllocation = allocation; 227 const selectedAllocations = 228 this.topology.allocationIndex[this.topology.selectedKey]; 229 if (selectedAllocations) { 230 selectedAllocations.forEach((allocation) => { 231 set(allocation, 'isSelected', false); 232 }); 233 } 234 235 set(this.topology, 'selectedKey', allocation.groupKey); 236 const newAllocations = 237 this.topology.allocationIndex[this.topology.selectedKey]; 238 if (newAllocations) { 239 newAllocations.forEach((allocation) => { 240 set(allocation, 'isSelected', true); 241 }); 242 } 243 244 // Only show the lines if the selected allocations are sparse (low count relative to the client count or low count generally). 245 if ( 246 newAllocations.length < 10 || 247 newAllocations.length < this.args.nodes.length * 0.75 248 ) { 249 this.computedActiveEdges(); 250 } else { 251 this.activeEdges = []; 252 } 253 } 254 if (this.args.onAllocationSelect) 255 this.args.onAllocationSelect( 256 this.activeAllocation && this.activeAllocation.allocation 257 ); 258 if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode); 259 } 260 261 @action 262 determineViewportColumns() { 263 this.viewportColumns = this.element.clientWidth < 900 ? 1 : 2; 264 } 265 266 @action 267 resizeEdges() { 268 if (this.activeEdges.length > 0) { 269 this.computedActiveEdges(); 270 } 271 } 272 273 @action 274 computedActiveEdges() { 275 // Wait a render cycle 276 next(() => { 277 const path = line().curve(curveBasis); 278 // 1. Get the active element 279 const allocation = this.activeAllocation.allocation; 280 const activeEl = this.element.querySelector( 281 `[data-allocation-id="${allocation.id}"]` 282 ); 283 const activePoint = centerOfBBox(activeEl.getBoundingClientRect()); 284 285 // 2. Collect the mem and cpu pairs for all selected allocs 286 const selectedMem = Array.from( 287 this.element.querySelectorAll('.memory .bar.is-selected') 288 ); 289 const selectedPairs = selectedMem.map((mem) => { 290 const id = mem.closest('[data-allocation-id]').dataset.allocationId; 291 const cpu = mem 292 .closest('.topo-viz-node') 293 .querySelector(`.cpu .bar[data-allocation-id="${id}"]`); 294 return [mem, cpu]; 295 }); 296 const selectedPoints = selectedPairs.map((pair) => { 297 return pair.map((el) => centerOfBBox(el.getBoundingClientRect())); 298 }); 299 300 // 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active] 301 selectedPoints.forEach((points) => { 302 const d1 = pointBetween(points[0], activePoint, 100, 0.5); 303 const d2 = pointBetween(points[1], activePoint, 100, 0.5); 304 points.push(midpoint(d1, d2)); 305 }); 306 307 // 4. Generate curves for each active->mem and active->cpu pair going through the bisector 308 const curves = []; 309 // Steps are used to restrict the range of curves. The closer control points are placed, the less 310 // curvature the curve generator will generate. 311 const stepsMain = [0, 0.8, 1.0]; 312 // The second prong the fork does not need to retrace the entire path from the activePoint 313 const stepsSecondary = [0.8, 1.0]; 314 selectedPoints.forEach((points) => { 315 curves.push( 316 curveFromPoints( 317 ...pointsAlongPath(activePoint, points[2], stepsMain), 318 points[0] 319 ), 320 curveFromPoints( 321 ...pointsAlongPath(activePoint, points[2], stepsSecondary), 322 points[1] 323 ) 324 ); 325 }); 326 327 this.activeEdges = curves.map((curve) => path(curve)); 328 this.edgeOffset = { x: window.scrollX, y: window.scrollY }; 329 }); 330 } 331 } 332 333 function centerOfBBox(bbox) { 334 return { 335 x: bbox.x + bbox.width / 2, 336 y: bbox.y + bbox.height / 2, 337 }; 338 } 339 340 function dist(p1, p2) { 341 return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); 342 } 343 344 // Return the point between p1 and p2 at len (or pct if len > dist(p1, p2)) 345 function pointBetween(p1, p2, len, pct) { 346 const d = dist(p1, p2); 347 const ratio = d < len ? pct : len / d; 348 return pointBetweenPct(p1, p2, ratio); 349 } 350 351 function pointBetweenPct(p1, p2, pct) { 352 const dx = p2.x - p1.x; 353 const dy = p2.y - p1.y; 354 return { x: p1.x + dx * pct, y: p1.y + dy * pct }; 355 } 356 357 function pointsAlongPath(p1, p2, pcts) { 358 return pcts.map((pct) => pointBetweenPct(p1, p2, pct)); 359 } 360 361 function midpoint(p1, p2) { 362 return pointBetweenPct(p1, p2, 0.5); 363 } 364 365 function curveFromPoints(...points) { 366 return points.map((p) => [p.x, p.y]); 367 }