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 }