Merge pull request #9077 from hashicorp/f-ui/topo-viz

UI: Topology Visualization
This commit is contained in:
Michael Lange 2020-10-15 11:58:10 -07:00 committed by GitHub
commit 12cae40388
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 3664 additions and 92 deletions

View file

@ -0,0 +1,66 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { run } from '@ember/runloop';
import { action } from '@ember/object';
import { minIndex, max } from 'd3-array';
export default class FlexMasonry extends Component {
@tracked element = null;
@action
captureElement(element) {
this.element = element;
}
@action
reflow() {
run.next(() => {
// There's nothing to do if this is a single column layout
if (!this.element || this.args.columns === 1 || !this.args.columns) return;
const columns = new Array(this.args.columns).fill(null).map(() => ({
height: 0,
elements: [],
}));
const items = this.element.querySelectorAll('.flex-masonry-item');
// First pass: assign each element to a column based on the running heights of each column
for (let item of items) {
const styles = window.getComputedStyle(item);
const marginTop = parseFloat(styles.marginTop);
const marginBottom = parseFloat(styles.marginBottom);
const height = item.clientHeight;
// Pick the shortest column accounting for margins
const column = columns[minIndex(columns, c => c.height)];
// Add the new element's height to the column height
column.height += marginTop + height + marginBottom;
column.elements.push(item);
}
// Second pass: assign an order to each element based on their column and position in the column
columns
.mapBy('elements')
.flat()
.forEach((dc, index) => {
dc.style.order = index;
});
// Guarantee column wrapping as predicted (if the first item of a column is shorter than the difference
// beteen the height of the column and the previous column, then flexbox will naturally place the first
// item at the end of the previous column).
columns.forEach((column, index) => {
const nextHeight = index < columns.length - 1 ? columns[index + 1].height : 0;
const item = column.elements.lastObject;
if (item) {
item.style.flexBasis = item.clientHeight + Math.max(0, nextHeight - column.height) + 'px';
}
});
// Set the max height of the container to the height of the tallest column
this.element.style.maxHeight = max(columns.mapBy('height')) + 1 + 'px';
});
}
}

View file

@ -0,0 +1,271 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action, set } from '@ember/object';
import { run } from '@ember/runloop';
import { scaleLinear } from 'd3-scale';
import { extent, deviation, mean } from 'd3-array';
import { line, curveBasis } from 'd3-shape';
export default class TopoViz extends Component {
@tracked element = null;
@tracked topology = { datacenters: [] };
@tracked activeNode = null;
@tracked activeAllocation = null;
@tracked activeEdges = [];
@tracked edgeOffset = { x: 0, y: 0 };
get isSingleColumn() {
if (this.topology.datacenters.length <= 1) return true;
// Compute the coefficient of variance to determine if it would be
// better to stack datacenters or place them in columns
const nodeCounts = this.topology.datacenters.map(datacenter => datacenter.nodes.length);
const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts);
// The point at which the varation is too extreme for a two column layout
const threshold = 0.5;
if (variationCoefficient > threshold) return true;
return false;
}
get datacenterIsSingleColumn() {
// If there are enough nodes, use two columns of nodes within
// a single column layout of datacenters to increase density.
return !this.isSingleColumn || (this.isSingleColumn && this.args.nodes.length <= 20);
}
// Once a cluster is large enough, the exact details of a node are
// typically irrelevant and a waste of space.
get isDense() {
return this.args.nodes.length > 50;
}
dataForNode(node) {
return {
node,
datacenter: node.datacenter,
memory: node.resources.memory,
cpu: node.resources.cpu,
allocations: [],
isSelected: false,
};
}
dataForAllocation(allocation, node) {
const jobId = allocation.belongsTo('job').id();
return {
allocation,
node,
jobId,
groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
memory: allocation.allocatedResources.memory,
cpu: allocation.allocatedResources.cpu,
memoryPercent: allocation.allocatedResources.memory / node.memory,
cpuPercent: allocation.allocatedResources.cpu / node.cpu,
isSelected: false,
};
}
@action
buildTopology() {
const nodes = this.args.nodes;
const allocations = this.args.allocations;
// Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment
const nodeContainers = [];
const nodeIndex = {};
nodes.forEach(node => {
const container = this.dataForNode(node);
nodeContainers.push(container);
nodeIndex[node.id] = container;
});
// Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation
// index keyed off of job and task group
const allocationIndex = {};
allocations.forEach(allocation => {
const nodeId = allocation.belongsTo('node').id();
const nodeContainer = nodeIndex[nodeId];
if (!nodeContainer)
throw new Error(`Node ${nodeId} for alloc ${allocation.id} not in index.`);
const allocationContainer = this.dataForAllocation(allocation, nodeContainer);
nodeContainer.allocations.push(allocationContainer);
const key = allocationContainer.groupKey;
if (!allocationIndex[key]) allocationIndex[key] = [];
allocationIndex[key].push(allocationContainer);
});
// Group nodes into datacenters
const datacentersMap = nodeContainers.reduce((datacenters, nodeContainer) => {
if (!datacenters[nodeContainer.datacenter]) datacenters[nodeContainer.datacenter] = [];
datacenters[nodeContainer.datacenter].push(nodeContainer);
return datacenters;
}, {});
// Turn hash of datacenters into a sorted array
const datacenters = Object.keys(datacentersMap)
.map(key => ({ name: key, nodes: datacentersMap[key] }))
.sortBy('name');
const topology = {
datacenters,
allocationIndex,
selectedKey: null,
heightScale: scaleLinear()
.range([15, 40])
.domain(extent(nodeContainers.mapBy('memory'))),
};
this.topology = topology;
}
@action
captureElement(element) {
this.element = element;
}
@action
showNodeDetails(node) {
if (this.activeNode) {
set(this.activeNode, 'isSelected', false);
}
this.activeNode = this.activeNode === node ? null : node;
if (this.activeNode) {
set(this.activeNode, 'isSelected', true);
}
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
}
@action
associateAllocations(allocation) {
if (this.activeAllocation === allocation) {
this.activeAllocation = null;
this.activeEdges = [];
if (this.topology.selectedKey) {
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (selectedAllocations) {
selectedAllocations.forEach(allocation => {
set(allocation, 'isSelected', false);
});
}
set(this.topology, 'selectedKey', null);
}
} else {
if (this.activeNode) {
set(this.activeNode, 'isSelected', false);
}
this.activeNode = null;
this.activeAllocation = allocation;
const selectedAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (selectedAllocations) {
selectedAllocations.forEach(allocation => {
set(allocation, 'isSelected', false);
});
}
set(this.topology, 'selectedKey', allocation.groupKey);
const newAllocations = this.topology.allocationIndex[this.topology.selectedKey];
if (newAllocations) {
newAllocations.forEach(allocation => {
set(allocation, 'isSelected', true);
});
}
this.computedActiveEdges();
}
if (this.args.onAllocationSelect)
this.args.onAllocationSelect(this.activeAllocation && this.activeAllocation.allocation);
if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
}
@action
computedActiveEdges() {
// Wait a render cycle
run.next(() => {
const path = line().curve(curveBasis);
// 1. Get the active element
const allocation = this.activeAllocation.allocation;
const activeEl = this.element.querySelector(`[data-allocation-id="${allocation.id}"]`);
const activePoint = centerOfBBox(activeEl.getBoundingClientRect());
// 2. Collect the mem and cpu pairs for all selected allocs
const selectedMem = Array.from(this.element.querySelectorAll('.memory .bar.is-selected'));
const selectedPairs = selectedMem.map(mem => {
const id = mem.closest('[data-allocation-id]').dataset.allocationId;
const cpu = mem
.closest('.topo-viz-node')
.querySelector(`.cpu .bar[data-allocation-id="${id}"]`);
return [mem, cpu];
});
const selectedPoints = selectedPairs.map(pair => {
return pair.map(el => centerOfBBox(el.getBoundingClientRect()));
});
// 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active]
selectedPoints.forEach(points => {
const d1 = pointBetween(points[0], activePoint, 100, 0.5);
const d2 = pointBetween(points[1], activePoint, 100, 0.5);
points.push(midpoint(d1, d2));
});
// 4. Generate curves for each active->mem and active->cpu pair going through the bisector
const curves = [];
// Steps are used to restrict the range of curves. The closer control points are placed, the less
// curvature the curve generator will generate.
const stepsMain = [0, 0.8, 1.0];
// The second prong the fork does not need to retrace the entire path from the activePoint
const stepsSecondary = [0.8, 1.0];
selectedPoints.forEach(points => {
curves.push(
curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsMain), points[0]),
curveFromPoints(...pointsAlongPath(activePoint, points[2], stepsSecondary), points[1])
);
});
this.activeEdges = curves.map(curve => path(curve));
this.edgeOffset = { x: window.visualViewport.pageLeft, y: window.visualViewport.pageTop };
});
}
}
function centerOfBBox(bbox) {
return {
x: bbox.x + bbox.width / 2,
y: bbox.y + bbox.height / 2,
};
}
function dist(p1, p2) {
return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
}
// Return the point between p1 and p2 at len (or pct if len > dist(p1, p2))
function pointBetween(p1, p2, len, pct) {
const d = dist(p1, p2);
const ratio = d < len ? pct : len / d;
return pointBetweenPct(p1, p2, ratio);
}
function pointBetweenPct(p1, p2, pct) {
const dx = p2.x - p1.x;
const dy = p2.y - p1.y;
return { x: p1.x + dx * pct, y: p1.y + dy * pct };
}
function pointsAlongPath(p1, p2, pcts) {
return pcts.map(pct => pointBetweenPct(p1, p2, pct));
}
function midpoint(p1, p2) {
return pointBetweenPct(p1, p2, 0.5);
}
function curveFromPoints(...points) {
return points.map(p => [p.x, p.y]);
}

View file

@ -0,0 +1,32 @@
import Component from '@glimmer/component';
export default class TopoVizDatacenter extends Component {
get scheduledAllocations() {
return this.args.datacenter.nodes.reduce(
(all, node) => all.concat(node.allocations.filterBy('allocation.isScheduled')),
[]
);
}
get aggregatedAllocationResources() {
return this.scheduledAllocations.reduce(
(totals, allocation) => {
totals.cpu += allocation.cpu;
totals.memory += allocation.memory;
return totals;
},
{ cpu: 0, memory: 0 }
);
}
get aggregatedNodeResources() {
return this.args.datacenter.nodes.reduce(
(totals, node) => {
totals.cpu += node.cpu;
totals.memory += node.memory;
return totals;
},
{ cpu: 0, memory: 0 }
);
}
}

View file

@ -0,0 +1,182 @@
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { guidFor } from '@ember/object/internals';
export default class TopoVizNode extends Component {
@tracked data = { cpu: [], memory: [] };
@tracked dimensionsWidth = 0;
@tracked padding = 5;
@tracked activeAllocation = null;
get height() {
return this.args.heightScale ? this.args.heightScale(this.args.node.memory) : 15;
}
get labelHeight() {
return this.height / 2;
}
get paddingLeft() {
const labelWidth = 20;
return this.padding + labelWidth;
}
// Since strokes are placed centered on the perimeter of fills, The width of the stroke needs to be removed from
// the height of the fill to match unstroked height and avoid clipping.
get selectedHeight() {
return this.height - 1;
}
// Since strokes are placed centered on the perimeter of fills, half the width of the stroke needs to be added to
// the yOffset to match heights with unstroked shapes.
get selectedYOffset() {
return this.height + 2.5;
}
get yOffset() {
return this.height + 2;
}
get maskHeight() {
return this.height + this.yOffset;
}
get totalHeight() {
return this.maskHeight + this.padding * 2;
}
get maskId() {
return `topo-viz-node-mask-${guidFor(this)}`;
}
get count() {
return this.args.node.allocations.length;
}
get allocations() {
// Sort by the delta between memory and cpu percent. This creates the least amount of
// drift between the positional alignment of an alloc's cpu and memory representations.
return this.args.node.allocations.filterBy('allocation.isScheduled').sort((a, b) => {
const deltaA = Math.abs(a.memoryPercent - a.cpuPercent);
const deltaB = Math.abs(b.memoryPercent - b.cpuPercent);
return deltaA - deltaB;
});
}
@action
async reloadNode() {
if (this.args.node.isPartial) {
await this.args.node.reload();
this.data = this.computeData(this.dimensionsWidth);
}
}
@action
render(svg) {
this.dimensionsWidth = svg.clientWidth - this.padding - this.paddingLeft;
this.data = this.computeData(this.dimensionsWidth);
}
@action
updateRender(svg) {
// Only update all data when the width changes
const newWidth = svg.clientWidth - this.padding - this.paddingLeft;
if (newWidth !== this.dimensionsWidth) {
this.dimensionsWidth = newWidth;
this.data = this.computeData(this.dimensionsWidth);
}
}
@action
highlightAllocation(allocation) {
this.activeAllocation = allocation;
}
@action
clearHighlight() {
this.activeAllocation = null;
}
@action
selectNode() {
if (this.args.isDense && this.args.onNodeSelect) {
this.args.onNodeSelect(this.args.node.isSelected ? null : this.args.node);
}
}
@action
selectAllocation(allocation) {
if (this.args.onAllocationSelect) this.args.onAllocationSelect(allocation);
}
containsActiveTaskGroup() {
return this.args.node.allocations.some(
allocation =>
allocation.taskGroupName === this.args.activeTaskGroup &&
allocation.belongsTo('job').id() === this.args.activeJobId
);
}
computeData(width) {
const allocations = this.allocations;
let cpuOffset = 0;
let memoryOffset = 0;
const cpu = [];
const memory = [];
for (const allocation of allocations) {
const { cpuPercent, memoryPercent, isSelected } = allocation;
const isFirst = allocation === allocations[0];
let cpuWidth = cpuPercent * width - 1;
let memoryWidth = memoryPercent * width - 1;
if (isFirst) {
cpuWidth += 0.5;
memoryWidth += 0.5;
}
if (isSelected) {
cpuWidth--;
memoryWidth--;
}
cpu.push({
allocation,
offset: cpuOffset * 100,
percent: cpuPercent * 100,
width: cpuWidth,
x: cpuOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
className: allocation.allocation.clientStatus,
});
memory.push({
allocation,
offset: memoryOffset * 100,
percent: memoryPercent * 100,
width: memoryWidth,
x: memoryOffset * width + (isFirst ? 0 : 0.5) + (isSelected ? 0.5 : 0),
className: allocation.allocation.clientStatus,
});
cpuOffset += cpuPercent;
memoryOffset += memoryPercent;
}
const cpuRemainder = {
x: cpuOffset * width + 0.5,
width: width - cpuOffset * width,
};
const memoryRemainder = {
x: memoryOffset * width + 0.5,
width: width - memoryOffset * width,
};
return {
cpu,
memory,
cpuRemainder,
memoryRemainder,
cpuLabel: { x: -this.paddingLeft / 2, y: this.height / 2 + this.yOffset },
memoryLabel: { x: -this.paddingLeft / 2, y: this.height / 2 },
};
}
}

