Merge pull request #9077 from hashicorp/f-ui/topo-viz
UI: Topology Visualization
This commit is contained in:
commit
12cae40388
66
ui/app/components/flex-masonry.js
Normal file
66
ui/app/components/flex-masonry.js
Normal file
|
@ -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';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
271
ui/app/components/topo-viz.js
Normal file
271
ui/app/components/topo-viz.js
Normal file
|
@ -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]);
|
||||||
|
}
|
32
ui/app/components/topo-viz/datacenter.js
Normal file
32
ui/app/components/topo-viz/datacenter.js
Normal file
|
@ -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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
182
ui/app/components/topo-viz/node.js
Normal file
182
ui/app/components/topo-viz/node.js
Normal file
|
@ -0,0 +1,182 @@
|
||||||
|
import Component from '@glimmer/component';
|
||||||
|
import { tracked } from '@glimmer/tracking';
|
||||||
|
import { action } from '@ember/object';
|
||||||
|
import { guidFor } from '@ember/object/internals';
|
||||||
|
|
||||||
|
export default class TopoVizNode extends Component {
|
||||||
|
@tracked data = { cpu: [], memory: [] };
|
||||||
|
@tracked dimensionsWidth = 0;
|
||||||
|
@tracked padding = 5;
|
||||||
|
@tracked activeAllocation = null;
|
||||||
|
|
||||||
|
get height() {
|
||||||
|
return this.args.heightScale ? this.args.heightScale(this.args.node.memory) : 15;
|
||||||
|
}
|
||||||
|
|
||||||
|
get labelHeight() {
|
||||||
|
return this.height / 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
get paddingLeft() {
|
||||||
|
const labelWidth = 20;
|
||||||
|
return this.padding + labelWidth;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since strokes are placed centered on the perimeter of fills, The width of the stroke needs to be removed from
|
||||||
|
// the height of the fill to match unstroked height and avoid clipping.
|
||||||
|
get selectedHeight() {
|
||||||
|
return this.height - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Since strokes are placed centered on the perimeter of fills, half the width of the stroke needs to be added to
|
||||||
|
// the yOffset to match heights with unstroked shapes.
|
||||||
|
get selectedYOffset() {
|
||||||
|
return this.height + 2.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
get yOffset() {
|
||||||
|
return this.height + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maskHeight() {
|
||||||
|
return this.height + this.yOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
get totalHeight() {
|
||||||
|
return this.maskHeight + this.padding * 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
get maskId() {
|
||||||
|
return `topo-viz-node-mask-${guidFor(this)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
get count() {
|
||||||
|
return this.args.node.allocations.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
get allocations() {
|
||||||
|
// Sort by the delta between memory and cpu percent. This creates the least amount of
|
||||||
|
// drift between the positional alignment of an alloc's cpu and memory representations.
|
||||||
|
return this.args.node.allocations.filterBy('allocation.isScheduled').sort((a, b) => {
|
||||||
|
const deltaA = Math.abs(a.memoryPercent - a.cpuPercent);
|
||||||
|
const deltaB = Math.abs(b.memoryPercent - b.cpuPercent);
|
||||||
|
return deltaA - deltaB;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async reloadNode() {
|
||||||
|
if (this.args.node.isPartial) {
|
||||||
|
await this.args.node.reload();
|
||||||
|
this.data = this.computeData(this.dimensionsWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
render(svg) {
|
||||||
|
this.dimensionsWidth = svg.clientWidth - this.padding - this.paddingLeft;
|
||||||
|
this.data = this.computeData(this.dimensionsWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
updateRender(svg) {
|
||||||
|
// Only update all data when the width changes
|
||||||
|
const newWidth = svg.clientWidth - this.padding - this.paddingLeft;
|
||||||
|
if (newWidth !== this.dimensionsWidth) {
|
||||||
|
this.dimensionsWidth = newWidth;
|
||||||
|
this.data = this.computeData(this.dimensionsWidth);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
highlightAllocation(allocation) {
|
||||||
|
this.activeAllocation = allocation;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
clearHighlight() {
|
||||||
|
this.activeAllocation = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectNode() {
|
||||||
|
if (this.args.isDense && this.args.onNodeSelect) {
|
||||||
|
this.args.onNodeSelect(this.args.node.isSelected ? null : this.args.node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
selectAllocation(allocation) {
|
||||||
|
if (this.args.onAllocationSelect) this.args.onAllocationSelect(allocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
containsActiveTaskGroup() {
|
||||||
|
return this.args.node.allocations.some(
|
||||||
|
allocation =>
|
||||||
|
allocation.taskGroupName === this.args.activeTaskGroup &&
|
||||||
|
allocation.belongsTo('job').id() === this.args.activeJobId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
computeData(width) {
|
||||||
|
const allocations = this.allocations;
|
||||||
|
let cpuOffset = 0;
|
||||||
|
let memoryOffset = 0;
|
||||||
|
|
||||||
|
const cpu = [];
|
||||||
|
const memory = [];
|
||||||
|
for (const allocation of allocations) {
|
||||||
|
const { cpuPercent, memoryPercent, isSelected } = allocation;
|
||||||
|
const isFirst = allocation === allocations[0];
|
||||||
|
|
||||||
|
let cpuWidth = cpuPercent * width - 1;
|
||||||
|
let memoryWidth = memoryPercent * width - 1;
|
||||||
|
if (isFirst) {
|
||||||
|
cpuWidth += 0.5;
|
||||||
|
memoryWidth += 0.5;
|
||||||
|
}
|
||||||
|
if (isSelected) {
|
||||||
|
cpuWidth--;
|
||||||
|
memoryWidth--;
|
||||||
|
}
|
||||||
|
|
||||||
|
cpu.push({
|
||||||
|
allocation,
|
||||||
|
offset: cpuOffset * 100,
|
||||||
|
percent: cpuPercent * 100,
|
||||||
|
width: cpuWidth,
|
||||||
|
x: cpuOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
|
||||||
|
className: allocation.allocation.clientStatus,
|
||||||
|
});
|
||||||
|
memory.push({
|
||||||
|
allocation,
|
||||||
|
offset: memoryOffset * 100,
|
||||||
|
percent: memoryPercent * 100,
|
||||||
|
width: memoryWidth,
|
||||||
|
x: memoryOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
|
||||||
|
className: allocation.allocation.clientStatus,
|
||||||
|
});
|
||||||
|
|
||||||
|
cpuOffset += cpuPercent;
|
||||||
|
memoryOffset += memoryPercent;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cpuRemainder = {
|
||||||
|
x: cpuOffset * width + 0.5,
|
||||||
|
width: width - cpuOffset * width,
|
||||||
|
};
|
||||||
|
const memoryRemainder = {
|
||||||
|
x: memoryOffset * width + 0.5,
|
||||||
|
width: width - memoryOffset * width,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
cpu,
|
||||||
|
memory,
|
||||||
|
cpuRemainder,
|
||||||
|
memoryRemainder,
|
||||||
|
cpuLabel: { x: -this.paddingLeft / 2, y: this.height / 2 + this.yOffset },
|
||||||
|
memoryLabel: { x: -this.paddingLeft / 2, y: this.height / 2 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
114
ui/app/controllers/topology.js
Normal file
114
ui/app/controllers/topology.js
Normal file
|
@ -0,0 +1,114 @@
|
||||||
|
import Controller from '@ember/controller';
|
||||||
|
import { computed, action } from '@ember/object';
|
||||||
|
import classic from 'ember-classic-decorator';
|
||||||
|
import { reduceToLargestUnit } from 'nomad-ui/helpers/format-bytes';
|
||||||
|
|
||||||
|
const sumAggregator = (sum, value) => sum + (value || 0);
|
||||||
|
|
||||||
|
@classic
|
||||||
|
export default class TopologyControllers extends Controller {
|
||||||
|
@computed('model.nodes.@each.datacenter')
|
||||||
|
get datacenters() {
|
||||||
|
return Array.from(new Set(this.model.nodes.mapBy('datacenter'))).compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('model.allocations.@each.isScheduled')
|
||||||
|
get scheduledAllocations() {
|
||||||
|
return this.model.allocations.filterBy('isScheduled');
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('model.nodes.@each.resources')
|
||||||
|
get totalMemory() {
|
||||||
|
const mibs = this.model.nodes.mapBy('resources.memory').reduce(sumAggregator, 0);
|
||||||
|
return mibs * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('model.nodes.@each.resources')
|
||||||
|
get totalCPU() {
|
||||||
|
return this.model.nodes.mapBy('resources.cpu').reduce((sum, cpu) => sum + (cpu || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('totalMemory')
|
||||||
|
get totalMemoryFormatted() {
|
||||||
|
return reduceToLargestUnit(this.totalMemory)[0].toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('totalCPU')
|
||||||
|
get totalMemoryUnits() {
|
||||||
|
return reduceToLargestUnit(this.totalMemory)[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('model.allocations.@each.allocatedResources')
|
||||||
|
get totalReservedMemory() {
|
||||||
|
const mibs = this.model.allocations.mapBy('allocatedResources.memory').reduce(sumAggregator, 0);
|
||||||
|
return mibs * 1024 * 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('model.allocations.@each.allocatedResources')
|
||||||
|
get totalReservedCPU() {
|
||||||
|
return this.model.allocations.mapBy('allocatedResources.cpu').reduce(sumAggregator, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('totalMemory', 'totalReservedMemory')
|
||||||
|
get reservedMemoryPercent() {
|
||||||
|
if (!this.totalReservedMemory || !this.totalMemory) return 0;
|
||||||
|
return this.totalReservedMemory / this.totalMemory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('totalCPU', 'totalReservedCPU')
|
||||||
|
get reservedCPUPercent() {
|
||||||
|
if (!this.totalReservedCPU || !this.totalCPU) return 0;
|
||||||
|
return this.totalReservedCPU / this.totalCPU;
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('activeAllocation', 'model.allocations.@each.{taskGroupName,job}')
|
||||||
|
get siblingAllocations() {
|
||||||
|
if (!this.activeAllocation) return [];
|
||||||
|
const taskGroup = this.activeAllocation.taskGroupName;
|
||||||
|
const jobId = this.activeAllocation.belongsTo('job').id();
|
||||||
|
|
||||||
|
return this.model.allocations.filter(allocation => {
|
||||||
|
return allocation.taskGroupName === taskGroup && allocation.belongsTo('job').id() === jobId;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('activeNode')
|
||||||
|
get nodeUtilization() {
|
||||||
|
const node = this.activeNode;
|
||||||
|
const [formattedMemory, memoryUnits] = reduceToLargestUnit(node.memory * 1024 * 1024);
|
||||||
|
const totalReservedMemory = node.allocations.mapBy('memory').reduce(sumAggregator, 0);
|
||||||
|
const totalReservedCPU = node.allocations.mapBy('cpu').reduce(sumAggregator, 0);
|
||||||
|
|
||||||
|
return {
|
||||||
|
totalMemoryFormatted: formattedMemory.toFixed(2),
|
||||||
|
totalMemoryUnits: memoryUnits,
|
||||||
|
|
||||||
|
totalMemory: node.memory * 1024 * 1024,
|
||||||
|
totalReservedMemory: totalReservedMemory * 1024 * 1024,
|
||||||
|
reservedMemoryPercent: totalReservedMemory / node.memory,
|
||||||
|
|
||||||
|
totalCPU: node.cpu,
|
||||||
|
totalReservedCPU,
|
||||||
|
reservedCPUPercent: totalReservedCPU / node.cpu,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@computed('siblingAllocations.@each.node')
|
||||||
|
get uniqueActiveAllocationNodes() {
|
||||||
|
return this.siblingAllocations.mapBy('node').uniq();
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
async setAllocation(allocation) {
|
||||||
|
if (allocation) {
|
||||||
|
await allocation.reload();
|
||||||
|
await allocation.job.reload();
|
||||||
|
}
|
||||||
|
this.set('activeAllocation', allocation);
|
||||||
|
}
|
||||||
|
|
||||||
|
@action
|
||||||
|
setNode(node) {
|
||||||
|
this.set('activeNode', node);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,6 @@
|
||||||
import Helper from '@ember/component/helper';
|
import Helper from '@ember/component/helper';
|
||||||
|
|
||||||
const UNITS = ['Bytes', 'KiB', 'MiB'];
|
const UNITS = ['Bytes', 'KiB', 'MiB', 'GiB'];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Bytes Formatter
|
* Bytes Formatter
|
||||||
|
@ -10,7 +10,7 @@ const UNITS = ['Bytes', 'KiB', 'MiB'];
|
||||||
* Outputs the bytes reduced to the largest supported unit size for which
|
* Outputs the bytes reduced to the largest supported unit size for which
|
||||||
* bytes is larger than one.
|
* bytes is larger than one.
|
||||||
*/
|
*/
|
||||||
export function formatBytes([bytes]) {
|
export function reduceToLargestUnit(bytes) {
|
||||||
bytes || (bytes = 0);
|
bytes || (bytes = 0);
|
||||||
let unitIndex = 0;
|
let unitIndex = 0;
|
||||||
while (bytes >= 1024 && unitIndex < UNITS.length - 1) {
|
while (bytes >= 1024 && unitIndex < UNITS.length - 1) {
|
||||||
|
@ -18,7 +18,12 @@ export function formatBytes([bytes]) {
|
||||||
unitIndex++;
|
unitIndex++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `${Math.floor(bytes)} ${UNITS[unitIndex]}`;
|
return [bytes, UNITS[unitIndex]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes([bytes]) {
|
||||||
|
const [number, unit] = reduceToLargestUnit(bytes);
|
||||||
|
return `${Math.floor(number)} ${unit}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Helper.helper(formatBytes);
|
export default Helper.helper(formatBytes);
|
||||||
|
|
|
@ -47,6 +47,11 @@ export default class Allocation extends Model {
|
||||||
@equal('clientStatus', 'running') isRunning;
|
@equal('clientStatus', 'running') isRunning;
|
||||||
@attr('boolean') isMigrating;
|
@attr('boolean') isMigrating;
|
||||||
|
|
||||||
|
@computed('clientStatus')
|
||||||
|
get isScheduled() {
|
||||||
|
return ['pending', 'running', 'failed'].includes(this.clientStatus);
|
||||||
|
}
|
||||||
|
|
||||||
// An allocation model created from any allocation list response will be lacking
|
// An allocation model created from any allocation list response will be lacking
|
||||||
// many properties (some of which can always be null). This is an indicator that
|
// many properties (some of which can always be null). This is an indicator that
|
||||||
// the allocation needs to be reloaded to get the complete allocation state.
|
// the allocation needs to be reloaded to get the complete allocation state.
|
||||||
|
|
10
ui/app/modifiers/window-resize.js
Normal file
10
ui/app/modifiers/window-resize.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { modifier } from 'ember-modifier';
|
||||||
|
|
||||||
|
export default modifier(function windowResize(element, [handler]) {
|
||||||
|
const boundHandler = ev => handler(element, ev);
|
||||||
|
window.addEventListener('resize', boundHandler);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', boundHandler);
|
||||||
|
};
|
||||||
|
});
|
|
@ -37,6 +37,8 @@ Router.map(function() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
this.route('topology');
|
||||||
|
|
||||||
this.route('csi', function() {
|
this.route('csi', function() {
|
||||||
this.route('volumes', function() {
|
this.route('volumes', function() {
|
||||||
this.route('volume', { path: '/:volume_name' });
|
this.route('volume', { path: '/:volume_name' });
|
||||||
|
|
27
ui/app/routes/topology.js
Normal file
27
ui/app/routes/topology.js
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
import { inject as service } from '@ember/service';
|
||||||
|
import Route from '@ember/routing/route';
|
||||||
|
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
|
||||||
|
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
|
||||||
|
import classic from 'ember-classic-decorator';
|
||||||
|
import RSVP from 'rsvp';
|
||||||
|
|
||||||
|
@classic
|
||||||
|
export default class TopologyRoute extends Route.extend(WithForbiddenState) {
|
||||||
|
@service store;
|
||||||
|
@service system;
|
||||||
|
|
||||||
|
breadcrumbs = [
|
||||||
|
{
|
||||||
|
label: 'Topology',
|
||||||
|
args: ['topology'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
model() {
|
||||||
|
return RSVP.hash({
|
||||||
|
jobs: this.store.findAll('job'),
|
||||||
|
allocations: this.store.query('allocation', { resources: true }),
|
||||||
|
nodes: this.store.query('node', { resources: true }),
|
||||||
|
}).catch(notifyForbidden(this));
|
||||||
|
}
|
||||||
|
}
|
|
@ -9,6 +9,21 @@ const taskGroupFromJob = (job, taskGroupName) => {
|
||||||
return taskGroup ? taskGroup : null;
|
return taskGroup ? taskGroup : null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const merge = tasks => {
|
||||||
|
const mergedResources = {
|
||||||
|
Cpu: { CpuShares: 0 },
|
||||||
|
Memory: { MemoryMB: 0 },
|
||||||
|
Disk: { DiskMB: 0 },
|
||||||
|
};
|
||||||
|
|
||||||
|
return tasks.reduce((resources, task) => {
|
||||||
|
resources.Cpu.CpuShares += (task.Cpu && task.Cpu.CpuShares) || 0;
|
||||||
|
resources.Memory.MemoryMB += (task.Memory && task.Memory.MemoryMB) || 0;
|
||||||
|
resources.Disk.DiskMB += (task.Disk && task.Disk.DiskMB) || 0;
|
||||||
|
return resources;
|
||||||
|
}, mergedResources);
|
||||||
|
};
|
||||||
|
|
||||||
@classic
|
@classic
|
||||||
export default class AllocationSerializer extends ApplicationSerializer {
|
export default class AllocationSerializer extends ApplicationSerializer {
|
||||||
@service system;
|
@service system;
|
||||||
|
@ -30,7 +45,7 @@ export default class AllocationSerializer extends ApplicationSerializer {
|
||||||
const state = states[key] || {};
|
const state = states[key] || {};
|
||||||
const summary = { Name: key };
|
const summary = { Name: key };
|
||||||
Object.keys(state).forEach(stateKey => (summary[stateKey] = state[stateKey]));
|
Object.keys(state).forEach(stateKey => (summary[stateKey] = state[stateKey]));
|
||||||
summary.Resources = hash.TaskResources && hash.TaskResources[key];
|
summary.Resources = hash.AllocatedResources && hash.AllocatedResources.Tasks[key];
|
||||||
return summary;
|
return summary;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -57,8 +72,13 @@ export default class AllocationSerializer extends ApplicationSerializer {
|
||||||
hash.PreemptedByAllocationID = hash.PreemptedByAllocation || null;
|
hash.PreemptedByAllocationID = hash.PreemptedByAllocation || null;
|
||||||
hash.WasPreempted = !!hash.PreemptedByAllocationID;
|
hash.WasPreempted = !!hash.PreemptedByAllocationID;
|
||||||
|
|
||||||
// When present, the resources are nested under AllocatedResources.Shared
|
const shared = hash.AllocatedResources && hash.AllocatedResources.Shared;
|
||||||
hash.AllocatedResources = hash.AllocatedResources && hash.AllocatedResources.Shared;
|
hash.AllocatedResources =
|
||||||
|
hash.AllocatedResources && merge(Object.values(hash.AllocatedResources.Tasks));
|
||||||
|
if (shared) {
|
||||||
|
hash.AllocatedResources.Ports = shared.Ports;
|
||||||
|
hash.AllocatedResources.Networks = shared.Networks;
|
||||||
|
}
|
||||||
|
|
||||||
// The Job definition for an allocation is only included in findRecord responses.
|
// The Job definition for an allocation is only included in findRecord responses.
|
||||||
hash.AllocationTaskGroup = !hash.Job ? null : taskGroupFromJob(hash.Job, hash.TaskGroup);
|
hash.AllocationTaskGroup = !hash.Job ? null : taskGroupFromJob(hash.Job, hash.TaskGroup);
|
||||||
|
|
|
@ -7,6 +7,8 @@ export default class NodeSerializer extends ApplicationSerializer {
|
||||||
attrs = {
|
attrs = {
|
||||||
isDraining: 'Drain',
|
isDraining: 'Drain',
|
||||||
httpAddr: 'HTTPAddr',
|
httpAddr: 'HTTPAddr',
|
||||||
|
resources: 'NodeResources',
|
||||||
|
reserved: 'ReservedResources',
|
||||||
};
|
};
|
||||||
|
|
||||||
mapToArray = ['Drivers', 'HostVolumes'];
|
mapToArray = ['Drivers', 'HostVolumes'];
|
||||||
|
|
|
@ -1,12 +1,21 @@
|
||||||
import ApplicationSerializer from './application';
|
import ApplicationSerializer from './application';
|
||||||
|
|
||||||
export default class ResourcesSerializer extends ApplicationSerializer {
|
export default class ResourcesSerializer extends ApplicationSerializer {
|
||||||
attrs = {
|
arrayNullOverrides = ['Ports', 'Networks'];
|
||||||
cpu: 'CPU',
|
|
||||||
memory: 'MemoryMB',
|
|
||||||
disk: 'DiskMB',
|
|
||||||
iops: 'IOPS',
|
|
||||||
};
|
|
||||||
|
|
||||||
arrayNullOverrides = ['Ports'];
|
normalize(typeHash, hash) {
|
||||||
|
hash.Cpu = hash.Cpu && hash.Cpu.CpuShares;
|
||||||
|
hash.Memory = hash.Memory && hash.Memory.MemoryMB;
|
||||||
|
hash.Disk = hash.Disk && hash.Disk.DiskMB;
|
||||||
|
|
||||||
|
// Networks for ReservedResources is different than for Resources.
|
||||||
|
// This smooths over the differences, but doesn't actually support
|
||||||
|
// anything in the ReservedResources.Networks object, since we don't
|
||||||
|
// use any of it in the UI.
|
||||||
|
if (!(hash.Networks instanceof Array)) {
|
||||||
|
hash.Networks = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.normalize(...arguments);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,9 @@
|
||||||
@import './charts/line-chart';
|
@import './charts/line-chart';
|
||||||
@import './charts/tooltip';
|
@import './charts/tooltip';
|
||||||
@import './charts/colors';
|
@import './charts/colors';
|
||||||
@import './charts/chart-annotation.scss';
|
@import './charts/chart-annotation';
|
||||||
|
@import './charts/topo-viz';
|
||||||
|
@import './charts/topo-viz-node';
|
||||||
|
|
||||||
.inline-chart {
|
.inline-chart {
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
|
|
|
@ -47,6 +47,10 @@ $lost: $dark;
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
border-radius: $radius;
|
border-radius: $radius;
|
||||||
|
|
||||||
|
&.is-wide {
|
||||||
|
width: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
$color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, $red;
|
$color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, $red;
|
||||||
@for $i from 1 through length($color-sequence) {
|
@for $i from 1 through length($color-sequence) {
|
||||||
&.swatch-#{$i - 1} {
|
&.swatch-#{$i - 1} {
|
||||||
|
|
94
ui/app/styles/charts/topo-viz-node.scss
Normal file
94
ui/app/styles/charts/topo-viz-node.scss
Normal file
|
@ -0,0 +1,94 @@
|
||||||
|
.topo-viz-node {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.label {
|
||||||
|
font-weight: $weight-normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart {
|
||||||
|
display: inline-block;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.node-background {
|
||||||
|
fill: $white-ter;
|
||||||
|
stroke-width: 1;
|
||||||
|
stroke: $grey-lighter;
|
||||||
|
|
||||||
|
&.is-interactive:hover {
|
||||||
|
fill: $white;
|
||||||
|
stroke: $grey-light;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-selected,
|
||||||
|
&.is-selected:hover {
|
||||||
|
fill: $white;
|
||||||
|
stroke: $grey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimension-background {
|
||||||
|
fill: lighten($grey-lighter, 5%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimensions.is-active {
|
||||||
|
.bar {
|
||||||
|
opacity: 0.2;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.is-selected {
|
||||||
|
stroke-width: 1px;
|
||||||
|
stroke: $blue;
|
||||||
|
fill: $blue-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.label {
|
||||||
|
text-anchor: middle;
|
||||||
|
alignment-baseline: central;
|
||||||
|
font-weight: $weight-normal;
|
||||||
|
fill: $grey;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
fill: $red;
|
||||||
|
transform: translate(50%, 50%);
|
||||||
|
|
||||||
|
text {
|
||||||
|
text-anchor: middle;
|
||||||
|
alignment-baseline: central;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
& + .topo-viz-node {
|
||||||
|
margin-top: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-empty {
|
||||||
|
.node-background {
|
||||||
|
stroke: $red;
|
||||||
|
stroke-width: 2;
|
||||||
|
fill: $white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dimension-background {
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex-masonry-columns-2 > .flex-masonry-item > .topo-viz-node .chart,
|
||||||
|
.flex-masonry-columns-2 > .flex-masonry-item > .topo-viz-node .label {
|
||||||
|
width: calc(100% - 0.75em);
|
||||||
|
}
|
35
ui/app/styles/charts/topo-viz.scss
Normal file
35
ui/app/styles/charts/topo-viz.scss
Normal file
|
@ -0,0 +1,35 @@
|
||||||
|
.topo-viz {
|
||||||
|
.topo-viz-datacenters {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: space-between;
|
||||||
|
margin-top: -0.75em;
|
||||||
|
|
||||||
|
.topo-viz-datacenter {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
width: calc(50% - 0.75em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-single-column .topo-viz-datacenter {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-viz-edges {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: visible;
|
||||||
|
|
||||||
|
.edge {
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke: $blue;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,6 +4,7 @@
|
||||||
@import './components/codemirror';
|
@import './components/codemirror';
|
||||||
@import './components/copy-button';
|
@import './components/copy-button';
|
||||||
@import './components/cli-window';
|
@import './components/cli-window';
|
||||||
|
@import './components/dashboard-metric';
|
||||||
@import './components/dropdown';
|
@import './components/dropdown';
|
||||||
@import './components/ember-power-select';
|
@import './components/ember-power-select';
|
||||||
@import './components/empty-message';
|
@import './components/empty-message';
|
||||||
|
@ -11,6 +12,7 @@
|
||||||
@import './components/event';
|
@import './components/event';
|
||||||
@import './components/exec-button';
|
@import './components/exec-button';
|
||||||
@import './components/exec-window';
|
@import './components/exec-window';
|
||||||
|
@import './components/flex-masonry';
|
||||||
@import './components/fs-explorer';
|
@import './components/fs-explorer';
|
||||||
@import './components/global-search-container';
|
@import './components/global-search-container';
|
||||||
@import './components/global-search-dropdown';
|
@import './components/global-search-dropdown';
|
||||||
|
@ -20,6 +22,7 @@
|
||||||
@import './components/inline-definitions';
|
@import './components/inline-definitions';
|
||||||
@import './components/job-diff';
|
@import './components/job-diff';
|
||||||
@import './components/json-viewer';
|
@import './components/json-viewer';
|
||||||
|
@import './components/legend';
|
||||||
@import './components/lifecycle-chart';
|
@import './components/lifecycle-chart';
|
||||||
@import './components/loading-spinner';
|
@import './components/loading-spinner';
|
||||||
@import './components/metrics';
|
@import './components/metrics';
|
||||||
|
|
50
ui/app/styles/components/dashboard-metric.scss
Normal file
50
ui/app/styles/components/dashboard-metric.scss
Normal file
|
@ -0,0 +1,50 @@
|
||||||
|
.dashboard-metric {
|
||||||
|
&:not(:last-child) {
|
||||||
|
margin-bottom: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.column:not(:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric {
|
||||||
|
text-align: left;
|
||||||
|
font-weight: $weight-bold;
|
||||||
|
font-size: $size-3;
|
||||||
|
|
||||||
|
.metric-units {
|
||||||
|
font-size: $size-4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.metric-label {
|
||||||
|
font-size: $body-size;
|
||||||
|
font-weight: $weight-normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.graphic {
|
||||||
|
padding-bottom: 0;
|
||||||
|
margin-bottom: 0;
|
||||||
|
|
||||||
|
> .column {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.annotation {
|
||||||
|
margin-top: -0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.with-divider {
|
||||||
|
border-top: 1px solid $grey-blue;
|
||||||
|
padding-top: 1.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pair {
|
||||||
|
font-size: $size-5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-faded {
|
||||||
|
color: darken($grey-blue, 20%);
|
||||||
|
}
|
||||||
|
}
|
37
ui/app/styles/components/flex-masonry.scss
Normal file
37
ui/app/styles/components/flex-masonry.scss
Normal file
|
@ -0,0 +1,37 @@
|
||||||
|
.flex-masonry {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-content: space-between;
|
||||||
|
margin-top: -0.75em;
|
||||||
|
|
||||||
|
&.flex-masonry-columns-1 > .flex-masonry-item {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
&.flex-masonry-columns-2 > .flex-masonry-item {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
&.flex-masonry-columns-3 > .flex-masonry-item {
|
||||||
|
width: 33%;
|
||||||
|
}
|
||||||
|
&.flex-masonry-columns-4 > .flex-masonry-item {
|
||||||
|
width: 25%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.with-spacing {
|
||||||
|
> .flex-masonry-item {
|
||||||
|
margin-top: 0.75em;
|
||||||
|
margin-bottom: 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.flex-masonry-columns-2 > .flex-masonry-item {
|
||||||
|
width: calc(50% - 0.75em);
|
||||||
|
}
|
||||||
|
&.flex-masonry-columns-3 > .flex-masonry-item {
|
||||||
|
width: calc(33% - 0.75em);
|
||||||
|
}
|
||||||
|
&.flex-masonry-columns-4 > .flex-masonry-item {
|
||||||
|
width: calc(25% - 0.75em);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
33
ui/app/styles/components/legend.scss
Normal file
33
ui/app/styles/components/legend.scss
Normal file
|
@ -0,0 +1,33 @@
|
||||||
|
.legend {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
|
||||||
|
.legend-label {
|
||||||
|
font-weight: $weight-bold;
|
||||||
|
margin-bottom: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-terms {
|
||||||
|
dt,
|
||||||
|
dd {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: $weight-bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-term {
|
||||||
|
display: inline-block;
|
||||||
|
whitespace: nowrap;
|
||||||
|
margin-right: 1.5em;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,6 +13,10 @@
|
||||||
height: 150px;
|
height: 150px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-short .primary-graphic {
|
||||||
|
height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
.secondary-graphic {
|
.secondary-graphic {
|
||||||
padding: 0.75em;
|
padding: 0.75em;
|
||||||
padding-bottom: 0;
|
padding-bottom: 0;
|
||||||
|
|
|
@ -23,4 +23,8 @@
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
margin-right: auto;
|
margin-right: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.is-flush {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,3 +23,7 @@ code {
|
||||||
.is-interactive {
|
.is-interactive {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.is-faded {
|
||||||
|
color: darken($grey-blue, 20%);
|
||||||
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ $blue: $vagrant-blue;
|
||||||
$purple: $terraform-purple;
|
$purple: $terraform-purple;
|
||||||
$red: #c84034;
|
$red: #c84034;
|
||||||
$grey-blue: #bbc4d1;
|
$grey-blue: #bbc4d1;
|
||||||
|
$blue-light: #c0d5ff;
|
||||||
|
|
||||||
$primary: $nomad-green;
|
$primary: $nomad-green;
|
||||||
$warning: $orange;
|
$warning: $orange;
|
||||||
|
|
13
ui/app/templates/components/flex-masonry.hbs
Normal file
13
ui/app/templates/components/flex-masonry.hbs
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
<div
|
||||||
|
data-test-flex-masonry
|
||||||
|
class="flex-masonry {{if @withSpacing "with-spacing"}} flex-masonry-columns-{{@columns}}"
|
||||||
|
{{did-insert this.captureElement}}
|
||||||
|
{{did-insert this.reflow}}
|
||||||
|
{{did-update this.reflow}}
|
||||||
|
{{window-resize this.reflow}}>
|
||||||
|
{{#each @items as |item|}}
|
||||||
|
<div data-test-flex-masonry-item class="flex-masonry-item">
|
||||||
|
{{yield item (action this.reflow)}}
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
|
@ -81,6 +81,7 @@
|
||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li><LinkTo @route="clients" @activeClass="is-active" data-test-gutter-link="clients">Clients</LinkTo></li>
|
<li><LinkTo @route="clients" @activeClass="is-active" data-test-gutter-link="clients">Clients</LinkTo></li>
|
||||||
<li><LinkTo @route="servers" @activeClass="is-active" data-test-gutter-link="servers">Servers</LinkTo></li>
|
<li><LinkTo @route="servers" @activeClass="is-active" data-test-gutter-link="servers">Servers</LinkTo></li>
|
||||||
|
<li><LinkTo @route="topology" @activeClass="is-active" data-test-gutter-link="topology">Topology</LinkTo></li>
|
||||||
</ul>
|
</ul>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
24
ui/app/templates/components/topo-viz.hbs
Normal file
24
ui/app/templates/components/topo-viz.hbs
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
<div data-test-topo-viz class="topo-viz {{if this.isSingleColumn "is-single-column"}}" {{did-insert this.buildTopology}} {{did-insert this.captureElement}}>
|
||||||
|
<FlexMasonry
|
||||||
|
@columns={{if this.isSingleColumn 1 2}}
|
||||||
|
@items={{this.topology.datacenters}}
|
||||||
|
@withSpacing={{true}} as |dc|>
|
||||||
|
<TopoViz::Datacenter
|
||||||
|
@datacenter={{dc}}
|
||||||
|
@isSingleColumn={{this.datacenterIsSingleColumn}}
|
||||||
|
@isDense={{this.isDense}}
|
||||||
|
@heightScale={{this.topology.heightScale}}
|
||||||
|
@onAllocationSelect={{this.associateAllocations}}
|
||||||
|
@onNodeSelect={{this.showNodeDetails}} />
|
||||||
|
</FlexMasonry>
|
||||||
|
|
||||||
|
{{#if this.activeAllocation}}
|
||||||
|
<svg data-test-allocation-associations class="chart topo-viz-edges" {{window-resize this.computedActiveEdges}}>
|
||||||
|
<g transform="translate({{this.edgeOffset.x}},{{this.edgeOffset.y}})">
|
||||||
|
{{#each this.activeEdges as |edge|}}
|
||||||
|
<path data-test-allocation-association class="edge" d={{edge}} />
|
||||||
|
{{/each}}
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
19
ui/app/templates/components/topo-viz/datacenter.hbs
Normal file
19
ui/app/templates/components/topo-viz/datacenter.hbs
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<div data-test-topo-viz-datacenter class="boxed-section topo-viz-datacenter">
|
||||||
|
<div data-test-topo-viz-datacenter-label class="boxed-section-head is-hollow">
|
||||||
|
<strong>{{@datacenter.name}}</strong>
|
||||||
|
<span class="bumper-left">{{this.scheduledAllocations.length}} Allocs</span>
|
||||||
|
<span class="bumper-left">{{@datacenter.nodes.length}} Nodes</span>
|
||||||
|
<span class="bumper-left is-faded">{{this.aggregatedAllocationResources.memory}}/{{this.aggregatedNodeResources.memory}} MiB,
|
||||||
|
{{this.aggregatedAllocationResources.cpu}}/{{this.aggregatedNodeResources.cpu}} Mhz</span>
|
||||||
|
</div>
|
||||||
|
<div class="boxed-section-body">
|
||||||
|
<FlexMasonry @columns={{if @isSingleColumn 1 2}} @items={{@datacenter.nodes}} as |node|>
|
||||||
|
<TopoViz::Node
|
||||||
|
@node={{node}}
|
||||||
|
@isDense={{@isDense}}
|
||||||
|
@heightScale={{@heightScale}}
|
||||||
|
@onAllocationSelect={{@onAllocationSelect}}
|
||||||
|
@onNodeSelect={{@onNodeSelect}}/>
|
||||||
|
</FlexMasonry>
|
||||||
|
</div>
|
||||||
|
</div>
|
116
ui/app/templates/components/topo-viz/node.hbs
Normal file
116
ui/app/templates/components/topo-viz/node.hbs
Normal file
|
@ -0,0 +1,116 @@
|
||||||
|
<div data-test-topo-viz-node class="topo-viz-node {{unless this.allocations.length "is-empty"}}" {{did-insert this.reloadNode}}>
|
||||||
|
{{#unless @isDense}}
|
||||||
|
<p data-test-label class="label">
|
||||||
|
{{#if @node.node.isDraining}}
|
||||||
|
<span data-test-status-icon class="tooltip" aria-label="Client is draining">{{x-icon "clock-outline" class="is-info"}}</span>
|
||||||
|
{{else if (not @node.node.isEligible)}}
|
||||||
|
<span data-test-status-icon class="tooltip" aria-label="Client is ineligible">{{x-icon "lock-closed" class="is-warning"}}</span>
|
||||||
|
{{/if}}
|
||||||
|
<strong>{{@node.node.name}}</strong>
|
||||||
|
<span class="bumper-left">{{this.count}} Allocs</span>
|
||||||
|
<span class="bumper-left is-faded">{{@node.memory}} MiB, {{@node.cpu}} Mhz</span>
|
||||||
|
</p>
|
||||||
|
{{/unless}}
|
||||||
|
<svg class="chart" height="{{this.totalHeight}}px" {{did-insert this.render}} {{did-update this.updateRender}} {{window-resize this.render}}>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="{{this.maskId}}">
|
||||||
|
<rect class="mask" x="0" y="0" width="{{this.dimensionsWidth}}px" height="{{this.maskHeight}}px" rx="2px" ry="2px" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
<rect
|
||||||
|
data-test-node-background
|
||||||
|
class="node-background {{if @node.isSelected "is-selected"}} {{if @isDense "is-interactive"}}"
|
||||||
|
width="100%"
|
||||||
|
height="{{this.totalHeight}}px"
|
||||||
|
rx="2px"
|
||||||
|
ry="2px"
|
||||||
|
{{on "click" this.selectNode}} />
|
||||||
|
{{#if this.allocations.length}}
|
||||||
|
<g
|
||||||
|
class="dimensions {{if this.activeAllocation "is-active"}}"
|
||||||
|
transform="translate({{this.paddingLeft}},{{this.padding}})"
|
||||||
|
width="{{this.dimensionsWidth}}px"
|
||||||
|
height="{{this.maskHeight}}px"
|
||||||
|
pointer-events="all"
|
||||||
|
{{on "mouseout" this.clearHighlight}}
|
||||||
|
>
|
||||||
|
<g class="memory">
|
||||||
|
{{#if this.data.memoryLabel}}
|
||||||
|
<text class="label" aria-label="Memory" transform="translate({{this.data.memoryLabel.x}},{{this.data.memoryLabel.y}})">M</text>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.data.memoryRemainder}}
|
||||||
|
<rect
|
||||||
|
class="dimension-background"
|
||||||
|
x="{{this.data.memoryRemainder.x}}px"
|
||||||
|
width="{{this.data.memoryRemainder.width}}px"
|
||||||
|
height="{{this.height}}px" />
|
||||||
|
{{/if}}
|
||||||
|
{{#each this.data.memory key="allocation.id" as |memory|}}
|
||||||
|
<g
|
||||||
|
data-test-memory-rect="{{memory.allocation.allocation.id}}"
|
||||||
|
class="bar {{memory.className}} {{if (eq this.activeAllocation memory.allocation) "is-active"}} {{if memory.allocation.isSelected "is-selected"}}"
|
||||||
|
clip-path="url(#{{this.maskId}})"
|
||||||
|
data-allocation-id="{{memory.allocation.allocation.id}}"
|
||||||
|
{{on "mouseenter" (fn this.highlightAllocation memory.allocation)}}
|
||||||
|
{{on "click" (fn this.selectAllocation memory.allocation)}}>
|
||||||
|
<rect
|
||||||
|
width="{{memory.width}}px"
|
||||||
|
height="{{if memory.allocation.isSelected this.selectedHeight this.height}}px"
|
||||||
|
x="{{memory.x}}px"
|
||||||
|
y="{{if memory.allocation.isSelected 0.5 0}}px"
|
||||||
|
class="layer-0" />
|
||||||
|
{{#if (or (eq memory.className "starting") (eq memory.className "pending"))}}
|
||||||
|
<rect
|
||||||
|
width="{{memory.width}}px"
|
||||||
|
height="{{if memory.allocation.isSelected this.selectedHeight this.height}}px"
|
||||||
|
x="{{memory.x}}px"
|
||||||
|
y="{{if memory.allocation.isSelected 0.5 0}}px"
|
||||||
|
class="layer-1" />
|
||||||
|
{{/if}}
|
||||||
|
</g>
|
||||||
|
{{/each}}
|
||||||
|
</g>
|
||||||
|
<g class="cpu">
|
||||||
|
{{#if this.data.cpuLabel}}
|
||||||
|
<text class="label" aria-label="CPU" transform="translate({{this.data.cpuLabel.x}},{{this.data.cpuLabel.y}})">C</text>
|
||||||
|
{{/if}}
|
||||||
|
{{#if this.data.cpuRemainder}}
|
||||||
|
<rect
|
||||||
|
class="dimension-background"
|
||||||
|
x="{{this.data.cpuRemainder.x}}px"
|
||||||
|
y="{{this.yOffset}}px"
|
||||||
|
width="{{this.data.cpuRemainder.width}}px"
|
||||||
|
height="{{this.height}}px" />
|
||||||
|
{{/if}}
|
||||||
|
{{#each this.data.cpu key="allocation.id" as |cpu|}}
|
||||||
|
<g
|
||||||
|
data-test-cpu-rect="{{cpu.allocation.allocation.id}}"
|
||||||
|
class="bar {{cpu.className}} {{if (eq this.activeAllocation cpu.allocation) "is-active"}} {{if cpu.allocation.isSelected "is-selected"}}"
|
||||||
|
clip-path="url(#{{this.maskId}})"
|
||||||
|
data-allocation-id="{{cpu.allocation.allocation.id}}"
|
||||||
|
{{on "mouseenter" (fn this.highlightAllocation cpu.allocation)}}
|
||||||
|
{{on "click" (fn this.selectAllocation cpu.allocation)}}>
|
||||||
|
<rect
|
||||||
|
width="{{cpu.width}}px"
|
||||||
|
height="{{if cpu.allocation.isSelected this.selectedHeight this.height}}px"
|
||||||
|
x="{{cpu.x}}px"
|
||||||
|
y="{{if cpu.allocation.isSelected this.selectedYOffset this.yOffset}}px"
|
||||||
|
class="layer-0" />
|
||||||
|
{{#if (or (eq cpu.className "starting") (eq cpu.className "pending"))}}
|
||||||
|
<rect
|
||||||
|
width="{{cpu.width}}px"
|
||||||
|
height="{{if cpu.allocation.isSelected this.selectedHeight this.height}}px"
|
||||||
|
x="{{cpu.x}}px"
|
||||||
|
y="{{if cpu.allocation.isSelected this.selectedYOffset this.yOffset}}px"
|
||||||
|
class="layer-1" />
|
||||||
|
{{/if}}
|
||||||
|
</g>
|
||||||
|
{{/each}}
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
{{else}}
|
||||||
|
<g class="empty-text"><text data-test-empty-message>Empty Client</text></g>
|
||||||
|
{{/if}}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
204
ui/app/templates/topology.hbs
Normal file
204
ui/app/templates/topology.hbs
Normal file
|
@ -0,0 +1,204 @@
|
||||||
|
{{title "Cluster Topology"}}
|
||||||
|
<PageLayout>
|
||||||
|
<section class="section is-full-width">
|
||||||
|
<div class="columns">
|
||||||
|
<div class="column is-one-quarter">
|
||||||
|
<div class="boxed-section">
|
||||||
|
<div class="boxed-section-head">Legend</div>
|
||||||
|
<div class="boxed-section-body">
|
||||||
|
<div class="legend">
|
||||||
|
<h3 class="legend-label">Metrics</h3>
|
||||||
|
<dl class="legend-terms">
|
||||||
|
<dt>M:</dt><dd>Memory</dd>
|
||||||
|
<dt>C:</dt><dd>CPU</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="legend">
|
||||||
|
<h3 class="legend-label">Allocation Status</h3>
|
||||||
|
<dl class="legend-terms">
|
||||||
|
<div class="legend-term"><dt><span class="color-swatch is-wide running" title="Running" /></dt><dd>Running</dd></div>
|
||||||
|
<div class="legend-term"><dt><span class="color-swatch is-wide failed" title="Failed" /></dt><dd>Failed</dd></div>
|
||||||
|
<div class="legend-term"><dt><span class="color-swatch is-wide pending" title="Starting" /></dt><dd>Starting</dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="boxed-section">
|
||||||
|
<div data-test-info-panel-title class="boxed-section-head">
|
||||||
|
{{#if this.activeNode}}Client{{else if this.activeAllocation}}Allocation{{else}}Cluster{{/if}} Details
|
||||||
|
</div>
|
||||||
|
<div class="boxed-section-body">
|
||||||
|
{{#if this.activeNode}}
|
||||||
|
{{#let this.activeNode.node as |node|}}
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<p class="metric">{{this.activeNode.allocations.length}} <span class="metric-label">Allocations</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<h3 class="pair">
|
||||||
|
<strong>Client:</strong>
|
||||||
|
<LinkTo @route="clients.client" @model={{node}}>
|
||||||
|
{{node.shortId}}
|
||||||
|
</LinkTo>
|
||||||
|
</h3>
|
||||||
|
<p><strong>Name:</strong> {{node.name}}</p>
|
||||||
|
<p><strong>Address:</strong> {{node.httpAddr}}</p>
|
||||||
|
<p><strong>Status:</strong> {{node.status}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<h3 class="pair">
|
||||||
|
<strong>Draining?</strong> <span class="{{if node.isDraining "status-text is-info"}}">{{if node.isDraining "Yes" "No"}}</span>
|
||||||
|
</h3>
|
||||||
|
<h3 class="pair">
|
||||||
|
<strong>Eligible?</strong> <span class="{{unless node.isEligible "status-text is-warning"}}">{{if node.isEligible "Yes" "No"}}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric with-divider">
|
||||||
|
<p class="metric">
|
||||||
|
{{this.nodeUtilization.totalMemoryFormatted}}
|
||||||
|
<span class="metric-units">{{this.nodeUtilization.totalMemoryUnits}}</span>
|
||||||
|
<span class="metric-label">of memory</span>
|
||||||
|
</p>
|
||||||
|
<div class="columns graphic">
|
||||||
|
<div class="column">
|
||||||
|
<div class="inline-chart" data-test-percentage-bar>
|
||||||
|
<progress
|
||||||
|
class="progress is-danger is-small"
|
||||||
|
value="{{this.nodeUtilization.reservedMemoryPercent}}"
|
||||||
|
max="1">
|
||||||
|
{{this.nodeUtilization.reservedMemoryPercent}}
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-minimum">
|
||||||
|
<span class="nowrap" data-test-percentage>{{format-percentage this.nodeUtilization.reservedMemoryPercent total=1}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="annotation" data-test-absolute-value>
|
||||||
|
<strong>{{format-bytes this.nodeUtilization.totalReservedMemory}}</strong> / {{format-bytes this.nodeUtilization.totalMemory}} reserved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<p class="metric">{{this.nodeUtilization.totalCPU}} <span class="metric-units">Mhz</span> <span class="metric-label">of CPU</span></p>
|
||||||
|
<div class="columns graphic">
|
||||||
|
<div class="column">
|
||||||
|
<div class="inline-chart" data-test-percentage-bar>
|
||||||
|
<progress
|
||||||
|
class="progress is-info is-small"
|
||||||
|
value="{{this.nodeUtilization.reservedCPUPercent}}"
|
||||||
|
max="1">
|
||||||
|
{{this.nodeUtilization.reservedCPUPercent}}
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-minimum">
|
||||||
|
<span class="nowrap" data-test-percentage>{{format-percentage this.nodeUtilization.reservedCPUPercent total=1}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="annotation" data-test-absolute-value>
|
||||||
|
<strong>{{this.nodeUtilization.totalReservedCPU}} Mhz</strong> / {{this.nodeUtilization.totalCPU}} Mhz reserved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/let}}
|
||||||
|
{{else if this.activeAllocation}}
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<h3 class="pair">
|
||||||
|
<strong>Allocation:</strong>
|
||||||
|
<LinkTo @route="allocations.allocation" @model={{this.activeAllocation}} class="is-primary">{{this.activeAllocation.shortId}}</LinkTo>
|
||||||
|
</h3>
|
||||||
|
<p><strong>Sibling Allocations:</strong> {{this.siblingAllocations.length}}</p>
|
||||||
|
<p><strong>Unique Client Placements:</strong> {{this.uniqueActiveAllocationNodes.length}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric with-divider">
|
||||||
|
<h3 class="pair">
|
||||||
|
<strong>Job:</strong>
|
||||||
|
<LinkTo
|
||||||
|
@route="jobs.job"
|
||||||
|
@model={{this.activeAllocation.job}}
|
||||||
|
@query={{hash jobNamespace=this.activeAllocation.job.namespace.id}} data-test-job>
|
||||||
|
{{this.activeAllocation.job.name}}</LinkTo>
|
||||||
|
<span class="is-faded" data-test-task-group> / {{this.activeAllocation.taskGroupName}}</span>
|
||||||
|
</h3>
|
||||||
|
<p><strong>Type:</strong> {{this.activeAllocation.job.type}}</p>
|
||||||
|
<p><strong>Priority:</strong> {{this.activeAllocation.job.priority}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric with-divider">
|
||||||
|
<h3 class="pair">
|
||||||
|
<strong>Client:</strong>
|
||||||
|
<LinkTo @route="clients.client" @model={{this.activeAllocation.node}}>
|
||||||
|
{{this.activeAllocation.node.shortId}}
|
||||||
|
</LinkTo>
|
||||||
|
</h3>
|
||||||
|
<p><strong>Name:</strong> {{this.activeAllocation.node.name}}</p>
|
||||||
|
<p><strong>Address:</strong> {{this.activeAllocation.node.httpAddr}}</p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric with-divider">
|
||||||
|
<PrimaryMetric @resource={{this.activeAllocation}} @metric="memory" class="is-short" />
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<PrimaryMetric @resource={{this.activeAllocation}} @metric="cpu" class="is-short" />
|
||||||
|
</div>
|
||||||
|
{{else}}
|
||||||
|
<div class="columns is-flush">
|
||||||
|
<div class="dashboard-metric column">
|
||||||
|
<p class="metric">{{this.model.nodes.length}} <span class="metric-label">Clients</span></p>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric column">
|
||||||
|
<p class="metric">{{this.scheduledAllocations.length}} <span class="metric-label">Allocations</span></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric with-divider">
|
||||||
|
<p class="metric">{{this.totalMemoryFormatted}} <span class="metric-units">{{this.totalMemoryUnits}}</span> <span class="metric-label">of memory</span></p>
|
||||||
|
<div class="columns graphic">
|
||||||
|
<div class="column">
|
||||||
|
<div class="inline-chart" data-test-percentage-bar>
|
||||||
|
<progress
|
||||||
|
class="progress is-danger is-small"
|
||||||
|
value="{{this.reservedMemoryPercent}}"
|
||||||
|
max="1">
|
||||||
|
{{this.reservedMemoryPercent}}
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-minimum">
|
||||||
|
<span class="nowrap" data-test-percentage>{{format-percentage this.reservedMemoryPercent total=1}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="annotation" data-test-absolute-value>
|
||||||
|
<strong>{{format-bytes this.totalReservedMemory}}</strong> / {{format-bytes this.totalMemory}} reserved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-metric">
|
||||||
|
<p class="metric">{{this.totalCPU}} <span class="metric-units">Mhz</span> <span class="metric-label">of CPU</span></p>
|
||||||
|
<div class="columns graphic">
|
||||||
|
<div class="column">
|
||||||
|
<div class="inline-chart" data-test-percentage-bar>
|
||||||
|
<progress
|
||||||
|
class="progress is-info is-small"
|
||||||
|
value="{{this.reservedCPUPercent}}"
|
||||||
|
max="1">
|
||||||
|
{{this.reservedCPUPercent}}
|
||||||
|
</progress>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column is-minimum">
|
||||||
|
<span class="nowrap" data-test-percentage>{{format-percentage this.reservedCPUPercent total=1}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="annotation" data-test-absolute-value>
|
||||||
|
<strong>{{this.totalReservedCPU}} Mhz</strong> / {{this.totalCPU}} Mhz reserved
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="column">
|
||||||
|
<TopoViz
|
||||||
|
@nodes={{this.model.nodes}}
|
||||||
|
@allocations={{this.model.allocations}}
|
||||||
|
@onAllocationSelect={{action this.setAllocation}}
|
||||||
|
@onNodeSelect={{action this.setNode}} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</PageLayout>
|
|
@ -25,8 +25,8 @@ module.exports = function(environment) {
|
||||||
|
|
||||||
APP: {
|
APP: {
|
||||||
blockingQueries: true,
|
blockingQueries: true,
|
||||||
mirageScenario: 'smallCluster',
|
mirageScenario: 'topoMedium',
|
||||||
mirageWithNamespaces: true,
|
mirageWithNamespaces: false,
|
||||||
mirageWithTokens: true,
|
mirageWithTokens: true,
|
||||||
mirageWithRegions: true,
|
mirageWithRegions: true,
|
||||||
},
|
},
|
||||||
|
|
|
@ -5,10 +5,8 @@ import { provide } from './utils';
|
||||||
const CPU_RESERVATIONS = [250, 500, 1000, 2000, 2500, 4000];
|
const CPU_RESERVATIONS = [250, 500, 1000, 2000, 2500, 4000];
|
||||||
const MEMORY_RESERVATIONS = [256, 512, 1024, 2048, 4096, 8192];
|
const MEMORY_RESERVATIONS = [256, 512, 1024, 2048, 4096, 8192];
|
||||||
const DISK_RESERVATIONS = [200, 500, 1000, 2000, 5000, 10000, 100000];
|
const DISK_RESERVATIONS = [200, 500, 1000, 2000, 5000, 10000, 100000];
|
||||||
const IOPS_RESERVATIONS = [100000, 250000, 500000, 1000000, 10000000, 20000000];
|
|
||||||
|
|
||||||
// There is also a good chance that certain resource restrictions are unbounded
|
// There is also a good chance that certain resource restrictions are unbounded
|
||||||
IOPS_RESERVATIONS.push(...Array(1000).fill(0));
|
|
||||||
DISK_RESERVATIONS.push(...Array(500).fill(0));
|
DISK_RESERVATIONS.push(...Array(500).fill(0));
|
||||||
|
|
||||||
const NETWORK_MODES = ['bridge', 'host'];
|
const NETWORK_MODES = ['bridge', 'host'];
|
||||||
|
@ -27,10 +25,15 @@ export const STORAGE_PROVIDERS = ['ebs', 'zfs', 'nfs', 'cow', 'moo'];
|
||||||
|
|
||||||
export function generateResources(options = {}) {
|
export function generateResources(options = {}) {
|
||||||
return {
|
return {
|
||||||
CPU: options.CPU || faker.helpers.randomize(CPU_RESERVATIONS),
|
Cpu: {
|
||||||
MemoryMB: options.MemoryMB || faker.helpers.randomize(MEMORY_RESERVATIONS),
|
CpuShares: options.CPU || faker.helpers.randomize(CPU_RESERVATIONS),
|
||||||
DiskMB: options.DiskMB || faker.helpers.randomize(DISK_RESERVATIONS),
|
},
|
||||||
IOPS: options.IOPS || faker.helpers.randomize(IOPS_RESERVATIONS),
|
Memory: {
|
||||||
|
MemoryMB: options.MemoryMB || faker.helpers.randomize(MEMORY_RESERVATIONS),
|
||||||
|
},
|
||||||
|
Disk: {
|
||||||
|
DiskMB: options.DiskMB || faker.helpers.randomize(DISK_RESERVATIONS),
|
||||||
|
},
|
||||||
Networks: generateNetworks(options.networks),
|
Networks: generateNetworks(options.networks),
|
||||||
Ports: generatePorts(options.networks),
|
Ports: generatePorts(options.networks),
|
||||||
};
|
};
|
||||||
|
|
|
@ -42,15 +42,16 @@ export default Factory.extend({
|
||||||
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
|
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
|
||||||
const resources = taskGroup.taskIds.map(id => {
|
const resources = taskGroup.taskIds.map(id => {
|
||||||
const task = server.db.tasks.find(id);
|
const task = server.db.tasks.find(id);
|
||||||
return server.create(
|
return server.create('task-resource', {
|
||||||
'task-resource',
|
allocation,
|
||||||
{
|
name: task.name,
|
||||||
allocation,
|
resources: generateResources({
|
||||||
name: task.name,
|
CPU: task.resources.CPU,
|
||||||
resources: task.Resources,
|
MemoryMB: task.resources.MemoryMB,
|
||||||
},
|
DiskMB: task.resources.DiskMB,
|
||||||
'withReservedPorts'
|
networks: { minPorts: 1 },
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
allocation.update({ taskResourceIds: resources.mapBy('id') });
|
allocation.update({ taskResourceIds: resources.mapBy('id') });
|
||||||
|
@ -62,29 +63,22 @@ export default Factory.extend({
|
||||||
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
|
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
|
||||||
const resources = taskGroup.taskIds.map(id => {
|
const resources = taskGroup.taskIds.map(id => {
|
||||||
const task = server.db.tasks.find(id);
|
const task = server.db.tasks.find(id);
|
||||||
return server.create(
|
return server.create('task-resource', {
|
||||||
'task-resource',
|
allocation,
|
||||||
{
|
name: task.name,
|
||||||
allocation,
|
resources: generateResources({
|
||||||
name: task.name,
|
CPU: task.resources.CPU,
|
||||||
resources: task.Resources,
|
MemoryMB: task.resources.MemoryMB,
|
||||||
},
|
DiskMB: task.resources.DiskMB,
|
||||||
'withoutReservedPorts'
|
networks: { minPorts: 0, maxPorts: 0 },
|
||||||
);
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
allocation.update({ taskResourceIds: resources.mapBy('id') });
|
allocation.update({ taskResourceIds: resources.mapBy('id') });
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
withAllocatedResources: trait({
|
|
||||||
allocatedResources: () => {
|
|
||||||
return {
|
|
||||||
Shared: generateResources({ networks: { minPorts: 2 } }),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
|
|
||||||
rescheduleAttempts: 0,
|
rescheduleAttempts: 0,
|
||||||
rescheduleSuccess: false,
|
rescheduleSuccess: false,
|
||||||
|
|
||||||
|
@ -200,13 +194,13 @@ export default Factory.extend({
|
||||||
return server.create('task-resource', {
|
return server.create('task-resource', {
|
||||||
allocation,
|
allocation,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
resources: task.Resources,
|
resources: task.originalResources,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
allocation.update({
|
allocation.update({
|
||||||
taskStateIds: allocation.clientStatus === 'pending' ? [] : states.mapBy('id'),
|
taskStateIds: allocation.clientStatus === 'pending' ? [] : states.mapBy('id'),
|
||||||
taskResourceIds: allocation.clientStatus === 'pending' ? [] : resources.mapBy('id'),
|
taskResourceIds: resources.mapBy('id'),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Each allocation has a corresponding allocation stats running on some client.
|
// Each allocation has a corresponding allocation stats running on some client.
|
||||||
|
|
|
@ -74,7 +74,7 @@ export default Factory.extend({
|
||||||
|
|
||||||
hostVolumes: makeHostVolumes,
|
hostVolumes: makeHostVolumes,
|
||||||
|
|
||||||
resources: generateResources,
|
nodeResources: generateResources,
|
||||||
|
|
||||||
attributes() {
|
attributes() {
|
||||||
// TODO add variability to these
|
// TODO add variability to these
|
||||||
|
|
|
@ -79,7 +79,7 @@ export default Factory.extend({
|
||||||
|
|
||||||
const maybeResources = {};
|
const maybeResources = {};
|
||||||
if (resources) {
|
if (resources) {
|
||||||
maybeResources.Resources = generateResources(resources[idx]);
|
maybeResources.originalResources = generateResources(resources[idx]);
|
||||||
}
|
}
|
||||||
return server.create('task', {
|
return server.create('task', {
|
||||||
taskGroup: group,
|
taskGroup: group,
|
||||||
|
|
|
@ -5,12 +5,4 @@ export default Factory.extend({
|
||||||
name: () => '!!!this should be set by the allocation that owns this task state!!!',
|
name: () => '!!!this should be set by the allocation that owns this task state!!!',
|
||||||
|
|
||||||
resources: generateResources,
|
resources: generateResources,
|
||||||
|
|
||||||
withReservedPorts: trait({
|
|
||||||
resources: () => generateResources({ networks: { minPorts: 1 } }),
|
|
||||||
}),
|
|
||||||
|
|
||||||
withoutReservedPorts: trait({
|
|
||||||
resources: () => generateResources({ networks: { minPorts: 0, maxPorts: 0 } }),
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
|
@ -16,7 +16,17 @@ export default Factory.extend({
|
||||||
name: id => `task-${faker.hacker.noun().dasherize()}-${id}`,
|
name: id => `task-${faker.hacker.noun().dasherize()}-${id}`,
|
||||||
driver: () => faker.helpers.randomize(DRIVERS),
|
driver: () => faker.helpers.randomize(DRIVERS),
|
||||||
|
|
||||||
Resources: generateResources,
|
originalResources: generateResources,
|
||||||
|
resources: function() {
|
||||||
|
// Generate resources the usual way, but transform to the old
|
||||||
|
// shape because that's what the job spec uses.
|
||||||
|
const resources = this.originalResources;
|
||||||
|
return {
|
||||||
|
CPU: resources.Cpu.CpuShares,
|
||||||
|
MemoryMB: resources.Memory.MemoryMB,
|
||||||
|
DiskMB: resources.Disk.DiskMB,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
Lifecycle: i => {
|
Lifecycle: i => {
|
||||||
const cycle = i % 5;
|
const cycle = i % 5;
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
import config from 'nomad-ui/config/environment';
|
import config from 'nomad-ui/config/environment';
|
||||||
|
import * as topoScenarios from './topo';
|
||||||
import { pickOne } from '../utils';
|
import { pickOne } from '../utils';
|
||||||
|
|
||||||
const withNamespaces = getConfigValue('mirageWithNamespaces', false);
|
const withNamespaces = getConfigValue('mirageWithNamespaces', false);
|
||||||
|
@ -14,6 +15,7 @@ const allScenarios = {
|
||||||
allNodeTypes,
|
allNodeTypes,
|
||||||
everyFeature,
|
everyFeature,
|
||||||
emptyCluster,
|
emptyCluster,
|
||||||
|
...topoScenarios,
|
||||||
};
|
};
|
||||||
|
|
||||||
const scenario = getConfigValue('mirageScenario', 'emptyCluster');
|
const scenario = getConfigValue('mirageScenario', 'emptyCluster');
|
||||||
|
|
109
ui/mirage/scenarios/topo.js
Normal file
109
ui/mirage/scenarios/topo.js
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
|
import { generateNetworks, generatePorts } from '../common';
|
||||||
|
|
||||||
|
const genResources = (CPU, Memory) => ({
|
||||||
|
Cpu: { CpuShares: CPU },
|
||||||
|
Memory: { MemoryMB: Memory },
|
||||||
|
Disk: { DiskMB: 10000 },
|
||||||
|
Networks: generateNetworks(),
|
||||||
|
Ports: generatePorts(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export function topoSmall(server) {
|
||||||
|
server.createList('agent', 3);
|
||||||
|
server.createList('node', 12, {
|
||||||
|
datacenter: 'dc1',
|
||||||
|
status: 'ready',
|
||||||
|
nodeResources: genResources(3000, 5192),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobResources = [
|
||||||
|
['M: 2560, C: 150'],
|
||||||
|
['M: 128, C: 400'],
|
||||||
|
['M: 512, C: 100'],
|
||||||
|
['M: 256, C: 150'],
|
||||||
|
['M: 200, C: 50'],
|
||||||
|
['M: 64, C: 100'],
|
||||||
|
['M: 128, C: 150'],
|
||||||
|
['M: 1024, C: 500'],
|
||||||
|
['M: 100, C: 300', 'M: 200, C: 150'],
|
||||||
|
['M: 512, C: 250', 'M: 600, C: 200'],
|
||||||
|
];
|
||||||
|
|
||||||
|
jobResources.forEach(spec => {
|
||||||
|
server.create('job', {
|
||||||
|
status: 'running',
|
||||||
|
datacenters: ['dc1'],
|
||||||
|
type: 'service',
|
||||||
|
createAllocations: false,
|
||||||
|
resourceSpec: spec,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.createList('allocation', 25, {
|
||||||
|
forceRunningClientStatus: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topoMedium(server) {
|
||||||
|
server.createList('agent', 3);
|
||||||
|
server.createList('node', 10, {
|
||||||
|
datacenter: 'us-west-1',
|
||||||
|
status: 'ready',
|
||||||
|
nodeResources: genResources(3000, 5192),
|
||||||
|
});
|
||||||
|
server.createList('node', 12, {
|
||||||
|
datacenter: 'us-east-1',
|
||||||
|
status: 'ready',
|
||||||
|
nodeResources: genResources(3000, 5192),
|
||||||
|
});
|
||||||
|
server.createList('node', 11, {
|
||||||
|
datacenter: 'eu-west-1',
|
||||||
|
status: 'ready',
|
||||||
|
nodeResources: genResources(3000, 5192),
|
||||||
|
});
|
||||||
|
|
||||||
|
server.createList('node', 8, {
|
||||||
|
datacenter: 'us-west-1',
|
||||||
|
status: 'ready',
|
||||||
|
nodeResources: genResources(8000, 12192),
|
||||||
|
});
|
||||||
|
server.createList('node', 9, {
|
||||||
|
datacenter: 'us-east-1',
|
||||||
|
status: 'ready',
|
||||||
|
nodeResources: genResources(8000, 12192),
|
||||||
|
});
|
||||||
|
|
||||||
|
const jobResources = [
|
||||||
|
['M: 2560, C: 150'],
|
||||||
|
['M: 128, C: 400'],
|
||||||
|
['M: 512, C: 100'],
|
||||||
|
['M: 256, C: 150'],
|
||||||
|
['M: 200, C: 50'],
|
||||||
|
['M: 64, C: 100'],
|
||||||
|
['M: 128, C: 150'],
|
||||||
|
['M: 1024, C: 500'],
|
||||||
|
|
||||||
|
['M: 1200, C: 50'],
|
||||||
|
['M: 1400, C: 200'],
|
||||||
|
['M: 50, C: 150'],
|
||||||
|
['M: 5000, C: 1800'],
|
||||||
|
|
||||||
|
['M: 100, C: 300', 'M: 200, C: 150'],
|
||||||
|
['M: 512, C: 250', 'M: 600, C: 200'],
|
||||||
|
];
|
||||||
|
|
||||||
|
jobResources.forEach(spec => {
|
||||||
|
server.create('job', {
|
||||||
|
status: 'running',
|
||||||
|
datacenters: ['dc1'],
|
||||||
|
type: 'service',
|
||||||
|
createAllocations: false,
|
||||||
|
resourceSpec: spec,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.createList('allocation', 100, {
|
||||||
|
forceRunningClientStatus: true,
|
||||||
|
});
|
||||||
|
}
|
|
@ -18,14 +18,14 @@ export default ApplicationSerializer.extend({
|
||||||
|
|
||||||
function serializeAllocation(allocation) {
|
function serializeAllocation(allocation) {
|
||||||
allocation.TaskStates = allocation.TaskStates.reduce(arrToObj('Name'), {});
|
allocation.TaskStates = allocation.TaskStates.reduce(arrToObj('Name'), {});
|
||||||
allocation.Resources = allocation.TaskResources.mapBy('Resources').reduce(
|
const { Ports, Networks } = allocation.TaskResources[0]
|
||||||
(hash, resources) => {
|
? allocation.TaskResources[0].Resources
|
||||||
['CPU', 'DiskMB', 'IOPS', 'MemoryMB'].forEach(key => (hash[key] += resources[key]));
|
: {};
|
||||||
hash.Networks = resources.Networks;
|
allocation.AllocatedResources = {
|
||||||
hash.Ports = resources.Ports;
|
Shared: { Ports, Networks },
|
||||||
return hash;
|
Tasks: allocation.TaskResources.map(({ Name, Resources }) => ({ Name, ...Resources })).reduce(
|
||||||
},
|
arrToObj('Name'),
|
||||||
{ CPU: 0, DiskMB: 0, IOPS: 0, MemoryMB: 0 }
|
{}
|
||||||
);
|
),
|
||||||
allocation.TaskResources = allocation.TaskResources.reduce(arrToObj('Name', 'Resources'), {});
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
"broccoli-asset-rev": "^3.0.0",
|
"broccoli-asset-rev": "^3.0.0",
|
||||||
"bulma": "0.6.1",
|
"bulma": "0.6.1",
|
||||||
"core-js": "^2.4.1",
|
"core-js": "^2.4.1",
|
||||||
"d3-array": "^1.2.0",
|
"d3-array": "^2.1.0",
|
||||||
"d3-axis": "^1.0.0",
|
"d3-axis": "^1.0.0",
|
||||||
"d3-format": "^1.3.0",
|
"d3-format": "^1.3.0",
|
||||||
"d3-scale": "^1.0.0",
|
"d3-scale": "^1.0.0",
|
||||||
|
@ -84,6 +84,7 @@
|
||||||
"ember-inline-svg": "^0.3.0",
|
"ember-inline-svg": "^0.3.0",
|
||||||
"ember-load-initializers": "^2.1.1",
|
"ember-load-initializers": "^2.1.1",
|
||||||
"ember-maybe-import-regenerator": "^0.1.6",
|
"ember-maybe-import-regenerator": "^0.1.6",
|
||||||
|
"ember-modifier": "^2.1.0",
|
||||||
"ember-moment": "^7.8.1",
|
"ember-moment": "^7.8.1",
|
||||||
"ember-overridable-computed": "^1.0.0",
|
"ember-overridable-computed": "^1.0.0",
|
||||||
"ember-page-title": "^5.0.2",
|
"ember-page-title": "^5.0.2",
|
||||||
|
|
|
@ -26,7 +26,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
||||||
withGroupServices: true,
|
withGroupServices: true,
|
||||||
createAllocations: false,
|
createAllocations: false,
|
||||||
});
|
});
|
||||||
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
|
allocation = server.create('allocation', 'withTaskWithPorts', {
|
||||||
clientStatus: 'running',
|
clientStatus: 'running',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -87,7 +87,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
||||||
createAllocations: false,
|
createAllocations: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
|
const allocation = server.create('allocation', 'withTaskWithPorts', {
|
||||||
clientStatus: 'running',
|
clientStatus: 'running',
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
|
@ -188,7 +188,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
||||||
createAllocations: false,
|
createAllocations: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
|
allocation = server.create('allocation', 'withTaskWithPorts', {
|
||||||
clientStatus: 'running',
|
clientStatus: 'running',
|
||||||
jobId: job.id,
|
jobId: job.id,
|
||||||
});
|
});
|
||||||
|
@ -216,7 +216,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ports are listed', async function(assert) {
|
test('ports are listed', async function(assert) {
|
||||||
const allServerPorts = allocation.allocatedResources.Shared.Ports;
|
const allServerPorts = allocation.taskResources.models[0].resources.Ports;
|
||||||
|
|
||||||
allServerPorts.sortBy('Label').forEach((serverPort, index) => {
|
allServerPorts.sortBy('Label').forEach((serverPort, index) => {
|
||||||
const renderedPort = Allocation.ports[index];
|
const renderedPort = Allocation.ports[index];
|
||||||
|
|
|
@ -134,8 +134,8 @@ module('Acceptance | client detail', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
||||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||||
|
|
||||||
await ClientDetail.visit({ id: node.id });
|
await ClientDetail.visit({ id: node.id });
|
||||||
|
|
||||||
|
|
|
@ -94,8 +94,8 @@ module('Acceptance | plugin detail', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
||||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||||
|
|
||||||
await PluginDetail.visit({ id: plugin.id });
|
await PluginDetail.visit({ id: plugin.id });
|
||||||
|
|
||||||
|
|
|
@ -74,8 +74,8 @@ module('Acceptance | task group detail', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) {
|
test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) {
|
||||||
const totalCPU = tasks.mapBy('Resources.CPU').reduce(sum, 0);
|
const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0);
|
||||||
const totalMemory = tasks.mapBy('Resources.MemoryMB').reduce(sum, 0);
|
const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0);
|
||||||
const totalDisk = taskGroup.ephemeralDisk.SizeMB;
|
const totalDisk = taskGroup.ephemeralDisk.SizeMB;
|
||||||
|
|
||||||
await TaskGroup.visit({ id: job.id, name: taskGroup.name });
|
await TaskGroup.visit({ id: job.id, name: taskGroup.name });
|
||||||
|
@ -199,8 +199,8 @@ module('Acceptance | task group detail', function(hooks) {
|
||||||
const allocStats = server.db.clientAllocationStats.find(allocation.id);
|
const allocStats = server.db.clientAllocationStats.find(allocation.id);
|
||||||
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
||||||
|
|
||||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||||
|
|
||||||
assert.equal(
|
assert.equal(
|
||||||
allocationRow.cpu,
|
allocationRow.cpu,
|
||||||
|
|
53
ui/tests/acceptance/topology-test.js
Normal file
53
ui/tests/acceptance/topology-test.js
Normal file
|
@ -0,0 +1,53 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupApplicationTest } from 'ember-qunit';
|
||||||
|
import { setupMirage } from 'ember-cli-mirage/test-support';
|
||||||
|
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
import Topology from 'nomad-ui/tests/pages/topology';
|
||||||
|
|
||||||
|
// TODO: Once we settle on the contents of the info panel, the contents
|
||||||
|
// should also get acceptance tests.
|
||||||
|
module('Acceptance | topology', function(hooks) {
|
||||||
|
setupApplicationTest(hooks);
|
||||||
|
setupMirage(hooks);
|
||||||
|
|
||||||
|
hooks.beforeEach(function() {
|
||||||
|
server.create('job', { createAllocations: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it passes an accessibility audit', async function(assert) {
|
||||||
|
server.createList('node', 3);
|
||||||
|
server.createList('allocation', 5);
|
||||||
|
|
||||||
|
await Topology.visit();
|
||||||
|
await a11yAudit(assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('by default the info panel shows cluster aggregate stats', async function(assert) {
|
||||||
|
server.createList('node', 3);
|
||||||
|
server.createList('allocation', 5);
|
||||||
|
|
||||||
|
await Topology.visit();
|
||||||
|
assert.equal(Topology.infoPanelTitle, 'Cluster Details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when an allocation is selected, the info panel shows information on the allocation', async function(assert) {
|
||||||
|
server.createList('node', 1);
|
||||||
|
server.createList('allocation', 5);
|
||||||
|
|
||||||
|
await Topology.visit();
|
||||||
|
|
||||||
|
await Topology.viz.datacenters[0].nodes[0].memoryRects[0].select();
|
||||||
|
assert.equal(Topology.infoPanelTitle, 'Allocation Details');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when a node is selected, the info panel shows information on the node', async function(assert) {
|
||||||
|
// A high node count is required for node selection
|
||||||
|
server.createList('node', 51);
|
||||||
|
server.createList('allocation', 5);
|
||||||
|
|
||||||
|
await Topology.visit();
|
||||||
|
|
||||||
|
await Topology.viz.datacenters[0].nodes[0].selectNode();
|
||||||
|
assert.equal(Topology.infoPanelTitle, 'Client Details');
|
||||||
|
});
|
||||||
|
});
|
|
@ -106,8 +106,8 @@ module('Acceptance | volume detail', function(hooks) {
|
||||||
});
|
});
|
||||||
|
|
||||||
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
||||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||||
|
|
||||||
await VolumeDetail.visit({ id: volume.id });
|
await VolumeDetail.visit({ id: volume.id });
|
||||||
|
|
||||||
|
|
32
ui/tests/helpers/glimmer-factory.js
Normal file
32
ui/tests/helpers/glimmer-factory.js
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
// Used in glimmer component unit tests. Glimmer components should typically
|
||||||
|
// be tested with integration tests, but occasionally individual methods or
|
||||||
|
// properties have logic that isn't coupled to rendering or the DOM and can
|
||||||
|
// be better tested in a unit fashion.
|
||||||
|
//
|
||||||
|
// Use like
|
||||||
|
//
|
||||||
|
// setupGlimmerComponentFactory(hooks, 'my-component')
|
||||||
|
//
|
||||||
|
// test('testing my component', function(assert) {
|
||||||
|
// const component = this.createComponent({ hello: 'world' });
|
||||||
|
// assert.equal(component.args.hello, 'world');
|
||||||
|
// });
|
||||||
|
export default function setupGlimmerComponentFactory(hooks, componentKey) {
|
||||||
|
hooks.beforeEach(function() {
|
||||||
|
this.createComponent = glimmerComponentInstantiator(this.owner, componentKey);
|
||||||
|
});
|
||||||
|
|
||||||
|
hooks.afterEach(function() {
|
||||||
|
delete this.createComponent;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up the component class in the glimmer component manager and return a
|
||||||
|
// function to construct components as if they were functions.
|
||||||
|
function glimmerComponentInstantiator(owner, componentKey) {
|
||||||
|
return args => {
|
||||||
|
const componentManager = owner.lookup('component-manager:glimmer');
|
||||||
|
const componentClass = owner.factoryFor(`component:${componentKey}`).class;
|
||||||
|
return componentManager.createComponent(componentClass, { named: args });
|
||||||
|
};
|
||||||
|
}
|
168
ui/tests/integration/components/flex-masonry-test.js
Normal file
168
ui/tests/integration/components/flex-masonry-test.js
Normal file
|
@ -0,0 +1,168 @@
|
||||||
|
import { htmlSafe } from '@ember/template';
|
||||||
|
import { click, find, findAll, settled } from '@ember/test-helpers';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
|
||||||
|
// Used to prevent XSS warnings in console
|
||||||
|
const h = height => htmlSafe(`height:${height}px`);
|
||||||
|
|
||||||
|
module('Integration | Component | FlexMasonry', function(hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
test('presents as a single div when @items is empty', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
items: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}}>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const div = find('[data-test-flex-masonry]');
|
||||||
|
assert.ok(div);
|
||||||
|
assert.equal(div.tagName.toLowerCase(), 'div');
|
||||||
|
assert.equal(div.children.length, 0);
|
||||||
|
|
||||||
|
await componentA11yAudit(this.element, assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each item in @items gets wrapped in a flex-masonry-item wrapper', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
items: ['one', 'two', 'three'],
|
||||||
|
columns: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}} as |item|>
|
||||||
|
<p>{{item}}</p>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.equal(findAll('[data-test-flex-masonry-item]').length, this.items.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the @withSpacing arg adds the with-spacing class', async function(assert) {
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}}
|
||||||
|
@withSpacing={{true}}>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
assert.ok(find('[data-test-flex-masonry]').classList.contains('with-spacing'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('individual items along with the reflow action are yielded', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
items: ['one', 'two'],
|
||||||
|
columns: 2,
|
||||||
|
height: h(50),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}} as |item reflow|>
|
||||||
|
<div style={{this.height}} {{on "click" reflow}}>{{item}}</div>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
const div = find('[data-test-flex-masonry]');
|
||||||
|
assert.equal(div.style.maxHeight, '51px');
|
||||||
|
assert.ok(div.textContent.includes('one'));
|
||||||
|
assert.ok(div.textContent.includes('two'));
|
||||||
|
|
||||||
|
this.set('height', h(500));
|
||||||
|
await settled();
|
||||||
|
assert.equal(div.style.maxHeight, '51px');
|
||||||
|
|
||||||
|
// The height of the div changes when reflow is called
|
||||||
|
await click('[data-test-flex-masonry-item]:first-child div');
|
||||||
|
await settled();
|
||||||
|
assert.equal(div.style.maxHeight, '501px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('items are rendered to the DOM in the order they were passed into the component', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
items: [
|
||||||
|
{ text: 'One', height: h(20) },
|
||||||
|
{ text: 'Two', height: h(100) },
|
||||||
|
{ text: 'Three', height: h(20) },
|
||||||
|
{ text: 'Four', height: h(20) },
|
||||||
|
],
|
||||||
|
columns: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}} as |item|>
|
||||||
|
<div style={{item.height}}>{{item.text}}</div>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
|
||||||
|
assert.equal(el.textContent.trim(), this.items[index].text);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each item gets an order property', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
items: [
|
||||||
|
{ text: 'One', height: h(20), expectedOrder: 0 },
|
||||||
|
{ text: 'Two', height: h(100), expectedOrder: 3 },
|
||||||
|
{ text: 'Three', height: h(20), expectedOrder: 1 },
|
||||||
|
{ text: 'Four', height: h(20), expectedOrder: 2 },
|
||||||
|
],
|
||||||
|
columns: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}} as |item|>
|
||||||
|
<div style={{item.height}}>{{item.text}}</div>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
|
||||||
|
assert.equal(el.style.order, this.items[index].expectedOrder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the last item in each column gets a specific flex-basis value', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
items: [
|
||||||
|
{ text: 'One', height: h(20) },
|
||||||
|
{ text: 'Two', height: h(100), flexBasis: '100px' },
|
||||||
|
{ text: 'Three', height: h(20) },
|
||||||
|
{ text: 'Four', height: h(100), flexBasis: '100px' },
|
||||||
|
{ text: 'Five', height: h(20), flexBasis: '80px' },
|
||||||
|
{ text: 'Six', height: h(20), flexBasis: '80px' },
|
||||||
|
],
|
||||||
|
columns: 4,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<FlexMasonry
|
||||||
|
@items={{this.items}}
|
||||||
|
@columns={{this.columns}} as |item|>
|
||||||
|
<div style={{item.height}}>{{item.text}}</div>
|
||||||
|
</FlexMasonry>
|
||||||
|
`);
|
||||||
|
|
||||||
|
findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
|
||||||
|
if (el.style.flexBasis) {
|
||||||
|
assert.equal(el.style.flexBasis, this.items[index].flexBasis);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
144
ui/tests/integration/components/topo-viz-test.js
Normal file
144
ui/tests/integration/components/topo-viz-test.js
Normal file
|
@ -0,0 +1,144 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
import { create } from 'ember-cli-page-object';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
|
import topoVizPageObject from 'nomad-ui/tests/pages/components/topo-viz';
|
||||||
|
|
||||||
|
const TopoViz = create(topoVizPageObject());
|
||||||
|
|
||||||
|
const alloc = (nodeId, jobId, taskGroupName, memory, cpu, props = {}) => ({
|
||||||
|
id: faker.random.uuid(),
|
||||||
|
taskGroupName,
|
||||||
|
isScheduled: true,
|
||||||
|
allocatedResources: {
|
||||||
|
cpu,
|
||||||
|
memory,
|
||||||
|
},
|
||||||
|
belongsTo: type => ({
|
||||||
|
id: () => (type === 'job' ? jobId : nodeId),
|
||||||
|
}),
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
|
||||||
|
const node = (datacenter, id, memory, cpu) => ({
|
||||||
|
datacenter,
|
||||||
|
id,
|
||||||
|
resources: { memory, cpu },
|
||||||
|
});
|
||||||
|
|
||||||
|
module('Integration | Component | TopoViz', function(hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
const commonTemplate = hbs`
|
||||||
|
<TopoViz
|
||||||
|
@nodes={{this.nodes}}
|
||||||
|
@allocations={{this.allocations}}
|
||||||
|
@onAllocationSelect={{this.onAllocationSelect}}
|
||||||
|
@onNodeSelect={{this.onNodeSelect}} />
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('presents as a FlexMasonry of datacenters', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
nodes: [node('dc1', 'node0', 1000, 500), node('dc2', 'node1', 1000, 500)],
|
||||||
|
|
||||||
|
allocations: [
|
||||||
|
alloc('node0', 'job1', 'group', 100, 100),
|
||||||
|
alloc('node0', 'job1', 'group', 100, 100),
|
||||||
|
alloc('node1', 'job1', 'group', 100, 100),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.equal(TopoViz.datacenters.length, 2);
|
||||||
|
assert.equal(TopoViz.datacenters[0].nodes.length, 1);
|
||||||
|
assert.equal(TopoViz.datacenters[1].nodes.length, 1);
|
||||||
|
assert.equal(TopoViz.datacenters[0].nodes[0].memoryRects.length, 2);
|
||||||
|
assert.equal(TopoViz.datacenters[1].nodes[0].memoryRects.length, 1);
|
||||||
|
|
||||||
|
await componentA11yAudit(this.element, assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking on a node in a deeply nested TopoViz::Node will toggle node selection and call @onNodeSelect', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
// TopoViz must be dense for node selection to be a feature
|
||||||
|
nodes: Array(55)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => node('dc1', `node${index}`, 1000, 500)),
|
||||||
|
allocations: [],
|
||||||
|
onNodeSelect: sinon.spy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
await TopoViz.datacenters[0].nodes[0].selectNode();
|
||||||
|
assert.ok(this.onNodeSelect.calledOnce);
|
||||||
|
assert.equal(this.onNodeSelect.getCall(0).args[0].node, this.nodes[0]);
|
||||||
|
|
||||||
|
await TopoViz.datacenters[0].nodes[0].selectNode();
|
||||||
|
assert.ok(this.onNodeSelect.calledTwice);
|
||||||
|
assert.equal(this.onNodeSelect.getCall(1).args[0], null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking on an allocation in a deeply nested TopoViz::Node will update the topology object with selections and call @onAllocationSelect and @onNodeSelect', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
nodes: [node('dc1', 'node0', 1000, 500)],
|
||||||
|
allocations: [alloc('node0', 'job1', 'group', 100, 100)],
|
||||||
|
onNodeSelect: sinon.spy(),
|
||||||
|
onAllocationSelect: sinon.spy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
|
||||||
|
assert.ok(this.onAllocationSelect.calledOnce);
|
||||||
|
assert.equal(this.onAllocationSelect.getCall(0).args[0], this.allocations[0]);
|
||||||
|
assert.ok(this.onNodeSelect.calledOnce);
|
||||||
|
|
||||||
|
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
|
||||||
|
assert.ok(this.onAllocationSelect.calledTwice);
|
||||||
|
assert.equal(this.onAllocationSelect.getCall(1).args[0], null);
|
||||||
|
assert.ok(this.onNodeSelect.calledTwice);
|
||||||
|
assert.ok(this.onNodeSelect.alwaysCalledWith(null));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking on an allocation in a deeply nested TopoViz::Node will associate sibling allocations with curves', async function(assert) {
|
||||||
|
this.setProperties({
|
||||||
|
nodes: [
|
||||||
|
node('dc1', 'node0', 1000, 500),
|
||||||
|
node('dc1', 'node1', 1000, 500),
|
||||||
|
node('dc2', 'node2', 1000, 500),
|
||||||
|
],
|
||||||
|
allocations: [
|
||||||
|
alloc('node0', 'job1', 'group', 100, 100),
|
||||||
|
alloc('node0', 'job1', 'group', 100, 100),
|
||||||
|
alloc('node1', 'job1', 'group', 100, 100),
|
||||||
|
alloc('node2', 'job1', 'group', 100, 100),
|
||||||
|
alloc('node0', 'job1', 'groupTwo', 100, 100),
|
||||||
|
alloc('node1', 'job2', 'group', 100, 100),
|
||||||
|
alloc('node2', 'job2', 'groupTwo', 100, 100),
|
||||||
|
],
|
||||||
|
onNodeSelect: sinon.spy(),
|
||||||
|
onAllocationSelect: sinon.spy(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectedAllocations = this.allocations.filter(
|
||||||
|
alloc => alloc.belongsTo('job').id() === 'job1' && alloc.taskGroupName === 'group'
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.notOk(TopoViz.allocationAssociationsArePresent);
|
||||||
|
|
||||||
|
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
|
||||||
|
|
||||||
|
assert.ok(TopoViz.allocationAssociationsArePresent);
|
||||||
|
assert.equal(TopoViz.allocationAssociations.length, selectedAllocations.length * 2);
|
||||||
|
|
||||||
|
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
|
||||||
|
assert.notOk(TopoViz.allocationAssociationsArePresent);
|
||||||
|
});
|
||||||
|
});
|
160
ui/tests/integration/components/topo-viz/datacenter-test.js
Normal file
160
ui/tests/integration/components/topo-viz/datacenter-test.js
Normal file
|
@ -0,0 +1,160 @@
|
||||||
|
import { find } from '@ember/test-helpers';
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
import { create } from 'ember-cli-page-object';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
|
import topoVizDatacenterPageObject from 'nomad-ui/tests/pages/components/topo-viz/datacenter';
|
||||||
|
|
||||||
|
const TopoVizDatacenter = create(topoVizDatacenterPageObject());
|
||||||
|
|
||||||
|
const nodeGen = (name, datacenter, memory, cpu, allocations = []) => ({
|
||||||
|
datacenter,
|
||||||
|
memory,
|
||||||
|
cpu,
|
||||||
|
node: { name },
|
||||||
|
allocations: allocations.map(alloc => ({
|
||||||
|
memory: alloc.memory,
|
||||||
|
cpu: alloc.cpu,
|
||||||
|
memoryPercent: alloc.memory / memory,
|
||||||
|
cpuPercent: alloc.cpu / cpu,
|
||||||
|
allocation: {
|
||||||
|
id: faker.random.uuid(),
|
||||||
|
isScheduled: true,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Used in Array#reduce to sum by a property common to an array of objects
|
||||||
|
const sumBy = prop => (sum, obj) => (sum += obj[prop]);
|
||||||
|
|
||||||
|
module('Integration | Component | TopoViz::Datacenter', function(hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
const commonProps = props => ({
|
||||||
|
isSingleColumn: true,
|
||||||
|
isDense: false,
|
||||||
|
heightScale: () => 50,
|
||||||
|
onAllocationSelect: sinon.spy(),
|
||||||
|
onNodeSelect: sinon.spy(),
|
||||||
|
...props,
|
||||||
|
});
|
||||||
|
|
||||||
|
const commonTemplate = hbs`
|
||||||
|
<TopoViz::Datacenter
|
||||||
|
@datacenter={{this.datacenter}}
|
||||||
|
@isSingleColumn={{this.isSingleColumn}}
|
||||||
|
@isDense={{this.isDense}}
|
||||||
|
@heightScale={{this.heightScale}}
|
||||||
|
@onAllocationSelect={{this.onAllocationSelect}}
|
||||||
|
@onNodeSelect={{this.onNodeSelect}} />
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('presents as a div with a label and a FlexMasonry with a collection of nodes', async function(assert) {
|
||||||
|
this.setProperties(
|
||||||
|
commonProps({
|
||||||
|
datacenter: {
|
||||||
|
name: 'dc1',
|
||||||
|
nodes: [nodeGen('node-1', 'dc1', 1000, 500)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TopoVizDatacenter.isPresent);
|
||||||
|
assert.equal(TopoVizDatacenter.nodes.length, this.datacenter.nodes.length);
|
||||||
|
|
||||||
|
await componentA11yAudit(this.element, assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('datacenter stats are an aggregate of node stats', async function(assert) {
|
||||||
|
this.setProperties(
|
||||||
|
commonProps({
|
||||||
|
datacenter: {
|
||||||
|
name: 'dc1',
|
||||||
|
nodes: [
|
||||||
|
nodeGen('node-1', 'dc1', 1000, 500, [
|
||||||
|
{ memory: 100, cpu: 300 },
|
||||||
|
{ memory: 200, cpu: 50 },
|
||||||
|
]),
|
||||||
|
nodeGen('node-2', 'dc1', 1500, 100, [
|
||||||
|
{ memory: 50, cpu: 80 },
|
||||||
|
{ memory: 100, cpu: 20 },
|
||||||
|
]),
|
||||||
|
nodeGen('node-3', 'dc1', 2000, 300),
|
||||||
|
nodeGen('node-4', 'dc1', 3000, 200),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
const allocs = this.datacenter.nodes.reduce(
|
||||||
|
(allocs, node) => allocs.concat(node.allocations),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
const memoryReserved = allocs.reduce(sumBy('memory'), 0);
|
||||||
|
const cpuReserved = allocs.reduce(sumBy('cpu'), 0);
|
||||||
|
const memoryTotal = this.datacenter.nodes.reduce(sumBy('memory'), 0);
|
||||||
|
const cpuTotal = this.datacenter.nodes.reduce(sumBy('cpu'), 0);
|
||||||
|
|
||||||
|
assert.ok(TopoVizDatacenter.label.includes(this.datacenter.name));
|
||||||
|
assert.ok(TopoVizDatacenter.label.includes(`${this.datacenter.nodes.length} Nodes`));
|
||||||
|
assert.ok(TopoVizDatacenter.label.includes(`${allocs.length} Allocs`));
|
||||||
|
assert.ok(TopoVizDatacenter.label.includes(`${memoryReserved}/${memoryTotal} MiB`));
|
||||||
|
assert.ok(TopoVizDatacenter.label.includes(`${cpuReserved}/${cpuTotal} Mhz`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when @isSingleColumn is true, the FlexMasonry layout gets one column, otherwise it gets two', async function(assert) {
|
||||||
|
this.setProperties(
|
||||||
|
commonProps({
|
||||||
|
isSingleColumn: true,
|
||||||
|
datacenter: {
|
||||||
|
name: 'dc1',
|
||||||
|
nodes: [nodeGen('node-1', 'dc1', 1000, 500), nodeGen('node-2', 'dc1', 1000, 500)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(find('[data-test-flex-masonry].flex-masonry-columns-1'));
|
||||||
|
|
||||||
|
this.set('isSingleColumn', false);
|
||||||
|
assert.ok(find('[data-test-flex-masonry].flex-masonry-columns-2'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('args get passed down to the TopViz::Node children', async function(assert) {
|
||||||
|
const heightSpy = sinon.spy();
|
||||||
|
this.setProperties(
|
||||||
|
commonProps({
|
||||||
|
isDense: true,
|
||||||
|
heightScale: (...args) => {
|
||||||
|
heightSpy(...args);
|
||||||
|
return 50;
|
||||||
|
},
|
||||||
|
datacenter: {
|
||||||
|
name: 'dc1',
|
||||||
|
nodes: [nodeGen('node-1', 'dc1', 1000, 500, [{ memory: 100, cpu: 300 }])],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
TopoVizDatacenter.nodes[0].as(async TopoVizNode => {
|
||||||
|
assert.notOk(TopoVizNode.labelIsPresent);
|
||||||
|
assert.ok(heightSpy.calledWith(this.datacenter.nodes[0].memory));
|
||||||
|
|
||||||
|
await TopoVizNode.selectNode();
|
||||||
|
assert.ok(this.onNodeSelect.calledWith(this.datacenter.nodes[0]));
|
||||||
|
|
||||||
|
await TopoVizNode.memoryRects[0].select();
|
||||||
|
assert.ok(this.onAllocationSelect.calledWith(this.datacenter.nodes[0].allocations[0]));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
339
ui/tests/integration/components/topo-viz/node-test.js
Normal file
339
ui/tests/integration/components/topo-viz/node-test.js
Normal file
|
@ -0,0 +1,339 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupRenderingTest } from 'ember-qunit';
|
||||||
|
import hbs from 'htmlbars-inline-precompile';
|
||||||
|
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
|
||||||
|
import { create } from 'ember-cli-page-object';
|
||||||
|
import sinon from 'sinon';
|
||||||
|
import faker from 'nomad-ui/mirage/faker';
|
||||||
|
import topoVisNodePageObject from 'nomad-ui/tests/pages/components/topo-viz/node';
|
||||||
|
|
||||||
|
const TopoVizNode = create(topoVisNodePageObject());
|
||||||
|
|
||||||
|
const nodeGen = (name, datacenter, memory, cpu, flags = {}) => ({
|
||||||
|
datacenter,
|
||||||
|
memory,
|
||||||
|
cpu,
|
||||||
|
isSelected: !!flags.isSelected,
|
||||||
|
node: {
|
||||||
|
name,
|
||||||
|
isEligible: flags.isEligible || flags.isEligible == null,
|
||||||
|
isDraining: !!flags.isDraining,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const allocGen = (node, memory, cpu, isSelected) => ({
|
||||||
|
memory,
|
||||||
|
cpu,
|
||||||
|
isSelected,
|
||||||
|
memoryPercent: memory / node.memory,
|
||||||
|
cpuPercent: cpu / node.cpu,
|
||||||
|
allocation: {
|
||||||
|
id: faker.random.uuid(),
|
||||||
|
isScheduled: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const props = overrides => ({
|
||||||
|
isDense: false,
|
||||||
|
heightScale: () => 50,
|
||||||
|
onAllocationSelect: sinon.spy(),
|
||||||
|
onNodeSelect: sinon.spy(),
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
module('Integration | Component | TopoViz::Node', function(hooks) {
|
||||||
|
setupRenderingTest(hooks);
|
||||||
|
|
||||||
|
const commonTemplate = hbs`
|
||||||
|
<TopoViz::Node
|
||||||
|
@node={{this.node}}
|
||||||
|
@isDense={{this.isDense}}
|
||||||
|
@heightScale={{this.heightScale}}
|
||||||
|
@onAllocationSelect={{this.onAllocationSelect}}
|
||||||
|
@onNodeSelect={{this.onNodeSelect}} />
|
||||||
|
`;
|
||||||
|
|
||||||
|
test('presents as a div with a label and an svg with CPU and memory rows', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TopoVizNode.isPresent);
|
||||||
|
assert.ok(TopoVizNode.memoryRects.length);
|
||||||
|
assert.ok(TopoVizNode.cpuRects.length);
|
||||||
|
|
||||||
|
await componentA11yAudit(this.element, assert);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the label contains aggregate information about the node', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TopoVizNode.label.includes(node.node.name));
|
||||||
|
assert.ok(TopoVizNode.label.includes(`${this.node.allocations.length} Allocs`));
|
||||||
|
assert.ok(TopoVizNode.label.includes(`${this.node.memory} MiB`));
|
||||||
|
assert.ok(TopoVizNode.label.includes(`${this.node.cpu} Mhz`));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the status icon indicates when the node is draining', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000, { isDraining: true });
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TopoVizNode.statusIcon.includes('icon-is-clock-outline'));
|
||||||
|
assert.equal(TopoVizNode.statusIconLabel, 'Client is draining');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the status icon indicates when the node is ineligible for scheduling', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000, { isEligible: false });
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TopoVizNode.statusIcon.includes('icon-is-lock-closed'));
|
||||||
|
assert.equal(TopoVizNode.statusIconLabel, 'Client is ineligible');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when isDense is false, clicking the node does nothing', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
isDense: false,
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
await TopoVizNode.selectNode();
|
||||||
|
|
||||||
|
assert.notOk(TopoVizNode.nodeIsInteractive);
|
||||||
|
assert.notOk(this.onNodeSelect.called);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when isDense is true, clicking the node calls onNodeSelect', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
isDense: true,
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
await TopoVizNode.selectNode();
|
||||||
|
|
||||||
|
assert.ok(TopoVizNode.nodeIsInteractive);
|
||||||
|
assert.ok(this.onNodeSelect.called);
|
||||||
|
assert.ok(this.onNodeSelect.calledWith(this.node));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the node gets the is-selected class when the node is selected', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000, { isSelected: true });
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
isDense: true,
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(TopoVizNode.nodeIsSelected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the node gets its height form the @heightScale arg', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
const height = 50;
|
||||||
|
const heightSpy = sinon.spy();
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
heightScale: (...args) => {
|
||||||
|
heightSpy(...args);
|
||||||
|
return height;
|
||||||
|
},
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.ok(heightSpy.called);
|
||||||
|
assert.ok(heightSpy.calledWith(this.node.memory));
|
||||||
|
assert.equal(TopoVizNode.memoryRects[0].height, `${height}px`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each allocation gets a memory rect and a cpu rect', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
assert.equal(TopoVizNode.memoryRects.length, this.node.allocations.length);
|
||||||
|
assert.equal(TopoVizNode.cpuRects.length, this.node.allocations.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('each allocation is sized according to its percentage of utilization', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(hbs`
|
||||||
|
<div style="width:100px">
|
||||||
|
<TopoViz::Node
|
||||||
|
@node={{this.node}}
|
||||||
|
@isDense={{this.isDense}}
|
||||||
|
@heightScale={{this.heightScale}}
|
||||||
|
@onAllocationSelect={{this.onAllocationSelect}}
|
||||||
|
@onNodeSelect={{this.onNodeSelect}} />
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Remove the width of the padding and the label from the SVG width
|
||||||
|
const width = 100 - 5 - 5 - 20;
|
||||||
|
this.node.allocations.forEach((alloc, index) => {
|
||||||
|
const memWidth = alloc.memoryPercent * width - (index === 0 ? 0.5 : 1);
|
||||||
|
const cpuWidth = alloc.cpuPercent * width - (index === 0 ? 0.5 : 1);
|
||||||
|
assert.equal(TopoVizNode.memoryRects[index].width, `${memWidth}px`);
|
||||||
|
assert.equal(TopoVizNode.cpuRects[index].width, `${cpuWidth}px`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clicking either the memory or cpu rect for an allocation will call onAllocationSelect', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
await TopoVizNode.memoryRects[0].select();
|
||||||
|
assert.ok(this.onAllocationSelect.callCount, 1);
|
||||||
|
assert.ok(this.onAllocationSelect.calledWith(this.node.allocations[0]));
|
||||||
|
|
||||||
|
await TopoVizNode.cpuRects[0].select();
|
||||||
|
assert.ok(this.onAllocationSelect.callCount, 2);
|
||||||
|
|
||||||
|
await TopoVizNode.cpuRects[1].select();
|
||||||
|
assert.ok(this.onAllocationSelect.callCount, 3);
|
||||||
|
assert.ok(this.onAllocationSelect.calledWith(this.node.allocations[1]));
|
||||||
|
|
||||||
|
await TopoVizNode.memoryRects[1].select();
|
||||||
|
assert.ok(this.onAllocationSelect.callCount, 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('allocations are sorted by smallest to largest delta of memory to cpu percent utilizations', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
|
||||||
|
const evenAlloc = allocGen(node, 100, 100);
|
||||||
|
const mediumMemoryAlloc = allocGen(node, 200, 150);
|
||||||
|
const largeMemoryAlloc = allocGen(node, 300, 50);
|
||||||
|
const mediumCPUAlloc = allocGen(node, 150, 200);
|
||||||
|
const largeCPUAlloc = allocGen(node, 50, 300);
|
||||||
|
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [
|
||||||
|
largeCPUAlloc,
|
||||||
|
mediumCPUAlloc,
|
||||||
|
evenAlloc,
|
||||||
|
mediumMemoryAlloc,
|
||||||
|
largeMemoryAlloc,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
|
||||||
|
const expectedOrder = [
|
||||||
|
evenAlloc,
|
||||||
|
mediumCPUAlloc,
|
||||||
|
mediumMemoryAlloc,
|
||||||
|
largeCPUAlloc,
|
||||||
|
largeMemoryAlloc,
|
||||||
|
];
|
||||||
|
expectedOrder.forEach((alloc, index) => {
|
||||||
|
assert.equal(TopoVizNode.memoryRects[index].id, alloc.allocation.id);
|
||||||
|
assert.equal(TopoVizNode.cpuRects[index].id, alloc.allocation.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('when there are no allocations, a "no allocations" note is shown', async function(assert) {
|
||||||
|
const node = nodeGen('Node One', 'dc1', 1000, 1000);
|
||||||
|
this.setProperties(
|
||||||
|
props({
|
||||||
|
node: {
|
||||||
|
...node,
|
||||||
|
allocations: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.render(commonTemplate);
|
||||||
|
assert.equal(TopoVizNode.emptyMessage, 'Empty Client');
|
||||||
|
});
|
||||||
|
});
|
11
ui/tests/pages/components/topo-viz.js
Normal file
11
ui/tests/pages/components/topo-viz.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { collection, isPresent } from 'ember-cli-page-object';
|
||||||
|
import TopoVizDatacenter from './topo-viz/datacenter';
|
||||||
|
|
||||||
|
export default scope => ({
|
||||||
|
scope,
|
||||||
|
|
||||||
|
datacenters: collection('[data-test-topo-viz-datacenter]', TopoVizDatacenter()),
|
||||||
|
|
||||||
|
allocationAssociationsArePresent: isPresent('[data-test-allocation-associations]'),
|
||||||
|
allocationAssociations: collection('[data-test-allocation-association]'),
|
||||||
|
});
|
9
ui/tests/pages/components/topo-viz/datacenter.js
Normal file
9
ui/tests/pages/components/topo-viz/datacenter.js
Normal file
|
@ -0,0 +1,9 @@
|
||||||
|
import { collection, text } from 'ember-cli-page-object';
|
||||||
|
import TopoVizNode from './node';
|
||||||
|
|
||||||
|
export default scope => ({
|
||||||
|
scope,
|
||||||
|
|
||||||
|
label: text('[data-test-topo-viz-datacenter-label]'),
|
||||||
|
nodes: collection('[data-test-topo-viz-node]', TopoVizNode()),
|
||||||
|
});
|
36
ui/tests/pages/components/topo-viz/node.js
Normal file
36
ui/tests/pages/components/topo-viz/node.js
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import { attribute, collection, clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
|
||||||
|
|
||||||
|
const allocationRect = {
|
||||||
|
select: clickable(),
|
||||||
|
width: attribute('width', '> rect'),
|
||||||
|
height: attribute('height', '> rect'),
|
||||||
|
isActive: hasClass('is-active'),
|
||||||
|
isSelected: hasClass('is-selected'),
|
||||||
|
running: hasClass('running'),
|
||||||
|
failed: hasClass('failed'),
|
||||||
|
pending: hasClass('pending'),
|
||||||
|
};
|
||||||
|
|
||||||
|
export default scope => ({
|
||||||
|
scope,
|
||||||
|
|
||||||
|
label: text('[data-test-label]'),
|
||||||
|
labelIsPresent: isPresent('[data-test-label]'),
|
||||||
|
statusIcon: attribute('class', '[data-test-status-icon] .icon'),
|
||||||
|
statusIconLabel: attribute('aria-label', '[data-test-status-icon]'),
|
||||||
|
|
||||||
|
selectNode: clickable('[data-test-node-background]'),
|
||||||
|
nodeIsInteractive: hasClass('is-interactive', '[data-test-node-background]'),
|
||||||
|
nodeIsSelected: hasClass('is-selected', '[data-test-node-background]'),
|
||||||
|
|
||||||
|
memoryRects: collection('[data-test-memory-rect]', {
|
||||||
|
...allocationRect,
|
||||||
|
id: attribute('data-test-memory-rect'),
|
||||||
|
}),
|
||||||
|
cpuRects: collection('[data-test-cpu-rect]', {
|
||||||
|
...allocationRect,
|
||||||
|
id: attribute('data-test-cpu-rect'),
|
||||||
|
}),
|
||||||
|
|
||||||
|
emptyMessage: text('[data-test-empty-message]'),
|
||||||
|
});
|
11
ui/tests/pages/topology.js
Normal file
11
ui/tests/pages/topology.js
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
import { create, text, visitable } from 'ember-cli-page-object';
|
||||||
|
|
||||||
|
import TopoViz from 'nomad-ui/tests/pages/components/topo-viz';
|
||||||
|
|
||||||
|
export default create({
|
||||||
|
visit: visitable('/topology'),
|
||||||
|
|
||||||
|
infoPanelTitle: text('[data-test-info-panel-title]'),
|
||||||
|
|
||||||
|
viz: TopoViz('[data-test-topo-viz]'),
|
||||||
|
});
|
191
ui/tests/unit/components/topo-viz-test.js
Normal file
191
ui/tests/unit/components/topo-viz-test.js
Normal file
|
@ -0,0 +1,191 @@
|
||||||
|
import { module, test } from 'qunit';
|
||||||
|
import { setupTest } from 'ember-qunit';
|
||||||
|
import setupGlimmerComponentFactory from 'nomad-ui/tests/helpers/glimmer-factory';
|
||||||
|
|
||||||
|
module('Unit | Component | TopoViz', function(hooks) {
|
||||||
|
setupTest(hooks);
|
||||||
|
setupGlimmerComponentFactory(hooks, 'topo-viz');
|
||||||
|
|
||||||
|
test('the topology object properly organizes a tree of datacenters > nodes > allocations', async function(assert) {
|
||||||
|
const nodes = [
|
||||||
|
{ datacenter: 'dc1', id: 'node0', resources: {} },
|
||||||
|
{ datacenter: 'dc2', id: 'node1', resources: {} },
|
||||||
|
{ datacenter: 'dc1', id: 'node2', resources: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const node0Allocs = [
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'group' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'group' }),
|
||||||
|
];
|
||||||
|
const node1Allocs = [
|
||||||
|
alloc({ nodeId: 'node1', jobId: 'job0', taskGroupName: 'group' }),
|
||||||
|
alloc({ nodeId: 'node1', jobId: 'job1', taskGroupName: 'group' }),
|
||||||
|
];
|
||||||
|
const node2Allocs = [
|
||||||
|
alloc({ nodeId: 'node2', jobId: 'job0', taskGroupName: 'group' }),
|
||||||
|
alloc({ nodeId: 'node2', jobId: 'job1', taskGroupName: 'group' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const allocations = [...node0Allocs, ...node1Allocs, ...node2Allocs];
|
||||||
|
|
||||||
|
const topoViz = this.createComponent({ nodes, allocations });
|
||||||
|
|
||||||
|
topoViz.buildTopology();
|
||||||
|
|
||||||
|
assert.deepEqual(topoViz.topology.datacenters.mapBy('name'), ['dc1', 'dc2']);
|
||||||
|
assert.deepEqual(topoViz.topology.datacenters[0].nodes.mapBy('node'), [nodes[0], nodes[2]]);
|
||||||
|
assert.deepEqual(topoViz.topology.datacenters[1].nodes.mapBy('node'), [nodes[1]]);
|
||||||
|
assert.deepEqual(
|
||||||
|
topoViz.topology.datacenters[0].nodes[0].allocations.mapBy('allocation'),
|
||||||
|
node0Allocs
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
topoViz.topology.datacenters[1].nodes[0].allocations.mapBy('allocation'),
|
||||||
|
node1Allocs
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
topoViz.topology.datacenters[0].nodes[1].allocations.mapBy('allocation'),
|
||||||
|
node2Allocs
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the topology object contains an allocation index keyed by jobId+taskGroupName', async function(assert) {
|
||||||
|
const allocations = [
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'one' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'one' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'two' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'one' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'two' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'three' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
|
||||||
|
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const nodes = [{ datacenter: 'dc1', id: 'node0', resources: {} }];
|
||||||
|
const topoViz = this.createComponent({ nodes, allocations });
|
||||||
|
|
||||||
|
topoViz.buildTopology();
|
||||||
|
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(topoViz.topology.allocationIndex).sort(),
|
||||||
|
[
|
||||||
|
JSON.stringify(['job0', 'one']),
|
||||||
|
JSON.stringify(['job0', 'two']),
|
||||||
|
|
||||||
|
JSON.stringify(['job1', 'one']),
|
||||||
|
JSON.stringify(['job1', 'two']),
|
||||||
|
JSON.stringify(['job1', 'three']),
|
||||||
|
|
||||||
|
JSON.stringify(['job2', 'one']),
|
||||||
|
].sort()
|
||||||
|
);
|
||||||
|
|
||||||
|
Object.keys(topoViz.topology.allocationIndex).forEach(key => {
|
||||||
|
const [jobId, group] = JSON.parse(key);
|
||||||
|
assert.deepEqual(
|
||||||
|
topoViz.topology.allocationIndex[key].mapBy('allocation'),
|
||||||
|
allocations.filter(alloc => alloc.jobId === jobId && alloc.taskGroupName === group)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isSingleColumn is true when there is only one datacenter', async function(assert) {
|
||||||
|
const oneDc = [{ datacenter: 'dc1', id: 'node0', resources: {} }];
|
||||||
|
const twoDc = [...oneDc, { datacenter: 'dc2', id: 'node1', resources: {} }];
|
||||||
|
|
||||||
|
const topoViz1 = this.createComponent({ nodes: oneDc, allocations: [] });
|
||||||
|
const topoViz2 = this.createComponent({ nodes: twoDc, allocations: [] });
|
||||||
|
|
||||||
|
topoViz1.buildTopology();
|
||||||
|
topoViz2.buildTopology();
|
||||||
|
|
||||||
|
assert.ok(topoViz1.isSingleColumn);
|
||||||
|
assert.notOk(topoViz2.isSingleColumn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('isSingleColumn is true when there are multiple datacenters with a high variance in node count', async function(assert) {
|
||||||
|
const uniformDcs = [
|
||||||
|
{ datacenter: 'dc1', id: 'node0', resources: {} },
|
||||||
|
{ datacenter: 'dc2', id: 'node1', resources: {} },
|
||||||
|
];
|
||||||
|
const skewedDcs = [
|
||||||
|
{ datacenter: 'dc1', id: 'node0', resources: {} },
|
||||||
|
{ datacenter: 'dc2', id: 'node1', resources: {} },
|
||||||
|
{ datacenter: 'dc2', id: 'node2', resources: {} },
|
||||||
|
{ datacenter: 'dc2', id: 'node3', resources: {} },
|
||||||
|
{ datacenter: 'dc2', id: 'node4', resources: {} },
|
||||||
|
];
|
||||||
|
|
||||||
|
const twoColumnViz = this.createComponent({ nodes: uniformDcs, allocations: [] });
|
||||||
|
const oneColumViz = this.createComponent({ nodes: skewedDcs, allocations: [] });
|
||||||
|
|
||||||
|
twoColumnViz.buildTopology();
|
||||||
|
oneColumViz.buildTopology();
|
||||||
|
|
||||||
|
assert.notOk(twoColumnViz.isSingleColumn);
|
||||||
|
assert.ok(oneColumViz.isSingleColumn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('datacenterIsSingleColumn is only ever false when isSingleColumn is false and the total node count is high', async function(assert) {
|
||||||
|
const manyUniformNodes = Array(25)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => ({
|
||||||
|
datacenter: index > 12 ? 'dc2' : 'dc1',
|
||||||
|
id: `node${index}`,
|
||||||
|
resources: {},
|
||||||
|
}));
|
||||||
|
const manySkewedNodes = Array(25)
|
||||||
|
.fill(null)
|
||||||
|
.map((_, index) => ({
|
||||||
|
datacenter: index > 5 ? 'dc2' : 'dc1',
|
||||||
|
id: `node${index}`,
|
||||||
|
resources: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const oneColumnViz = this.createComponent({ nodes: manyUniformNodes, allocations: [] });
|
||||||
|
const twoColumnViz = this.createComponent({ nodes: manySkewedNodes, allocations: [] });
|
||||||
|
|
||||||
|
oneColumnViz.buildTopology();
|
||||||
|
twoColumnViz.buildTopology();
|
||||||
|
|
||||||
|
assert.ok(oneColumnViz.datacenterIsSingleColumn);
|
||||||
|
assert.notOk(oneColumnViz.isSingleColumn);
|
||||||
|
|
||||||
|
assert.notOk(twoColumnViz.datacenterIsSingleColumn);
|
||||||
|
assert.ok(twoColumnViz.isSingleColumn);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dataForAllocation correctly calculates proportion of node utilization and group key', async function(assert) {
|
||||||
|
const nodes = [{ datacenter: 'dc1', id: 'node0', resources: { cpu: 100, memory: 250 } }];
|
||||||
|
const allocations = [
|
||||||
|
alloc({
|
||||||
|
nodeId: 'node0',
|
||||||
|
jobId: 'job0',
|
||||||
|
taskGroupName: 'group',
|
||||||
|
allocatedResources: { cpu: 50, memory: 25 },
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const topoViz = this.createComponent({ nodes, allocations });
|
||||||
|
topoViz.buildTopology();
|
||||||
|
|
||||||
|
assert.equal(topoViz.topology.datacenters[0].nodes[0].allocations[0].cpuPercent, 0.5);
|
||||||
|
assert.equal(topoViz.topology.datacenters[0].nodes[0].allocations[0].memoryPercent, 0.1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function alloc(props) {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
allocatedResources: props.allocatedResources || {},
|
||||||
|
belongsTo(type) {
|
||||||
|
return {
|
||||||
|
id() {
|
||||||
|
return type === 'job' ? props.jobId : props.nodeId;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -24,8 +24,13 @@ module('Unit | Helper | format-bytes', function() {
|
||||||
assert.equal(formatBytes([128974848]), '123 MiB');
|
assert.equal(formatBytes([128974848]), '123 MiB');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('formats x > 1024 * 1024 * 1024 as MiB, since it is the highest allowed unit', function(assert) {
|
test('formats 1024 * 1024 * 1024 <= x < 1024 * 1024 * 1024 * 1024 as GiB', function(assert) {
|
||||||
assert.equal(formatBytes([1024 * 1024 * 1024]), '1024 MiB');
|
assert.equal(formatBytes([1024 * 1024 * 1024]), '1 GiB');
|
||||||
assert.equal(formatBytes([1024 * 1024 * 1024 * 4]), '4096 MiB');
|
assert.equal(formatBytes([1024 * 1024 * 1024 * 4]), '4 GiB');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formats x > 1024 * 1024 * 1024 * 1024 as GiB, since it is the highest allowed unit', function(assert) {
|
||||||
|
assert.equal(formatBytes([1024 * 1024 * 1024 * 1024]), '1024 GiB');
|
||||||
|
assert.equal(formatBytes([1024 * 1024 * 1024 * 1024 * 4]), '4096 GiB');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
915
ui/yarn.lock
915
ui/yarn.lock
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue