blob: 9f7715335a91830c76ca5080c62f8b02d916cda6 [file] [log] [blame]
/**
* @module module/digest-details-sk
* @description <h2><code>digest-details-sk</code></h2>
*
* Displays the details about a digest. These details include comparing it to other digests in the
* same grouping (e.g. test), if those are available. It provides the affordances to triage the
* given digest and makes the POST request to triage this given digest.
*
* <h2>Events</h2>
* This element produces the following events:
* @evt begin-task/end-task - when a POST request is in flight to handle triaging.
* @evt triage - Emitted when the user triages the digest. e.detail contains the assigned Label.
*
* Children elements emit the following events of note:
* @evt show-commits - Event generated when a trace dot is clicked. e.detail contains
* the blamelist (an array of commits that could have made up that dot).
*
*/
import { define } from '../../../elements-sk/modules/define';
import { html } from 'lit-html';
import { errorMessage } from '../../../elements-sk/modules/errorMessage';
import { diffDate } from '../../../infra-sk/modules/human';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import { truncate } from '../../../infra-sk/modules/string';
import {
clusterPageHref,
detailHref, diffPageHref, sendBeginTask, sendEndTask, sendFetchError,
} from '../common';
import '../../../elements-sk/modules/icons/group-work-icon-sk';
import '../dots-sk';
import '../dots-legend-sk';
import '../triage-sk';
import '../image-compare-sk';
import '../blamelist-panel-sk';
import '../../../infra-sk/modules/paramset-sk';
import {
Commit, GroupingsResponse, Label, Params, RefClosest, SearchResult, SRDiffDigest, TraceID, TriageRequestV3, TriageResponse,
} from '../rpc_types';
import { SearchCriteria } from '../search-controls-sk/search-controls-sk';
import { DotsSk } from '../dots-sk/dots-sk';
import { BlamelistPanelSk } from '../blamelist-panel-sk/blamelist-panel-sk';
import { TriageSk } from '../triage-sk/triage-sk';
import { ImageCompareSk, ImageComparisonData } from '../image-compare-sk/image-compare-sk';
function toggleButtonMouseover(canToggle: boolean) {
if (canToggle) {
return 'By default, Gold shows the closest image, whether it has been marked positive or '
+ 'negative. This button allows you to explicitly select the closest positive or negative.';
}
return 'There are no other reference image types to compare against.';
}
const validRefs: RefClosest[] = ['pos', 'neg'];
const DISALLOW_TRIAGING_OPTIONAL_KEY = 'disallow_triaging';
const DISALLOW_TRIAGING_OPTIONAL_KEY_VALUE = 'true';
export class DigestDetailsSk extends ElementSk {
private static template = (ele: DigestDetailsSk) => html`
<div class=container>
<div class=top_bar>
<span class=grouping_name>Test: ${ele._details.test}</span>
<span class=expand></span>
<a href=${ele.clusterHref()} target=_blank rel=noopener class=cluster_link>
<group-work-icon-sk title="Cluster view of this digest and all others for this test.">
</group-work-icon-sk>
</a>
</div>
<div class=comparison>
<div class=digest_labels>
<span class="digest_label left">Left: ${ele._details.digest}</span>
<span class=expand></span>
<span class="digest_label right" ?hidden=${!ele.right}>
Right: ${ele.right && ele.right.digest}
</span>
</div>
<div class=comparison_data>
<div>${DigestDetailsSk.detailsAndTriageTemplate(ele)}</div>
<div>${DigestDetailsSk.imageComparisonTemplate(ele)}</div>
<div>
<button
@click=${ele.toggleRightRef}
?disabled=${!ele.canToggle()}
class=toggle_ref
?hidden=${ele.overrideRight || !ele.right}
title=${toggleButtonMouseover(ele.canToggle())}>
Toggle Reference
</button>
<div ?hidden=${!ele.right || ele.right.status !== 'negative'} class=negative_warning>
Closest image is negative!
</div>
<!-- TODO(kjlubick) Comments would go here -->
</div>
</div>
</div>
${DigestDetailsSk.traceInfoTemplate(ele)}
${DigestDetailsSk.paramsetTemplate(ele)}
</div>
<dialog class=blamelist_dialog>
<blamelist-panel-sk></blamelist-panel-sk>
<button class=close_btn @click=${ele.closeBlamelistDialog}>Close</button>
</dialog>
`;
private static detailsAndTriageTemplate = (ele: DigestDetailsSk) => {
const disallowTriaging = ele._details.paramset.hasOwnProperty(DISALLOW_TRIAGING_OPTIONAL_KEY)
&& ele._details.paramset[DISALLOW_TRIAGING_OPTIONAL_KEY]
.includes(DISALLOW_TRIAGING_OPTIONAL_KEY_VALUE);
const disallowTriagingMessage = disallowTriaging
? html`
<div class=triaging_disallowed>
<p>
Triaging is disallowed as per the <strong>${DISALLOW_TRIAGING_OPTIONAL_KEY}</strong>
optional key.
</p>
<p>
If this change is expected, either update the test name to create a new grouping, or
update the test to remove the <strong>${DISALLOW_TRIAGING_OPTIONAL_KEY}</strong>
optional key.
</p>
</div>
`
: '';
if (!ele.right) {
return html`
<div class=metrics_and_triage>
<triage-sk @change=${ele.triageChangeHandler}
.value=${ele._details.status}
.readOnly=${disallowTriaging}>
</triage-sk>
${DigestDetailsSk.triageHistoryTemplate(ele)}
${disallowTriagingMessage}
</div>
`;
}
// TODO(kjlubick) would it be clearer to just tell the user the images differ in size and omit
// the (probably useless metrics)? Could we also include the actual dimensions of the two?
// We need the digest's grouping to compute a link to the diff page. If the digest has no
// params, we'll get an exception when computing the grouping. If we don't catch the exception,
// this whole element will fail to render.
let maybeGrouping: Params | null = null;
try {
maybeGrouping = ele.getGrouping();
} catch {
// Nothing to do.
}
// Only show a link to the diff page if we successfully computed the digest's grouping.
const diffPageLinkTemplate = maybeGrouping
? html`
<div>
<a href=${diffPageHref(
ele.getGrouping(),
ele._details.digest,
ele.right.digest,
ele._changeListID,
ele._crs,
)}
target=_blank rel=noopener class=diffpage_link>
Diff Details
</a>
</div>
`
: '';
return html`
<div class=metrics_and_triage>
${diffPageLinkTemplate}
<div class=size_warning ?hidden=${!ele.right.dimDiffer}>Images differ in size!</div>
<div class=metric>
<span>Diff metric:</span>
<span>${ele.right.combinedMetric.toFixed(3)}</span>
</div>
<div class=metric>
<span>Diff %:</span>
<span>${ele.right.pixelDiffPercent.toFixed(2)}</span>
</div>
<div class=metric>
<span>Pixels:</span>
<span>${ele.right.numDiffPixels}</span>
</div>
<div class=metric>
<span>Max RGBA:</span>
<span>[${ele.right.maxRGBADiffs.join(',')}]</span>
</div>
<triage-sk @change=${ele.triageChangeHandler}
.value=${ele._details.status}
.readOnly=${disallowTriaging}>
</triage-sk>
${DigestDetailsSk.triageHistoryTemplate(ele)}
${disallowTriagingMessage}
</div>
`;
};
private static triageHistoryTemplate = (ele: DigestDetailsSk) => {
if (!ele._details.triage_history || ele._details.triage_history.length === 0) return '';
const mostRecent = ele._details.triage_history![0];
return html`
<div class=triage-history title="Last triaged on ${mostRecent.ts} by ${mostRecent.user}">
${diffDate(mostRecent.ts)} ago by
${mostRecent.user.includes('@')
? mostRecent.user.substring(0, mostRecent.user.indexOf('@') + 1)
: mostRecent.user}
</div>
`;
}
private static imageComparisonTemplate = (ele: DigestDetailsSk) => {
// We need the digest's grouping to compute a link to the details page. If the digest has no
// params, we'll get an exception when computing the grouping. If we don't catch the exception,
// this whole element will fail to render.
let maybeGrouping: Params | null = null;
try {
maybeGrouping = ele.getGrouping();
} catch {
// Nothing to do.
}
const left: ImageComparisonData = {
digest: ele._details.digest,
title: truncate(ele._details.digest, 15),
detail: maybeGrouping
? detailHref(maybeGrouping, ele._details.digest, ele._changeListID, ele._crs)
: '',
};
if (!ele.right) {
const hasOtherDigests = (ele._details.traces?.digests?.length || 0) > 1;
return html`
<image-compare-sk .left=${left}
.isComputingDiffs=${hasOtherDigests}
.fullSizeImages=${ele._fullSizeImages}>
</image-compare-sk>
`;
}
const right: ImageComparisonData = {
digest: ele.right.digest,
title: ele.right.status === 'positive' ? 'Closest Positive' : 'Closest Negative',
detail: maybeGrouping
? detailHref(maybeGrouping, ele.right.digest, ele._changeListID, ele._crs)
: '',
};
if (ele.overrideRight) {
right.title = truncate(ele.right.digest, 15);
}
return html`
<image-compare-sk .left=${left}
.right=${right}
.fullSizeImages=${ele._fullSizeImages}>
</image-compare-sk>
`;
};
private static traceInfoTemplate = (ele: DigestDetailsSk) => {
if (!ele._details.traces || !ele._details.traces.traces || !ele._details.traces.traces.length) {
return '';
}
return html`
<div class=trace_info>
<dots-sk
.value=${ele._details.traces}
.commits=${ele._commits}
@hover=${ele.hoverOverTrace}
@mouseleave=${ele.clearTraceHighlights}
@showblamelist=${ele.showBlamelist}>
</dots-sk>
<dots-legend-sk
.grouping=${ele.getGrouping()}
.digests=${ele._details.traces.digests}
.changeListID=${ele._changeListID}
.crs=${ele._crs}
.totalDigests=${ele._details.traces.total_digests || 0}>
</dots-legend-sk>
</div>
`;
};
private static paramsetTemplate = (ele: DigestDetailsSk) => {
if (!ele._details.digest || !ele._details.paramset) {
return ''; // details might not be loaded yet.
}
const titles = [truncate(ele._details.digest, 15)];
const paramsets = [ele._details.paramset];
if (ele.right && ele.right.paramset) {
titles.push(truncate(ele.right.digest, 15));
paramsets.push(ele.right.paramset);
}
return html`
<paramset-sk .titles=${titles}
.paramsets=${paramsets}
.highlight=${ele.highlightedParams}>
</paramset-sk>
`;
};
private _details: SearchResult = {
digest: '',
test: '',
status: 'untriaged',
triage_history: null,
paramset: {},
traces: {
traces: null,
digests: null,
total_digests: 0,
},
refDiffs: null,
closestRef: '',
};
private _changeListID = '';
private _crs = '';
private _groupings: GroupingsResponse = {
grouping_param_keys_by_corpus: {},
};
private _commits: Commit[] = [];
// This tracks which ref we are showing on the right. It will default to the closest one, but
// can be changed with the toggle.
private rightRef: RefClosest = '';
private overrideRight: SRDiffDigest | null= null;
private highlightedParams: { [key: string]: string } = {};
private _fullSizeImages = false;
constructor() {
super(DigestDetailsSk.template);
}
connectedCallback(): void {
super.connectedCallback();
this._render();
}
/** GroupingsResponse used to derive the correct grouping to use when triaging. */
set groupings(groupings: GroupingsResponse) {
this._groupings = groupings;
}
/**
* An array of the commits in the tile. Used to compute the blamelist for representing traces.
*/
set commits(arr: Commit[]) {
this._commits = arr;
this._render();
}
/** SearchResult from which to pull the digest details to show. */
set details(details: SearchResult) {
this._details = details;
this.rightRef = details.closestRef;
this._render();
}
/** The changelist id (or empty string if this is the master branch). */
set changeListID(id: string) {
this._changeListID = id;
this._render();
}
/** The Code Review System (e.g. "gerrit") if changeListID is set. */
set crs(c: string) {
this._crs = c;
this._render();
}
/** Forces the left image to be compared to the given ref. */
get right(): SRDiffDigest | null {
if (this.overrideRight) {
return this.overrideRight;
}
return this._details.refDiffs ? this._details.refDiffs[this.rightRef] : null;
}
set right(override: SRDiffDigest | null) {
this.overrideRight = override;
this._render();
}
/** Whether to show thumbnails or full size images. */
set fullSizeImages(val: boolean) {
this._fullSizeImages = val;
this._render();
}
private canToggle(): boolean {
let totalRefs = 0;
for (const ref of validRefs) {
if (this._details.refDiffs && this._details.refDiffs[ref]) {
totalRefs++;
}
}
return totalRefs > 1;
}
private clearTraceHighlights() {
this.highlightedParams = {};
this._render();
}
private closeBlamelistDialog() {
this.querySelector<HTMLDialogElement>('dialog.blamelist_dialog')?.close();
}
private clusterHref() {
if (!this._details.test || !this._details.paramset || !this._details.paramset.source_type
|| this._details.paramset.source_type.length === 0) {
return '';
}
const searchCriteria: Partial<SearchCriteria> = {
corpus: this._details.paramset.source_type[0],
includePositiveDigests: true,
includeNegativeDigests: true,
includeUntriagedDigests: true,
includeDigestsNotAtHead: true,
};
return clusterPageHref(
this.getGrouping(),
searchCriteria,
);
}
private hoverOverTrace(e: CustomEvent<TraceID>) {
// Find the matching trace in details.traces.
const trace = this._details.traces?.traces?.find((trace) => trace.label === e.detail);
this.highlightedParams = trace?.params || {};
this._render();
}
protected _render() {
super._render();
// By default, the browser will show this long trace scrolled all the way to the left. This
// is the oldest traces and typically not helpful, so after we load, we ask the traces to
// scroll itself to the left, which it will do once (and not repeatedly on each render).
this.querySelector<DotsSk>('dots-sk')?.autoscroll();
}
private showBlamelist(e: CustomEvent<Commit[]>) {
e.stopPropagation();
const dialog = this.querySelector<HTMLDialogElement>('dialog.blamelist_dialog')!;
const blamelist = dialog.querySelector<BlamelistPanelSk>('blamelist-panel-sk')!;
blamelist.commits = e.detail;
dialog.showModal();
}
private toggleRightRef() {
if (!this.canToggle()) {
return;
}
let idx = validRefs.indexOf(this.rightRef);
let newRight: RefClosest = '';
while (!this._details.refDiffs![newRight]) {
idx = (idx + 1) % validRefs.length;
newRight = validRefs[idx];
}
this.rightRef = newRight;
this._render();
}
private triageChangeHandler(e: CustomEvent<Label>) {
e.stopPropagation();
const newLabel = e.detail;
this.setTriaged(newLabel);
}
private getGrouping(): Params {
// Extract corpus.
const corpusKey = 'source_type';
if (!this._details.paramset[corpusKey]) {
throw new Error(`Digest is missing key "${corpusKey}".`);
}
if (this._details.paramset[corpusKey].length !== 1) {
throw new Error(
`Digest key "${corpusKey}" must have exactly one value;`
+ `${this._details.paramset[corpusKey].length} values found.`,
);
}
const corpus = this._details.paramset[corpusKey][0];
// Build grouping.
const grouping: Params = {};
const groupingKeys = this._groupings.grouping_param_keys_by_corpus![corpus];
groupingKeys?.forEach((key) => {
if (!this._details.paramset[key]) {
throw new Error(`Digest is missing key "${key}"`);
}
if (this._details.paramset[key].length !== 1) {
throw new Error(
`Digest key ${key} must have exactly one value;`
+ `${this._details.paramset[key].length} values found.`,
);
}
grouping[key] = this._details.paramset[key][0];
});
return grouping;
}
setTriaged(label: Label): void {
// We save the label before the triage action because the search page might change the label
// when it handles the "triage" event, see
// https://skia.googlesource.com/buildbot/+/6cfe69ae17a74c87224196b6e170dad01bad558a/golden/modules/search-page-sk/search-page-sk.ts#512.
const labelBefore = this._details.status;
this.dispatchEvent(
new CustomEvent<Label>('triage', { bubbles: true, detail: label }),
);
let grouping: Params;
try {
grouping = this.getGrouping();
} catch (e) {
if (e instanceof Error) {
errorMessage(e.message);
return;
}
throw e;
}
const triageRequest: TriageRequestV3 = {
deltas: [
{
grouping: grouping,
digest: this._details.digest,
label_before: labelBefore,
label_after: label,
},
],
};
if (this._changeListID && this._crs) {
triageRequest.changelist_id = this._changeListID;
triageRequest.crs = this._crs;
}
const restorePreviousStatusInUI = () => {
this.querySelector<TriageSk>('triage-sk')!.value = labelBefore;
this._render();
};
sendBeginTask(this);
const url = '/json/v3/triage';
fetch(url, {
method: 'POST',
body: JSON.stringify(triageRequest),
headers: {
'Content-Type': 'application/json',
},
}).then(async (resp: Response) => {
if (resp.ok) {
const triageResponse = await resp.json() as TriageResponse;
if (triageResponse.status === 'ok') {
// Triaging was successful.
this._details.status = label;
this._details.triage_history ||= [];
this._details.triage_history!.unshift({
user: 'me',
ts: new Date(Date.now()).toISOString(),
});
this._render();
sendEndTask(this);
} else if (triageResponse.status === 'conflict') {
// Triage conflict. We want to set the status of the triage-sk back to what it was to
// give a visual indication it did not go through. Additionally, toast error message
// should catch the user's attention.
console.error('TriageResponse indicates triage conflict:', triageResponse);
errorMessage(
'Triage conflict: Attempted to triage from '
+ `${triageResponse.conflict?.actual_label_before} to ${label}, `
+ 'but the digest\'s current label is '
+ `${triageResponse.conflict?.expected_label_before}. `
+ 'It is possible that another user triaged this digest. Try refreshing the page.',
);
restorePreviousStatusInUI();
sendEndTask(this);
} else {
// Unknown triage status. We want to set the status of the triage-sk back to what it was
// to give a visual indication it did not go through. Additionally, toast error message
// should catch the user's attention.
console.error('Unknown TriageResponse status:', triageResponse);
errorMessage(`Unexpected TriageResponse status: ${triageResponse.status}.`, 8000);
restorePreviousStatusInUI();
sendEndTask(this);
}
} else {
// Triaging did not work (possibly because the user was not logged in). We want to set
// the status of the triage-sk back to what it was to give a visual indication it did not
// go through. Additionally, toast error message should catch the user's attention.
console.error(resp);
errorMessage(
`Unexpected error triaging: ${resp.status} ${resp.statusText} `
+ '(Are you logged in with the right account?)', 8000,
);
restorePreviousStatusInUI();
sendEndTask(this);
}
}).catch((e) => {
sendFetchError(this, e, 'triaging');
});
}
openZoom(): void {
const compare = this.querySelector<ImageCompareSk>('image-compare-sk');
if (!compare) {
return;
}
compare.openZoomWindow();
}
}
define('digest-details-sk', DigestDetailsSk);