View file

@ -0,0 +1,114 @@
import Controller from '@ember/controller';
import { computed, action } from '@ember/object';
import classic from 'ember-classic-decorator';
import { reduceToLargestUnit } from 'nomad-ui/helpers/format-bytes';
const sumAggregator = (sum, value) => sum + (value || 0);
@classic
export default class TopologyControllers extends Controller {
@computed('model.nodes.@each.datacenter')
get datacenters() {
return Array.from(new Set(this.model.nodes.mapBy('datacenter'))).compact();
}
@computed('model.allocations.@each.isScheduled')
get scheduledAllocations() {
return this.model.allocations.filterBy('isScheduled');
}
@computed('model.nodes.@each.resources')
get totalMemory() {
const mibs = this.model.nodes.mapBy('resources.memory').reduce(sumAggregator, 0);
return mibs * 1024 * 1024;
}
@computed('model.nodes.@each.resources')
get totalCPU() {
return this.model.nodes.mapBy('resources.cpu').reduce((sum, cpu) => sum + (cpu || 0), 0);
}
@computed('totalMemory')
get totalMemoryFormatted() {
return reduceToLargestUnit(this.totalMemory)[0].toFixed(2);
}
@computed('totalCPU')
get totalMemoryUnits() {
return reduceToLargestUnit(this.totalMemory)[1];
}
@computed('model.allocations.@each.allocatedResources')
get totalReservedMemory() {
const mibs = this.model.allocations.mapBy('allocatedResources.memory').reduce(sumAggregator, 0);
return mibs * 1024 * 1024;
}
@computed('model.allocations.@each.allocatedResources')
get totalReservedCPU() {
return this.model.allocations.mapBy('allocatedResources.cpu').reduce(sumAggregator, 0);
}
@computed('totalMemory', 'totalReservedMemory')
get reservedMemoryPercent() {
if (!this.totalReservedMemory || !this.totalMemory) return 0;
return this.totalReservedMemory / this.totalMemory;
}
@computed('totalCPU', 'totalReservedCPU')
get reservedCPUPercent() {
if (!this.totalReservedCPU || !this.totalCPU) return 0;
return this.totalReservedCPU / this.totalCPU;
}
@computed('activeAllocation', 'model.allocations.@each.{taskGroupName,job}')
get siblingAllocations() {
if (!this.activeAllocation) return [];
const taskGroup = this.activeAllocation.taskGroupName;
const jobId = this.activeAllocation.belongsTo('job').id();
return this.model.allocations.filter(allocation => {
return allocation.taskGroupName === taskGroup && allocation.belongsTo('job').id() === jobId;
});
}
@computed('activeNode')
get nodeUtilization() {
const node = this.activeNode;
const [formattedMemory, memoryUnits] = reduceToLargestUnit(node.memory * 1024 * 1024);
const totalReservedMemory = node.allocations.mapBy('memory').reduce(sumAggregator, 0);
const totalReservedCPU = node.allocations.mapBy('cpu').reduce(sumAggregator, 0);
return {
totalMemoryFormatted: formattedMemory.toFixed(2),
totalMemoryUnits: memoryUnits,
totalMemory: node.memory * 1024 * 1024,
totalReservedMemory: totalReservedMemory * 1024 * 1024,
reservedMemoryPercent: totalReservedMemory / node.memory,
totalCPU: node.cpu,
totalReservedCPU,
reservedCPUPercent: totalReservedCPU / node.cpu,
};
}
@computed('siblingAllocations.@each.node')
get uniqueActiveAllocationNodes() {
return this.siblingAllocations.mapBy('node').uniq();
}
@action
async setAllocation(allocation) {
if (allocation) {
await allocation.reload();
await allocation.job.reload();
}
this.set('activeAllocation', allocation);
}
@action
setNode(node) {
this.set('activeNode', node);
}
}

View file

