0ab6b31cab
Now all data loading happens in the TopoViz component as well as computation of resource proportions. Allocation selection state is also managed centrally uses a dedicated structure indexed by group key (job id and task group name). This way allocations don't need to be scanned at the node level, which is O(n) at the best (assuming no ember overhead on recomputes).
223 lines
7 KiB
JavaScript
223 lines
7 KiB
JavaScript
import Component from '@glimmer/component';
|
|
import { tracked } from '@glimmer/tracking';
|
|
import { action, set } from '@ember/object';
|
|
import { run } from '@ember/runloop';
|
|
import { task } from 'ember-concurrency';
|
|
import { scaleLinear } from 'd3-scale';
|
|
import { extent } from 'd3-array';
|
|
import RSVP from 'rsvp';
|
|
|
|
export default class TopoViz extends Component {
|
|
@tracked heightScale = null;
|
|
@tracked isLoaded = false;
|
|
@tracked element = null;
|
|
@tracked topology = {};
|
|
|
|
@tracked activeAllocation = null;
|
|
@tracked activeEdges = [];
|
|
|
|
get activeTaskGroup() {
|
|
return this.activeAllocation && this.activeAllocation.taskGroupName;
|
|
}
|
|
|
|
get activeJobId() {
|
|
return this.activeAllocation && this.activeAllocation.belongsTo('job').id();
|
|
}
|
|
|
|
dataForNode(node) {
|
|
return {
|
|
node,
|
|
datacenter: node.datacenter,
|
|
memory: node.resources.memory,
|
|
cpu: node.resources.cpu,
|
|
allocations: [],
|
|
};
|
|
}
|
|
|
|
dataForAllocation(allocation, node) {
|
|
const jobId = allocation.belongsTo('job').id();
|
|
return {
|
|
allocation,
|
|
node,
|
|
jobId,
|
|
groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
|
|
memory: allocation.resources.memory,
|
|
cpu: allocation.resources.cpu,
|
|
memoryPercent: allocation.resources.memory / node.memory,
|
|
cpuPercent: allocation.resources.cpu / node.cpu,
|
|
isSelected: false,
|
|
};
|
|
}
|
|
|
|
@task(function*() {
|
|
const nodes = this.args.nodes;
|
|
const allocations = this.args.allocations;
|
|
|
|
// Nodes are probably partials and we'll need the resources on them
|
|
// TODO: this is an API update waiting to happen.
|
|
yield RSVP.all(nodes.map(node => (node.isPartial ? node.reload() : RSVP.resolve(node))));
|
|
|
|
// 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;
|
|
})
|
|
buildTopology;
|
|
|
|
@action
|
|
async loadNodes() {
|
|
await RSVP.all(this.args.nodes.map(node => node.reload()));
|
|
|
|
// TODO: Make the range dynamic based on the extent of the domain
|
|
this.heightScale = scaleLinear()
|
|
.range([15, 40])
|
|
.domain(extent(this.args.nodes.map(node => node.resources.memory)));
|
|
this.isLoaded = true;
|
|
|
|
// schedule masonry
|
|
run.schedule('afterRender', () => {
|
|
this.masonry();
|
|
});
|
|
}
|
|
|
|
@action
|
|
masonry() {
|
|
run.next(() => {
|
|
const datacenterSections = this.element.querySelectorAll('.topo-viz-datacenter');
|
|
const elementStyles = window.getComputedStyle(this.element);
|
|
if (!elementStyles) return;
|
|
|
|
const rowHeight = parseInt(elementStyles.getPropertyValue('grid-auto-rows')) || 0;
|
|
const rowGap = parseInt(elementStyles.getPropertyValue('grid-row-gap')) || 0;
|
|
|
|
if (!rowHeight) return;
|
|
|
|
for (let dc of datacenterSections) {
|
|
const contents = dc.querySelector('.masonry-container');
|
|
const height = contents.getBoundingClientRect().height;
|
|
const rowSpan = Math.ceil((height + rowGap) / (rowHeight + rowGap));
|
|
dc.style.gridRowEnd = `span ${rowSpan}`;
|
|
}
|
|
});
|
|
}
|
|
|
|
@action
|
|
captureElement(element) {
|
|
this.element = element;
|
|
}
|
|
|
|
@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 {
|
|
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);
|
|
}
|
|
|
|
@action
|
|
computedActiveEdges() {
|
|
// Wait a render cycle
|
|
run.next(() => {
|
|
const activeEl = this.element.querySelector(
|
|
`[data-allocation-id="${this.activeAllocation.allocation.id}"]`
|
|
);
|
|
const selectedAllocations = this.element.querySelectorAll('.memory .bar.is-selected');
|
|
const activeBBox = activeEl.getBoundingClientRect();
|
|
|
|
const vLeft = window.visualViewport.pageLeft;
|
|
const vTop = window.visualViewport.pageTop;
|
|
|
|
const edges = [];
|
|
for (let allocation of selectedAllocations) {
|
|
if (allocation !== activeEl) {
|
|
const bbox = allocation.getBoundingClientRect();
|
|
edges.push({
|
|
x1: activeBBox.x + activeBBox.width / 2 + vLeft,
|
|
y1: activeBBox.y + activeBBox.height / 2 + vTop,
|
|
x2: bbox.x + bbox.width / 2 + vLeft,
|
|
y2: bbox.y + bbox.height / 2 + vTop,
|
|
});
|
|
}
|
|
}
|
|
|
|
this.activeEdges = edges;
|
|
});
|
|
// get element for active alloc
|
|
// get element for all selected allocs
|
|
// draw lines between centroid of each
|
|
}
|
|
}
|