-
Notifications
You must be signed in to change notification settings - Fork 2k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
UI: Topology Visualization #9077
Changes from 45 commits
753bfbf
1e9e2a1
6d9f59f
b6867ef
a003a8a
773404a
7cf3b74
b3ddd1f
606910f
36df145
0b3a78b
c2b7f7c
f5c3d0e
f56f631
290f7d8
3cd464d
98dd258
2704c3e
e9626ce
9991dc6
d13c065
37cc9b7
be2d0fc
a9e2917
c152ccb
c5e8474
4e81ab8
f5f964b
a4c8ce4
bb68a14
de9124e
927343f
90dbab1
639b3ff
611b0b3
84f88ec
faaf697
b185d09
334f72c
f9dccf9
a5ea84d
9390d24
3a48dbf
13415df
b5809d4
229fa29
e540312
223011e
2cf4b5d
0d920ae
214ce4a
8f94a98
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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 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'; | ||
}); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I appreciate the explanations, I hope this ends up being useful somewhere else too! |
||
} | ||
} |
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 | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I appreciate these comments, would be nice if it were easy to have a reference diagram, but such is not the way hehe |
||
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]); | ||
} |
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 } | ||
); | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
tiny typo!