blob: a1944255ab6ff6a8216aec434237284aa89d7119 [file] [log] [blame]
/**
* @module modules/anomalies-table-sk
* @description <h2><code>anomalies-table-skr: </code></h2>
*
* Display table of anomalies
*/
import { html, TemplateResult } from 'lit/html.js';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { define } from '../../../elements-sk/modules/define';
import '../../../elements-sk/modules/checkbox-sk';
import { jsonOrThrow } from '../../../infra-sk/modules/jsonOrThrow';
import '../../../infra-sk/modules/sort-sk';
import { Anomaly, GetGroupReportResponse } from '../json';
import { GraphConfig } from '../explore-simple-sk/explore-simple-sk';
import { AnomalySk } from '../anomaly-sk/anomaly-sk';
import '../window/window';
import { TriageMenuSk } from '../triage-menu-sk/triage-menu-sk';
import '../triage-menu-sk/triage-menu-sk';
import { CheckOrRadio } from '../../../elements-sk/modules/checkbox-sk/checkbox-sk';
import '@material/web/button/outlined-button.js';
import { errorMessage } from '../errorMessage';
import { updateShortcut } from '../explore-simple-sk/explore-simple-sk';
import { ChromeTraceFormatter } from '../trace-details-formatter/traceformatter';
import '../../../elements-sk/modules/spinner-sk';
const weekInSeconds = 7 * 24 * 60 * 60;
class AnomalyGroup {
anomalies: Anomaly[] = [];
expanded: boolean = false;
}
export class AnomaliesTableSk extends ElementSk {
private anomalyList: Anomaly[] = [];
private anomalyGroups: AnomalyGroup[] = [];
private showPopup: boolean = false;
private checkedAnomaliesSet: Set<Anomaly> = new Set<Anomaly>();
private triageMenu: TriageMenuSk | null = null;
private headerCheckbox: CheckOrRadio | null = null;
private traceFormatter: ChromeTraceFormatter | null = null;
private shortcutUrl: string = '';
private getGroupReportResponse: GetGroupReportResponse | null = null;
private loadingGraphForAnomaly: Map<number, boolean> = new Map<number, boolean>();
private multiChartUrlToAnomalyMap: Map<number, string> = new Map<number, string>();
private regressionsPageHost = '/a/';
private isParentRow = false;
constructor() {
super(AnomaliesTableSk.template);
}
async connectedCallback() {
super.connectedCallback();
this._render();
this.triageMenu = this.querySelector('#triage-menu');
this.triageMenu!.disableNudge();
this.triageMenu!.toggleButtons(this.checkedAnomaliesSet.size > 0);
this.headerCheckbox = this.querySelector('#header-checkbox') as CheckOrRadio;
this.traceFormatter = new ChromeTraceFormatter();
this.addEventListener('click', (e: Event) => {
const triageButton = this.querySelector('#triage-button');
const popup = this.querySelector('.popup');
if (this.showPopup && !popup!.contains(e.target as Node) && e.target !== triageButton) {
this.showPopup = false;
this._render();
}
});
}
private static template = (ele: AnomaliesTableSk) => html`
<div class="filter-buttons" ?hidden="${ele.anomalyList.length === 0}">
<button
id="triage-button"
@click="${ele.togglePopup}"
?disabled="${ele.checkedAnomaliesSet.size === 0}">
Triage
</button>
<button
id="graph-button"
@click="${ele.openReport}"
?disabled="${ele.checkedAnomaliesSet.size === 0}">
Graph
</button>
</div>
<div class="popup-container" ?hidden="${!ele.showPopup}">
<div class="popup">
<triage-menu-sk id="triage-menu"></triage-menu-sk>
</div>
</div>
${ele.generateTable()}
<h1 id="clear-msg" hidden>All anomalies are triaged!</h1>
`;
private async openReport() {
const idList = [...this.checkedAnomaliesSet].map((a) => a.id);
// If only one anomaly is selected, open the report page using
// the anomaly id directly.
// TODO(b/384952008): offload the handling to backend.
if (idList.length === 1) {
const key = idList[0];
window.open(`/u/?anomalyIDs=${key}`, '_blank');
return;
}
const idString = idList.join(',');
// TODO(wenbinzhang): ideally, we should open the url:
// /u/?keys=idString.
// Then from the report-page-sk.ts, we can call
// /_anomalies/group_report?keys=idString.
// From the response, we can use the .anomaly_list to
// populate the tablem and use the .sid to update the url.
// As the report-page-sk.ts is not finalized yet, I'm puting
// the logic here to make the implementation more clear.
// Though, this will cause one extra call to Chromeperf, which
// will slow down the repsonse time.
// I will move this to report-page-sk when the page is ready.
await this.fetchGroupReportApi(idString);
const sid: string = this.getGroupReportResponse!.sid || '';
const url = `/u/?sid=${sid}`;
window.open(url, '_blank');
}
private togglePopup() {
this.showPopup = !this.showPopup;
if (this.showPopup) {
const triageMenu = this.querySelector('#triage-menu') as TriageMenuSk;
triageMenu.setAnomalies(Array.from(this.checkedAnomaliesSet), [], []);
}
this._render();
}
private rangeIntersects(aMin: number, aMax: number, bMin: number, bMax: number) {
return aMin <= bMax && bMin <= aMax;
}
private shouldMerge(a: Anomaly, b: Anomaly) {
return this.rangeIntersects(a.start_revision, a.end_revision, b.start_revision, b.end_revision);
}
/**
* Merge anomalies into groups.
*
* The criteria for merging two anomalies A and B is if A.start_revision and A.end_revision
* intersect with B.start_revision and B.end_revision.
*/
private groupAnomalies() {
const groups: AnomalyGroup[] = [];
for (let i = 0; i < this.anomalyList.length; i++) {
let merged = false;
const anomaly = this.anomalyList[i];
for (const group of groups) {
let doMerge = true;
for (const other of group.anomalies) {
const should = this.shouldMerge(anomaly, other);
if (!should) {
doMerge = false;
break;
}
}
if (doMerge) {
group.anomalies.push(anomaly);
merged = true;
break;
}
}
if (!merged) {
groups.push({
anomalies: [anomaly],
expanded: false,
});
}
}
this.anomalyGroups = groups;
}
private generateTable() {
return html`
<sort-sk id="as_table" target="rows">
<table id="anomalies-table" hidden>
<tr class="headers">
<th id="group"></th>
<th id="checkbox">
<checkbox-sk id="header-checkbox" @change=${this.toggleAllCheckboxes}> </checkbox-sk>
</th>
<th id="graph_header">Chart</th>
<th id="bug_id" data-key="bugid">Bug ID</th>
<th id="revision_range" data-key="revisions" data-default="down">Revisions</th>
<th id="bot" data-key="bot" data-sort-type="alpha">Bot</th>
<th id="testsuite" data-key="testsuite" data-sort-type="alpha">Test Suite</th>
<th id="test" data-key="test" data-sort-type="alpha">Test</th>
<th id="percent_changed" data-key="delta">Delta %</th>
</tr>
<tbody id="rows">
${this.generateGroups()}
</tbody>
</table>
</sort-sk>
`;
}
private generateGroups() {
const groups: TemplateResult[][] = [];
for (let i = 0; i < this.anomalyGroups.length; i++) {
const anomalyGroup = this.anomalyGroups[i];
groups.push(this.generateRows(anomalyGroup) as TemplateResult[]);
}
return groups;
}
private async preGenerateMultiGraphUrl(): Promise<void> {
for (const anomaly of this.anomalyList) {
await this.generateMultiGraphUrl(anomaly);
}
}
private anomalyChecked(chkbox: CheckOrRadio, a: Anomaly) {
if (chkbox.checked === true) {
this.checkedAnomaliesSet.add(a);
if (this.checkedAnomaliesSet.size === this.anomalyList.length) {
this.headerCheckbox!.checked = true;
}
} else {
this.headerCheckbox!.checked = false;
this.checkedAnomaliesSet.delete(a);
}
this.dispatchEvent(
new CustomEvent('anomalies_checked', {
detail: {
anomaly: a,
checked: chkbox.checked,
},
bubbles: true,
})
);
this.triageMenu!.toggleButtons(this.checkedAnomaliesSet.size > 0);
this._render();
}
private getProcessedAnomaly(anomaly: Anomaly) {
const bugId = anomaly.bug_id;
const testPathPieces = anomaly.test_path.split('/');
const bot = testPathPieces[1];
const testsuite = testPathPieces[2];
const test = testPathPieces.slice(3, testPathPieces.length).join('/');
const revision = anomaly.end_revision;
const delta = AnomalySk.getPercentChange(
anomaly.median_before_anomaly,
anomaly.median_after_anomaly
);
return {
bugId,
revision,
bot,
testsuite,
test,
delta,
};
}
private generateRows(anomalyGroup: AnomalyGroup): TemplateResult[] {
const rows: TemplateResult[] | never = [];
const length = anomalyGroup.anomalies.length;
if (length > 1) {
rows.push(this.generateSummaryRow(anomalyGroup));
}
for (let i = 0; i < anomalyGroup.anomalies.length; i++) {
const anomalySortValues = this.getProcessedAnomaly(anomalyGroup.anomalies[i]);
const anomaly = anomalyGroup.anomalies[i];
const processedAnomaly = this.getProcessedAnomaly(anomaly);
const anomalyClass = anomaly.is_improvement ? 'improvement' : 'regression';
const isLoading = this.loadingGraphForAnomaly.get(anomaly.id) || false;
rows.push(html`
<tr
data-bugid="${anomalySortValues.bugId}"
data-revisions="${anomalySortValues.revision}"
data-bot="${anomalySortValues.bot}"
data-testsuite="${anomalySortValues.testsuite}"
data-test="${anomalySortValues.test}"
data-delta="${anomalySortValues.delta}"
class=${this.getRowClass(i + 1, anomalyGroup)}
?hidden=${
(!this.isParentRow && !anomalyGroup.expanded) ||
(anomalyGroup.anomalies.length > 1 && !anomalyGroup.expanded)
}>
<td>
</td>
<td>
<checkbox-sk
@change=${(e: Event) => {
// If we just need to check 1 anomaly, just mark it as checked.
if (length === 1 || anomalyGroup.expanded) {
this.anomalyChecked(e.target as CheckOrRadio, anomaly);
} else {
// If the the summary row gets checked, check all children anomalies.
this.toggleChildrenCheckboxes(anomalyGroup);
}
}}
id="anomaly-row-${anomaly.id}">
</checkbox-sk>
</td>
<td class="center-content">
${
isLoading
? html`<spinner-sk active></spinner-sk>` // Show spinner if loading
: html`
<button
id="trendingicon-link"
@click=${async () => {
this.loadingGraphForAnomaly.set(anomaly.id, true);
this._render();
await this.openMultiGraphUrl(anomaly);
this.loadingGraphForAnomaly.set(anomaly.id, false);
this._render();
}}>
<trending-up-icon-sk></trending-up-icon-sk>
</button>
`
}
</td>
<td>
${this.getReportLinkForBugId(anomaly.bug_id)}
<close-icon-sk
id="btnUnassociate"
@click=${() => {
this.triageMenu!.makeEditAnomalyRequest([anomaly], [], 'RESET');
}}
?hidden=${anomaly!.bug_id === 0}>
</close-icon-sk>
</td>
<td>
<span
>${this.computeRevisionRange(anomaly.start_revision, anomaly.end_revision)}</span
>
</a>
</td>
<td>${processedAnomaly.bot}</td>
<td>${processedAnomaly.testsuite}</td>
<td>${processedAnomaly.test}</td>
<td class=${anomalyClass}>${AnomalySk.formatPercentage(processedAnomaly.delta)}%</td>
</tr>
`);
}
return rows;
}
private generateSummaryRow(anomalyGroup: AnomalyGroup): TemplateResult {
const firstAnomaly = anomalyGroup.anomalies[0];
const summary = {
bugId: 0,
startRevision: firstAnomaly.start_revision,
endRevision: firstAnomaly.end_revision,
bot: '*',
testsuite: '*',
test: '*',
delta: 0,
};
let sameBot = true;
let sameTestSuite = true;
const firstProcessed = this.getProcessedAnomaly(firstAnomaly);
summary.delta = firstProcessed.delta;
for (let i = 1; i < anomalyGroup.anomalies.length; i++) {
const processed = this.getProcessedAnomaly(anomalyGroup.anomalies[i]);
if (processed.bot !== firstProcessed.bot) {
sameBot = false;
}
if (processed.testsuite !== firstProcessed.testsuite) {
sameTestSuite = false;
}
if (summary.startRevision > anomalyGroup.anomalies[i].start_revision) {
summary.startRevision = anomalyGroup.anomalies[i].start_revision;
}
if (summary.endRevision < anomalyGroup.anomalies[i].end_revision) {
summary.endRevision = anomalyGroup.anomalies[i].end_revision;
}
const delta = AnomalySk.getPercentChange(
anomalyGroup.anomalies[i].median_before_anomaly,
anomalyGroup.anomalies[i].median_after_anomaly
);
if (summary.delta < delta) {
summary.delta = delta;
}
}
summary.test = this.findLongestSubTestPath(anomalyGroup.anomalies);
if (sameBot) {
summary.bot = firstProcessed.bot;
}
if (sameTestSuite) {
summary.testsuite = firstProcessed.testsuite;
}
const anomalyForBugReportLink = this.getReportLinkForSummaryRowBugId(anomalyGroup);
return html`
<tr
data-bugid="${anomalyForBugReportLink ? anomalyForBugReportLink.bug_id : 0}"
data-revisions="${summary.endRevision}"
data-bot="${summary.bot}"
data-testsuite="${summary.testsuite}"
data-test="${summary.test}"
data-delta="${summary.delta}"
class="${this.getRowClass(0, anomalyGroup)}}">
<td>
<button
class="expand-button"
@click=${() => this.expandGroup(anomalyGroup)}
?hidden=${anomalyGroup.anomalies.length === 1}>
${anomalyGroup.anomalies.length}
</button>
</td>
<td>
<checkbox-sk
@change="${() => {
// If the summary row checkbox gets checked and the
// group is not expanded, check all children anomalies.
this.toggleChildrenCheckboxes(anomalyGroup);
}}"
id="anomaly-row-${anomalyGroup.anomalies.length}">
</checkbox-sk>
</td>
<td class="center-content"></td>
<td>
${this.getReportLinkForBugId(
anomalyForBugReportLink ? anomalyForBugReportLink.bug_id : 0
)}
<close-icon-sk
id="btnUnassociate"
@click=${() => {
this.triageMenu!.makeEditAnomalyRequest(
[anomalyForBugReportLink ? anomalyForBugReportLink : firstAnomaly],
[],
'RESET'
);
}}
?hidden=${anomalyForBugReportLink === undefined}>
</close-icon-sk>
</td>
<td>
<span>${this.computeRevisionRange(summary.startRevision, summary.endRevision)}</span>
</td>
<td>${summary.bot}</td>
<td>${summary.testsuite}</td>
<td>${summary.test}</td>
<td>${AnomalySk.formatPercentage(summary.delta)}%</td>
</tr>
`;
}
private findLongestSubTestPath(anomalyList: Anomaly[]): string {
// Check if this character exists at the same position in all other strings.
let longestCommonTestPath = anomalyList.at(0)!.test_path;
for (let i = 1; i < anomalyList.length; i++) {
const currentString = anomalyList[i].test_path;
while (currentString.indexOf(longestCommonTestPath) !== 0) {
longestCommonTestPath = longestCommonTestPath.substring(
0,
longestCommonTestPath.length - 1
);
if (longestCommonTestPath === '') {
return '*';
}
}
}
// Return the common test path plus '' if the paths in the grouped rows are not the same.
// '*' indicates where the test names differ in the collapsed rows.
if (longestCommonTestPath.length !== anomalyList.at(0)!.test_path.length) {
const testPath = longestCommonTestPath.split('/');
return testPath.slice(3, testPath.length).join('/') + '*';
}
// else return the original test path.
return anomalyList.at(0)!.test_path;
}
private getReportLinkForBugId(bug_id: number) {
if (bug_id === 0) {
return html``;
}
if (bug_id === -1) {
return html`Invalid Alert`;
}
if (bug_id === -2) {
return html`Ignored Alert`;
}
return html`<a href="http://b/${bug_id}" target="_blank">${bug_id}</a>`;
}
private getReportLinkForSummaryRowBugId(anomalyGroup: AnomalyGroup): Anomaly | undefined {
for (const anomaly of anomalyGroup.anomalies) {
if (anomaly.bug_id !== null && anomaly.bug_id !== 0) {
return anomaly;
}
}
return undefined;
}
private getRowClass(index: number, anomalyGroup: AnomalyGroup) {
if (anomalyGroup.expanded) {
if (index === 0) {
this.isParentRow = true;
return 'parent-expanded-row';
} else {
this.isParentRow = false;
return 'child-expanded-row';
}
}
return '';
}
private expandGroup(anomalyGroup: AnomalyGroup) {
anomalyGroup.expanded = !anomalyGroup.expanded;
this._render();
}
private computeRevisionRange(start: number | null, end: number | null): string {
if (start === null || end === null) {
return '';
}
if (start === end) {
return '' + end;
}
return start + ' - ' + end;
}
async populateTable(anomalyList: Anomaly[]): Promise<void> {
const msg = this.querySelector('#clear-msg') as HTMLHeadingElement;
const table = this.querySelector('#anomalies-table') as HTMLTableElement;
if (anomalyList.length > 0) {
msg.hidden = true;
table.hidden = false;
this.anomalyList = anomalyList;
if (window.location.pathname !== this.regressionsPageHost) {
await this.preGenerateMultiGraphUrl();
}
this.groupAnomalies();
this._render();
} else {
msg.hidden = false;
table.hidden = true;
}
}
/**
* Set checkboxes to true for list of provided anomalies.
* @param anomalyList
*/
checkSelectedAnomalies(anomalyList: Anomaly[]): void {
anomalyList.forEach((anomaly) => {
this.checkAnomaly(anomaly);
});
this._render();
}
private checkAnomaly(checkedAnomaly: Anomaly) {
const checkbox = this.querySelector(
`checkbox-sk[id="anomaly-row-${checkedAnomaly.id}"]`
) as CheckOrRadio;
if (checkbox !== null) {
checkbox.checked = true;
this.anomalyChecked(checkbox, checkedAnomaly);
}
}
/**
* Toggles the checked state of all child checkboxes within an anomaly group when the
* group is collapsed. This allows the user to check/uncheck all children anomalies
* at once by interacting with the parent checkbox.
*/
private toggleChildrenCheckboxes(anomalyGroup: AnomalyGroup) {
const summaryRowCheckbox = this.querySelector(
`checkbox-sk[id="anomaly-row-${anomalyGroup.anomalies.length}"]`
) as CheckOrRadio;
anomalyGroup.anomalies.forEach((anomaly) => {
const checkbox = this.querySelector(
`checkbox-sk[id="anomaly-row-${anomaly.id}"]`
) as CheckOrRadio;
checkbox.checked = summaryRowCheckbox.checked;
this.anomalyChecked(checkbox, anomaly);
});
this._render();
}
/**
* Toggles the 'checked' state of all checkboxes in the table based on the state of
* the header checkbox. This provides a convenient way to select or deselect all
* anomalies at once.
*/
private toggleAllCheckboxes() {
const checked = this.headerCheckbox!.checked;
this.anomalyGroups.forEach((group) => {
group.anomalies.forEach((anomaly) => {
const summaryRowCheckbox = this.querySelector(
`checkbox-sk[id=anomaly-row-${group.anomalies.length}]`
) as CheckOrRadio;
summaryRowCheckbox!.checked = checked;
const checkbox = this.querySelector(
`checkbox-sk[id="anomaly-row-${anomaly.id}"]`
) as CheckOrRadio;
checkbox!.checked = checked;
this.anomalyChecked(checkbox, anomaly);
});
});
this._render();
}
private async openMultiGraphUrl(anomaly: Anomaly) {
// Skip pre-generating the multi-chart on the Regression page(/a/)
// to prevent spikes in page loading time.
// For example, there's a common scenario where more than 500 rows will be initially loaded
// when the user chooses 'V8 Javascript Perf' on the Regressions page.
// It would significantly increase the page loading time if it pre-generates each row's url.
// To prevent this, we will only pre-generate the URLs on the Report page.
if (window.location.pathname !== this.regressionsPageHost) {
const url = this.multiChartUrlToAnomalyMap.get(anomaly.id);
return this.openAnomalyUrl(url);
} else {
console.log('Loading multi graph with Spinner');
const url = await this.generateMultiGraphUrl(anomaly);
return this.openAnomalyUrl(url);
}
}
//helper method to handle the async multi chart url opening
private async openAnomalyUrl(url: string | undefined): Promise<void> {
if (url) {
const resolvedUrl = url;
window.open(resolvedUrl, '_blank');
} else {
console.warn('multi chart not found');
}
}
getCheckedAnomalies(): Anomaly[] {
return Array.from(this.checkedAnomaliesSet);
}
private async fetchGroupReportApi(idString: string): Promise<any> {
await fetch('/_/anomalies/group_report', {
method: 'POST',
body: JSON.stringify({
anomalyIDs: idString,
}),
headers: {
'Content-Type': 'application/json',
},
})
.then(jsonOrThrow)
.catch((msg) => {
errorMessage(msg);
})
.then(async (response) => {
const json: GetGroupReportResponse = response;
this.getGroupReportResponse = json;
});
}
// openMultiGraphLink generates a multi-graph url for the given parameters
private async generateMultiGraphUrl(anomaly: Anomaly): Promise<string> {
await this.fetchGroupReportApi(String(anomaly.id));
const begin = this.getGroupReportResponse?.timerange_map![anomaly.id].begin;
const end = this.getGroupReportResponse?.timerange_map![anomaly.id].end;
// generate data one week ahead and one week behind to make it easier
// for user to discern trends
const rangeBegin = begin ? (begin - weekInSeconds).toString() : '';
const rangeEnd = end ? (end + weekInSeconds).toString() : '';
const graphConfigs = [] as GraphConfig[];
const config: GraphConfig = {
keys: '',
formulas: [],
queries: [],
};
config.queries = [this.traceFormatter!.formatQuery(anomaly.test_path)];
graphConfigs.push(config);
await updateShortcut(graphConfigs)
.then((shortcut) => {
if (shortcut === '') {
this.shortcutUrl = '';
return;
}
this.shortcutUrl = shortcut;
})
.catch(errorMessage);
// request_type=0 only selects data points for within the range
// rather than show 250 data points by default
const url =
`${window.location.protocol}//${window.location.host}` +
`/m/?begin=${rangeBegin}&end=${rangeEnd}` +
`&request_type=0&shortcut=${this.shortcutUrl}&totalGraphs=1`;
this.multiChartUrlToAnomalyMap.set(anomaly.id, url);
return url;
}
initialCheckAllCheckbox() {
this.headerCheckbox!.checked = true;
this.anomalyGroups.forEach((group) => {
group.anomalies.forEach((anomaly) => {
const summaryRowCheckbox = this.querySelector(
`checkbox-sk[id=anomaly-row-${group.anomalies.length}]`
) as CheckOrRadio;
summaryRowCheckbox!.checked = true;
const checkbox = this.querySelector(
`checkbox-sk[id="anomaly-row-${anomaly.id}"]`
) as CheckOrRadio;
checkbox.checked = true;
this.checkedAnomaliesSet.add(anomaly);
});
});
}
}
define('anomalies-table-sk', AnomaliesTableSk);