Merge pull request #9077 from hashicorp/f-ui/topo-viz
UI: Topology Visualization
This commit is contained in:
commit
12cae40388
|
@ -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';
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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]);
|
||||
}
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
|
@ -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 },
|
||||
};
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
||||
const UNITS = ['Bytes', 'KiB', 'MiB'];
|
||||
const UNITS = ['Bytes', 'KiB', 'MiB', 'GiB'];
|
||||
|
||||
/**
|
||||
* Bytes Formatter
|
||||
|
@ -10,7 +10,7 @@ const UNITS = ['Bytes', 'KiB', 'MiB'];
|
|||
* Outputs the bytes reduced to the largest supported unit size for which
|
||||
* bytes is larger than one.
|
||||
*/
|
||||
export function formatBytes([bytes]) {
|
||||
export function reduceToLargestUnit(bytes) {
|
||||
bytes || (bytes = 0);
|
||||
let unitIndex = 0;
|
||||
while (bytes >= 1024 && unitIndex < UNITS.length - 1) {
|
||||
|
@ -18,7 +18,12 @@ export function formatBytes([bytes]) {
|
|||
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);
|
||||
|
|
|
@ -47,6 +47,11 @@ export default class Allocation extends Model {
|
|||
@equal('clientStatus', 'running') isRunning;
|
||||
@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
|
||||
// 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.
|
||||
|
|
|
@ -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('volumes', function() {
|
||||
this.route('volume', { path: '/:volume_name' });
|
||||
|
|
|
@ -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;
|
||||
};
|
||||
|
||||
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
|
||||
export default class AllocationSerializer extends ApplicationSerializer {
|
||||
@service system;
|
||||
|
@ -30,7 +45,7 @@ export default class AllocationSerializer extends ApplicationSerializer {
|
|||
const state = states[key] || {};
|
||||
const summary = { Name: key };
|
||||
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;
|
||||
});
|
||||
|
||||
|
@ -57,8 +72,13 @@ export default class AllocationSerializer extends ApplicationSerializer {
|
|||
hash.PreemptedByAllocationID = hash.PreemptedByAllocation || null;
|
||||
hash.WasPreempted = !!hash.PreemptedByAllocationID;
|
||||
|
||||
// When present, the resources are nested under AllocatedResources.Shared
|
||||
hash.AllocatedResources = hash.AllocatedResources && hash.AllocatedResources.Shared;
|
||||
const shared = 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.
|
||||
hash.AllocationTaskGroup = !hash.Job ? null : taskGroupFromJob(hash.Job, hash.TaskGroup);
|
||||
|
|
|
@ -7,6 +7,8 @@ export default class NodeSerializer extends ApplicationSerializer {
|
|||
attrs = {
|
||||
isDraining: 'Drain',
|
||||
httpAddr: 'HTTPAddr',
|
||||
resources: 'NodeResources',
|
||||
reserved: 'ReservedResources',
|
||||
};
|
||||
|
||||
mapToArray = ['Drivers', 'HostVolumes'];
|
||||
|
|
|
@ -1,12 +1,21 @@
|
|||
import ApplicationSerializer from './application';
|
||||
|
||||
export default class ResourcesSerializer extends ApplicationSerializer {
|
||||
attrs = {
|
||||
cpu: 'CPU',
|
||||
memory: 'MemoryMB',
|
||||
disk: 'DiskMB',
|
||||
iops: 'IOPS',
|
||||
};
|
||||
arrayNullOverrides = ['Ports', 'Networks'];
|
||||
|
||||
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/tooltip';
|
||||
@import './charts/colors';
|
||||
@import './charts/chart-annotation.scss';
|
||||
@import './charts/chart-annotation';
|
||||
@import './charts/topo-viz';
|
||||
@import './charts/topo-viz-node';
|
||||
|
||||
.inline-chart {
|
||||
height: 1.5rem;
|
||||
|
|
|
@ -47,6 +47,10 @@ $lost: $dark;
|
|||
vertical-align: middle;
|
||||
border-radius: $radius;
|
||||
|
||||
&.is-wide {
|
||||
width: 2rem;
|
||||
}
|
||||
|
||||
$color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, $red;
|
||||
@for $i from 1 through length($color-sequence) {
|
||||
&.swatch-#{$i - 1} {
|
||||
|
|
|
@ -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);
|
||||
}
|
|
@ -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/copy-button';
|
||||
@import './components/cli-window';
|
||||
@import './components/dashboard-metric';
|
||||
@import './components/dropdown';
|
||||
@import './components/ember-power-select';
|
||||
@import './components/empty-message';
|
||||
|
@ -11,6 +12,7 @@
|
|||
@import './components/event';
|
||||
@import './components/exec-button';
|
||||
@import './components/exec-window';
|
||||
@import './components/flex-masonry';
|
||||
@import './components/fs-explorer';
|
||||
@import './components/global-search-container';
|
||||
@import './components/global-search-dropdown';
|
||||
|
@ -20,6 +22,7 @@
|
|||
@import './components/inline-definitions';
|
||||
@import './components/job-diff';
|
||||
@import './components/json-viewer';
|
||||
@import './components/legend';
|
||||
@import './components/lifecycle-chart';
|
||||
@import './components/loading-spinner';
|
||||
@import './components/metrics';
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
||||
&.is-short .primary-graphic {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.secondary-graphic {
|
||||
padding: 0.75em;
|
||||
padding-bottom: 0;
|
||||
|
|
|
@ -23,4 +23,8 @@
|
|||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
&.is-flush {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,3 +23,7 @@ code {
|
|||
.is-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.is-faded {
|
||||
color: darken($grey-blue, 20%);
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ $blue: $vagrant-blue;
|
|||
$purple: $terraform-purple;
|
||||
$red: #c84034;
|
||||
$grey-blue: #bbc4d1;
|
||||
$blue-light: #c0d5ff;
|
||||
|
||||
$primary: $nomad-green;
|
||||
$warning: $orange;
|
||||
|
|
|
@ -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">
|
||||
<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="topology" @activeClass="is-active" data-test-gutter-link="topology">Topology</LinkTo></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
@ -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: {
|
||||
blockingQueries: true,
|
||||
mirageScenario: 'smallCluster',
|
||||
mirageWithNamespaces: true,
|
||||
mirageScenario: 'topoMedium',
|
||||
mirageWithNamespaces: false,
|
||||
mirageWithTokens: true,
|
||||
mirageWithRegions: true,
|
||||
},
|
||||
|
|
|
@ -5,10 +5,8 @@ import { provide } from './utils';
|
|||
const CPU_RESERVATIONS = [250, 500, 1000, 2000, 2500, 4000];
|
||||
const MEMORY_RESERVATIONS = [256, 512, 1024, 2048, 4096, 8192];
|
||||
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
|
||||
IOPS_RESERVATIONS.push(...Array(1000).fill(0));
|
||||
DISK_RESERVATIONS.push(...Array(500).fill(0));
|
||||
|
||||
const NETWORK_MODES = ['bridge', 'host'];
|
||||
|
@ -27,10 +25,15 @@ export const STORAGE_PROVIDERS = ['ebs', 'zfs', 'nfs', 'cow', 'moo'];
|
|||
|
||||
export function generateResources(options = {}) {
|
||||
return {
|
||||
CPU: options.CPU || faker.helpers.randomize(CPU_RESERVATIONS),
|
||||
MemoryMB: options.MemoryMB || faker.helpers.randomize(MEMORY_RESERVATIONS),
|
||||
DiskMB: options.DiskMB || faker.helpers.randomize(DISK_RESERVATIONS),
|
||||
IOPS: options.IOPS || faker.helpers.randomize(IOPS_RESERVATIONS),
|
||||
Cpu: {
|
||||
CpuShares: options.CPU || faker.helpers.randomize(CPU_RESERVATIONS),
|
||||
},
|
||||
Memory: {
|
||||
MemoryMB: options.MemoryMB || faker.helpers.randomize(MEMORY_RESERVATIONS),
|
||||
},
|
||||
Disk: {
|
||||
DiskMB: options.DiskMB || faker.helpers.randomize(DISK_RESERVATIONS),
|
||||
},
|
||||
Networks: generateNetworks(options.networks),
|
||||
Ports: generatePorts(options.networks),
|
||||
};
|
||||
|
|
|
@ -42,15 +42,16 @@ export default Factory.extend({
|
|||
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
|
||||
const resources = taskGroup.taskIds.map(id => {
|
||||
const task = server.db.tasks.find(id);
|
||||
return server.create(
|
||||
'task-resource',
|
||||
{
|
||||
allocation,
|
||||
name: task.name,
|
||||
resources: task.Resources,
|
||||
},
|
||||
'withReservedPorts'
|
||||
);
|
||||
return server.create('task-resource', {
|
||||
allocation,
|
||||
name: task.name,
|
||||
resources: generateResources({
|
||||
CPU: task.resources.CPU,
|
||||
MemoryMB: task.resources.MemoryMB,
|
||||
DiskMB: task.resources.DiskMB,
|
||||
networks: { minPorts: 1 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
allocation.update({ taskResourceIds: resources.mapBy('id') });
|
||||
|
@ -62,29 +63,22 @@ export default Factory.extend({
|
|||
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
|
||||
const resources = taskGroup.taskIds.map(id => {
|
||||
const task = server.db.tasks.find(id);
|
||||
return server.create(
|
||||
'task-resource',
|
||||
{
|
||||
allocation,
|
||||
name: task.name,
|
||||
resources: task.Resources,
|
||||
},
|
||||
'withoutReservedPorts'
|
||||
);
|
||||
return server.create('task-resource', {
|
||||
allocation,
|
||||
name: task.name,
|
||||
resources: generateResources({
|
||||
CPU: task.resources.CPU,
|
||||
MemoryMB: task.resources.MemoryMB,
|
||||
DiskMB: task.resources.DiskMB,
|
||||
networks: { minPorts: 0, maxPorts: 0 },
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
allocation.update({ taskResourceIds: resources.mapBy('id') });
|
||||
},
|
||||
}),
|
||||
|
||||
withAllocatedResources: trait({
|
||||
allocatedResources: () => {
|
||||
return {
|
||||
Shared: generateResources({ networks: { minPorts: 2 } }),
|
||||
};
|
||||
},
|
||||
}),
|
||||
|
||||
rescheduleAttempts: 0,
|
||||
rescheduleSuccess: false,
|
||||
|
||||
|
@ -200,13 +194,13 @@ export default Factory.extend({
|
|||
return server.create('task-resource', {
|
||||
allocation,
|
||||
name: task.name,
|
||||
resources: task.Resources,
|
||||
resources: task.originalResources,
|
||||
});
|
||||
});
|
||||
|
||||
allocation.update({
|
||||
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.
|
||||
|
|
|
@ -74,7 +74,7 @@ export default Factory.extend({
|
|||
|
||||
hostVolumes: makeHostVolumes,
|
||||
|
||||
resources: generateResources,
|
||||
nodeResources: generateResources,
|
||||
|
||||
attributes() {
|
||||
// TODO add variability to these
|
||||
|
|
|
@ -79,7 +79,7 @@ export default Factory.extend({
|
|||
|
||||
const maybeResources = {};
|
||||
if (resources) {
|
||||
maybeResources.Resources = generateResources(resources[idx]);
|
||||
maybeResources.originalResources = generateResources(resources[idx]);
|
||||
}
|
||||
return server.create('task', {
|
||||
taskGroup: group,
|
||||
|
|
|
@ -5,12 +5,4 @@ export default Factory.extend({
|
|||
name: () => '!!!this should be set by the allocation that owns this task state!!!',
|
||||
|
||||
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}`,
|
||||
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 => {
|
||||
const cycle = i % 5;
|
||||
|
|
|
@ -1,4 +1,5 @@
|
|||
import config from 'nomad-ui/config/environment';
|
||||
import * as topoScenarios from './topo';
|
||||
import { pickOne } from '../utils';
|
||||
|
||||
const withNamespaces = getConfigValue('mirageWithNamespaces', false);
|
||||
|
@ -14,6 +15,7 @@ const allScenarios = {
|
|||
allNodeTypes,
|
||||
everyFeature,
|
||||
emptyCluster,
|
||||
...topoScenarios,
|
||||
};
|
||||
|
||||
const scenario = getConfigValue('mirageScenario', 'emptyCluster');
|
||||
|
|
|
@ -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) {
|
||||
allocation.TaskStates = allocation.TaskStates.reduce(arrToObj('Name'), {});
|
||||
allocation.Resources = allocation.TaskResources.mapBy('Resources').reduce(
|
||||
(hash, resources) => {
|
||||
['CPU', 'DiskMB', 'IOPS', 'MemoryMB'].forEach(key => (hash[key] += resources[key]));
|
||||
hash.Networks = resources.Networks;
|
||||
hash.Ports = resources.Ports;
|
||||
return hash;
|
||||
},
|
||||
{ CPU: 0, DiskMB: 0, IOPS: 0, MemoryMB: 0 }
|
||||
);
|
||||
allocation.TaskResources = allocation.TaskResources.reduce(arrToObj('Name', 'Resources'), {});
|
||||
const { Ports, Networks } = allocation.TaskResources[0]
|
||||
? allocation.TaskResources[0].Resources
|
||||
: {};
|
||||
allocation.AllocatedResources = {
|
||||
Shared: { Ports, Networks },
|
||||
Tasks: allocation.TaskResources.map(({ Name, Resources }) => ({ Name, ...Resources })).reduce(
|
||||
arrToObj('Name'),
|
||||
{}
|
||||
),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
"broccoli-asset-rev": "^3.0.0",
|
||||
"bulma": "0.6.1",
|
||||
"core-js": "^2.4.1",
|
||||
"d3-array": "^1.2.0",
|
||||
"d3-array": "^2.1.0",
|
||||
"d3-axis": "^1.0.0",
|
||||
"d3-format": "^1.3.0",
|
||||
"d3-scale": "^1.0.0",
|
||||
|
@ -84,6 +84,7 @@
|
|||
"ember-inline-svg": "^0.3.0",
|
||||
"ember-load-initializers": "^2.1.1",
|
||||
"ember-maybe-import-regenerator": "^0.1.6",
|
||||
"ember-modifier": "^2.1.0",
|
||||
"ember-moment": "^7.8.1",
|
||||
"ember-overridable-computed": "^1.0.0",
|
||||
"ember-page-title": "^5.0.2",
|
||||
|
|
|
@ -26,7 +26,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
withGroupServices: true,
|
||||
createAllocations: false,
|
||||
});
|
||||
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
|
||||
allocation = server.create('allocation', 'withTaskWithPorts', {
|
||||
clientStatus: 'running',
|
||||
});
|
||||
|
||||
|
@ -87,7 +87,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
createAllocations: false,
|
||||
});
|
||||
|
||||
const allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
|
||||
const allocation = server.create('allocation', 'withTaskWithPorts', {
|
||||
clientStatus: 'running',
|
||||
jobId: job.id,
|
||||
});
|
||||
|
@ -188,7 +188,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
createAllocations: false,
|
||||
});
|
||||
|
||||
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', {
|
||||
allocation = server.create('allocation', 'withTaskWithPorts', {
|
||||
clientStatus: 'running',
|
||||
jobId: job.id,
|
||||
});
|
||||
|
@ -216,7 +216,7 @@ module('Acceptance | allocation detail', function(hooks) {
|
|||
});
|
||||
|
||||
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) => {
|
||||
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 cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||
|
||||
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 cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||
|
||||
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) {
|
||||
const totalCPU = tasks.mapBy('Resources.CPU').reduce(sum, 0);
|
||||
const totalMemory = tasks.mapBy('Resources.MemoryMB').reduce(sum, 0);
|
||||
const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0);
|
||||
const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0);
|
||||
const totalDisk = taskGroup.ephemeralDisk.SizeMB;
|
||||
|
||||
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 tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
|
||||
|
||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||
|
||||
assert.equal(
|
||||
allocationRow.cpu,
|
||||
|
|
|
@ -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 cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0);
|
||||
const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
|
||||
const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
|
||||
|
||||
await VolumeDetail.visit({ id: volume.id });
|
||||
|
||||
|
|
|
@ -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 });
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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]));
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
});
|
|
@ -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]'),
|
||||
});
|
|
@ -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()),
|
||||
});
|
|
@ -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]'),
|
||||
});
|
|
@ -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]'),
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
|
||||
test('formats x > 1024 * 1024 * 1024 as MiB, since it is the highest allowed unit', function(assert) {
|
||||
assert.equal(formatBytes([1024 * 1024 * 1024]), '1024 MiB');
|
||||
assert.equal(formatBytes([1024 * 1024 * 1024 * 4]), '4096 MiB');
|
||||
test('formats 1024 * 1024 * 1024 <= x < 1024 * 1024 * 1024 * 1024 as GiB', function(assert) {
|
||||
assert.equal(formatBytes([1024 * 1024 * 1024]), '1 GiB');
|
||||
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 New Issue