github.com/anth0d/nomad@v0.0.0-20221214183521-ae3a0a2cad06/ui/app/components/topo-viz.js (about)

     1  import Component from '@glimmer/component';
     2  import { tracked } from '@glimmer/tracking';
     3  import { action, set } from '@ember/object';
     4  import { inject as service } from '@ember/service';
     5  import { next } from '@ember/runloop';
     6  import { scaleLinear } from 'd3-scale';
     7  import { extent, deviation, mean } from 'd3-array';
     8  import { line, curveBasis } from 'd3-shape';
     9  import styleStringProperty from '../utils/properties/style-string';
    10  
    11  export default class TopoViz extends Component {
    12    @service system;
    13  
    14    @tracked element = null;
    15    @tracked topology = { datacenters: [] };
    16  
    17    @tracked activeNode = null;
    18    @tracked activeAllocation = null;
    19    @tracked activeEdges = [];
    20    @tracked edgeOffset = { x: 0, y: 0 };
    21    @tracked viewportColumns = 2;
    22  
    23    @tracked highlightAllocation = null;
    24    @tracked tooltipProps = {};
    25  
    26    @styleStringProperty('tooltipProps') tooltipStyle;
    27  
    28    get isSingleColumn() {
    29      if (this.topology.datacenters.length <= 1 || this.viewportColumns === 1)
    30        return true;
    31  
    32      // Compute the coefficient of variance to determine if it would be
    33      // better to stack datacenters or place them in columns
    34      const nodeCounts = this.topology.datacenters.map(
    35        (datacenter) => datacenter.nodes.length
    36      );
    37      const variationCoefficient = deviation(nodeCounts) / mean(nodeCounts);
    38  
    39      // The point at which the varation is too extreme for a two column layout
    40      const threshold = 0.5;
    41      if (variationCoefficient > threshold) return true;
    42      return false;
    43    }
    44  
    45    get datacenterIsSingleColumn() {
    46      // If there are enough nodes, use two columns of nodes within
    47      // a single column layout of datacenters to increase density.
    48      if (this.viewportColumns === 1) return true;
    49      return (
    50        !this.isSingleColumn ||
    51        (this.isSingleColumn && this.args.nodes.length <= 20)
    52      );
    53    }
    54  
    55    // Once a cluster is large enough, the exact details of a node are
    56    // typically irrelevant and a waste of space.
    57    get isDense() {
    58      return this.args.nodes.length > 50;
    59    }
    60  
    61    dataForNode(node) {
    62      return {
    63        node,
    64        datacenter: node.datacenter,
    65        memory: node.resources.memory,
    66        cpu: node.resources.cpu,
    67        allocations: [],
    68        isSelected: false,
    69      };
    70    }
    71  
    72    dataForAllocation(allocation, node) {
    73      const jobId = allocation.belongsTo('job').id();
    74      return {
    75        allocation,
    76        node,
    77        jobId,
    78        groupKey: JSON.stringify([jobId, allocation.taskGroupName]),
    79        memory: allocation.allocatedResources.memory,
    80        cpu: allocation.allocatedResources.cpu,
    81        memoryPercent: allocation.allocatedResources.memory / node.memory,
    82        cpuPercent: allocation.allocatedResources.cpu / node.cpu,
    83        isSelected: false,
    84      };
    85    }
    86  
    87    @action
    88    buildTopology() {
    89      const nodes = this.args.nodes;
    90      const allocations = this.args.allocations;
    91  
    92      // Nodes may not have a resources property due to having an old Nomad agent version.
    93      const badNodes = [];
    94  
    95      // Wrap nodes in a topo viz specific data structure and build an index to speed up allocation assignment
    96      const nodeContainers = [];
    97      const nodeIndex = {};
    98      nodes.forEach((node) => {
    99        if (!node.resources) {
   100          badNodes.push(node);
   101          return;
   102        }
   103  
   104        const container = this.dataForNode(node);
   105        nodeContainers.push(container);
   106        nodeIndex[node.id] = container;
   107      });
   108  
   109      // Wrap allocations in a topo viz specific data structure, assign allocations to nodes, and build an allocation
   110      // index keyed off of job and task group
   111      const allocationIndex = {};
   112      allocations.forEach((allocation) => {
   113        const nodeId = allocation.belongsTo('node').id();
   114        const nodeContainer = nodeIndex[nodeId];
   115  
   116        // Ignore orphaned allocations and allocations on nodes with an old Nomad agent version.
   117        if (!nodeContainer) return;
   118  
   119        const allocationContainer = this.dataForAllocation(
   120          allocation,
   121          nodeContainer
   122        );
   123        nodeContainer.allocations.push(allocationContainer);
   124  
   125        const key = allocationContainer.groupKey;
   126        if (!allocationIndex[key]) allocationIndex[key] = [];
   127        allocationIndex[key].push(allocationContainer);
   128      });
   129  
   130      // Group nodes into datacenters
   131      const datacentersMap = nodeContainers.reduce(
   132        (datacenters, nodeContainer) => {
   133          if (!datacenters[nodeContainer.datacenter])
   134            datacenters[nodeContainer.datacenter] = [];
   135          datacenters[nodeContainer.datacenter].push(nodeContainer);
   136          return datacenters;
   137        },
   138        {}
   139      );
   140  
   141      // Turn hash of datacenters into a sorted array
   142      const datacenters = Object.keys(datacentersMap)
   143        .map((key) => ({ name: key, nodes: datacentersMap[key] }))
   144        .sortBy('name');
   145  
   146      const topology = {
   147        datacenters,
   148        allocationIndex,
   149        selectedKey: null,
   150        heightScale: scaleLinear()
   151          .range([15, 40])
   152          .domain(extent(nodeContainers.mapBy('memory'))),
   153      };
   154      this.topology = topology;
   155  
   156      if (badNodes.length && this.args.onDataError) {
   157        this.args.onDataError([
   158          {
   159            type: 'filtered-nodes',
   160            context: badNodes,
   161          },
   162        ]);
   163      }
   164    }
   165  
   166    @action
   167    captureElement(element) {
   168      this.element = element;
   169      this.determineViewportColumns();
   170    }
   171  
   172    @action
   173    showNodeDetails(node) {
   174      if (this.activeNode) {
   175        set(this.activeNode, 'isSelected', false);
   176      }
   177  
   178      this.activeNode = this.activeNode === node ? null : node;
   179  
   180      if (this.activeNode) {
   181        set(this.activeNode, 'isSelected', true);
   182      }
   183  
   184      if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
   185    }
   186  
   187    @action showTooltip(allocation, element) {
   188      const bbox = element.getBoundingClientRect();
   189      this.highlightAllocation = allocation;
   190      this.tooltipProps = {
   191        left: window.scrollX + bbox.left + bbox.width / 2,
   192        top: window.scrollY + bbox.top,
   193      };
   194    }
   195  
   196    @action hideTooltip() {
   197      this.highlightAllocation = null;
   198    }
   199  
   200    @action
   201    associateAllocations(allocation) {
   202      if (this.activeAllocation === allocation) {
   203        this.activeAllocation = null;
   204        this.activeEdges = [];
   205  
   206        if (this.topology.selectedKey) {
   207          const selectedAllocations =
   208            this.topology.allocationIndex[this.topology.selectedKey];
   209          if (selectedAllocations) {
   210            selectedAllocations.forEach((allocation) => {
   211              set(allocation, 'isSelected', false);
   212            });
   213          }
   214          set(this.topology, 'selectedKey', null);
   215        }
   216      } else {
   217        if (this.activeNode) {
   218          set(this.activeNode, 'isSelected', false);
   219        }
   220        this.activeNode = null;
   221        this.activeAllocation = allocation;
   222        const selectedAllocations =
   223          this.topology.allocationIndex[this.topology.selectedKey];
   224        if (selectedAllocations) {
   225          selectedAllocations.forEach((allocation) => {
   226            set(allocation, 'isSelected', false);
   227          });
   228        }
   229  
   230        set(this.topology, 'selectedKey', allocation.groupKey);
   231        const newAllocations =
   232          this.topology.allocationIndex[this.topology.selectedKey];
   233        if (newAllocations) {
   234          newAllocations.forEach((allocation) => {
   235            set(allocation, 'isSelected', true);
   236          });
   237        }
   238  
   239        // Only show the lines if the selected allocations are sparse (low count relative to the client count or low count generally).
   240        if (
   241          newAllocations.length < 10 ||
   242          newAllocations.length < this.args.nodes.length * 0.75
   243        ) {
   244          this.computedActiveEdges();
   245        } else {
   246          this.activeEdges = [];
   247        }
   248      }
   249      if (this.args.onAllocationSelect)
   250        this.args.onAllocationSelect(
   251          this.activeAllocation && this.activeAllocation.allocation
   252        );
   253      if (this.args.onNodeSelect) this.args.onNodeSelect(this.activeNode);
   254    }
   255  
   256    @action
   257    determineViewportColumns() {
   258      this.viewportColumns = this.element.clientWidth < 900 ? 1 : 2;
   259    }
   260  
   261    @action
   262    resizeEdges() {
   263      if (this.activeEdges.length > 0) {
   264        this.computedActiveEdges();
   265      }
   266    }
   267  
   268    @action
   269    computedActiveEdges() {
   270      // Wait a render cycle
   271      next(() => {
   272        const path = line().curve(curveBasis);
   273        // 1. Get the active element
   274        const allocation = this.activeAllocation.allocation;
   275        const activeEl = this.element.querySelector(
   276          `[data-allocation-id="${allocation.id}"]`
   277        );
   278        const activePoint = centerOfBBox(activeEl.getBoundingClientRect());
   279  
   280        // 2. Collect the mem and cpu pairs for all selected allocs
   281        const selectedMem = Array.from(
   282          this.element.querySelectorAll('.memory .bar.is-selected')
   283        );
   284        const selectedPairs = selectedMem.map((mem) => {
   285          const id = mem.closest('[data-allocation-id]').dataset.allocationId;
   286          const cpu = mem
   287            .closest('.topo-viz-node')
   288            .querySelector(`.cpu .bar[data-allocation-id="${id}"]`);
   289          return [mem, cpu];
   290        });
   291        const selectedPoints = selectedPairs.map((pair) => {
   292          return pair.map((el) => centerOfBBox(el.getBoundingClientRect()));
   293        });
   294  
   295        // 3. For each pair, compute the midpoint of the truncated triangle of points [Mem, Cpu, Active]
   296        selectedPoints.forEach((points) => {
   297          const d1 = pointBetween(points[0], activePoint, 100, 0.5);
   298          const d2 = pointBetween(points[1], activePoint, 100, 0.5);
   299          points.push(midpoint(d1, d2));
   300        });
   301  
   302        // 4. Generate curves for each active->mem and active->cpu pair going through the bisector
   303        const curves = [];
   304        // Steps are used to restrict the range of curves. The closer control points are placed, the less
   305        // curvature the curve generator will generate.
   306        const stepsMain = [0, 0.8, 1.0];
   307        // The second prong the fork does not need to retrace the entire path from the activePoint
   308        const stepsSecondary = [0.8, 1.0];
   309        selectedPoints.forEach((points) => {
   310          curves.push(
   311            curveFromPoints(
   312              ...pointsAlongPath(activePoint, points[2], stepsMain),
   313              points[0]
   314            ),
   315            curveFromPoints(
   316              ...pointsAlongPath(activePoint, points[2], stepsSecondary),
   317              points[1]
   318            )
   319          );
   320        });
   321  
   322        this.activeEdges = curves.map((curve) => path(curve));
   323        this.edgeOffset = { x: window.scrollX, y: window.scrollY };
   324      });
   325    }
   326  }
   327  
   328  function centerOfBBox(bbox) {
   329    return {
   330      x: bbox.x + bbox.width / 2,
   331      y: bbox.y + bbox.height / 2,
   332    };
   333  }
   334  
   335  function dist(p1, p2) {
   336    return Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
   337  }
   338  
   339  // Return the point between p1 and p2 at len (or pct if len > dist(p1, p2))
   340  function pointBetween(p1, p2, len, pct) {
   341    const d = dist(p1, p2);
   342    const ratio = d < len ? pct : len / d;
   343    return pointBetweenPct(p1, p2, ratio);
   344  }
   345  
   346  function pointBetweenPct(p1, p2, pct) {
   347    const dx = p2.x - p1.x;
   348    const dy = p2.y - p1.y;
   349    return { x: p1.x + dx * pct, y: p1.y + dy * pct };
   350  }
   351  
   352  function pointsAlongPath(p1, p2, pcts) {
   353    return pcts.map((pct) => pointBetweenPct(p1, p2, pct));
   354  }
   355  
   356  function midpoint(p1, p2) {
   357    return pointBetweenPct(p1, p2, 0.5);
   358  }
   359  
   360  function curveFromPoints(...points) {
   361    return points.map((p) => [p.x, p.y]);
   362  }