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