@ -1,6 +1,6 @@
import Helper from '@ember/component/helper'; import Helper from '@ember/component/helper';
const UNITS = ['Bytes', 'KiB', 'MiB']; const UNITS = ['Bytes', 'KiB', 'MiB', 'GiB'];
/** /**
* Bytes Formatter * Bytes Formatter
@ -10,7 +10,7 @@ const UNITS = ['Bytes', 'KiB', 'MiB'];
* Outputs the bytes reduced to the largest supported unit size for which * Outputs the bytes reduced to the largest supported unit size for which
* bytes is larger than one. * bytes is larger than one.
*/ */
export function formatBytes([bytes]) { export function reduceToLargestUnit(bytes) {
bytes || (bytes = 0); bytes || (bytes = 0);
let unitIndex = 0; let unitIndex = 0;
while (bytes >= 1024 && unitIndex < UNITS.length - 1) { while (bytes >= 1024 && unitIndex < UNITS.length - 1) {
@ -18,7 +18,12 @@ export function formatBytes([bytes]) {
unitIndex++; unitIndex++;
} }
return `${Math.floor(bytes)} ${UNITS[unitIndex]}`; return [bytes, UNITS[unitIndex]];
}
export function formatBytes([bytes]) {
const [number, unit] = reduceToLargestUnit(bytes);
return `${Math.floor(number)} ${unit}`;
} }
export default Helper.helper(formatBytes); export default Helper.helper(formatBytes);

View file

@ -47,6 +47,11 @@ export default class Allocation extends Model {
@equal('clientStatus', 'running') isRunning; @equal('clientStatus', 'running') isRunning;
@attr('boolean') isMigrating; @attr('boolean') isMigrating;
@computed('clientStatus')
get isScheduled() {
return ['pending', 'running', 'failed'].includes(this.clientStatus);
}
// An allocation model created from any allocation list response will be lacking // An allocation model created from any allocation list response will be lacking
// many properties (some of which can always be null). This is an indicator that // many properties (some of which can always be null). This is an indicator that
// the allocation needs to be reloaded to get the complete allocation state. // the allocation needs to be reloaded to get the complete allocation state.

View file

@ -0,0 +1,10 @@
import { modifier } from 'ember-modifier';
export default modifier(function windowResize(element, [handler]) {
const boundHandler = ev => handler(element, ev);
window.addEventListener('resize', boundHandler);
return () => {
window.removeEventListener('resize', boundHandler);
};
});

View file

@ -37,6 +37,8 @@ Router.map(function() {
}); });
}); });
this.route('topology');
this.route('csi', function() { this.route('csi', function() {
this.route('volumes', function() { this.route('volumes', function() {
this.route('volume', { path: '/:volume_name' }); this.route('volume', { path: '/:volume_name' });

27
ui/app/routes/topology.js Normal file
View file

@ -0,0 +1,27 @@
import { inject as service } from '@ember/service';
import Route from '@ember/routing/route';
import WithForbiddenState from 'nomad-ui/mixins/with-forbidden-state';
import notifyForbidden from 'nomad-ui/utils/notify-forbidden';
import classic from 'ember-classic-decorator';
import RSVP from 'rsvp';
@classic
export default class TopologyRoute extends Route.extend(WithForbiddenState) {
@service store;
@service system;
breadcrumbs = [
{
label: 'Topology',
args: ['topology'],
},
];
model() {
return RSVP.hash({
jobs: this.store.findAll('job'),
allocations: this.store.query('allocation', { resources: true }),
nodes: this.store.query('node', { resources: true }),
}).catch(notifyForbidden(this));
}
}

View file

@ -9,6 +9,21 @@ const taskGroupFromJob = (job, taskGroupName) => {
return taskGroup ? taskGroup : null; return taskGroup ? taskGroup : null;
}; };
const merge = tasks => {
const mergedResources = {
Cpu: { CpuShares: 0 },
Memory: { MemoryMB: 0 },
Disk: { DiskMB: 0 },
};
return tasks.reduce((resources, task) => {
resources.Cpu.CpuShares += (task.Cpu && task.Cpu.CpuShares) || 0;
resources.Memory.MemoryMB += (task.Memory && task.Memory.MemoryMB) || 0;
resources.Disk.DiskMB += (task.Disk && task.Disk.DiskMB) || 0;
return resources;
}, mergedResources);
};
@classic @classic
export default class AllocationSerializer extends ApplicationSerializer { export default class AllocationSerializer extends ApplicationSerializer {
@service system; @service system;
@ -30,7 +45,7 @@ export default class AllocationSerializer extends ApplicationSerializer {
const state = states[key] || {}; const state = states[key] || {};
const summary = { Name: key }; const summary = { Name: key };
Object.keys(state).forEach(stateKey => (summary[stateKey] = state[stateKey])); Object.keys(state).forEach(stateKey => (summary[stateKey] = state[stateKey]));
summary.Resources = hash.TaskResources && hash.TaskResources[key]; summary.Resources = hash.AllocatedResources && hash.AllocatedResources.Tasks[key];
return summary; return summary;
}); });
@ -57,8 +72,13 @@ export default class AllocationSerializer extends ApplicationSerializer {
hash.PreemptedByAllocationID = hash.PreemptedByAllocation || null; hash.PreemptedByAllocationID = hash.PreemptedByAllocation || null;
hash.WasPreempted = !!hash.PreemptedByAllocationID; hash.WasPreempted = !!hash.PreemptedByAllocationID;
// When present, the resources are nested under AllocatedResources.Shared const shared = hash.AllocatedResources && hash.AllocatedResources.Shared;
hash.AllocatedResources = hash.AllocatedResources && hash.AllocatedResources.Shared; hash.AllocatedResources =
hash.AllocatedResources && merge(Object.values(hash.AllocatedResources.Tasks));
if (shared) {
hash.AllocatedResources.Ports = shared.Ports;
hash.AllocatedResources.Networks = shared.Networks;
}
// The Job definition for an allocation is only included in findRecord responses. // The Job definition for an allocation is only included in findRecord responses.
hash.AllocationTaskGroup = !hash.Job ? null : taskGroupFromJob(hash.Job, hash.TaskGroup); hash.AllocationTaskGroup = !hash.Job ? null : taskGroupFromJob(hash.Job, hash.TaskGroup);

View file

@ -7,6 +7,8 @@ export default class NodeSerializer extends ApplicationSerializer {
attrs = { attrs = {
isDraining: 'Drain', isDraining: 'Drain',
httpAddr: 'HTTPAddr', httpAddr: 'HTTPAddr',
resources: 'NodeResources',
reserved: 'ReservedResources',
}; };
mapToArray = ['Drivers', 'HostVolumes']; mapToArray = ['Drivers', 'HostVolumes'];

View file

@ -1,12 +1,21 @@
import ApplicationSerializer from './application'; import ApplicationSerializer from './application';
export default class ResourcesSerializer extends ApplicationSerializer { export default class ResourcesSerializer extends ApplicationSerializer {
attrs = { arrayNullOverrides = ['Ports', 'Networks'];
cpu: 'CPU',
memory: 'MemoryMB',
disk: 'DiskMB',
iops: 'IOPS',
};
arrayNullOverrides = ['Ports']; normalize(typeHash, hash) {
hash.Cpu = hash.Cpu && hash.Cpu.CpuShares;
hash.Memory = hash.Memory && hash.Memory.MemoryMB;
hash.Disk = hash.Disk && hash.Disk.DiskMB;
// Networks for ReservedResources is different than for Resources.
// This smooths over the differences, but doesn't actually support
// anything in the ReservedResources.Networks object, since we don't
// use any of it in the UI.
if (!(hash.Networks instanceof Array)) {
hash.Networks = [];
}
return super.normalize(...arguments);
}
} }

View file

@ -3,7 +3,9 @@
@import './charts/line-chart'; @import './charts/line-chart';
@import './charts/tooltip'; @import './charts/tooltip';
@import './charts/colors'; @import './charts/colors';
@import './charts/chart-annotation.scss'; @import './charts/chart-annotation';
@import './charts/topo-viz';
@import './charts/topo-viz-node';
.inline-chart { .inline-chart {
height: 1.5rem; height: 1.5rem;

View file

@ -47,6 +47,10 @@ $lost: $dark;
vertical-align: middle; vertical-align: middle;
border-radius: $radius; border-radius: $radius;
&.is-wide {
width: 2rem;
}
$color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, $red; $color-sequence: $orange, $yellow, $green, $turquoise, $blue, $purple, $red;
@for $i from 1 through length($color-sequence) { @for $i from 1 through length($color-sequence) {
&.swatch-#{$i - 1} { &.swatch-#{$i - 1} {

View file

@ -0,0 +1,94 @@
.topo-viz-node {
display: block;
.label {
font-weight: $weight-normal;
}
.chart {
display: inline-block;
height: 100%;
width: 100%;
overflow: visible;
.node-background {
fill: $white-ter;
stroke-width: 1;
stroke: $grey-lighter;
&.is-interactive:hover {
fill: $white;
stroke: $grey-light;
}
&.is-selected,
&.is-selected:hover {
fill: $white;
stroke: $grey;
}
}
.dimension-background {
fill: lighten($grey-lighter, 5%);
}
.dimensions.is-active {
.bar {
opacity: 0.2;
&.is-active {
opacity: 1;
}
}
}
.bar {
cursor: pointer;
&.is-selected {
stroke-width: 1px;
stroke: $blue;
fill: $blue-light;
}
}
.label {
text-anchor: middle;
alignment-baseline: central;
font-weight: $weight-normal;
fill: $grey;
pointer-events: none;
}
}
.empty-text {
fill: $red;
transform: translate(50%, 50%);
text {
text-anchor: middle;
alignment-baseline: central;
}
}
& + .topo-viz-node {
margin-top: 1em;
}
&.is-empty {
.node-background {
stroke: $red;
stroke-width: 2;
fill: $white;
}
.dimension-background {
fill: none;
}
}
}
.flex-masonry-columns-2 > .flex-masonry-item > .topo-viz-node .chart,
.flex-masonry-columns-2 > .flex-masonry-item > .topo-viz-node .label {
width: calc(100% - 0.75em);
}

View file

@ -0,0 +1,35 @@
.topo-viz {
.topo-viz-datacenters {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-content: space-between;
margin-top: -0.75em;
.topo-viz-datacenter {
margin-top: 0.75em;
margin-bottom: 0.75em;
width: calc(50% - 0.75em);
}
}
&.is-single-column .topo-viz-datacenter {
width: 100%;
}
.topo-viz-edges {
width: 100%;
height: 100%;
position: absolute;
left: 0;
top: 0;
pointer-events: none;
overflow: visible;
.edge {
stroke-width: 2;
stroke: $blue;
fill: none;
}
}
}

View file

@ -4,6 +4,7 @@
@import './components/codemirror'; @import './components/codemirror';
@import './components/copy-button'; @import './components/copy-button';
@import './components/cli-window'; @import './components/cli-window';
@import './components/dashboard-metric';
@import './components/dropdown'; @import './components/dropdown';
@import './components/ember-power-select'; @import './components/ember-power-select';
@import './components/empty-message'; @import './components/empty-message';
@ -11,6 +12,7 @@
@import './components/event'; @import './components/event';
@import './components/exec-button'; @import './components/exec-button';
@import './components/exec-window'; @import './components/exec-window';
@import './components/flex-masonry';
@import './components/fs-explorer'; @import './components/fs-explorer';
@import './components/global-search-container'; @import './components/global-search-container';
@import './components/global-search-dropdown'; @import './components/global-search-dropdown';
@ -20,6 +22,7 @@
@import './components/inline-definitions'; @import './components/inline-definitions';
@import './components/job-diff'; @import './components/job-diff';
@import './components/json-viewer'; @import './components/json-viewer';
@import './components/legend';
@import './components/lifecycle-chart'; @import './components/lifecycle-chart';
@import './components/loading-spinner'; @import './components/loading-spinner';
@import './components/metrics'; @import './components/metrics';

View file

@ -0,0 +1,50 @@
.dashboard-metric {
&:not(:last-child) {
margin-bottom: 1.5em;
}
&.column:not(:last-child) {
margin-bottom: 0;
}
.metric {
text-align: left;
font-weight: $weight-bold;
font-size: $size-3;
.metric-units {
font-size: $size-4;
}
.metric-label {
font-size: $body-size;
font-weight: $weight-normal;
}
}
.graphic {
padding-bottom: 0;
margin-bottom: 0;
> .column {
padding: 0.5rem 0.75rem;
}
}
.annotation {
margin-top: -0.75rem;
}
&.with-divider {
border-top: 1px solid $grey-blue;
padding-top: 1.5em;
}
.pair {
font-size: $size-5;
}
.is-faded {
color: darken($grey-blue, 20%);
}
}

View file

@ -0,0 +1,37 @@
.flex-masonry {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-content: space-between;
margin-top: -0.75em;
&.flex-masonry-columns-1 > .flex-masonry-item {
width: 100%;
}
&.flex-masonry-columns-2 > .flex-masonry-item {
width: 50%;
}
&.flex-masonry-columns-3 > .flex-masonry-item {
width: 33%;
}
&.flex-masonry-columns-4 > .flex-masonry-item {
width: 25%;
}
&.with-spacing {
> .flex-masonry-item {
margin-top: 0.75em;
margin-bottom: 0.75em;
}
&.flex-masonry-columns-2 > .flex-masonry-item {
width: calc(50% - 0.75em);
}
&.flex-masonry-columns-3 > .flex-masonry-item {
width: calc(33% - 0.75em);
}
&.flex-masonry-columns-4 > .flex-masonry-item {
width: calc(25% - 0.75em);
}
}
}

View file

@ -0,0 +1,33 @@
.legend {
margin-bottom: 1em;
.legend-label {
font-weight: $weight-bold;
margin-bottom: 0.3em;
}
.legend-terms {
dt,
dd {
display: inline;
}
dt {
font-weight: $weight-bold;
}
dd {
margin-left: 0.5em;
}
.legend-term {
display: inline-block;
whitespace: nowrap;
margin-right: 1.5em;
&:last-child {
margin-right: 0;
}
}
}
}

View file

@ -13,6 +13,10 @@
height: 150px; height: 150px;
} }
&.is-short .primary-graphic {
height: 100px;
}
.secondary-graphic { .secondary-graphic {
padding: 0.75em; padding: 0.75em;
padding-bottom: 0; padding-bottom: 0;

View file

@ -23,4 +23,8 @@
margin-left: auto; margin-left: auto;
margin-right: auto; margin-right: auto;
} }
&.is-flush {
margin-bottom: 0;
}
} }

View file

@ -23,3 +23,7 @@ code {
.is-interactive { .is-interactive {
cursor: pointer; cursor: pointer;
} }
.is-faded {
color: darken($grey-blue, 20%);
}

View file

@ -4,6 +4,7 @@ $blue: $vagrant-blue;
$purple: $terraform-purple; $purple: $terraform-purple;
$red: #c84034; $red: #c84034;
$grey-blue: #bbc4d1; $grey-blue: #bbc4d1;
$blue-light: #c0d5ff;
$primary: $nomad-green; $primary: $nomad-green;
$warning: $orange; $warning: $orange;

View file

@ -0,0 +1,13 @@
<div
data-test-flex-masonry
class="flex-masonry {{if @withSpacing "with-spacing"}} flex-masonry-columns-{{@columns}}"
{{did-insert this.captureElement}}
{{did-insert this.reflow}}
{{did-update this.reflow}}
{{window-resize this.reflow}}>
{{#each @items as |item|}}
<div data-test-flex-masonry-item class="flex-masonry-item">
{{yield item (action this.reflow)}}
</div>
{{/each}}
</div>

View file

@ -81,6 +81,7 @@
<ul class="menu-list"> <ul class="menu-list">
<li><LinkTo @route="clients" @activeClass="is-active" data-test-gutter-link="clients">Clients</LinkTo></li> <li><LinkTo @route="clients" @activeClass="is-active" data-test-gutter-link="clients">Clients</LinkTo></li>
<li><LinkTo @route="servers" @activeClass="is-active" data-test-gutter-link="servers">Servers</LinkTo></li> <li><LinkTo @route="servers" @activeClass="is-active" data-test-gutter-link="servers">Servers</LinkTo></li>
<li><LinkTo @route="topology" @activeClass="is-active" data-test-gutter-link="topology">Topology</LinkTo></li>
</ul> </ul>
</aside> </aside>
</div> </div>

View file

@ -0,0 +1,24 @@
<div data-test-topo-viz class="topo-viz {{if this.isSingleColumn "is-single-column"}}" {{did-insert this.buildTopology}} {{did-insert this.captureElement}}>
<FlexMasonry
@columns={{if this.isSingleColumn 1 2}}
@items={{this.topology.datacenters}}
@withSpacing={{true}} as |dc|>
<TopoViz::Datacenter
@datacenter={{dc}}
@isSingleColumn={{this.datacenterIsSingleColumn}}
@isDense={{this.isDense}}
@heightScale={{this.topology.heightScale}}
@onAllocationSelect={{this.associateAllocations}}
@onNodeSelect={{this.showNodeDetails}} />
</FlexMasonry>
{{#if this.activeAllocation}}
<svg data-test-allocation-associations class="chart topo-viz-edges" {{window-resize this.computedActiveEdges}}>
<g transform="translate({{this.edgeOffset.x}},{{this.edgeOffset.y}})">
{{#each this.activeEdges as |edge|}}
<path data-test-allocation-association class="edge" d={{edge}} />
{{/each}}
</g>
</svg>
{{/if}}
</div>

View file

@ -0,0 +1,19 @@
<div data-test-topo-viz-datacenter class="boxed-section topo-viz-datacenter">
<div data-test-topo-viz-datacenter-label class="boxed-section-head is-hollow">
<strong>{{@datacenter.name}}</strong>
<span class="bumper-left">{{this.scheduledAllocations.length}} Allocs</span>
<span class="bumper-left">{{@datacenter.nodes.length}} Nodes</span>
<span class="bumper-left is-faded">{{this.aggregatedAllocationResources.memory}}/{{this.aggregatedNodeResources.memory}} MiB,
{{this.aggregatedAllocationResources.cpu}}/{{this.aggregatedNodeResources.cpu}} Mhz</span>
</div>
<div class="boxed-section-body">
<FlexMasonry @columns={{if @isSingleColumn 1 2}} @items={{@datacenter.nodes}} as |node|>
<TopoViz::Node
@node={{node}}
@isDense={{@isDense}}
@heightScale={{@heightScale}}
@onAllocationSelect={{@onAllocationSelect}}
@onNodeSelect={{@onNodeSelect}}/>
</FlexMasonry>
</div>
</div>

View file

@ -0,0 +1,116 @@
<div data-test-topo-viz-node class="topo-viz-node {{unless this.allocations.length "is-empty"}}" {{did-insert this.reloadNode}}>
{{#unless @isDense}}
<p data-test-label class="label">
{{#if @node.node.isDraining}}
<span data-test-status-icon class="tooltip" aria-label="Client is draining">{{x-icon "clock-outline" class="is-info"}}</span>
{{else if (not @node.node.isEligible)}}
<span data-test-status-icon class="tooltip" aria-label="Client is ineligible">{{x-icon "lock-closed" class="is-warning"}}</span>
{{/if}}
<strong>{{@node.node.name}}</strong>
<span class="bumper-left">{{this.count}} Allocs</span>
<span class="bumper-left is-faded">{{@node.memory}} MiB, {{@node.cpu}} Mhz</span>
</p>
{{/unless}}
<svg class="chart" height="{{this.totalHeight}}px" {{did-insert this.render}} {{did-update this.updateRender}} {{window-resize this.render}}>
<defs>
<clipPath id="{{this.maskId}}">
<rect class="mask" x="0" y="0" width="{{this.dimensionsWidth}}px" height="{{this.maskHeight}}px" rx="2px" ry="2px" />
</clipPath>
</defs>
<rect
data-test-node-background
class="node-background {{if @node.isSelected "is-selected"}} {{if @isDense "is-interactive"}}"
width="100%"
height="{{this.totalHeight}}px"
rx="2px"
ry="2px"
{{on "click" this.selectNode}} />
{{#if this.allocations.length}}
<g
class="dimensions {{if this.activeAllocation "is-active"}}"
transform="translate({{this.paddingLeft}},{{this.padding}})"
width="{{this.dimensionsWidth}}px"
height="{{this.maskHeight}}px"
pointer-events="all"
{{on "mouseout" this.clearHighlight}}
>
<g class="memory">
{{#if this.data.memoryLabel}}
<text class="label" aria-label="Memory" transform="translate({{this.data.memoryLabel.x}},{{this.data.memoryLabel.y}})">M</text>
{{/if}}
{{#if this.data.memoryRemainder}}
<rect
class="dimension-background"
x="{{this.data.memoryRemainder.x}}px"
width="{{this.data.memoryRemainder.width}}px"
height="{{this.height}}px" />
{{/if}}
{{#each this.data.memory key="allocation.id" as |memory|}}
<g
data-test-memory-rect="{{memory.allocation.allocation.id}}"
class="bar {{memory.className}} {{if (eq this.activeAllocation memory.allocation) "is-active"}} {{if memory.allocation.isSelected "is-selected"}}"
clip-path="url(#{{this.maskId}})"
data-allocation-id="{{memory.allocation.allocation.id}}"
{{on "mouseenter" (fn this.highlightAllocation memory.allocation)}}
{{on "click" (fn this.selectAllocation memory.allocation)}}>
<rect
width="{{memory.width}}px"
height="{{if memory.allocation.isSelected this.selectedHeight this.height}}px"
x="{{memory.x}}px"
y="{{if memory.allocation.isSelected 0.5 0}}px"
class="layer-0" />
{{#if (or (eq memory.className "starting") (eq memory.className "pending"))}}
<rect
width="{{memory.width}}px"
height="{{if memory.allocation.isSelected this.selectedHeight this.height}}px"
x="{{memory.x}}px"
y="{{if memory.allocation.isSelected 0.5 0}}px"
class="layer-1" />
{{/if}}
</g>
{{/each}}
</g>
<g class="cpu">
{{#if this.data.cpuLabel}}
<text class="label" aria-label="CPU" transform="translate({{this.data.cpuLabel.x}},{{this.data.cpuLabel.y}})">C</text>
{{/if}}
{{#if this.data.cpuRemainder}}
<rect
class="dimension-background"
x="{{this.data.cpuRemainder.x}}px"
y="{{this.yOffset}}px"
width="{{this.data.cpuRemainder.width}}px"
height="{{this.height}}px" />
{{/if}}
{{#each this.data.cpu key="allocation.id" as |cpu|}}
<g
data-test-cpu-rect="{{cpu.allocation.allocation.id}}"
class="bar {{cpu.className}} {{if (eq this.activeAllocation cpu.allocation) "is-active"}} {{if cpu.allocation.isSelected "is-selected"}}"
clip-path="url(#{{this.maskId}})"
data-allocation-id="{{cpu.allocation.allocation.id}}"
{{on "mouseenter" (fn this.highlightAllocation cpu.allocation)}}
{{on "click" (fn this.selectAllocation cpu.allocation)}}>
<rect
width="{{cpu.width}}px"
height="{{if cpu.allocation.isSelected this.selectedHeight this.height}}px"
x="{{cpu.x}}px"
y="{{if cpu.allocation.isSelected this.selectedYOffset this.yOffset}}px"
class="layer-0" />
{{#if (or (eq cpu.className "starting") (eq cpu.className "pending"))}}
<rect
width="{{cpu.width}}px"
height="{{if cpu.allocation.isSelected this.selectedHeight this.height}}px"
x="{{cpu.x}}px"
y="{{if cpu.allocation.isSelected this.selectedYOffset this.yOffset}}px"
class="layer-1" />
{{/if}}
</g>
{{/each}}
</g>
</g>
{{else}}
<g class="empty-text"><text data-test-empty-message>Empty Client</text></g>
{{/if}}
</svg>
</div>

View file

@ -0,0 +1,204 @@
{{title "Cluster Topology"}}
<PageLayout>
<section class="section is-full-width">
<div class="columns">
<div class="column is-one-quarter">
<div class="boxed-section">
<div class="boxed-section-head">Legend</div>
<div class="boxed-section-body">
<div class="legend">
<h3 class="legend-label">Metrics</h3>
<dl class="legend-terms">
<dt>M:</dt><dd>Memory</dd>
<dt>C:</dt><dd>CPU</dd>
</dl>
</div>
<div class="legend">
<h3 class="legend-label">Allocation Status</h3>
<dl class="legend-terms">
<div class="legend-term"><dt><span class="color-swatch is-wide running" title="Running" /></dt><dd>Running</dd></div>
<div class="legend-term"><dt><span class="color-swatch is-wide failed" title="Failed" /></dt><dd>Failed</dd></div>
<div class="legend-term"><dt><span class="color-swatch is-wide pending" title="Starting" /></dt><dd>Starting</dd></div>
</dl>
</div>
</div>
</div>
<div class="boxed-section">
<div data-test-info-panel-title class="boxed-section-head">
{{#if this.activeNode}}Client{{else if this.activeAllocation}}Allocation{{else}}Cluster{{/if}} Details
</div>
<div class="boxed-section-body">
{{#if this.activeNode}}
{{#let this.activeNode.node as |node|}}
<div class="dashboard-metric">
<p class="metric">{{this.activeNode.allocations.length}} <span class="metric-label">Allocations</span></p>
</div>
<div class="dashboard-metric">
<h3 class="pair">
<strong>Client:</strong>
<LinkTo @route="clients.client" @model={{node}}>
{{node.shortId}}
</LinkTo>
</h3>
<p><strong>Name:</strong> {{node.name}}</p>
<p><strong>Address:</strong> {{node.httpAddr}}</p>
<p><strong>Status:</strong> {{node.status}}</p>
</div>
<div class="dashboard-metric">
<h3 class="pair">
<strong>Draining?</strong> <span class="{{if node.isDraining "status-text is-info"}}">{{if node.isDraining "Yes" "No"}}</span>
</h3>
<h3 class="pair">
<strong>Eligible?</strong> <span class="{{unless node.isEligible "status-text is-warning"}}">{{if node.isEligible "Yes" "No"}}</span>
</h3>
</div>
<div class="dashboard-metric with-divider">
<p class="metric">
{{this.nodeUtilization.totalMemoryFormatted}}
<span class="metric-units">{{this.nodeUtilization.totalMemoryUnits}}</span>
<span class="metric-label">of memory</span>
</p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
<progress
class="progress is-danger is-small"
value="{{this.nodeUtilization.reservedMemoryPercent}}"
max="1">
{{this.nodeUtilization.reservedMemoryPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>{{format-percentage this.nodeUtilization.reservedMemoryPercent total=1}}</span>
</div>
</div>
<div class="annotation" data-test-absolute-value>
<strong>{{format-bytes this.nodeUtilization.totalReservedMemory}}</strong> / {{format-bytes this.nodeUtilization.totalMemory}} reserved
</div>
</div>
<div class="dashboard-metric">
<p class="metric">{{this.nodeUtilization.totalCPU}} <span class="metric-units">Mhz</span> <span class="metric-label">of CPU</span></p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
<progress
class="progress is-info is-small"
value="{{this.nodeUtilization.reservedCPUPercent}}"
max="1">
{{this.nodeUtilization.reservedCPUPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>{{format-percentage this.nodeUtilization.reservedCPUPercent total=1}}</span>
</div>
</div>
<div class="annotation" data-test-absolute-value>
<strong>{{this.nodeUtilization.totalReservedCPU}} Mhz</strong> / {{this.nodeUtilization.totalCPU}} Mhz reserved
</div>
</div>
{{/let}}
{{else if this.activeAllocation}}
<div class="dashboard-metric">
<h3 class="pair">
<strong>Allocation:</strong>
<LinkTo @route="allocations.allocation" @model={{this.activeAllocation}} class="is-primary">{{this.activeAllocation.shortId}}</LinkTo>
</h3>
<p><strong>Sibling Allocations:</strong> {{this.siblingAllocations.length}}</p>
<p><strong>Unique Client Placements:</strong> {{this.uniqueActiveAllocationNodes.length}}</p>
</div>
<div class="dashboard-metric with-divider">
<h3 class="pair">
<strong>Job:</strong>
<LinkTo
@route="jobs.job"
@model={{this.activeAllocation.job}}
@query={{hash jobNamespace=this.activeAllocation.job.namespace.id}} data-test-job>
{{this.activeAllocation.job.name}}</LinkTo>
<span class="is-faded" data-test-task-group> / {{this.activeAllocation.taskGroupName}}</span>
</h3>
<p><strong>Type:</strong> {{this.activeAllocation.job.type}}</p>
<p><strong>Priority:</strong> {{this.activeAllocation.job.priority}}</p>
</div>
<div class="dashboard-metric with-divider">
<h3 class="pair">
<strong>Client:</strong>
<LinkTo @route="clients.client" @model={{this.activeAllocation.node}}>
{{this.activeAllocation.node.shortId}}
</LinkTo>
</h3>
<p><strong>Name:</strong> {{this.activeAllocation.node.name}}</p>
<p><strong>Address:</strong> {{this.activeAllocation.node.httpAddr}}</p>
</div>
<div class="dashboard-metric with-divider">
<PrimaryMetric @resource={{this.activeAllocation}} @metric="memory" class="is-short" />
</div>
<div class="dashboard-metric">
<PrimaryMetric @resource={{this.activeAllocation}} @metric="cpu" class="is-short" />
</div>
{{else}}
<div class="columns is-flush">
<div class="dashboard-metric column">
<p class="metric">{{this.model.nodes.length}} <span class="metric-label">Clients</span></p>
</div>
<div class="dashboard-metric column">
<p class="metric">{{this.scheduledAllocations.length}} <span class="metric-label">Allocations</span></p>
</div>
</div>
<div class="dashboard-metric with-divider">
<p class="metric">{{this.totalMemoryFormatted}} <span class="metric-units">{{this.totalMemoryUnits}}</span> <span class="metric-label">of memory</span></p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
<progress
class="progress is-danger is-small"
value="{{this.reservedMemoryPercent}}"
max="1">
{{this.reservedMemoryPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>{{format-percentage this.reservedMemoryPercent total=1}}</span>
</div>
</div>
<div class="annotation" data-test-absolute-value>
<strong>{{format-bytes this.totalReservedMemory}}</strong> / {{format-bytes this.totalMemory}} reserved
</div>
</div>
<div class="dashboard-metric">
<p class="metric">{{this.totalCPU}} <span class="metric-units">Mhz</span> <span class="metric-label">of CPU</span></p>
<div class="columns graphic">
<div class="column">
<div class="inline-chart" data-test-percentage-bar>
<progress
class="progress is-info is-small"
value="{{this.reservedCPUPercent}}"
max="1">
{{this.reservedCPUPercent}}
</progress>
</div>
</div>
<div class="column is-minimum">
<span class="nowrap" data-test-percentage>{{format-percentage this.reservedCPUPercent total=1}}</span>
</div>
</div>
<div class="annotation" data-test-absolute-value>
<strong>{{this.totalReservedCPU}} Mhz</strong> / {{this.totalCPU}} Mhz reserved
</div>
</div>
{{/if}}
</div>
</div>
</div>
<div class="column">
<TopoViz
@nodes={{this.model.nodes}}
@allocations={{this.model.allocations}}
@onAllocationSelect={{action this.setAllocation}}
@onNodeSelect={{action this.setNode}} />
</div>
</div>
</section>
</PageLayout>

View file

@ -25,8 +25,8 @@ module.exports = function(environment) {
APP: { APP: {
blockingQueries: true, blockingQueries: true,
mirageScenario: 'smallCluster', mirageScenario: 'topoMedium',
mirageWithNamespaces: true, mirageWithNamespaces: false,
mirageWithTokens: true, mirageWithTokens: true,
mirageWithRegions: true, mirageWithRegions: true,
}, },

View file

@ -5,10 +5,8 @@ import { provide } from './utils';
const CPU_RESERVATIONS = [250, 500, 1000, 2000, 2500, 4000]; const CPU_RESERVATIONS = [250, 500, 1000, 2000, 2500, 4000];
const MEMORY_RESERVATIONS = [256, 512, 1024, 2048, 4096, 8192]; const MEMORY_RESERVATIONS = [256, 512, 1024, 2048, 4096, 8192];
const DISK_RESERVATIONS = [200, 500, 1000, 2000, 5000, 10000, 100000]; const DISK_RESERVATIONS = [200, 500, 1000, 2000, 5000, 10000, 100000];
const IOPS_RESERVATIONS = [100000, 250000, 500000, 1000000, 10000000, 20000000];
// There is also a good chance that certain resource restrictions are unbounded // There is also a good chance that certain resource restrictions are unbounded
IOPS_RESERVATIONS.push(...Array(1000).fill(0));
DISK_RESERVATIONS.push(...Array(500).fill(0)); DISK_RESERVATIONS.push(...Array(500).fill(0));
const NETWORK_MODES = ['bridge', 'host']; const NETWORK_MODES = ['bridge', 'host'];
@ -27,10 +25,15 @@ export const STORAGE_PROVIDERS = ['ebs', 'zfs', 'nfs', 'cow', 'moo'];
export function generateResources(options = {}) { export function generateResources(options = {}) {
return { return {
CPU: options.CPU || faker.helpers.randomize(CPU_RESERVATIONS), Cpu: {
MemoryMB: options.MemoryMB || faker.helpers.randomize(MEMORY_RESERVATIONS), CpuShares: options.CPU || faker.helpers.randomize(CPU_RESERVATIONS),
DiskMB: options.DiskMB || faker.helpers.randomize(DISK_RESERVATIONS), },
IOPS: options.IOPS || faker.helpers.randomize(IOPS_RESERVATIONS), Memory: {
MemoryMB: options.MemoryMB || faker.helpers.randomize(MEMORY_RESERVATIONS),
},
Disk: {
DiskMB: options.DiskMB || faker.helpers.randomize(DISK_RESERVATIONS),
},
Networks: generateNetworks(options.networks), Networks: generateNetworks(options.networks),
Ports: generatePorts(options.networks), Ports: generatePorts(options.networks),
}; };

View file

@ -42,15 +42,16 @@ export default Factory.extend({
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup }); const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
const resources = taskGroup.taskIds.map(id => { const resources = taskGroup.taskIds.map(id => {
const task = server.db.tasks.find(id); const task = server.db.tasks.find(id);
return server.create( return server.create('task-resource', {
'task-resource', allocation,
{ name: task.name,
allocation, resources: generateResources({
name: task.name, CPU: task.resources.CPU,
resources: task.Resources, MemoryMB: task.resources.MemoryMB,
}, DiskMB: task.resources.DiskMB,
'withReservedPorts' networks: { minPorts: 1 },
); }),
});
}); });
allocation.update({ taskResourceIds: resources.mapBy('id') }); allocation.update({ taskResourceIds: resources.mapBy('id') });
@ -62,29 +63,22 @@ export default Factory.extend({
const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup }); const taskGroup = server.db.taskGroups.findBy({ name: allocation.taskGroup });
const resources = taskGroup.taskIds.map(id => { const resources = taskGroup.taskIds.map(id => {
const task = server.db.tasks.find(id); const task = server.db.tasks.find(id);
return server.create( return server.create('task-resource', {
'task-resource', allocation,
{ name: task.name,
allocation, resources: generateResources({
name: task.name, CPU: task.resources.CPU,
resources: task.Resources, MemoryMB: task.resources.MemoryMB,
}, DiskMB: task.resources.DiskMB,
'withoutReservedPorts' networks: { minPorts: 0, maxPorts: 0 },
); }),
});
}); });
allocation.update({ taskResourceIds: resources.mapBy('id') }); allocation.update({ taskResourceIds: resources.mapBy('id') });
}, },
}), }),
withAllocatedResources: trait({
allocatedResources: () => {
return {
Shared: generateResources({ networks: { minPorts: 2 } }),
};
},
}),
rescheduleAttempts: 0, rescheduleAttempts: 0,
rescheduleSuccess: false, rescheduleSuccess: false,
@ -200,13 +194,13 @@ export default Factory.extend({
return server.create('task-resource', { return server.create('task-resource', {
allocation, allocation,
name: task.name, name: task.name,
resources: task.Resources, resources: task.originalResources,
}); });
}); });
allocation.update({ allocation.update({
taskStateIds: allocation.clientStatus === 'pending' ? [] : states.mapBy('id'), taskStateIds: allocation.clientStatus === 'pending' ? [] : states.mapBy('id'),
taskResourceIds: allocation.clientStatus === 'pending' ? [] : resources.mapBy('id'), taskResourceIds: resources.mapBy('id'),
}); });
// Each allocation has a corresponding allocation stats running on some client. // Each allocation has a corresponding allocation stats running on some client.

View file

@ -74,7 +74,7 @@ export default Factory.extend({
hostVolumes: makeHostVolumes, hostVolumes: makeHostVolumes,
resources: generateResources, nodeResources: generateResources,
attributes() { attributes() {
// TODO add variability to these // TODO add variability to these

View file

@ -79,7 +79,7 @@ export default Factory.extend({
const maybeResources = {}; const maybeResources = {};
if (resources) { if (resources) {
maybeResources.Resources = generateResources(resources[idx]); maybeResources.originalResources = generateResources(resources[idx]);
} }
return server.create('task', { return server.create('task', {
taskGroup: group, taskGroup: group,

View file

@ -5,12 +5,4 @@ export default Factory.extend({
name: () => '!!!this should be set by the allocation that owns this task state!!!', name: () => '!!!this should be set by the allocation that owns this task state!!!',
resources: generateResources, resources: generateResources,
withReservedPorts: trait({
resources: () => generateResources({ networks: { minPorts: 1 } }),
}),
withoutReservedPorts: trait({
resources: () => generateResources({ networks: { minPorts: 0, maxPorts: 0 } }),
}),
}); });

View file

@ -16,7 +16,17 @@ export default Factory.extend({
name: id => `task-${faker.hacker.noun().dasherize()}-${id}`, name: id => `task-${faker.hacker.noun().dasherize()}-${id}`,
driver: () => faker.helpers.randomize(DRIVERS), driver: () => faker.helpers.randomize(DRIVERS),
Resources: generateResources, originalResources: generateResources,
resources: function() {
// Generate resources the usual way, but transform to the old
// shape because that's what the job spec uses.
const resources = this.originalResources;
return {
CPU: resources.Cpu.CpuShares,
MemoryMB: resources.Memory.MemoryMB,
DiskMB: resources.Disk.DiskMB,
};
},
Lifecycle: i => { Lifecycle: i => {
const cycle = i % 5; const cycle = i % 5;

View file

@ -1,4 +1,5 @@
import config from 'nomad-ui/config/environment'; import config from 'nomad-ui/config/environment';
import * as topoScenarios from './topo';
import { pickOne } from '../utils'; import { pickOne } from '../utils';
const withNamespaces = getConfigValue('mirageWithNamespaces', false); const withNamespaces = getConfigValue('mirageWithNamespaces', false);
@ -14,6 +15,7 @@ const allScenarios = {
allNodeTypes, allNodeTypes,
everyFeature, everyFeature,
emptyCluster, emptyCluster,
...topoScenarios,
}; };
const scenario = getConfigValue('mirageScenario', 'emptyCluster'); const scenario = getConfigValue('mirageScenario', 'emptyCluster');

109
ui/mirage/scenarios/topo.js Normal file
View file

@ -0,0 +1,109 @@
import faker from 'nomad-ui/mirage/faker';
import { generateNetworks, generatePorts } from '../common';
const genResources = (CPU, Memory) => ({
Cpu: { CpuShares: CPU },
Memory: { MemoryMB: Memory },
Disk: { DiskMB: 10000 },
Networks: generateNetworks(),
Ports: generatePorts(),
});
export function topoSmall(server) {
server.createList('agent', 3);
server.createList('node', 12, {
datacenter: 'dc1',
status: 'ready',
nodeResources: genResources(3000, 5192),
});
const jobResources = [
['M: 2560, C: 150'],
['M: 128, C: 400'],
['M: 512, C: 100'],
['M: 256, C: 150'],
['M: 200, C: 50'],
['M: 64, C: 100'],
['M: 128, C: 150'],
['M: 1024, C: 500'],
['M: 100, C: 300', 'M: 200, C: 150'],
['M: 512, C: 250', 'M: 600, C: 200'],
];
jobResources.forEach(spec => {
server.create('job', {
status: 'running',
datacenters: ['dc1'],
type: 'service',
createAllocations: false,
resourceSpec: spec,
});
});
server.createList('allocation', 25, {
forceRunningClientStatus: true,
});
}
export function topoMedium(server) {
server.createList('agent', 3);
server.createList('node', 10, {
datacenter: 'us-west-1',
status: 'ready',
nodeResources: genResources(3000, 5192),
});
server.createList('node', 12, {
datacenter: 'us-east-1',
status: 'ready',
nodeResources: genResources(3000, 5192),
});
server.createList('node', 11, {
datacenter: 'eu-west-1',
status: 'ready',
nodeResources: genResources(3000, 5192),
});
server.createList('node', 8, {
datacenter: 'us-west-1',
status: 'ready',
nodeResources: genResources(8000, 12192),
});
server.createList('node', 9, {
datacenter: 'us-east-1',
status: 'ready',
nodeResources: genResources(8000, 12192),
});
const jobResources = [
['M: 2560, C: 150'],
['M: 128, C: 400'],
['M: 512, C: 100'],
['M: 256, C: 150'],
['M: 200, C: 50'],
['M: 64, C: 100'],
['M: 128, C: 150'],
['M: 1024, C: 500'],
['M: 1200, C: 50'],
['M: 1400, C: 200'],
['M: 50, C: 150'],
['M: 5000, C: 1800'],
['M: 100, C: 300', 'M: 200, C: 150'],
['M: 512, C: 250', 'M: 600, C: 200'],
];
jobResources.forEach(spec => {
server.create('job', {
status: 'running',
datacenters: ['dc1'],
type: 'service',
createAllocations: false,
resourceSpec: spec,
});
});
server.createList('allocation', 100, {
forceRunningClientStatus: true,
});
}

View file

@ -18,14 +18,14 @@ export default ApplicationSerializer.extend({
function serializeAllocation(allocation) { function serializeAllocation(allocation) {
allocation.TaskStates = allocation.TaskStates.reduce(arrToObj('Name'), {}); allocation.TaskStates = allocation.TaskStates.reduce(arrToObj('Name'), {});
allocation.Resources = allocation.TaskResources.mapBy('Resources').reduce( const { Ports, Networks } = allocation.TaskResources[0]
(hash, resources) => { ? allocation.TaskResources[0].Resources
['CPU', 'DiskMB', 'IOPS', 'MemoryMB'].forEach(key => (hash[key] += resources[key])); : {};
hash.Networks = resources.Networks; allocation.AllocatedResources = {
hash.Ports = resources.Ports; Shared: { Ports, Networks },
return hash; Tasks: allocation.TaskResources.map(({ Name, Resources }) => ({ Name, ...Resources })).reduce(
}, arrToObj('Name'),
{ CPU: 0, DiskMB: 0, IOPS: 0, MemoryMB: 0 } {}
); ),
allocation.TaskResources = allocation.TaskResources.reduce(arrToObj('Name', 'Resources'), {}); };
} }

View file

@ -43,7 +43,7 @@
"broccoli-asset-rev": "^3.0.0", "broccoli-asset-rev": "^3.0.0",
"bulma": "0.6.1", "bulma": "0.6.1",
"core-js": "^2.4.1", "core-js": "^2.4.1",
"d3-array": "^1.2.0", "d3-array": "^2.1.0",
"d3-axis": "^1.0.0", "d3-axis": "^1.0.0",
"d3-format": "^1.3.0", "d3-format": "^1.3.0",
"d3-scale": "^1.0.0", "d3-scale": "^1.0.0",
@ -84,6 +84,7 @@
"ember-inline-svg": "^0.3.0", "ember-inline-svg": "^0.3.0",
"ember-load-initializers": "^2.1.1", "ember-load-initializers": "^2.1.1",
"ember-maybe-import-regenerator": "^0.1.6", "ember-maybe-import-regenerator": "^0.1.6",
"ember-modifier": "^2.1.0",
"ember-moment": "^7.8.1", "ember-moment": "^7.8.1",
"ember-overridable-computed": "^1.0.0", "ember-overridable-computed": "^1.0.0",
"ember-page-title": "^5.0.2", "ember-page-title": "^5.0.2",

View file

@ -26,7 +26,7 @@ module('Acceptance | allocation detail', function(hooks) {
withGroupServices: true, withGroupServices: true,
createAllocations: false, createAllocations: false,
}); });
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', { allocation = server.create('allocation', 'withTaskWithPorts', {
clientStatus: 'running', clientStatus: 'running',
}); });
@ -87,7 +87,7 @@ module('Acceptance | allocation detail', function(hooks) {
createAllocations: false, createAllocations: false,
}); });
const allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', { const allocation = server.create('allocation', 'withTaskWithPorts', {
clientStatus: 'running', clientStatus: 'running',
jobId: job.id, jobId: job.id,
}); });
@ -188,7 +188,7 @@ module('Acceptance | allocation detail', function(hooks) {
createAllocations: false, createAllocations: false,
}); });
allocation = server.create('allocation', 'withTaskWithPorts', 'withAllocatedResources', { allocation = server.create('allocation', 'withTaskWithPorts', {
clientStatus: 'running', clientStatus: 'running',
jobId: job.id, jobId: job.id,
}); });
@ -216,7 +216,7 @@ module('Acceptance | allocation detail', function(hooks) {
}); });
test('ports are listed', async function(assert) { test('ports are listed', async function(assert) {
const allServerPorts = allocation.allocatedResources.Shared.Ports; const allServerPorts = allocation.taskResources.models[0].resources.Ports;
allServerPorts.sortBy('Label').forEach((serverPort, index) => { allServerPorts.sortBy('Label').forEach((serverPort, index) => {
const renderedPort = Allocation.ports[index]; const renderedPort = Allocation.ports[index];

View file

@ -134,8 +134,8 @@ module('Acceptance | client detail', function(hooks) {
}); });
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id)); const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0); const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0); const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
await ClientDetail.visit({ id: node.id }); await ClientDetail.visit({ id: node.id });

View file

@ -94,8 +94,8 @@ module('Acceptance | plugin detail', function(hooks) {
}); });
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id)); const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0); const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0); const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
await PluginDetail.visit({ id: plugin.id }); await PluginDetail.visit({ id: plugin.id });

View file

@ -74,8 +74,8 @@ module('Acceptance | task group detail', function(hooks) {
}); });
test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) { test('/jobs/:id/:task-group should list high-level metrics for the allocation', async function(assert) {
const totalCPU = tasks.mapBy('Resources.CPU').reduce(sum, 0); const totalCPU = tasks.mapBy('resources.CPU').reduce(sum, 0);
const totalMemory = tasks.mapBy('Resources.MemoryMB').reduce(sum, 0); const totalMemory = tasks.mapBy('resources.MemoryMB').reduce(sum, 0);
const totalDisk = taskGroup.ephemeralDisk.SizeMB; const totalDisk = taskGroup.ephemeralDisk.SizeMB;
await TaskGroup.visit({ id: job.id, name: taskGroup.name }); await TaskGroup.visit({ id: job.id, name: taskGroup.name });
@ -199,8 +199,8 @@ module('Acceptance | task group detail', function(hooks) {
const allocStats = server.db.clientAllocationStats.find(allocation.id); const allocStats = server.db.clientAllocationStats.find(allocation.id);
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id)); const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0); const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0); const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
assert.equal( assert.equal(
allocationRow.cpu, allocationRow.cpu,

View file

@ -0,0 +1,53 @@
import { module, test } from 'qunit';
import { setupApplicationTest } from 'ember-qunit';
import { setupMirage } from 'ember-cli-mirage/test-support';
import a11yAudit from 'nomad-ui/tests/helpers/a11y-audit';
import Topology from 'nomad-ui/tests/pages/topology';
// TODO: Once we settle on the contents of the info panel, the contents
// should also get acceptance tests.
module('Acceptance | topology', function(hooks) {
setupApplicationTest(hooks);
setupMirage(hooks);
hooks.beforeEach(function() {
server.create('job', { createAllocations: false });
});
test('it passes an accessibility audit', async function(assert) {
server.createList('node', 3);
server.createList('allocation', 5);
await Topology.visit();
await a11yAudit(assert);
});
test('by default the info panel shows cluster aggregate stats', async function(assert) {
server.createList('node', 3);
server.createList('allocation', 5);
await Topology.visit();
assert.equal(Topology.infoPanelTitle, 'Cluster Details');
});
test('when an allocation is selected, the info panel shows information on the allocation', async function(assert) {
server.createList('node', 1);
server.createList('allocation', 5);
await Topology.visit();
await Topology.viz.datacenters[0].nodes[0].memoryRects[0].select();
assert.equal(Topology.infoPanelTitle, 'Allocation Details');
});
test('when a node is selected, the info panel shows information on the node', async function(assert) {
// A high node count is required for node selection
server.createList('node', 51);
server.createList('allocation', 5);
await Topology.visit();
await Topology.viz.datacenters[0].nodes[0].selectNode();
assert.equal(Topology.infoPanelTitle, 'Client Details');
});
});

View file

@ -106,8 +106,8 @@ module('Acceptance | volume detail', function(hooks) {
}); });
const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id)); const tasks = taskGroup.taskIds.map(id => server.db.tasks.find(id));
const cpuUsed = tasks.reduce((sum, task) => sum + task.Resources.CPU, 0); const cpuUsed = tasks.reduce((sum, task) => sum + task.resources.CPU, 0);
const memoryUsed = tasks.reduce((sum, task) => sum + task.Resources.MemoryMB, 0); const memoryUsed = tasks.reduce((sum, task) => sum + task.resources.MemoryMB, 0);
await VolumeDetail.visit({ id: volume.id }); await VolumeDetail.visit({ id: volume.id });

View file

@ -0,0 +1,32 @@
// Used in glimmer component unit tests. Glimmer components should typically
// be tested with integration tests, but occasionally individual methods or
// properties have logic that isn't coupled to rendering or the DOM and can
// be better tested in a unit fashion.
//
// Use like
//
// setupGlimmerComponentFactory(hooks, 'my-component')
//
// test('testing my component', function(assert) {
// const component = this.createComponent({ hello: 'world' });
// assert.equal(component.args.hello, 'world');
// });
export default function setupGlimmerComponentFactory(hooks, componentKey) {
hooks.beforeEach(function() {
this.createComponent = glimmerComponentInstantiator(this.owner, componentKey);
});
hooks.afterEach(function() {
delete this.createComponent;
});
}
// Look up the component class in the glimmer component manager and return a
// function to construct components as if they were functions.
function glimmerComponentInstantiator(owner, componentKey) {
return args => {
const componentManager = owner.lookup('component-manager:glimmer');
const componentClass = owner.factoryFor(`component:${componentKey}`).class;
return componentManager.createComponent(componentClass, { named: args });
};
}

View file

@ -0,0 +1,168 @@
import { htmlSafe } from '@ember/template';
import { click, find, findAll, settled } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
// Used to prevent XSS warnings in console
const h = height => htmlSafe(`height:${height}px`);
module('Integration | Component | FlexMasonry', function(hooks) {
setupRenderingTest(hooks);
test('presents as a single div when @items is empty', async function(assert) {
this.setProperties({
items: [],
});
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}}>
</FlexMasonry>
`);
const div = find('[data-test-flex-masonry]');
assert.ok(div);
assert.equal(div.tagName.toLowerCase(), 'div');
assert.equal(div.children.length, 0);
await componentA11yAudit(this.element, assert);
});
test('each item in @items gets wrapped in a flex-masonry-item wrapper', async function(assert) {
this.setProperties({
items: ['one', 'two', 'three'],
columns: 2,
});
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}} as |item|>
<p>{{item}}</p>
</FlexMasonry>
`);
assert.equal(findAll('[data-test-flex-masonry-item]').length, this.items.length);
});
test('the @withSpacing arg adds the with-spacing class', async function(assert) {
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}}
@withSpacing={{true}}>
</FlexMasonry>
`);
assert.ok(find('[data-test-flex-masonry]').classList.contains('with-spacing'));
});
test('individual items along with the reflow action are yielded', async function(assert) {
this.setProperties({
items: ['one', 'two'],
columns: 2,
height: h(50),
});
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}} as |item reflow|>
<div style={{this.height}} {{on "click" reflow}}>{{item}}</div>
</FlexMasonry>
`);
const div = find('[data-test-flex-masonry]');
assert.equal(div.style.maxHeight, '51px');
assert.ok(div.textContent.includes('one'));
assert.ok(div.textContent.includes('two'));
this.set('height', h(500));
await settled();
assert.equal(div.style.maxHeight, '51px');
// The height of the div changes when reflow is called
await click('[data-test-flex-masonry-item]:first-child div');
await settled();
assert.equal(div.style.maxHeight, '501px');
});
test('items are rendered to the DOM in the order they were passed into the component', async function(assert) {
this.setProperties({
items: [
{ text: 'One', height: h(20) },
{ text: 'Two', height: h(100) },
{ text: 'Three', height: h(20) },
{ text: 'Four', height: h(20) },
],
columns: 2,
});
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}} as |item|>
<div style={{item.height}}>{{item.text}}</div>
</FlexMasonry>
`);
findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
assert.equal(el.textContent.trim(), this.items[index].text);
});
});
test('each item gets an order property', async function(assert) {
this.setProperties({
items: [
{ text: 'One', height: h(20), expectedOrder: 0 },
{ text: 'Two', height: h(100), expectedOrder: 3 },
{ text: 'Three', height: h(20), expectedOrder: 1 },
{ text: 'Four', height: h(20), expectedOrder: 2 },
],
columns: 2,
});
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}} as |item|>
<div style={{item.height}}>{{item.text}}</div>
</FlexMasonry>
`);
findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
assert.equal(el.style.order, this.items[index].expectedOrder);
});
});
test('the last item in each column gets a specific flex-basis value', async function(assert) {
this.setProperties({
items: [
{ text: 'One', height: h(20) },
{ text: 'Two', height: h(100), flexBasis: '100px' },
{ text: 'Three', height: h(20) },
{ text: 'Four', height: h(100), flexBasis: '100px' },
{ text: 'Five', height: h(20), flexBasis: '80px' },
{ text: 'Six', height: h(20), flexBasis: '80px' },
],
columns: 4,
});
await this.render(hbs`
<FlexMasonry
@items={{this.items}}
@columns={{this.columns}} as |item|>
<div style={{item.height}}>{{item.text}}</div>
</FlexMasonry>
`);
findAll('[data-test-flex-masonry-item]').forEach((el, index) => {
if (el.style.flexBasis) {
assert.equal(el.style.flexBasis, this.items[index].flexBasis);
}
});
});
});

View file

@ -0,0 +1,144 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
import sinon from 'sinon';
import faker from 'nomad-ui/mirage/faker';
import topoVizPageObject from 'nomad-ui/tests/pages/components/topo-viz';
const TopoViz = create(topoVizPageObject());
const alloc = (nodeId, jobId, taskGroupName, memory, cpu, props = {}) => ({
id: faker.random.uuid(),
taskGroupName,
isScheduled: true,
allocatedResources: {
cpu,
memory,
},
belongsTo: type => ({
id: () => (type === 'job' ? jobId : nodeId),
}),
...props,
});
const node = (datacenter, id, memory, cpu) => ({
datacenter,
id,
resources: { memory, cpu },
});
module('Integration | Component | TopoViz', function(hooks) {
setupRenderingTest(hooks);
const commonTemplate = hbs`
<TopoViz
@nodes={{this.nodes}}
@allocations={{this.allocations}}
@onAllocationSelect={{this.onAllocationSelect}}
@onNodeSelect={{this.onNodeSelect}} />
`;
test('presents as a FlexMasonry of datacenters', async function(assert) {
this.setProperties({
nodes: [node('dc1', 'node0', 1000, 500), node('dc2', 'node1', 1000, 500)],
allocations: [
alloc('node0', 'job1', 'group', 100, 100),
alloc('node0', 'job1', 'group', 100, 100),
alloc('node1', 'job1', 'group', 100, 100),
],
});
await this.render(commonTemplate);
assert.equal(TopoViz.datacenters.length, 2);
assert.equal(TopoViz.datacenters[0].nodes.length, 1);
assert.equal(TopoViz.datacenters[1].nodes.length, 1);
assert.equal(TopoViz.datacenters[0].nodes[0].memoryRects.length, 2);
assert.equal(TopoViz.datacenters[1].nodes[0].memoryRects.length, 1);
await componentA11yAudit(this.element, assert);
});
test('clicking on a node in a deeply nested TopoViz::Node will toggle node selection and call @onNodeSelect', async function(assert) {
this.setProperties({
// TopoViz must be dense for node selection to be a feature
nodes: Array(55)
.fill(null)
.map((_, index) => node('dc1', `node${index}`, 1000, 500)),
allocations: [],
onNodeSelect: sinon.spy(),
});
await this.render(commonTemplate);
await TopoViz.datacenters[0].nodes[0].selectNode();
assert.ok(this.onNodeSelect.calledOnce);
assert.equal(this.onNodeSelect.getCall(0).args[0].node, this.nodes[0]);
await TopoViz.datacenters[0].nodes[0].selectNode();
assert.ok(this.onNodeSelect.calledTwice);
assert.equal(this.onNodeSelect.getCall(1).args[0], null);
});
test('clicking on an allocation in a deeply nested TopoViz::Node will update the topology object with selections and call @onAllocationSelect and @onNodeSelect', async function(assert) {
this.setProperties({
nodes: [node('dc1', 'node0', 1000, 500)],
allocations: [alloc('node0', 'job1', 'group', 100, 100)],
onNodeSelect: sinon.spy(),
onAllocationSelect: sinon.spy(),
});
await this.render(commonTemplate);
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
assert.ok(this.onAllocationSelect.calledOnce);
assert.equal(this.onAllocationSelect.getCall(0).args[0], this.allocations[0]);
assert.ok(this.onNodeSelect.calledOnce);
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
assert.ok(this.onAllocationSelect.calledTwice);
assert.equal(this.onAllocationSelect.getCall(1).args[0], null);
assert.ok(this.onNodeSelect.calledTwice);
assert.ok(this.onNodeSelect.alwaysCalledWith(null));
});
test('clicking on an allocation in a deeply nested TopoViz::Node will associate sibling allocations with curves', async function(assert) {
this.setProperties({
nodes: [
node('dc1', 'node0', 1000, 500),
node('dc1', 'node1', 1000, 500),
node('dc2', 'node2', 1000, 500),
],
allocations: [
alloc('node0', 'job1', 'group', 100, 100),
alloc('node0', 'job1', 'group', 100, 100),
alloc('node1', 'job1', 'group', 100, 100),
alloc('node2', 'job1', 'group', 100, 100),
alloc('node0', 'job1', 'groupTwo', 100, 100),
alloc('node1', 'job2', 'group', 100, 100),
alloc('node2', 'job2', 'groupTwo', 100, 100),
],
onNodeSelect: sinon.spy(),
onAllocationSelect: sinon.spy(),
});
const selectedAllocations = this.allocations.filter(
alloc => alloc.belongsTo('job').id() === 'job1' && alloc.taskGroupName === 'group'
);
await this.render(commonTemplate);
assert.notOk(TopoViz.allocationAssociationsArePresent);
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
assert.ok(TopoViz.allocationAssociationsArePresent);
assert.equal(TopoViz.allocationAssociations.length, selectedAllocations.length * 2);
await TopoViz.datacenters[0].nodes[0].memoryRects[0].select();
assert.notOk(TopoViz.allocationAssociationsArePresent);
});
});

View file

@ -0,0 +1,160 @@
import { find } from '@ember/test-helpers';
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
import sinon from 'sinon';
import faker from 'nomad-ui/mirage/faker';
import topoVizDatacenterPageObject from 'nomad-ui/tests/pages/components/topo-viz/datacenter';
const TopoVizDatacenter = create(topoVizDatacenterPageObject());
const nodeGen = (name, datacenter, memory, cpu, allocations = []) => ({
datacenter,
memory,
cpu,
node: { name },
allocations: allocations.map(alloc => ({
memory: alloc.memory,
cpu: alloc.cpu,
memoryPercent: alloc.memory / memory,
cpuPercent: alloc.cpu / cpu,
allocation: {
id: faker.random.uuid(),
isScheduled: true,
},
})),
});
// Used in Array#reduce to sum by a property common to an array of objects
const sumBy = prop => (sum, obj) => (sum += obj[prop]);
module('Integration | Component | TopoViz::Datacenter', function(hooks) {
setupRenderingTest(hooks);
const commonProps = props => ({
isSingleColumn: true,
isDense: false,
heightScale: () => 50,
onAllocationSelect: sinon.spy(),
onNodeSelect: sinon.spy(),
...props,
});
const commonTemplate = hbs`
<TopoViz::Datacenter
@datacenter={{this.datacenter}}
@isSingleColumn={{this.isSingleColumn}}
@isDense={{this.isDense}}
@heightScale={{this.heightScale}}
@onAllocationSelect={{this.onAllocationSelect}}
@onNodeSelect={{this.onNodeSelect}} />
`;
test('presents as a div with a label and a FlexMasonry with a collection of nodes', async function(assert) {
this.setProperties(
commonProps({
datacenter: {
name: 'dc1',
nodes: [nodeGen('node-1', 'dc1', 1000, 500)],
},
})
);
await this.render(commonTemplate);
assert.ok(TopoVizDatacenter.isPresent);
assert.equal(TopoVizDatacenter.nodes.length, this.datacenter.nodes.length);
await componentA11yAudit(this.element, assert);
});
test('datacenter stats are an aggregate of node stats', async function(assert) {
this.setProperties(
commonProps({
datacenter: {
name: 'dc1',
nodes: [
nodeGen('node-1', 'dc1', 1000, 500, [
{ memory: 100, cpu: 300 },
{ memory: 200, cpu: 50 },
]),
nodeGen('node-2', 'dc1', 1500, 100, [
{ memory: 50, cpu: 80 },
{ memory: 100, cpu: 20 },
]),
nodeGen('node-3', 'dc1', 2000, 300),
nodeGen('node-4', 'dc1', 3000, 200),
],
},
})
);
await this.render(commonTemplate);
const allocs = this.datacenter.nodes.reduce(
(allocs, node) => allocs.concat(node.allocations),
[]
);
const memoryReserved = allocs.reduce(sumBy('memory'), 0);
const cpuReserved = allocs.reduce(sumBy('cpu'), 0);
const memoryTotal = this.datacenter.nodes.reduce(sumBy('memory'), 0);
const cpuTotal = this.datacenter.nodes.reduce(sumBy('cpu'), 0);
assert.ok(TopoVizDatacenter.label.includes(this.datacenter.name));
assert.ok(TopoVizDatacenter.label.includes(`${this.datacenter.nodes.length} Nodes`));
assert.ok(TopoVizDatacenter.label.includes(`${allocs.length} Allocs`));
assert.ok(TopoVizDatacenter.label.includes(`${memoryReserved}/${memoryTotal} MiB`));
assert.ok(TopoVizDatacenter.label.includes(`${cpuReserved}/${cpuTotal} Mhz`));
});
test('when @isSingleColumn is true, the FlexMasonry layout gets one column, otherwise it gets two', async function(assert) {
this.setProperties(
commonProps({
isSingleColumn: true,
datacenter: {
name: 'dc1',
nodes: [nodeGen('node-1', 'dc1', 1000, 500), nodeGen('node-2', 'dc1', 1000, 500)],
},
})
);
await this.render(commonTemplate);
assert.ok(find('[data-test-flex-masonry].flex-masonry-columns-1'));
this.set('isSingleColumn', false);
assert.ok(find('[data-test-flex-masonry].flex-masonry-columns-2'));
});
test('args get passed down to the TopViz::Node children', async function(assert) {
const heightSpy = sinon.spy();
this.setProperties(
commonProps({
isDense: true,
heightScale: (...args) => {
heightSpy(...args);
return 50;
},
datacenter: {
name: 'dc1',
nodes: [nodeGen('node-1', 'dc1', 1000, 500, [{ memory: 100, cpu: 300 }])],
},
})
);
await this.render(commonTemplate);
TopoVizDatacenter.nodes[0].as(async TopoVizNode => {
assert.notOk(TopoVizNode.labelIsPresent);
assert.ok(heightSpy.calledWith(this.datacenter.nodes[0].memory));
await TopoVizNode.selectNode();
assert.ok(this.onNodeSelect.calledWith(this.datacenter.nodes[0]));
await TopoVizNode.memoryRects[0].select();
assert.ok(this.onAllocationSelect.calledWith(this.datacenter.nodes[0].allocations[0]));
});
});
});

View file

@ -0,0 +1,339 @@
import { module, test } from 'qunit';
import { setupRenderingTest } from 'ember-qunit';
import hbs from 'htmlbars-inline-precompile';
import { componentA11yAudit } from 'nomad-ui/tests/helpers/a11y-audit';
import { create } from 'ember-cli-page-object';
import sinon from 'sinon';
import faker from 'nomad-ui/mirage/faker';
import topoVisNodePageObject from 'nomad-ui/tests/pages/components/topo-viz/node';
const TopoVizNode = create(topoVisNodePageObject());
const nodeGen = (name, datacenter, memory, cpu, flags = {}) => ({
datacenter,
memory,
cpu,
isSelected: !!flags.isSelected,
node: {
name,
isEligible: flags.isEligible || flags.isEligible == null,
isDraining: !!flags.isDraining,
},
});
const allocGen = (node, memory, cpu, isSelected) => ({
memory,
cpu,
isSelected,
memoryPercent: memory / node.memory,
cpuPercent: cpu / node.cpu,
allocation: {
id: faker.random.uuid(),
isScheduled: true,
},
});
const props = overrides => ({
isDense: false,
heightScale: () => 50,
onAllocationSelect: sinon.spy(),
onNodeSelect: sinon.spy(),
...overrides,
});
module('Integration | Component | TopoViz::Node', function(hooks) {
setupRenderingTest(hooks);
const commonTemplate = hbs`
<TopoViz::Node
@node={{this.node}}
@isDense={{this.isDense}}
@heightScale={{this.heightScale}}
@onAllocationSelect={{this.onAllocationSelect}}
@onNodeSelect={{this.onNodeSelect}} />
`;
test('presents as a div with a label and an svg with CPU and memory rows', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
node: {
...node,
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
},
})
);
await this.render(commonTemplate);
assert.ok(TopoVizNode.isPresent);
assert.ok(TopoVizNode.memoryRects.length);
assert.ok(TopoVizNode.cpuRects.length);
await componentA11yAudit(this.element, assert);
});
test('the label contains aggregate information about the node', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
node: {
...node,
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
},
})
);
await this.render(commonTemplate);
assert.ok(TopoVizNode.label.includes(node.node.name));
assert.ok(TopoVizNode.label.includes(`${this.node.allocations.length} Allocs`));
assert.ok(TopoVizNode.label.includes(`${this.node.memory} MiB`));
assert.ok(TopoVizNode.label.includes(`${this.node.cpu} Mhz`));
});
test('the status icon indicates when the node is draining', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000, { isDraining: true });
this.setProperties(
props({
node: {
...node,
allocations: [],
},
})
);
await this.render(commonTemplate);
assert.ok(TopoVizNode.statusIcon.includes('icon-is-clock-outline'));
assert.equal(TopoVizNode.statusIconLabel, 'Client is draining');
});
test('the status icon indicates when the node is ineligible for scheduling', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000, { isEligible: false });
this.setProperties(
props({
node: {
...node,
allocations: [],
},
})
);
await this.render(commonTemplate);
assert.ok(TopoVizNode.statusIcon.includes('icon-is-lock-closed'));
assert.equal(TopoVizNode.statusIconLabel, 'Client is ineligible');
});
test('when isDense is false, clicking the node does nothing', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
isDense: false,
node: {
...node,
allocations: [],
},
})
);
await this.render(commonTemplate);
await TopoVizNode.selectNode();
assert.notOk(TopoVizNode.nodeIsInteractive);
assert.notOk(this.onNodeSelect.called);
});
test('when isDense is true, clicking the node calls onNodeSelect', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
isDense: true,
node: {
...node,
allocations: [],
},
})
);
await this.render(commonTemplate);
await TopoVizNode.selectNode();
assert.ok(TopoVizNode.nodeIsInteractive);
assert.ok(this.onNodeSelect.called);
assert.ok(this.onNodeSelect.calledWith(this.node));
});
test('the node gets the is-selected class when the node is selected', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000, { isSelected: true });
this.setProperties(
props({
isDense: true,
node: {
...node,
allocations: [],
},
})
);
await this.render(commonTemplate);
assert.ok(TopoVizNode.nodeIsSelected);
});
test('the node gets its height form the @heightScale arg', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
const height = 50;
const heightSpy = sinon.spy();
this.setProperties(
props({
heightScale: (...args) => {
heightSpy(...args);
return height;
},
node: {
...node,
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
},
})
);
await this.render(commonTemplate);
assert.ok(heightSpy.called);
assert.ok(heightSpy.calledWith(this.node.memory));
assert.equal(TopoVizNode.memoryRects[0].height, `${height}px`);
});
test('each allocation gets a memory rect and a cpu rect', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
node: {
...node,
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
},
})
);
await this.render(commonTemplate);
assert.equal(TopoVizNode.memoryRects.length, this.node.allocations.length);
assert.equal(TopoVizNode.cpuRects.length, this.node.allocations.length);
});
test('each allocation is sized according to its percentage of utilization', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
node: {
...node,
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
},
})
);
await this.render(hbs`
<div style="width:100px">
<TopoViz::Node
@node={{this.node}}
@isDense={{this.isDense}}
@heightScale={{this.heightScale}}
@onAllocationSelect={{this.onAllocationSelect}}
@onNodeSelect={{this.onNodeSelect}} />
</div>
`);
// Remove the width of the padding and the label from the SVG width
const width = 100 - 5 - 5 - 20;
this.node.allocations.forEach((alloc, index) => {
const memWidth = alloc.memoryPercent * width - (index === 0 ? 0.5 : 1);
const cpuWidth = alloc.cpuPercent * width - (index === 0 ? 0.5 : 1);
assert.equal(TopoVizNode.memoryRects[index].width, `${memWidth}px`);
assert.equal(TopoVizNode.cpuRects[index].width, `${cpuWidth}px`);
});
});
test('clicking either the memory or cpu rect for an allocation will call onAllocationSelect', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
node: {
...node,
allocations: [allocGen(node, 100, 100), allocGen(node, 250, 250)],
},
})
);
await this.render(commonTemplate);
await TopoVizNode.memoryRects[0].select();
assert.ok(this.onAllocationSelect.callCount, 1);
assert.ok(this.onAllocationSelect.calledWith(this.node.allocations[0]));
await TopoVizNode.cpuRects[0].select();
assert.ok(this.onAllocationSelect.callCount, 2);
await TopoVizNode.cpuRects[1].select();
assert.ok(this.onAllocationSelect.callCount, 3);
assert.ok(this.onAllocationSelect.calledWith(this.node.allocations[1]));
await TopoVizNode.memoryRects[1].select();
assert.ok(this.onAllocationSelect.callCount, 4);
});
test('allocations are sorted by smallest to largest delta of memory to cpu percent utilizations', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
const evenAlloc = allocGen(node, 100, 100);
const mediumMemoryAlloc = allocGen(node, 200, 150);
const largeMemoryAlloc = allocGen(node, 300, 50);
const mediumCPUAlloc = allocGen(node, 150, 200);
const largeCPUAlloc = allocGen(node, 50, 300);
this.setProperties(
props({
node: {
...node,
allocations: [
largeCPUAlloc,
mediumCPUAlloc,
evenAlloc,
mediumMemoryAlloc,
largeMemoryAlloc,
],
},
})
);
await this.render(commonTemplate);
const expectedOrder = [
evenAlloc,
mediumCPUAlloc,
mediumMemoryAlloc,
largeCPUAlloc,
largeMemoryAlloc,
];
expectedOrder.forEach((alloc, index) => {
assert.equal(TopoVizNode.memoryRects[index].id, alloc.allocation.id);
assert.equal(TopoVizNode.cpuRects[index].id, alloc.allocation.id);
});
});
test('when there are no allocations, a "no allocations" note is shown', async function(assert) {
const node = nodeGen('Node One', 'dc1', 1000, 1000);
this.setProperties(
props({
node: {
...node,
allocations: [],
},
})
);
await this.render(commonTemplate);
assert.equal(TopoVizNode.emptyMessage, 'Empty Client');
});
});

