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