github.com/shashidharatd/test-infra@v0.0.0-20171006011030-71304e1ca560/triage/render.js (about)

     1  "use strict";
     2  
     3  var rightArrow = "\u25ba";
     4  var downArrow = "\u25bc";
     5  
     6  const kGraphHeight = 200;      // keep synchronized with style.css:div.graph
     7  const kCollapseThreshold = 5;  // maximum number of entries before being collapsed
     8  
     9  if (Object.entries === undefined) {
    10    // Simple polyfill for Safari compatibility.
    11    // Object.entries is an ES2017 feature.
    12    Object.entries = function(obj) {
    13      var ret = [];
    14      for (let key of Object.keys(obj)) {
    15        ret.push([key, obj[key]]);
    16      }
    17      return ret;
    18    }
    19  }
    20  
    21  if (typeof Element !== 'undefined') {
    22    // Useful extension for DOM nodes.
    23    Element.prototype.removeChildren = function() {
    24      while (this.firstChild) {
    25        this.removeChild(this.firstChild);
    26      }
    27    }
    28  }
    29  
    30  // Create a new DOM node of `type` with `opts` attributes and with given children.
    31  // If children is a string, or an array with string elements, they become text nodes.
    32  function createElement(type, opts, children) {
    33    var el = document.createElement(type);
    34    if (opts) {
    35      for (let [key, value] of Object.entries(opts)) {
    36        if (typeof value === "object") {
    37          for (let [subkey, subvalue] of Object.entries(value)) {
    38            el[key][subkey] = subvalue;
    39          }
    40        } else {
    41          el[key] = value;
    42        }
    43      }
    44    }
    45    if (children) {
    46      if (typeof children === "string") {
    47        el.textContent = children;
    48      } else {
    49        for (let child of children) {
    50          if (typeof child === "string")
    51            child = document.createTextNode(child);
    52          el.appendChild(child);
    53        }
    54      }
    55    }
    56    return el;
    57  }
    58  
    59  // Like createElement, but also appends the new node to parent's children.
    60  function addElement(parent, type, opts, children) {
    61    var el = createElement(type, opts, children);
    62    parent.appendChild(el);
    63    return el;
    64  }
    65  
    66  // Creates a urlsafe slug out of a string-- MUST match Gubernator's slugify function.
    67  function slugify(inp) {
    68    return inp.replace(/[^\w\s-]+/g, '').replace(/\s+/g, '-').toLowerCase();
    69  }
    70  
    71  // Turn a build object into a link with information.
    72  function buildToHtml(build, test, skipNumber) {
    73    let started = tsToString(build.started);
    74    let buildPath = builds.jobPaths[build.job] + '/' + build.number;
    75    var gubernatorURL = 'https://k8s-gubernator.appspot.com/build/' + buildPath.slice(5);
    76    if (build.pr) {
    77      gubernatorURL = gubernatorURL.replace(/(\/pr-logs\/pull\/)[^/]*\//, '$1' + build.pr + '/');
    78    }
    79    if (test) {
    80      gubernatorURL += '#' + slugify(test);
    81    }
    82    return `<a href="${gubernatorURL}" target="_blank" rel="noopener">${skipNumber ? "" : build.number} ${started}</a>`;
    83  }
    84  
    85  // Turn a job and array of build numbers into a list of build links.
    86  function buildNumbersToHtml(job, buildNumbers, test) {
    87    var buildCount = builds.count(job);
    88    var pct = buildNumbers.length / builds.count(job);
    89    var out = `Failed in ${Math.round(pct * 100)}% (${buildNumbers.length}/${buildCount}) of builds: <ul>`;
    90    for (let number of buildNumbers) {
    91      out += '\n<li>' + buildToHtml(builds.get(job, number), test);
    92    }
    93    out += '\n</ul>';
    94    return out;
    95  }
    96  
    97  // Append a list item containing information about a job's runs.
    98  function addBuildListItem(jobList, job, buildNumbers, hits, test) {
    99    var jobEl = addElement(jobList, 'li', null, [sparkLineSVG(hits), ` ${buildNumbers.length} ${job} ${rightArrow}`,
   100      createElement('p', {
   101        style: {display: 'none'},
   102        dataset: {job: job, test: test || '', buildNumbers: JSON.stringify(buildNumbers)},
   103      })
   104    ]);
   105  }
   106  
   107  // Render a list of builds as a list of jobs with expandable build sections.
   108  function renderJobs(parent, clusterId) {
   109    if (parent.children.length > 0) {
   110      return;  // already done
   111    }
   112  
   113    var counts = clustered.makeCounts(clusterId);
   114  
   115    var jobs = {};
   116    for (let build of clustered.buildsForClusterById(clusterId)) {
   117      let job = build.job;
   118      if (!jobs[job]) {
   119        jobs[job] = new Set();
   120      }
   121      jobs[job].add(build.number);
   122    }
   123  
   124    var jobs = Object.entries(jobs);
   125    jobs = sortByKey(jobs, j => [-dayCounts(counts[j[0]]), -j[1].size]);
   126  
   127    var jobAllSection = false;
   128    var dayCount = dayCounts(counts['']);
   129    var countSum = 0;
   130  
   131    var jobList = addElement(parent, 'ul');
   132    for (let [job, buildNumbersSet] of jobs) {
   133      let buildNumbers = Array.from(buildNumbersSet).sort((a,b) => b - a);
   134      var count = counts[job];
   135      if (jobs.length > kCollapseThreshold && !jobAllSection && countSum > 0.8 * dayCount) {
   136        addElement(jobList, 'button', {className: 'rest', title: 'Show Daily Bottom 20%'}, 'More');
   137        addElement(jobList, 'hr');
   138        jobAllSection = true;
   139      }
   140      countSum += dayCounts(count);
   141      addBuildListItem(jobList, job, buildNumbers, count);
   142    }
   143  }
   144  
   145  // Return an SVG path displaying the given histogram arr, with width
   146  // being per element and height being the total height of the graph.
   147  function sparkLinePath(arr, width, height) {
   148    var max = 0;
   149    for (var i = 0; i < arr.length; i++) {
   150      if (arr[i] > max)
   151        max = arr[i];
   152    }
   153    var scale = max > 0 ? height / max : 1;
   154  
   155    // Full documentation here: https://www.w3.org/TR/SVG/paths.html#PathData
   156    // Basics:
   157    // 0,0 is the the top left corner
   158    // Commands:
   159    //    M x y: move to x, y
   160    //    h dx: move horizontally +/- dx
   161    //    V y: move vertically to y
   162    // Here, we're drawing a histogram as a single polygon with right angles.
   163    var out = 'M0,' + height;
   164    var x = 0, y = height;
   165    for (var i = 0; i < arr.length; i++) {
   166      var h = height - Math.ceil(arr[i] * scale);
   167      if (h != y) {
   168        // h2V0 draws horizontally across, then a line to the top of the canvas.
   169        out += `h${i * width - x}V${h}`;
   170        x = i * width;
   171        y = h;
   172      }
   173    }
   174    out += `h${arr.length * width - x}`;
   175    if (y != height)
   176      out += `V${height}`;
   177  
   178    return out;
   179  }
   180  
   181  function sparkLineSVG(arr) {
   182    var width = 4;
   183    var height = 16;
   184    var path = sparkLinePath(arr, width, height);
   185    return createElement('span', {
   186      dataset: {tooltip: 'hits over last week, newest on the right'},
   187      innerHTML: `<svg height=${height} width='${(arr.length) * width}'><path d="${path}" /></svg>`,
   188    });
   189  }
   190  
   191  function dayCounts(arr) {
   192    var l = arr.length;
   193    return arr[l-1]+arr[l-2]+arr[l-3]+arr[l-4];
   194  }
   195  
   196  function renderLatest(el, keyId) {
   197    var ctxs = [];
   198    for (let ctx of clustered.buildsWithContextForClusterById(keyId)) {
   199      ctxs.push(ctx);
   200    }
   201    ctxs.sort((a, b) => { return (b[0].started - a[0].started) || (b[2] < a[2]); })
   202    var n = 0;
   203    addElement(el, 'tr', null, [
   204      createElement('th', null, 'Time'),
   205      createElement('th', null, 'Job'),
   206      createElement('th', null, 'Test')
   207    ]);
   208    var buildsEmitted = new Set();
   209    var n = 0;
   210    for (let [build, job, test] of ctxs) {
   211      var key = job + build.number;
   212      if (buildsEmitted.has(key)) continue;
   213      buildsEmitted.add(key);
   214      addElement(el, 'tr', null, [
   215        createElement('td', {innerHTML: `${buildToHtml(build, test, true)}`}),
   216        createElement('td', null, job),
   217        createElement('td', null, test),
   218      ]);
   219      if (++n >= 5) break;
   220    }
   221  }
   222  
   223  // Return a list of strings and spans made from text according to spans.
   224  // Spans is a list of [text segment length, span segment length, ...] repeating.
   225  function renderSpans(text, spans) {
   226    if (!spans) {
   227      return [text];
   228    }
   229    var out = [];
   230    var c = 0;
   231    for (var i = 0; i < spans.length; i += 2) {
   232      out.push(text.slice(c, c + spans[i]));
   233      c += spans[i];
   234      if (i + 1 < spans.length) {
   235        out.push(createElement('span',
   236                               {className: 'mm', title: 'not present in all failure messages'},
   237                               text.slice(c, c + spans[i+1])));
   238        c += spans[i + 1];
   239      }
   240    }
   241    return out;
   242  }
   243  
   244  // Render a section for each cluster, including the text, a graph, and expandable sections
   245  // to dive into failures for each test or job.
   246  function renderCluster(top, cluster) {
   247    let {key, id, text, tests, spans, owner} = cluster;
   248  
   249    function plural(count, word, suffix) {
   250      return count + ' ' + word + (count == 1 ? '' : suffix);
   251    }
   252  
   253    function swapArrow(el) {
   254      el.textContent = el.textContent.replace(downArrow, rightArrow);
   255    }
   256  
   257    var counts = clustered.makeCounts(id);
   258  
   259    var clusterSum = clustersSum(tests);
   260    var todayCount = clustered.getHitsInLastDayById(id);
   261    var ownerTag = createElement('span', {className: 'owner sig-' + (owner || ''), dataset: {tooltip: 'inferred owner'}});
   262    var failureNode = addElement(top, 'div', {id: id}, [
   263      createElement('h2', null, [
   264        `${plural(clusterSum, 'FAILURE', 'S')} (${todayCount} TODAY) MATCHING `,
   265        createElement('a', {href: '#' + id}, id),
   266        createElement('a', {href: 'https://github.com/search?type=Issues&q=org:kubernetes%20' + id, target: '_blank', rel: 'noopener'}, 'github search'),
   267        ownerTag,
   268      ]),
   269      createElement('pre', null, options.showNormalize ? key : renderSpans(text, spans)),
   270      createElement('div', {className: 'graph', dataset: {cluster: id}}),
   271    ]);
   272  
   273    if (owner) {
   274      ownerTag.innerText = owner;
   275    } else {
   276      ownerTag.remove();
   277    }
   278  
   279  
   280    var latest = createElement('table');
   281    var list = addElement(failureNode, 'ul', null, [
   282      createElement('span', null, [`Latest Failures`, latest]),
   283    ]);
   284  
   285    renderLatest(latest, id);
   286  
   287    var clusterJobs = addElement(list, 'li');
   288  
   289    var jobSet = new Set();
   290  
   291    var testList = createElement('ul');
   292  
   293    var expander = addElement(list, 'li', null, [`Failed in ${plural(tests.length, 'Test', 's')} ${downArrow}`, testList]);
   294  
   295    // If we expanded all the tests and jobs, how many rows would it take?
   296    var jobCount = sum(tests, t => t.jobs.length);
   297  
   298    // Sort tests by descending [last day hits, total hits]
   299    var testsSorted = sortByKey(tests, t => [-dayCounts(counts[t.name]), -sum(t.jobs, j => j.builds.length)]);
   300  
   301    var allTestsDayCount = dayCounts(counts['']);
   302    var testsDayCountSum = 0;
   303  
   304    var allSection = false;
   305    var testsShown = 0;
   306    var i = 0;
   307  
   308    for (var test of testsSorted) {
   309      i++;
   310      var testCount = sum(test.jobs, j => j.builds.length);
   311  
   312      var testDayCount = dayCounts(counts[test.name]);
   313  
   314      if (tests.length > kCollapseThreshold) {
   315        if (!allSection && testsDayCountSum > 0.8 * allTestsDayCount) {
   316          testsShown = i;
   317          addElement(testList, 'button', {className: 'rest', title: 'Show Daily Bottom 20% Tests'}, 'More');
   318          addElement(testList, 'hr');
   319          allSection = true;
   320        }
   321      }
   322  
   323      var testDayCountSum = 0;
   324      testsDayCountSum += testDayCount;
   325  
   326      var el = addElement(testList, 'li', null, [
   327        sparkLineSVG(counts[test.name]),
   328        ` ${testCount} ${test.name} ${rightArrow}`,
   329      ]);
   330  
   331      var jobList = addElement(el, 'ul', {style: {display: 'none'}});
   332  
   333      var jobs = sortByKey(test.jobs, j => [-dayCounts(counts[j.name + ' ' + test.name]), -j.builds.length]);
   334  
   335      var jobAllSection = false;
   336  
   337      var j = 0;
   338      for (var job of jobs) {
   339        var jobCount = counts[job.name + ' ' + test.name];
   340        if (jobs.length > kCollapseThreshold && !jobAllSection && testDayCountSum > 0.8 * testDayCount) {
   341          addElement(jobList, 'button', {className: 'rest', title: 'Show Daily Bottom 20% Jobs'}, 'More');
   342          addElement(jobList, 'hr');
   343          jobAllSection = true;
   344        }
   345        jobSet.add(job.name);
   346        addBuildListItem(jobList, job.name, job.builds, jobCount, test.name);
   347        testDayCountSum += dayCounts(jobCount);
   348      }
   349    }
   350  
   351    if ((testsShown === 0 && tests.length > kCollapseThreshold) || testsShown > kCollapseThreshold) {
   352      testList.style.display = 'none';
   353      swapArrow(expander.firstChild);
   354    }
   355  
   356    clusterJobs.innerHTML = `Failed in ${plural(jobSet.size, 'Job', 's')} ${rightArrow}<div style="display:none" class="jobs" data-cluster="${id}">`;
   357    if (jobSet.size <= 10) {  // automatically expand small job lists to save clicking
   358      expand(clusterJobs.children[0]);
   359    }
   360  
   361    return 1;
   362  }
   363  
   364  // Convert an array of integers into a histogram array of two-element arrays.
   365  function makeBuckets(hits, width, start, end) {
   366    var cur = start;
   367    cur -= (cur % width);  // align to width
   368    var counts = new Uint32Array(Math.floor((end - cur) / width) + 1);
   369    for (var hit of hits) {
   370      counts[Math.floor((hit - cur) / width)] += 1;
   371    }
   372    var buckets = [];
   373    for (var c of counts) {
   374      buckets.push([cur, c]);
   375      cur += width;
   376    }
   377    return buckets;
   378  }
   379  
   380  // Display a line graph on `element` showing failure occurrences.
   381  function renderGraph(element, buildsIterator) {
   382    // Defer rendering until later if the Charts API isn't available.
   383    if (!google.charts.loaded) {
   384      setTimeout(() => renderGraph(element, buildsIterator), 100);
   385      return;
   386    }
   387  
   388    // Find every build time in the current cluster.
   389    var hits = [];
   390    var buildsSeen = new Set();
   391    var buildTimes = [];  // one for each build
   392    for (let build of buildsIterator) {
   393      hits.push(build.started);
   394      let buildKey = build.job + build.number;
   395      if (!buildsSeen.has(buildKey)) {
   396        buildsSeen.add(buildKey);
   397        buildTimes.push(build.started);
   398      }
   399    }
   400  
   401    var width = 60 * 60; // Bucket into 1 hour chunks
   402    var widthRecip = 60 * 60 / width;
   403    var hitBuckets = makeBuckets(hits, width, builds.timespan[0], builds.timespan[1]);
   404    var buildBuckets = makeBuckets(buildTimes, width, builds.timespan[0], builds.timespan[1]);
   405    var buckets = buildBuckets.map((x, i) => [new Date(x[0] * 1000), x[1] * widthRecip, hitBuckets[i][1] * widthRecip]);
   406  
   407    var data = new google.visualization.DataTable();
   408    data.addColumn('date', 'X');
   409    data.addColumn('number', 'Builds');
   410    data.addColumn('number', 'Tests');
   411    data.addRows(buckets);
   412  
   413    var formatter = new google.visualization.DateFormat({'pattern': 'yyyy-MM-dd HH:mm z'});
   414    formatter.format(data, 0);
   415  
   416    var options = {
   417      width: 1200,
   418      height: kGraphHeight,
   419      hAxis: {title: 'Time', format: 'M/d'},
   420      vAxis: {title: 'Failures per hour'},
   421      legend: {position: 'none'},
   422      focusTarget: 'category',
   423    };
   424  
   425    var chart = new google.visualization.LineChart(element);
   426    chart.draw(data, options);
   427  }
   428  
   429  // When someone clicks on an expandable element, render the sub content as necessary.
   430  function expand(target) {
   431    if (target.nodeName === "BUTTON" && target.className === "rest") {
   432      target.remove();
   433      return true;
   434    }
   435    while (target.nodeName !== "LI" && target.parentNode) {
   436      target = target.parentNode;
   437    }
   438    var text = target.childNodes[target.childNodes.length - 2];
   439    var child = target.children[target.children.length - 1];
   440    if (target.nodeName == "LI" && child && text) {
   441      if (text.textContent.includes(rightArrow)) {
   442        text.textContent = text.textContent.replace(rightArrow, downArrow);
   443        child.style = "";
   444  
   445        if (child.dataset.buildNumbers) {
   446          var job = child.dataset.job;
   447          var test = child.dataset.test;
   448          var buildNumbers = JSON.parse(child.dataset.buildNumbers);
   449          child.innerHTML = buildNumbersToHtml(job, buildNumbers, test);
   450        } else if (child.dataset.cluster) {
   451          var cluster = child.dataset.cluster;
   452          if (child.className === 'graph') {
   453            renderGraph(child, clustered.buildsForClusterById(cluster));
   454          } else if (child.className === 'jobs') {
   455            renderJobs(child, cluster);
   456          }
   457        }
   458  
   459        return true;
   460      } else if (text.textContent.includes(downArrow)) {
   461        text.textContent = text.textContent.replace(downArrow, rightArrow);
   462        child.style = "display: none";
   463        return true;
   464      }
   465    }
   466    return false;
   467  }
   468  
   469  if (typeof module !== 'undefined' && module.exports) {
   470    // enable node.js `require` to work for testing
   471    module.exports = {
   472      makeBuckets: makeBuckets,
   473      sparkLinePath: sparkLinePath,
   474    }
   475  }