View file

@ -0,0 +1,11 @@
import { collection, isPresent } from 'ember-cli-page-object';
import TopoVizDatacenter from './topo-viz/datacenter';
export default scope => ({
scope,
datacenters: collection('[data-test-topo-viz-datacenter]', TopoVizDatacenter()),
allocationAssociationsArePresent: isPresent('[data-test-allocation-associations]'),
allocationAssociations: collection('[data-test-allocation-association]'),
});

View file

@ -0,0 +1,9 @@
import { collection, text } from 'ember-cli-page-object';
import TopoVizNode from './node';
export default scope => ({
scope,
label: text('[data-test-topo-viz-datacenter-label]'),
nodes: collection('[data-test-topo-viz-node]', TopoVizNode()),
});

View file

@ -0,0 +1,36 @@
import { attribute, collection, clickable, hasClass, isPresent, text } from 'ember-cli-page-object';
const allocationRect = {
select: clickable(),
width: attribute('width', '> rect'),
height: attribute('height', '> rect'),
isActive: hasClass('is-active'),
isSelected: hasClass('is-selected'),
running: hasClass('running'),
failed: hasClass('failed'),
pending: hasClass('pending'),
};
export default scope => ({
scope,
label: text('[data-test-label]'),
labelIsPresent: isPresent('[data-test-label]'),
statusIcon: attribute('class', '[data-test-status-icon] .icon'),
statusIconLabel: attribute('aria-label', '[data-test-status-icon]'),
selectNode: clickable('[data-test-node-background]'),
nodeIsInteractive: hasClass('is-interactive', '[data-test-node-background]'),
nodeIsSelected: hasClass('is-selected', '[data-test-node-background]'),
memoryRects: collection('[data-test-memory-rect]', {
...allocationRect,
id: attribute('data-test-memory-rect'),
}),
cpuRects: collection('[data-test-cpu-rect]', {
...allocationRect,
id: attribute('data-test-cpu-rect'),
}),
emptyMessage: text('[data-test-empty-message]'),
});

