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