blob: 7525a28d05e3682597235d6f933ab7c092abe9ea [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/define';
import { html } from 'lit-html';
import { errorMessage } from 'elements-sk/errorMessage';
import { fromObject } from 'common-sk/modules/query';
import dialogPolyfill from 'dialog-polyfill';
import { HintableObject } from 'common-sk/modules/hintable';
import { diffDate } from 'common-sk/modules/human';
import { ElementSk } from '../../../infra-sk/modules/ElementSk';
import {
truncateWithEllipses, detailHref, diffPageHref, sendBeginTask, sendEndTask, sendFetchError,
} from '../common';
import 'elements-sk/icon/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 { SearchCriteriaToHintableObject } from '../search-controls-sk';
import {
Commit, Digest, Label, ParamSet, SearchResult, SRDiffDigest, TestName, TraceGroup, TraceID, TriageHistory, TriageRequest,
} from '../rpc_types';
import { SearchCriteria, SearchCriteriaHintableObject } from '../search-controls-sk/search-controls-sk';
import { DotsSk } from '../dots-sk/dots-sk';
import { BlamelistPanelSk } from '../blamelist-panel-sk/blamelist-panel-sk';
import { LabelOrEmpty, TriageSk } from '../triage-sk/triage-sk';
import { 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 = ['pos', 'neg'];
export class DigestDetailsSk extends ElementSk {
private static template = (ele: DigestDetailsSk) => html`
<div class=container>
<div class=top_bar>
<span class=grouping_name>Test: ${ele.grouping}</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.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) => {
if (!ele.right) {
return html`
<div class=metrics_and_triage>
<triage-sk @change=${ele.triageChangeHandler} .value=${ele.status}></triage-sk>
${DigestDetailsSk.triageHistoryTemplate(ele)}
</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?
return html`
<div class=metrics_and_triage>
<div>
<a href=${diffPageHref(
ele.grouping,
ele.digest,
ele.right.digest,
ele.changeListID,
ele.crs,
)}
target=_blank rel=noopener class=diffpage_link>
Diff Details
</a>
</div>
<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.status}></triage-sk>
${DigestDetailsSk.triageHistoryTemplate(ele)}
</div>
`;
};
private static triageHistoryTemplate = (ele: DigestDetailsSk) => {
if (ele.triageHistory.length === 0) return '';
const mostRecent = ele.triageHistory[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) => {
const left: ImageComparisonData = {
digest: ele.digest,
title: truncateWithEllipses(ele.digest),
detail: detailHref(ele.grouping, ele.digest, ele.changeListID, ele.crs),
};
if (!ele.right) {
const hasOtherDigests = (ele.traces?.digests?.length || 0) > 1;
return html`<image-compare-sk .left=${left}
.isComputingDiffs=${hasOtherDigests}></image-compare-sk>`;
}
const right: ImageComparisonData = {
digest: ele.right.digest,
title: ele.right.status === 'positive' ? 'Closest Positive' : 'Closest Negative',
detail: detailHref(ele.grouping, ele.right.digest, ele.changeListID, ele.crs),
};
if (ele._overrideRight) {
right.title = truncateWithEllipses(ele.right.digest);
}
return html`<image-compare-sk .left=${left} .right=${right}></image-compare-sk>`;
};
private static traceInfoTemplate = (ele: DigestDetailsSk) => {
if (!ele.traces || !ele.traces.traces || !ele.traces.traces.length) {
return '';
}
return html`
<div class=trace_info>
<dots-sk
.value=${ele.traces}
.commits=${ele._commits}
@hover=${ele.hoverOverTrace}
@mouseleave=${ele.clearTraceHighlights}
@showblamelist=${ele.showBlamelist}>
</dots-sk>
<dots-legend-sk
.digests=${ele.traces.digests}
.changeListID=${ele.changeListID}
.crs=${ele.crs}
.test=${ele.grouping}
.totalDigests=${ele.traces.total_digests || 0}>
</dots-legend-sk>
</div>
`;
};
private static paramsetTemplate = (ele: DigestDetailsSk) => {
if (!ele.digest || !ele.params) {
return ''; // details might not be loaded yet.
}
const titles = [truncateWithEllipses(ele.digest)];
const paramsets = [ele.params];
if (ele.right && ele.right.paramset) {
titles.push(truncateWithEllipses(ele.right.digest));
paramsets.push(ele.right.paramset);
}
return html`
<paramset-sk .titles=${titles}
.paramsets=${paramsets}
.highlight=${ele._highlightedParams}>
</paramset-sk>
`;
};
private grouping: TestName = '';
private digest: Digest = '';
private status: Label = 'untriaged';
private triageHistory: TriageHistory[] = [];
private params: ParamSet | null = null;
private traces: TraceGroup | null = null;
private refDiffs: { [key: string]: SRDiffDigest | null } = {};
private _changeListID = '';
private _crs = '';
private _useNewAPI = false;
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 = '';
private _overrideRight: SRDiffDigest | null= null;
private _highlightedParams: { [key: string]: string } = {};
constructor() {
super(DigestDetailsSk.template);
}
connectedCallback() {
super.connectedCallback();
this._render();
dialogPolyfill.registerDialog(this.querySelector('dialog.blamelist_dialog')!);
}
/**
* An array of the commits in the tile. Used to compute the blamelist for representing traces.
*/
get commits(): Commit[] { return this._commits; }
set commits(arr: Commit[]) {
this._commits = arr;
this._render();
}
/** SearchResult from which to pull the digest details to show. */
set details(obj: SearchResult) {
this.grouping = obj.test || '';
this.digest = obj.digest || '';
this.traces = obj.traces || {};
this.params = obj.paramset;
this.refDiffs = obj.refDiffs || {};
this._rightRef = obj.closestRef || '';
this.status = obj.status || '';
this.triageHistory = obj.triage_history || [];
this._render();
}
/** The changelist id (or empty string if this is the master branch). */
get changeListID(): string { return this._changeListID; }
set changeListID(id: string) {
this._changeListID = id;
this._render();
}
/** The Code Review System (e.g. "gerrit") if changeListID is set. */
get crs(): string { return this._crs; }
set crs(c: string) {
this._crs = c;
this._render();
}
/**
* @prop right {Object} Forces the left image to be compared to the given ref.
*/
get right(): SRDiffDigest | null {
if (this._overrideRight) {
return this._overrideRight;
}
return this.refDiffs[this._rightRef] || null;
}
set right(override: SRDiffDigest | null) {
this._overrideRight = override;
this._render();
}
get useNewAPI(): boolean {
return this._useNewAPI;
}
set useNewAPI(b: boolean) {
this._useNewAPI = b;
}
private canToggle(): boolean {
let totalRefs = 0;
for (const ref of validRefs) {
if (this.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.grouping || !this.params || !this.params.source_type
|| this.params.source_type.length === 0) {
return '';
}
const searchCriteria: Partial<SearchCriteria> = {
corpus: this.params.source_type[0],
includePositiveDigests: true,
includeNegativeDigests: true,
includeUntriagedDigests: true,
includeDigestsNotAtHead: true,
};
const clusterState: SearchCriteriaHintableObject & {grouping?: TestName} = SearchCriteriaToHintableObject(searchCriteria);
clusterState.grouping = this.grouping;
return `/cluster?${fromObject(clusterState as HintableObject)}`;
}
private hoverOverTrace(e: CustomEvent<TraceID>) {
// Find the matching trace in details.traces.
const trace = this.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 = '';
while (!this.refDiffs[newRight]) {
idx = (idx + 1) % validRefs.length;
newRight = validRefs[idx];
}
this._rightRef = newRight;
this._render();
}
private triageChangeHandler(e: CustomEvent<LabelOrEmpty>) {
e.stopPropagation();
const newStatus = e.detail as Label;
this.dispatchEvent(
new CustomEvent<LabelOrEmpty>('triage', { bubbles: true, detail: newStatus }),
);
const triageRequest: TriageRequest = {
testDigestStatus: {
[this.grouping]: {
[this.digest]: newStatus,
},
},
changelist_id: this.changeListID,
crs: this.crs,
};
sendBeginTask(this);
const url = this._useNewAPI ? '/json/v2/triage' : '/json/v1/triage';
fetch(url, {
method: 'POST',
body: JSON.stringify(triageRequest),
headers: {
'Content-Type': 'application/json',
},
}).then((resp) => {
if (resp.ok) {
// Triaging was successful.
this.status = newStatus;
this.triageHistory.unshift({
user: 'me',
ts: Date.now().toString(),
});
this._render();
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,
);
this.querySelector<TriageSk>('triage-sk')!.value = this.status;
this._render();
sendEndTask(this);
}
}).catch((e) => {
sendFetchError(this, e, 'triaging');
});
}
}
define('digest-details-sk', DigestDetailsSk);