View file

@ -0,0 +1,11 @@
import { create, text, visitable } from 'ember-cli-page-object';
import TopoViz from 'nomad-ui/tests/pages/components/topo-viz';
export default create({
visit: visitable('/topology'),
infoPanelTitle: text('[data-test-info-panel-title]'),
viz: TopoViz('[data-test-topo-viz]'),
});

View file

@ -0,0 +1,191 @@
import { module, test } from 'qunit';
import { setupTest } from 'ember-qunit';
import setupGlimmerComponentFactory from 'nomad-ui/tests/helpers/glimmer-factory';
module('Unit | Component | TopoViz', function(hooks) {
setupTest(hooks);
setupGlimmerComponentFactory(hooks, 'topo-viz');
test('the topology object properly organizes a tree of datacenters > nodes > allocations', async function(assert) {
const nodes = [
{ datacenter: 'dc1', id: 'node0', resources: {} },
{ datacenter: 'dc2', id: 'node1', resources: {} },
{ datacenter: 'dc1', id: 'node2', resources: {} },
];
const node0Allocs = [
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'group' }),
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'group' }),
];
const node1Allocs = [
alloc({ nodeId: 'node1', jobId: 'job0', taskGroupName: 'group' }),
alloc({ nodeId: 'node1', jobId: 'job1', taskGroupName: 'group' }),
];
const node2Allocs = [
alloc({ nodeId: 'node2', jobId: 'job0', taskGroupName: 'group' }),
alloc({ nodeId: 'node2', jobId: 'job1', taskGroupName: 'group' }),
];
const allocations = [...node0Allocs, ...node1Allocs, ...node2Allocs];
const topoViz = this.createComponent({ nodes, allocations });
topoViz.buildTopology();
assert.deepEqual(topoViz.topology.datacenters.mapBy('name'), ['dc1', 'dc2']);
assert.deepEqual(topoViz.topology.datacenters[0].nodes.mapBy('node'), [nodes[0], nodes[2]]);
assert.deepEqual(topoViz.topology.datacenters[1].nodes.mapBy('node'), [nodes[1]]);
assert.deepEqual(
topoViz.topology.datacenters[0].nodes[0].allocations.mapBy('allocation'),
node0Allocs
);
assert.deepEqual(
topoViz.topology.datacenters[1].nodes[0].allocations.mapBy('allocation'),
node1Allocs
);
assert.deepEqual(
topoViz.topology.datacenters[0].nodes[1].allocations.mapBy('allocation'),
node2Allocs
);
});
test('the topology object contains an allocation index keyed by jobId+taskGroupName', async function(assert) {
const allocations = [
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'one' }),
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'one' }),
alloc({ nodeId: 'node0', jobId: 'job0', taskGroupName: 'two' }),
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'one' }),
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'two' }),
alloc({ nodeId: 'node0', jobId: 'job1', taskGroupName: 'three' }),
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
alloc({ nodeId: 'node0', jobId: 'job2', taskGroupName: 'one' }),
];
const nodes = [{ datacenter: 'dc1', id: 'node0', resources: {} }];
const topoViz = this.createComponent({ nodes, allocations });
topoViz.buildTopology();
assert.deepEqual(
Object.keys(topoViz.topology.allocationIndex).sort(),
[
JSON.stringify(['job0', 'one']),
JSON.stringify(['job0', 'two']),
JSON.stringify(['job1', 'one']),
JSON.stringify(['job1', 'two']),
JSON.stringify(['job1', 'three']),
JSON.stringify(['job2', 'one']),
].sort()
);
Object.keys(topoViz.topology.allocationIndex).forEach(key => {
const [jobId, group] = JSON.parse(key);
assert.deepEqual(
topoViz.topology.allocationIndex[key].mapBy('allocation'),
allocations.filter(alloc => alloc.jobId === jobId && alloc.taskGroupName === group)
);
});
});
test('isSingleColumn is true when there is only one datacenter', async function(assert) {
const oneDc = [{ datacenter: 'dc1', id: 'node0', resources: {} }];
const twoDc = [...oneDc, { datacenter: 'dc2', id: 'node1', resources: {} }];
const topoViz1 = this.createComponent({ nodes: oneDc, allocations: [] });
const topoViz2 = this.createComponent({ nodes: twoDc, allocations: [] });
topoViz1.buildTopology();
topoViz2.buildTopology();
assert.ok(topoViz1.isSingleColumn);
assert.notOk(topoViz2.isSingleColumn);
});
test('isSingleColumn is true when there are multiple datacenters with a high variance in node count', async function(assert) {
const uniformDcs = [
{ datacenter: 'dc1', id: 'node0', resources: {} },
{ datacenter: 'dc2', id: 'node1', resources: {} },
];
const skewedDcs = [
{ datacenter: 'dc1', id: 'node0', resources: {} },
{ datacenter: 'dc2', id: 'node1', resources: {} },
{ datacenter: 'dc2', id: 'node2', resources: {} },
{ datacenter: 'dc2', id: 'node3', resources: {} },
{ datacenter: 'dc2', id: 'node4', resources: {} },
];
const twoColumnViz = this.createComponent({ nodes: uniformDcs, allocations: [] });
const oneColumViz = this.createComponent({ nodes: skewedDcs, allocations: [] });
twoColumnViz.buildTopology();
oneColumViz.buildTopology();
assert.notOk(twoColumnViz.isSingleColumn);
assert.ok(oneColumViz.isSingleColumn);
});
test('datacenterIsSingleColumn is only ever false when isSingleColumn is false and the total node count is high', async function(assert) {
const manyUniformNodes = Array(25)
.fill(null)
.map((_, index) => ({
datacenter: index > 12 ? 'dc2' : 'dc1',
id: `node${index}`,
resources: {},
}));
const manySkewedNodes = Array(25)
.fill(null)
.map((_, index) => ({
datacenter: index > 5 ? 'dc2' : 'dc1',
id: `node${index}`,
resources: {},
}));
const oneColumnViz = this.createComponent({ nodes: manyUniformNodes, allocations: [] });
const twoColumnViz = this.createComponent({ nodes: manySkewedNodes, allocations: [] });
oneColumnViz.buildTopology();
twoColumnViz.buildTopology();
assert.ok(oneColumnViz.datacenterIsSingleColumn);
assert.notOk(oneColumnViz.isSingleColumn);
assert.notOk(twoColumnViz.datacenterIsSingleColumn);
assert.ok(twoColumnViz.isSingleColumn);
});
test('dataForAllocation correctly calculates proportion of node utilization and group key', async function(assert) {
const nodes = [{ datacenter: 'dc1', id: 'node0', resources: { cpu: 100, memory: 250 } }];
const allocations = [
alloc({
nodeId: 'node0',
jobId: 'job0',
taskGroupName: 'group',
allocatedResources: { cpu: 50, memory: 25 },
}),
];
const topoViz = this.createComponent({ nodes, allocations });
topoViz.buildTopology();
assert.equal(topoViz.topology.datacenters[0].nodes[0].allocations[0].cpuPercent, 0.5);
assert.equal(topoViz.topology.datacenters[0].nodes[0].allocations[0].memoryPercent, 0.1);
});
});
function alloc(props) {
return {
...props,
allocatedResources: props.allocatedResources || {},
belongsTo(type) {
return {
id() {
return type === 'job' ? props.jobId : props.nodeId;
},
};
},
};
}

View file

@ -24,8 +24,13 @@ module('Unit | Helper | format-bytes', function() {
assert.equal(formatBytes([128974848]), '123 MiB'); assert.equal(formatBytes([128974848]), '123 MiB');
}); });
test('formats x > 1024 * 1024 * 1024 as MiB, since it is the highest allowed unit', function(assert) { test('formats 1024 * 1024 * 1024 <= x < 1024 * 1024 * 1024 * 1024 as GiB', function(assert) {
assert.equal(formatBytes([1024 * 1024 * 1024]), '1024 MiB'); assert.equal(formatBytes([1024 * 1024 * 1024]), '1 GiB');
assert.equal(formatBytes([1024 * 1024 * 1024 * 4]), '4096 MiB'); assert.equal(formatBytes([1024 * 1024 * 1024 * 4]), '4 GiB');
});
test('formats x > 1024 * 1024 * 1024 * 1024 as GiB, since it is the highest allowed unit', function(assert) {
assert.equal(formatBytes([1024 * 1024 * 1024 * 1024]), '1024 GiB');
assert.equal(formatBytes([1024 * 1024 * 1024 * 1024 * 4]), '4096 GiB');
}); });
}); });

File diff suppressed because it is too large Load diff