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 }