-
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
Merged
Merged
Changes from all commits
Commits
Show all changes
52 commits
Select commit
Hold shift + click to select a range
753bfbf
Scaffold a new topology page
DingoEatingFuzz 1e9e2a1
Small cluster example scenario for the topo viz
DingoEatingFuzz 6d9f59f
Scaffold topo viz components
DingoEatingFuzz b6867ef
Add ember modifiers addon
DingoEatingFuzz a003a8a
Quick window resize modifier
DingoEatingFuzz 773404a
Prototype of the topo viz
DingoEatingFuzz 7cf3b74
Fix factory bug that made it so pending allocs had no resources
DingoEatingFuzz b3ddd1f
Start click interaction for topo viz allocs
DingoEatingFuzz 606910f
Tweak topo scenario
DingoEatingFuzz 36df145
Fleshing out the first prototype of the topology visualization
DingoEatingFuzz 0b3a78b
Touch up topo viz interactions
DingoEatingFuzz c2b7f7c
Add cluster details to the topology page
DingoEatingFuzz f5c3d0e
Super rough allocation details, needs some style love
DingoEatingFuzz f56f631
Associate sibling allocations by drawing lines
DingoEatingFuzz 290f7d8
Guard against undefined denominators
DingoEatingFuzz 3cd464d
Updated alloc and cluster details on topo page
DingoEatingFuzz 98dd258
Medium scale topo scenario
DingoEatingFuzz 2704c3e
CSS Grid based masonry layout
DingoEatingFuzz e9626ce
Add more variety to the node heights
DingoEatingFuzz 9991dc6
Make the alloc select render path more efficient by not needlessly re…
DingoEatingFuzz d13c065
Refactor topo viz to do as much computation upfront & use faster data…
DingoEatingFuzz 37cc9b7
Connect the memory and cpu rectangles
DingoEatingFuzz be2d0fc
A better loading screen for the topo viz while nodes load
DingoEatingFuzz a9e2917
Second attempt at a masonry layout
DingoEatingFuzz c152ccb
New FlexMasonry component implements a masonry layout using flexbox
DingoEatingFuzz c5e8474
Apply FlexMasonry to the TopoViz component
DingoEatingFuzz 4e81ab8
Conditionally use the FlexMasonry layout for datacenters within TopoViz
DingoEatingFuzz f5f964b
Make the topo viz denser when there are >50 nodes
DingoEatingFuzz a4c8ce4
Fix a re-render bug with flexmasonry
DingoEatingFuzz bb68a14
Improved curves for allocation associations
DingoEatingFuzz de9124e
Label empty clients in the topo viz chart
DingoEatingFuzz 927343f
Add icons to denote draining or ineligibility of clients
DingoEatingFuzz 90dbab1
Filter total alloc count by only scheduled allocs
DingoEatingFuzz 639b3ff
More information about clients in the info panel
DingoEatingFuzz 611b0b3
FlexMasonry integration tests
DingoEatingFuzz 84f88ec
Test coverage for TopoViz::Node
DingoEatingFuzz faaf697
Migrate to the new resources properties for allocs and nodes
DingoEatingFuzz b185d09
Update topo viz code to use new alloc/node resources pattern
DingoEatingFuzz 334f72c
Unit test for for GiB in format-bytes
DingoEatingFuzz f9dccf9
Adjust topo viz controller to new resource code
DingoEatingFuzz a5ea84d
Update scenarios to use new resources code
DingoEatingFuzz 9390d24
Remove temp reloading of nodes in the TopoViz component
DingoEatingFuzz 3a48dbf
Test coverage for TopoViz::Datacenter
DingoEatingFuzz 13415df
Unit and integration tests for TopoViz component
DingoEatingFuzz b5809d4
Some light topology acceptance tests
DingoEatingFuzz 229fa29
Treat legend term pairs as single wrapping elements
DingoEatingFuzz e540312
Reset the standard environment values
DingoEatingFuzz 223011e
Describe the glimmer-factory better including the motive
DingoEatingFuzz 2cf4b5d
Remove the scenarios I didn't need/didn't get to
DingoEatingFuzz 0d920ae
Factor out the common sum aggregator used in the topology controller
DingoEatingFuzz 214ce4a
Work around Safari's lack of text transform support
DingoEatingFuzz 8f94a98
Typo
DingoEatingFuzz File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 | ||
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]); | ||
} |
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
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.
I appreciate the explanations, I hope this ends up being useful somewhere else too!