-
Notifications
You must be signed in to change notification settings - Fork 2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #9077 from hashicorp/f-ui/topo-viz
UI: Topology Visualization
- Loading branch information
Showing
60 changed files
with
3,665 additions
and
93 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,66 @@ | ||
import Component from '@glimmer/component'; | ||
import { tracked } from '@glimmer/tracking'; | ||
import { run } from '@ember/runloop'; | ||
import { action } from '@ember/object'; | ||
import { minIndex, max } from 'd3-array'; | ||
|
||
export default class FlexMasonry extends Component { | ||
@tracked element = null; | ||
|
||
@action | ||
captureElement(element) { | ||
this.element = element; | ||
} | ||
|
||
@action | ||
reflow() { | ||
run.next(() => { | ||
// There's nothing to do if this is a single column layout | ||
if (!this.element || this.args.columns === 1 || !this.args.columns) return; | ||
|
||
const columns = new Array(this.args.columns).fill(null).map(() => ({ | ||
height: 0, | ||
elements: [], | ||
})); | ||
|
||
const items = this.element.querySelectorAll('.flex-masonry-item'); | ||
|
||
// First pass: assign each element to a column based on the running heights of each column | ||
for (let item of items) { | ||
const styles = window.getComputedStyle(item); | ||
const marginTop = parseFloat(styles.marginTop); | ||
const marginBottom = parseFloat(styles.marginBottom); | ||
const height = item.clientHeight; | ||
|
||
// Pick the shortest column accounting for margins | ||
const column = columns[minIndex(columns, c => c.height)]; | ||
|
||
// Add the new element's height to the column height | ||
column.height += marginTop + height + marginBottom; | ||
column.elements.push(item); | ||
} | ||
|
||
// Second pass: assign an order to each element based on their column and position in the column | ||
columns | ||
.mapBy('elements') | ||
.flat() | ||
.forEach((dc, index) => { | ||
dc.style.order = index; | ||
}); | ||
|
||
// Guarantee column wrapping as predicted (if the first item of a column is shorter than the difference | ||
// beteen the height of the column and the previous column, then flexbox will naturally place the first | ||
// item at the end of the previous column). | ||
columns.forEach((column, index) => { | ||
const nextHeight = index < columns.length - 1 ? columns[index + 1].height : 0; | ||
const item = column.elements.lastObject; | ||
if (item) { | ||
item.style.flexBasis = item.clientHeight + Math.max(0, nextHeight - column.height) + 'px'; | ||
} | ||
}); | ||
|
||
// Set the max height of the container to the height of the tallest column | ||
this.element.style.maxHeight = max(columns.mapBy('height')) + 1 + 'px'; | ||
}); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,271 @@ | ||
import Component from '@glimmer/component'; | ||
import { tracked } from '@glimmer/tracking'; | ||
import { action, set } from '@ember/object'; | ||
import { run } from '@ember/runloop'; | ||
import { scaleLinear } from 'd3-scale'; | ||
import { extent, deviation, mean } from 'd3-array'; | ||
import { line, curveBasis } from 'd3-shape'; | ||
|
||
export default class TopoViz extends Component { | ||
@tracked element = null; | ||
@tracked topology = { datacenters: [] }; | ||
|
||
@tracked activeNode = null; | ||
@tracked activeAllocation = null; | ||
@tracked activeEdges = []; | ||
@tracked edgeOffset = { x: 0, y: 0 }; | ||
|
||
get isSingleColumn() { | ||
if (this.topology.datacenters.length <= 1) return true; | ||
|
||
// Compute the coefficient of variance to determine if it would be | ||
// better to stack datacenters or place them in columns | ||
const nodeCounts = this.topology.datacenters.map(datacenter => datacenter.nodes.length); | ||
const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts); | ||
|
||
// The point at which the varation is too extreme for a two column layout | ||
const threshold = 0.5; | ||
if (variationCoefficient > threshold) return true; | ||
return false; | ||
} | ||
|
||
get datacenterIsSingleColumn() { | ||
// If there are enough nodes, use two columns of nodes within | ||
// a single column layout of datacenters to increase density. | ||
return !this.isSingleColumn || (this.isSingleColumn && this.args.nodes.length <= 20); | ||
} | ||
|
||
// Once a cluster is large enough, the exact details of a node are | ||
// typically irrelevant and a waste of space. | ||
get isDense() { | ||
return this.args.nodes.length > 50; | ||
} | ||
|
||
dataForNode(node) { | ||
return { | ||
node, | ||
datacenter: node.datacenter, | ||
memory: node.resources.memory, | ||
cpu: node.resources.cpu, | ||
allocations: [], | ||
isSelected: false, | ||
}; | ||
} | ||
|
||
dataForAllocation(allocation, node) { | ||
const jobId = allocation.belongsTo('job').id(); | ||
return { | ||
allocation, | ||
node, | ||
jobId, | ||
groupKey: JSON.stringify([jobId, allocation.taskGroupName]), | ||
memory: allocation.allocatedResources.memory, | ||
cpu: allocation.allocatedResources.cpu, | ||
memoryPercent: allocation.allocatedResources.memory / node.memory, | ||
cpuPercent: allocation.allocatedResources.cpu / node.cpu, | ||
isSelected: false, | ||
}; | ||
} | ||
|
||
@action | ||
buildTopology() { | ||
const nodes = this.args.nodes; | ||
const allocations = this.args.allocations; | ||
|
||
// Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment | ||
const nodeContainers = []; | ||
const nodeIndex = {}; | ||
nodes.forEach(node => { | ||
const container = this.dataForNode(node); | ||
nodeContainers.push(container); | ||
nodeIndex[node.id] = container; | ||
}); | ||
|
||
// Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation | ||
// index keyed off of job and task group | ||
const allocationIndex = {}; | ||
allocations.forEach(allocation => { | ||
const nodeId = allocation.belongsTo('node').id(); | ||
const nodeContainer = nodeIndex[nodeId]; | ||
if (!nodeContainer) | ||
throw new Error(`Node ${nodeId} for alloc ${allocation.id} not in index.`); | ||
|
||
const allocationContainer = this.dataForAllocation(allocation, nodeContainer); | ||
nodeContainer.allocations.push(allocationContainer); | ||
|
||
const key = allocationContainer.groupKey; | ||
if (!allocationIndex[key]) allocationIndex[key] = []; | ||
allocationIndex[key].push(allocationContainer); | ||
}); | ||
|
||
// Group nodes into datacenters | ||
const datacentersMap = nodeContainers.reduce((datacenters, nodeContainer) => { | ||
if (!datacenters[nodeContainer.datacenter]) datacenters[nodeContainer.datacenter] = []; | ||
datacenters[nodeContainer.datacenter].push(nodeContainer); | ||
return datacenters; | ||
}, {}); | ||
|
||
// Turn hash of datacenters into a sorted array | ||
const datacenters = Object.keys(datacentersMap) | ||
.map(key => ({ name: key, nodes: datacentersMap[key] })) | ||
.sortBy('name'); | ||
|
||
const topology = { | ||
datacenters, | ||
allocationIndex, | ||
selectedKey: null, | ||
heightScale: scaleLinear() | ||
.range([15, 40]) | ||
.domain(extent(nodeContainers.mapBy('memory'))), | ||
}; | ||
this.topology = topology; | ||
} | ||
|
||
@action | ||
captureElement(element) { | ||
this.element = element; | ||
} | ||
|
||
@action | ||
showNodeDetails(node) { | ||
if (this.activeNode) { | ||
set(this.activeNode, 'isSelected', false); | ||
} | ||
|
||
this.activeNode = this.activeNode === node ? null : node; | ||
|
||
if (this.activeNode) { | ||
set(this.activeNode, 'isSelected', true); | ||
} | ||
|
||
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode); | ||
} | ||
|
||
@action | ||
associateAllocations(allocation) { | ||
if (this.activeAllocation === allocation) { | ||
this.activeAllocation = null; | ||
this.activeEdges = []; | ||
|
||
if (this.topology.selectedKey) { | ||
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey]; | ||
if (selectedAllocations) { | ||
selectedAllocations.forEach(allocation => { | ||
set(allocation, 'isSelected', false); | ||
}); | ||
} | ||
set(this.topology, 'selectedKey', null); | ||
} | ||
} else { | ||
if (this.activeNode) { | ||
set(this.activeNode, 'isSelected', false); | ||
} | ||
this.activeNode = null; | ||
this.activeAllocation = allocation; | ||
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey]; | ||
if (selectedAllocations) { | ||
selectedAllocations.forEach(allocation => { | ||
set(allocation, 'isSelected', false); | ||
}); | ||
} | ||
|
||
set(this.topology, 'selectedKey', allocation.groupKey); | ||
const newAllocations = this.topology.allocationIndex[this.topology.selectedKey]; | ||
if (newAllocations) { | ||
newAllocations.forEach(allocation => { | ||
set(allocation, 'isSelected', true); | ||
}); | ||
} | ||
|
||
this.computedActiveEdges(); | ||
} | ||
if (this.args.onAllocationSelect) | ||
this.args.onAllocationSelect(this.activeAllocation && this.activeAllocation.allocation); | ||
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode); | ||
} | ||
|
||
@action | ||
computedActiveEdges() { | ||
// Wait a render cycle | ||
run.next(() => { | ||
const path = line().curve(curveBasis); | ||
// 1. Get the active element | ||
const allocation = this.activeAllocation.allocation; | ||
const activeEl = this.element.querySelector(`[data-allocation-id="${allocation.id}"]`); | ||
const activePoint = centerOfBBox(activeEl.getBoundingClientRect()); | ||
|
||
// 2. Collect the mem and cpu pairs for all selected allocs | ||
const selectedMem = Array.from(this.element.querySelectorAll('.memory .bar.is-selected')); | ||
const selectedPairs = selectedMem.map(mem => { | ||
const id = mem.closest('[data-allocation-id]').dataset.allocationId; | ||
const cpu = mem | ||
.closest('.topo-viz-node') | ||
.querySelector(`.cpu .bar[data-allocation-id="${id}"]`); | ||
return [mem, cpu]; | ||
}); | ||
const selectedPoints = selectedPairs.map(pair => { | ||
return pair.map(el => centerOfBBox(el.getBoundingClientRect())); | ||
}); | ||
|
||
// 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active] | ||
selectedPoints.forEach(points => { | ||
const d1 = pointBetween(points[0], activePoint, 100, 0.5); | ||
const d2 = pointBetween(points[1], activePoint, 100, 0.5); | ||
points.push(midpoint(d1, d2)); | ||
}); | ||
|
||
// 4. Generate curves for each active->mem and active->cpu pair going through the bisector | ||
const curves = []; | ||
// Steps are used to restrict the range of curves. The closer control points are placed, the less | ||
// curvature the curve generator will generate. | ||
const stepsMain = [0, 0.8, 1.0]; | ||
// The second prong the fork does not need to retrace the entire path from the activePoint | ||
const stepsSecondary = [0.8, 1.0]; | ||
selectedPoints.forEach(points => { | ||
curves.push( | ||
curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsMain), points[0]), | ||
curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsSecondary), points[1]) | ||
); | ||
}); | ||
|
||
this.activeEdges = curves.map(curve => path(curve)); | ||
this.edgeOffset = { x: window.visualViewport.pageLeft, y: window.visualViewport.pageTop }; | ||
}); | ||
} | ||
} | ||
|
||
function centerOfBBox(bbox) { | ||
return { | ||
x: bbox.x + bbox.width / 2, | ||
y: bbox.y + bbox.height / 2, | ||
}; | ||
} | ||
|
||
function dist(p1, p2) { | ||
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2)); | ||
} | ||
|
||
// Return the point between p1 and p2 at len (or pct if len > dist(p1, p2)) | ||
function pointBetween(p1, p2, len, pct) { | ||
const d = dist(p1, p2); | ||
const ratio = d < len ? pct : len / d; | ||
return pointBetweenPct(p1, p2, ratio); | ||
} | ||
|
||
function pointBetweenPct(p1, p2, pct) { | ||
const dx = p2.x - p1.x; | ||
const dy = p2.y - p1.y; | ||
return { x: p1.x + dx * pct, y: p1.y + dy * pct }; | ||
} | ||
|
||
function pointsAlongPath(p1, p2, pcts) { | ||
return pcts.map(pct => pointBetweenPct(p1, p2, pct)); | ||
} | ||
|
||
function midpoint(p1, p2) { | ||
return pointBetweenPct(p1, p2, 0.5); | ||
} | ||
|
||
function curveFromPoints(...points) { | ||
return points.map(p => [p.x, p.y]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import Component from '@glimmer/component'; | ||
|
||
export default class TopoVizDatacenter extends Component { | ||
get scheduledAllocations() { | ||
return this.args.datacenter.nodes.reduce( | ||
(all, node) => all.concat(node.allocations.filterBy('allocation.isScheduled')), | ||
[] | ||
); | ||
} | ||
|
||
get aggregatedAllocationResources() { | ||
return this.scheduledAllocations.reduce( | ||
(totals, allocation) => { | ||
totals.cpu += allocation.cpu; | ||
totals.memory += allocation.memory; | ||
return totals; | ||
}, | ||
{ cpu: 0, memory: 0 } | ||
); | ||
} | ||
|
||
get aggregatedNodeResources() { | ||
return this.args.datacenter.nodes.reduce( | ||
(totals, node) => { | ||
totals.cpu += node.cpu; | ||
totals.memory += node.memory; | ||
return totals; | ||
}, | ||
{ cpu: 0, memory: 0 } | ||
); | ||
} | ||
} |
Oops, something went wrong.