Improve test freshness page interaction experience.
[WebKit.git] / Websites / perf.webkit.org / public / v3 / pages / test-freshness-page.js
1
2 class TestFreshnessPage extends PageWithHeading {
3     constructor(summaryPageConfiguration, testAgeToleranceInHours)
4     {
5         super('test-freshness', null);
6         this._testAgeTolerance = (testAgeToleranceInHours || 24) * 3600 * 1000;
7         this._timeDuration = this._testAgeTolerance * 2;
8         this._excludedConfigurations = {};
9         this._lastDataPointByConfiguration = null;
10         this._indicatorByConfiguration = null;
11         this._renderTableLazily = new LazilyEvaluatedFunction(this._renderTable.bind(this));
12         this._hoveringIndicator = null;
13         this._indicatorForTooltip = null;
14         this._firstIndicatorAnchor = null;
15         this._showTooltip = false;
16         this._builderByIndicator = null;
17         this._tabIndexForIndicator = null;
18         this._coordinateForIndicator = null;
19         this._indicatorAnchorGrid = null;
20         this._skipNextClick = false;
21         this._skipNextStateCleanOnScroll = false;
22         this._lastFocusedCell = null;
23         this._renderTooltipLazily = new LazilyEvaluatedFunction(this._renderTooltip.bind(this));
24
25         this._loadConfig(summaryPageConfiguration);
26     }
27
28     name() { return 'Test-Freshness'; }
29
30     _loadConfig(summaryPageConfiguration)
31     {
32         const platformIdSet = new Set;
33         const metricIdSet = new Set;
34
35         for (const config of summaryPageConfiguration) {
36             for (const platformGroup of config.platformGroups) {
37                 for (const platformId of platformGroup.platforms)
38                     platformIdSet.add(platformId);
39             }
40
41             for (const metricGroup of config.metricGroups) {
42                 for (const subgroup of metricGroup.subgroups) {
43                     for (const metricId of subgroup.metrics)
44                         metricIdSet.add(metricId);
45                 }
46             }
47
48             const excludedConfigs = config.excludedConfigurations;
49             for (const platform in excludedConfigs) {
50                 if (platform in this._excludedConfigurations)
51                     this._excludedConfigurations[platform] = this._excludedConfigurations[platform].concat(excludedConfigs[platform]);
52                 else
53                     this._excludedConfigurations[platform] = excludedConfigs[platform];
54             }
55         }
56         this._platforms = [...platformIdSet].map((platformId) => Platform.findById(platformId));
57         this._metrics = [...metricIdSet].map((metricId) => Metric.findById(metricId));
58     }
59
60     open(state)
61     {
62         this._fetchTestResults();
63         super.open(state);
64     }
65
66     didConstructShadowTree()
67     {
68         super.didConstructShadowTree();
69
70         const tooltipTable = this.content('tooltip-table');
71         this.content().addEventListener('click', (event) => {
72             if (!tooltipTable.contains(event.target))
73                 this._clearIndicatorState(false);
74         });
75
76         tooltipTable.onkeydown = this.createEventHandler((event) => {
77             if (event.code == 'Escape') {
78                 event.preventDefault();
79                 event.stopPropagation();
80                 this._lastFocusedCell.focus({preventScroll: true});
81             }
82         }, {preventDefault: false, stopPropagation: false});
83
84         window.addEventListener('keydown', (event) => {
85             if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.code))
86                 return;
87
88             event.preventDefault();
89             if (!this._indicatorForTooltip && !this._hoveringIndicator) {
90                 if (this._firstIndicatorAnchor)
91                     this._firstIndicatorAnchor.focus({preventScroll: true});
92                 return;
93             }
94
95             let [row, column] = this._coordinateForIndicator.get(this._indicatorForTooltip || this._hoveringIndicator);
96             let direction = null;
97
98             switch (event.code) {
99                 case 'ArrowUp':
100                     row -= 1;
101                     break;
102                 case 'ArrowDown':
103                     row += 1;
104                     break;
105                 case 'ArrowLeft':
106                     column -= 1;
107                     direction = 'leftOnly'
108                     break;
109                 case 'ArrowRight':
110                     column += 1;
111                     direction = 'rightOnly'
112             }
113
114             const closestIndicatorAnchor = this._findClosestIndicatorAnchorForCoordinate(column, row, this._indicatorAnchorGrid, direction);
115             if (closestIndicatorAnchor)
116                 closestIndicatorAnchor.focus({preventScroll: true});
117         });
118     }
119
120     _findClosestIndicatorAnchorForCoordinate(columnIndex, rowIndex, grid, direction)
121     {
122         rowIndex = Math.min(Math.max(rowIndex, 0), grid.length - 1);
123         const row = grid[rowIndex];
124         if (!row.length)
125             return null;
126
127         const start = Math.min(Math.max(columnIndex, 0), row.length - 1);
128         if (row[start])
129             return row[start];
130
131         let offset = 1;
132         while (true) {
133             const leftIndex = start - offset;
134             if (leftIndex >= 0 && row[leftIndex] && direction != 'rightOnly')
135                 return row[leftIndex];
136             const rightIndex = start + offset;
137             if (rightIndex < row.length && row[rightIndex] && direction != 'leftOnly')
138                 return row[rightIndex];
139             if (leftIndex < 0 && rightIndex >= row.length)
140                 break;
141             offset += 1;
142         }
143         return null;
144     }
145
146     _fetchTestResults()
147     {
148         this._measurementSetFetchTime = Date.now();
149         this._lastDataPointByConfiguration = new Map;
150         this._builderByIndicator = new Map;
151
152         const startTime = this._measurementSetFetchTime - this._timeDuration;
153
154         for (const platform of this._platforms) {
155             const lastDataPointByMetric = new Map;
156             this._lastDataPointByConfiguration.set(platform, lastDataPointByMetric);
157
158             for (const metric of this._metrics) {
159                 if (!this._isValidPlatformMetricCombination(platform, metric, this._excludedConfigurations))
160                     continue;
161
162                 const measurementSet = MeasurementSet.findSet(platform.id(), metric.id(), platform.lastModified(metric));
163                 measurementSet.fetchBetween(startTime, this._measurementSetFetchTime).then(() => {
164                     const currentTimeSeries = measurementSet.fetchedTimeSeries('current', false, false);
165
166                     let timeForLatestBuild = startTime;
167                     let lastBuild = null;
168                     let builder = null;
169                     let commitSetOfLastPoint = null;
170                     const lastPoint = currentTimeSeries.lastPoint();
171                     if (lastPoint) {
172                         timeForLatestBuild = lastPoint.build().buildTime().getTime();
173                         const view = currentTimeSeries.viewBetweenPoints(currentTimeSeries.firstPoint(), lastPoint);
174                         for (const point of view) {
175                             const build = point.build();
176                             if (!build)
177                                 continue;
178                             if (build.buildTime().getTime() >= timeForLatestBuild) {
179                                 timeForLatestBuild = build.buildTime().getTime();
180                                 lastBuild = build;
181                                 builder = build.builder();
182                             }
183                         }
184                         commitSetOfLastPoint = lastPoint.commitSet();
185                     }
186
187                     lastDataPointByMetric.set(metric, {time: timeForLatestBuild, hasCurrentDataPoint: !!lastPoint,
188                         lastBuild, builder, commitSetOfLastPoint});
189                     this.enqueueToRender();
190                 });
191             }
192         }
193     }
194
195     render()
196     {
197         super.render();
198
199         this._renderTableLazily.evaluate(this._platforms, this._metrics);
200
201         let buildSummaryForFocusingIndicator = null;
202         let buildForFocusingIndicator = null;
203         let commitSetForFocusingdIndicator = null;
204         let chartURLForFocusingIndicator = null;
205         let platformForFocusingIndicator = null;
206         let metricForFocusingIndicator = null;
207         const builderForFocusingIndicator = this._indicatorForTooltip ? this._builderByIndicator.get(this._indicatorForTooltip) : null;
208         const builderForHoveringIndicator = this._hoveringIndicator ? this._builderByIndicator.get(this._hoveringIndicator) : null;
209         for (const [platform, lastDataPointByMetric] of this._lastDataPointByConfiguration.entries()) {
210             for (const [metric, lastDataPoint] of lastDataPointByMetric.entries()) {
211                 const timeDuration = this._measurementSetFetchTime - lastDataPoint.time;
212                 const timeDurationSummaryPrefix = lastDataPoint.hasCurrentDataPoint ? '' : 'More than ';
213                 const timeDurationSummary = BuildRequest.formatTimeInterval(timeDuration);
214                 const summary = `${timeDurationSummaryPrefix}${timeDurationSummary} since latest data point.`;
215
216                 const indicator = this._indicatorByConfiguration.get(platform).get(metric);
217                 if (this._indicatorForTooltip && this._indicatorForTooltip === indicator) {
218                     buildSummaryForFocusingIndicator = summary;
219                     buildForFocusingIndicator = lastDataPoint.lastBuild;
220                     commitSetForFocusingdIndicator = lastDataPoint.commitSetOfLastPoint;
221                     chartURLForFocusingIndicator =  this._router.url('charts', ChartsPage.createStateForDashboardItem(platform.id(), metric.id(),
222                         this._measurementSetFetchTime - this._timeDuration));
223                     platformForFocusingIndicator = platform;
224                     metricForFocusingIndicator = metric;
225                 }
226                 this._builderByIndicator.set(indicator, lastDataPoint.builder);
227                 const highlighted = builderForFocusingIndicator && builderForFocusingIndicator == lastDataPoint.builder
228                     || builderForHoveringIndicator && builderForHoveringIndicator === lastDataPoint.builder;
229                 indicator.update(timeDuration, this._testAgeTolerance, highlighted);
230             }
231         }
232         this._renderTooltipLazily.evaluate(this._indicatorForTooltip, this._showTooltip, buildSummaryForFocusingIndicator, buildForFocusingIndicator,
233             commitSetForFocusingdIndicator, chartURLForFocusingIndicator, platformForFocusingIndicator, metricForFocusingIndicator, this._tabIndexForIndicator.get(this._indicatorForTooltip));
234     }
235
236     _renderTooltip(indicator, showTooltip, buildSummary, build, commitSet, chartURL, platform, metric, tabIndex)
237     {
238         if (!indicator || !buildSummary || !showTooltip) {
239             this.content('tooltip-anchor').style.display =  showTooltip ? null : 'none';
240             return;
241         }
242         const element = ComponentBase.createElement;
243         const link = ComponentBase.createLink;
244
245         const rect = indicator.element().getBoundingClientRect();
246         const tooltipAnchor = this.content('tooltip-anchor');
247         tooltipAnchor.style.display = null;
248         tooltipAnchor.style.top = rect.top + 'px';
249         tooltipAnchor.style.left = rect.left + rect.width / 2 + 'px';
250
251         let tableContent = [element('tr', element('td', {colspan: 2}, buildSummary))];
252
253         if (chartURL) {
254             const linkDescription = `${metric.test().name()} on ${platform.name()}`;
255             tableContent.push(element('tr', [
256                 element('td', 'Chart'),
257                 element('td', {colspan: 2}, link(linkDescription, linkDescription, chartURL, true, tabIndex))
258             ]));
259         }
260
261         if (commitSet) {
262             if (commitSet.repositories().length)
263                 tableContent.push(element('tr', element('th', {colspan: 2}, 'Latest build information')));
264
265             tableContent.push(Repository.sortByNamePreferringOnesWithURL(commitSet.repositories()).map((repository) => {
266                 const commit = commitSet.commitForRepository(repository);
267                 return element('tr', [
268                     element('td', repository.name()),
269                     element('td', commit.url() ? link(commit.label(), commit.label(), commit.url(), true, tabIndex) : commit.label())
270                 ]);
271             }));
272         }
273
274         if (build) {
275             const url = build.url();
276             const buildNumber = build.buildNumber();
277             tableContent.push(element('tr', [
278                 element('td', 'Build'),
279                 element('td', {colspan: 2}, [
280                     url ? link(buildNumber, build.label(), url, true, tabIndex) : buildNumber
281                 ]),
282             ]));
283         }
284
285         this.renderReplace(this.content("tooltip-table"),  tableContent);
286     }
287
288     _renderTable(platforms, metrics)
289     {
290         const element = ComponentBase.createElement;
291         const tableHeadElements = [element('th',  {class: 'table-corner row-head'}, 'Platform \\ Test')];
292
293         for (const metric of metrics)
294             tableHeadElements.push(element('th', {class: 'diagonal-head'}, element('div', metric.test().fullName())));
295
296         this._indicatorByConfiguration = new Map;
297         this._coordinateForIndicator = new Map;
298         this._tabIndexForIndicator = new Map;
299         this._indicatorAnchorGrid = [];
300         this._firstIndicatorAnchor = null;
301         let currentTabIndex = 1;
302
303         const tableBodyElement = platforms.map((platform, rowIndex) =>  {
304             const indicatorByMetric = new Map;
305             this._indicatorByConfiguration.set(platform, indicatorByMetric);
306
307             let indicatorAnchorsInCurrentRow = [];
308
309             const cells = metrics.map((metric, columnIndex) => {
310                 const [cell, anchor, indicator] = this._constructTableCell(platform, metric, currentTabIndex);
311
312                 indicatorAnchorsInCurrentRow.push(anchor);
313                 if (!indicator)
314                     return cell;
315
316                 indicatorByMetric.set(metric, indicator);
317                 this._tabIndexForIndicator.set(indicator, currentTabIndex);
318                 this._coordinateForIndicator.set(indicator, [rowIndex, columnIndex]);
319
320                 ++currentTabIndex;
321                 if (!this._firstIndicatorAnchor)
322                     this._firstIndicatorAnchor = anchor;
323                 return cell;
324             });
325             this._indicatorAnchorGrid.push(indicatorAnchorsInCurrentRow);
326
327             const row = element('tr', [element('th', {class: 'row-head'}, platform.label()), ...cells]);
328             return row;
329         });
330
331         const tableBody = element('tbody', tableBodyElement);
332         tableBody.onscroll = this.createEventHandler(() => this._clearIndicatorState(true));
333
334         this.renderReplace(this.content('test-health'), [element('thead', tableHeadElements), tableBody]);
335     }
336
337     _isValidPlatformMetricCombination(platform, metric)
338     {
339         return !(this._excludedConfigurations && this._excludedConfigurations[platform.id()]
340             && this._excludedConfigurations[platform.id()].some((metricId) => metricId == metric.id()))
341             && platform.hasMetric(metric);
342     }
343
344     _constructTableCell(platform, metric, tabIndex)
345     {
346         const element = ComponentBase.createElement;
347         const link = ComponentBase.createLink;
348         if (!this._isValidPlatformMetricCombination(platform, metric))
349             return [element('td', {class: 'blank-cell'}, element('div')), null, null];
350
351         const indicator = new FreshnessIndicator;
352         const anchor = link(indicator, '', () => {
353             if (this._skipNextClick) {
354                 this._skipNextClick = false;
355                 return;
356             }
357             this._showTooltip = !this._showTooltip;
358             this.enqueueToRender();
359         }, false, tabIndex);
360
361         const cell = element('td', {class: 'status-cell'}, anchor);
362         this._configureAnchorForIndicator(anchor, indicator);
363         return [cell, anchor, indicator];
364     }
365
366     _configureAnchorForIndicator(anchor, indicator)
367     {
368         anchor.onmouseover = this.createEventHandler(() => {
369             this._hoveringIndicator = indicator;
370             this.enqueueToRender();
371         });
372         anchor.onmousedown = this.createEventHandler(() =>
373             this._skipNextClick = this._indicatorForTooltip != indicator, {preventDefault: false, stopPropagation: false});
374         anchor.onfocus = this.createEventHandler(() => {
375             this._showTooltip = this._indicatorForTooltip != indicator;
376             this._hoveringIndicator = indicator;
377             this._indicatorForTooltip = indicator;
378             this._lastFocusedCell = anchor;
379             this._skipNextStateCleanOnScroll = true;
380             this.enqueueToRender();
381         });
382         anchor.onkeydown = this.createEventHandler((event) => {
383             if (event.code == 'Escape') {
384                 event.preventDefault();
385                 event.stopPropagation();
386                 this._showTooltip = event.code == 'Enter' ? !this._showTooltip : false;
387                 this.enqueueToRender();
388             }
389         }, {preventDefault: false, stopPropagation: false});
390     }
391
392     _clearIndicatorState(dueToScroll)
393     {
394         if (this._skipNextStateCleanOnScroll) {
395             this._skipNextStateCleanOnScroll = false;
396             if (dueToScroll)
397                 return;
398         }
399         this._showTooltip = false;
400         this._indicatorForTooltip = null;
401         this._hoveringIndicator = null;
402         this.enqueueToRender();
403     }
404
405     static htmlTemplate()
406     {
407         return `<section class="page-with-heading">
408             <table id="test-health"></table>
409             <div id="tooltip-anchor">
410                 <table id="tooltip-table"></table>
411             </div>
412         </section>`;
413     }
414
415     static cssTemplate()
416     {
417         return `
418             .page-with-heading {
419                 display: flex;
420                 justify-content: center;
421             }
422             #test-health {
423                 font-size: 1rem;
424             }
425             #test-health thead {
426                 display: block;
427                 align: right;
428             }
429             #test-health th.table-corner {
430                 text-align: right;
431                 vertical-align: bottom;
432             }
433             #test-health .row-head {
434                 min-width: 15.5rem;
435             }
436             #test-health th {
437                 text-align: left;
438                 border-bottom: 0.1rem solid #ccc;
439                 font-weight: normal;
440             }
441             #test-health th.diagonal-head {
442                 white-space: nowrap;
443                 height: 16rem;
444                 width: 2.2rem;
445                 border-bottom: 0rem;
446                 padding: 0;
447             }
448             #test-health th.diagonal-head > div {
449                 transform: translate(1.1rem, 7.5rem) rotate(315deg);
450                 transform-origin: center left;
451                 width: 2.2rem;
452                 border: 0rem;
453             }
454             #test-health tbody {
455                 display: block;
456                 overflow: auto;
457                 height: calc(100vh - 24rem);
458             }
459             #test-health td.status-cell {
460                 position: relative;
461                 margin: 0;
462                 padding: 0;
463                 max-width: 2.2rem;
464                 max-height: 2.2rem;
465                 min-width: 2.2rem;
466                 min-height: 2.2rem;
467             }
468             #test-health td.status-cell>a {
469                 display: block;
470             }
471             #test-health td.status-cell>a:focus {
472                 outline: none;
473             }
474             #test-health td.status-cell>a:focus::after {
475                 position: absolute;
476                 content: "";
477                 bottom: -0.1rem;
478                 left: 50%;
479                 margin-left: -0.2rem;
480                 height: 0rem;
481                 border-width: 0.2rem;
482                 border-style: solid;
483                 border-color: transparent transparent red transparent;
484                 outline: none;
485             }
486             #test-health td.blank-cell {
487                 margin: 0;
488                 padding: 0;
489                 max-width: 2.2rem;
490                 max-height: 2.2rem;
491                 min-width: 2.2rem;
492                 min-height: 2.2rem;
493             }
494             #test-health td.blank-cell > div  {
495                 background-color: #F9F9F9;
496                 height: 1.6rem;
497                 width: 1.6rem;
498                 margin: auto;
499                 padding: 0;
500                 position: relative;
501                 overflow: hidden;
502             }
503             #test-health td.blank-cell > div::before {
504                 content: "";
505                 position: absolute;
506                 top: -1px;
507                 left: -1px;
508                 display: block;
509                 width: 0px;
510                 height: 0px;
511                 border-right: calc(1.6rem + 1px) solid #ddd;
512                 border-top: calc(1.6rem + 1px) solid transparent;
513             }
514             #test-health td.blank-cell > div::after {
515                 content: "";
516                 display: block;
517                 position: absolute;
518                 top: 1px;
519                 left: 1px;
520                 width: 0px;
521                 height: 0px;
522                 border-right: calc(1.6rem - 1px) solid #F9F9F9;
523                 border-top: calc(1.6rem - 1px) solid transparent;
524             }
525             #tooltip-anchor {
526                 width: 0;
527                 height: 0;
528                 position: absolute;
529             }
530             #tooltip-table {
531                 position: absolute;
532                 width: 22rem;
533                 background-color: #696969;
534                 opacity: 0.96;
535                 margin: 0.3rem;
536                 padding: 0.3rem;
537                 border-radius: 0.4rem;
538                 z-index: 1;
539                 text-align: center;
540                 display: inline-table;
541                 color: white;
542                 bottom: 0;
543                 left: -11.3rem;
544             }
545             #tooltip-table td {
546                 overflow: hidden;
547                 max-width: 22rem;
548                 text-overflow: ellipsis;
549             }
550             #tooltip-table::after {
551                 content: " ";
552                 position: absolute;
553                 top: 100%;
554                 left: 50%;
555                 margin-left: -0.3rem;
556                 border-width: 0.3rem;
557                 border-style: solid;
558                 border-color: #696969 transparent transparent transparent;
559             }
560             #tooltip-table a {
561                 color: white;
562                 font-weight: bold;
563             }
564             #tooltip-table a:focus {
565                 background-color: #AAB7B8;
566                 outline: none;
567             }
568         `;
569     }
570
571     routeName() { return 'test-freshness'; }
572 }