blob: 414be212a55306889f24787204416ded8f9b693c [file] [log] [blame]
/**
* @license
* Copyright (C) 2020 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import '@polymer/iron-icon/iron-icon';
import {classMap} from 'lit/directives/class-map';
import './gr-hovercard-run';
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators';
import './gr-checks-attempt';
import {Action, Link, RunStatus} from '../../api/checks';
import {sharedStyles} from '../../styles/shared-styles';
import {
AttemptDetail,
compareByWorstCategory,
headerForStatus,
iconFor,
iconForRun,
PRIMARY_STATUS_ACTIONS,
primaryRunAction,
worstCategory,
} from '../../models/checks/checks-util';
import {
CheckRun,
ChecksPatchset,
ErrorMessages,
} from '../../models/checks/checks-model';
import {
clearAllFakeRuns,
fakeActions,
fakeLinks,
fakeRun0,
fakeRun1,
fakeRun2,
fakeRun3,
fakeRun4Att,
fakeRun5,
setAllFakeRuns,
} from '../../models/checks/checks-fakes';
import {assertIsDefined} from '../../utils/common-util';
import {modifierPressed, whenVisible} from '../../utils/dom-util';
import {
fireAttemptSelected,
fireRunSelected,
fireRunSelectionReset,
} from './gr-checks-util';
import {ChecksTabState} from '../../types/events';
import {charsOnly} from '../../utils/string-util';
import {getAppContext} from '../../services/app-context';
import {KnownExperimentId} from '../../services/flags/flags';
import {subscribe} from '../lit/subscription-controller';
import {fontStyles} from '../../styles/gr-font-styles';
import {durationString} from '../../utils/date-util';
import {resolve} from '../../models/dependency';
import {checksModelToken} from '../../models/checks/checks-model';
import {Interaction} from '../../constants/reporting';
import {Deduping} from '../../api/reporting';
@customElement('gr-checks-run')
export class GrChecksRun extends LitElement {
static override get styles() {
return [
sharedStyles,
css`
:host {
display: block;
--thick-border: 6px;
}
.chip {
display: flex;
justify-content: space-between;
border: 1px solid var(--border-color);
border-radius: var(--border-radius);
padding: var(--spacing-s) var(--spacing-m);
margin-top: var(--spacing-s);
cursor: pointer;
}
.left {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
flex-shrink: 1;
}
.middle {
/* extra space must go between middle and right */
flex-grow: 1;
white-space: nowrap;
}
.middle gr-checks-attempt {
margin-left: var(--spacing-s);
}
.name {
font-weight: var(--font-weight-bold);
}
.eta {
color: var(--deemphasized-text-color);
padding-left: var(--spacing-s);
}
.chip.error {
border-left: var(--thick-border) solid var(--error-foreground);
}
.chip.warning {
border-left: var(--thick-border) solid var(--warning-foreground);
}
.chip.info-outline {
border-left: var(--thick-border) solid var(--info-foreground);
}
.chip.check-circle-outline {
border-left: var(--thick-border) solid var(--success-foreground);
}
.chip.timelapse,
.chip.scheduled {
border-left: var(--thick-border) solid var(--border-color);
}
.chip.placeholder {
border-left: var(--thick-border) solid var(--border-color);
}
.chip.placeholder iron-icon {
display: none;
}
iron-icon.error {
color: var(--error-foreground);
}
iron-icon.warning {
color: var(--warning-foreground);
}
iron-icon.info-outline {
color: var(--info-foreground);
}
iron-icon.check-circle-outline {
color: var(--success-foreground);
}
div.chip:hover {
background-color: var(--hover-background-color);
}
div.chip:focus-within {
background-color: var(--selection-background-color);
}
/* Additional 'div' for increased specificity. */
div.chip.selected {
border: 1px solid var(--selected-background);
background-color: var(--selected-background);
padding-left: calc(var(--spacing-m) + var(--thick-border) - 1px);
}
div.chip.selected .name,
div.chip.selected iron-icon.filter {
color: var(--selected-foreground);
}
gr-checks-action {
/* The button should fit into the 20px line-height. The negative
margin provides the extra space needed for the vertical padding.
Alternatively we could have set the vertical padding to 0, but
that would not have been a nice click target. */
margin: calc(0px - var(--spacing-s));
margin-left: var(--spacing-s);
}
.attemptDetails {
padding-bottom: var(--spacing-s);
}
.attemptDetail {
/* This is thick-border (6) + spacing-m (8) + icon (20) + padding. */
padding-left: 39px;
padding-top: var(--spacing-s);
}
.attemptDetail input {
width: 14px;
height: 14px;
/* The next 3 are for placing in the middle of 20px line-height. */
vertical-align: top;
position: relative;
top: 3px;
margin-right: var(--spacing-s);
}
.statusLinkIcon {
color: var(--link-color);
margin-left: var(--spacing-s);
}
`,
];
}
@query('.chip')
chipElement?: HTMLElement;
@property({attribute: false})
run!: CheckRun;
@property({attribute: false})
selected = false;
@property({attribute: false})
selectedAttempt?: number;
@property({attribute: false})
deselected = false;
@state()
shouldRender = false;
private readonly reporting = getAppContext().reportingService;
override firstUpdated() {
assertIsDefined(this.chipElement, 'chip element');
whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
}
protected override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
// For some reason the browser does not pick up the correct `checked` state
// that is set in renderAttempt(). So we have to set it programmatically
// here.
const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
const inputToBeSelected = this.shadowRoot?.querySelector(
`.attemptDetails input#attempt-${selectedAttempt}`
) as HTMLInputElement | undefined;
if (inputToBeSelected) {
inputToBeSelected.checked = true;
}
}
override render() {
if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
const icon = iconForRun(this.run);
const classes = {
chip: true,
[icon]: true,
selected: this.selected,
deselected: this.deselected,
};
const action = primaryRunAction(this.run);
return html`
<div
@click=${this.handleChipClick}
@keydown=${this.handleChipKey}
class=${classMap(classes)}
tabindex="0"
>
<div class="left">
<gr-hovercard-run .run=${this.run}></gr-hovercard-run>
${this.renderFilterIcon()}
<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
${this.renderAdditionalIcon()}
<span class="name">${this.run.checkName}</span>
${this.renderETA()}
</div>
<div class="middle">
<gr-checks-attempt .run=${this.run}></gr-checks-attempt>
${this.renderStatusLink()}
</div>
<div class="right">
${action
? html`<gr-checks-action
context="runs"
.action=${action}
></gr-checks-action>`
: ''}
</div>
</div>
<div
class="attemptDetails"
?hidden=${this.run.isSingleAttempt || !this.selected}
>
${this.run.attemptDetails.map(a => this.renderAttempt(a))}
</div>
`;
}
isSelected(detail: AttemptDetail) {
// this.selectedAttempt may be undefined, then choose the latest attempt,
// which is what this.run has.
const selectedAttempt = this.selectedAttempt ?? this.run.attempt;
return detail.attempt === selectedAttempt;
}
renderAttempt(detail: AttemptDetail) {
const checkNameId = charsOnly(this.run.checkName).toLowerCase();
const id = `attempt-${detail.attempt}`;
const icon = detail.icon;
const wasNotRun = icon === iconFor(RunStatus.RUNNABLE);
return html`<div class="attemptDetail">
<input
type="radio"
id=${id}
name=${`${checkNameId}-attempt-choice`}
?checked=${this.isSelected(detail)}
?disabled=${!this.isSelected(detail) && wasNotRun}
@change=${() => this.handleAttemptChange(detail)}
/>
<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
<label for=${id}>
Attempt ${detail.attempt}${wasNotRun ? ' (not run)' : ''}
</label>
</div>`;
}
handleAttemptChange(detail: AttemptDetail) {
if (!this.isSelected(detail)) {
fireAttemptSelected(this, this.run.checkName, detail.attempt);
}
}
renderETA() {
if (this.run.status !== RunStatus.RUNNING) return;
if (!this.run.finishedTimestamp) return;
const now = new Date();
if (this.run.finishedTimestamp.getTime() < now.getTime()) return;
const eta = durationString(new Date(), this.run.finishedTimestamp, true);
return html`<span class="eta">ETA: ${eta}</span>`;
}
renderStatusLink() {
const link = this.run.statusLink;
if (!link) return;
return html`
<a href=${link} target="_blank" @click=${this.onLinkClick}
><iron-icon
class="statusLinkIcon"
icon="gr-icons:launch"
aria-label="external link to run status details"
></iron-icon>
<paper-tooltip offset="5">Link to run status details</paper-tooltip>
</a>
`;
}
private onLinkClick(e: MouseEvent) {
// Prevents handleChipClick() from reacting to <a> link clicks.
e.stopPropagation();
this.reporting.reportInteraction(Interaction.CHECKS_RUN_LINK_CLICKED, {
checkName: this.run.checkName,
status: this.run.status,
});
}
renderFilterIcon() {
if (!this.selected) return;
return html`
<iron-icon class="filter" icon="gr-icons:filter"></iron-icon>
`;
}
/**
* For RUNNING we also want to render an icon representing the worst result
* that has been reported until now - if there are any results already.
*/
renderAdditionalIcon() {
if (this.run.status !== RunStatus.RUNNING) return nothing;
const category = worstCategory(this.run);
if (!category) return nothing;
const icon = iconFor(category);
return html`
<iron-icon class=${icon} icon="gr-icons:${icon}"></iron-icon>
`;
}
private handleChipClick(e: MouseEvent) {
e.stopPropagation();
e.preventDefault();
fireRunSelected(this, this.run.checkName);
}
private handleChipKey(e: KeyboardEvent) {
if (modifierPressed(e)) return;
// Only react to `return` and `space`.
if (e.keyCode !== 13 && e.keyCode !== 32) return;
e.preventDefault();
e.stopPropagation();
fireRunSelected(this, this.run.checkName);
}
}
@customElement('gr-checks-runs')
export class GrChecksRuns extends LitElement {
@query('#filterInput')
filterInput?: HTMLInputElement;
/**
* We prefer `undefined` over a RegExp with '', because `.source` yields
* a strange '(?:)' for ''.
*/
@state()
filterRegExp?: RegExp;
@property({attribute: false})
runs: CheckRun[] = [];
@property({type: Boolean, reflect: true})
collapsed = false;
@property({attribute: false})
selectedRuns: string[] = [];
/** Maps checkName to selected attempt number. `undefined` means `latest`. */
@property({attribute: false})
selectedAttempts: Map<string, number | undefined> = new Map<
string,
number | undefined
>();
@property({attribute: false})
tabState?: ChecksTabState;
@state()
errorMessages: ErrorMessages = {};
@state()
loginCallback?: () => void;
private isSectionExpanded = new Map<RunStatus, boolean>();
private flagService = getAppContext().flagsService;
private getChecksModel = resolve(this, checksModelToken);
private readonly reporting = getAppContext().reportingService;
constructor() {
super();
subscribe(
this,
() => this.getChecksModel().allRunsSelectedPatchset$,
x => (this.runs = x)
);
subscribe(
this,
() => this.getChecksModel().errorMessagesLatest$,
x => (this.errorMessages = x)
);
subscribe(
this,
() => this.getChecksModel().loginCallbackLatest$,
x => (this.loginCallback = x)
);
}
static override get styles() {
return [
sharedStyles,
fontStyles,
css`
:host {
display: block;
}
:host(:not([collapsed])) {
min-width: 320px;
padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
var(--spacing-xl);
}
:host([collapsed]) {
padding: var(--spacing-l) 0;
}
.title {
display: flex;
}
.title .flex-space {
flex-grow: 1;
}
.title gr-button {
--gr-button-padding: var(--spacing-s) var(--spacing-m);
white-space: nowrap;
}
.title gr-button.expandButton {
--gr-button-padding: var(--spacing-xs) var(--spacing-s);
}
:host(:not([collapsed])) .expandButton {
margin-right: calc(0px - var(--spacing-m));
}
.expandIcon {
width: var(--line-height-h3);
height: var(--line-height-h3);
}
.sectionHeader {
padding-top: var(--spacing-l);
text-transform: capitalize;
cursor: default;
}
.sectionHeader h3 {
display: inline-block;
}
.collapsed .sectionRuns {
display: none;
}
.collapsed {
border-bottom: 1px solid var(--border-color);
padding-bottom: var(--spacing-m);
}
input#filterInput {
margin-top: var(--spacing-m);
padding: var(--spacing-s) var(--spacing-m);
width: 100%;
}
.testing {
margin-top: var(--spacing-xxl);
color: var(--deemphasized-text-color);
}
.testing gr-button {
min-width: 25px;
}
.testing * {
visibility: hidden;
}
.testing:hover * {
visibility: visible;
}
.zero {
padding: var(--spacing-m) 0;
color: var(--primary-text-color);
margin-top: var(--spacing-m);
}
.login,
.error {
padding: var(--spacing-m);
color: var(--primary-text-color);
margin-top: var(--spacing-m);
max-width: 400px;
}
.error {
display: flex;
background-color: var(--error-background);
}
.error iron-icon {
color: var(--error-foreground);
margin-right: var(--spacing-m);
}
.login {
background: var(--info-background);
}
.login iron-icon {
color: var(--info-foreground);
}
.login .buttonRow {
text-align: right;
margin-top: var(--spacing-xl);
}
.login gr-button {
margin: 0 var(--spacing-s);
}
`,
];
}
protected override updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
// This update is done is response to setting this.filterRegExp below, but
// this.filterInput not yet being available at that point.
if (this.filterInput && !this.filterInput.value && this.filterRegExp) {
this.filterInput.value = this.filterRegExp.source;
}
if (changedProperties.has('tabState') && this.tabState) {
// Note that tabState.select and tabState.attempt are processed by
// <gr-checks-tab>.
if (
this.tabState.filter &&
this.tabState.filter !== this.filterRegExp?.source
) {
this.filterRegExp = new RegExp(this.tabState.filter, 'i');
}
const {statusOrCategory} = this.tabState;
if (
statusOrCategory === RunStatus.RUNNING ||
statusOrCategory === RunStatus.RUNNABLE
) {
this.updateComplete.then(() => {
const s = statusOrCategory.toString().toLowerCase();
const el = this.shadowRoot?.querySelector(`.${s} .sectionHeader`);
el?.scrollIntoView({block: 'center'});
});
}
}
}
override render() {
if (this.collapsed) {
return html`${this.renderCollapseButton()}`;
}
return html`
<h2 class="title">
<div class="heading-2">Runs</div>
<div class="flex-space"></div>
${this.renderTitleButtons()} ${this.renderCollapseButton()}
</h2>
${this.renderErrors()} ${this.renderSignIn()} ${this.renderZeroState()}
<input
id="filterInput"
type="text"
placeholder="Filter runs by regular expression"
?hidden=${!this.showFilter()}
@input=${this.onInput}
/>
${this.renderSection(RunStatus.RUNNING)}
${this.renderSection(RunStatus.COMPLETED)}
${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
`;
}
private renderZeroState() {
if (this.runs.length > 0) return;
return html`<div class="zero">No Check Run to show</div>`;
}
private renderErrors() {
return Object.entries(this.errorMessages).map(
([plugin, message]) =>
html`
<div class="error">
<div class="left">
<iron-icon icon="gr-icons:error"></iron-icon>
</div>
<div class="right">
<div class="message">
Error while fetching results for ${plugin}:<br />${message}
</div>
</div>
</div>
`
);
}
private renderSignIn() {
if (!this.loginCallback) return;
return html`
<div class="login">
<div>
<iron-icon
class="info-outline"
icon="gr-icons:info-outline"
></iron-icon>
Sign in to Checks Plugin to see runs and results
</div>
<div class="buttonRow">
<gr-button @click=${this.loginCallback} link>Sign in</gr-button>
</div>
</div>
`;
}
private renderTitleButtons() {
if (this.selectedRuns.length < 2) return;
const actions = this.selectedRuns.map(selected => {
const run = this.runs.find(
run => run.isLatestAttempt && run.checkName === selected
);
return primaryRunAction(run);
});
const runButtonDisabled = !actions.every(
action =>
action?.name === PRIMARY_STATUS_ACTIONS.RUN ||
action?.name === PRIMARY_STATUS_ACTIONS.RERUN
);
return html`
<gr-button
class="font-normal"
link
@click=${() => fireRunSelectionReset(this)}
>Unselect All</gr-button
>
<gr-tooltip-content
title=${runButtonDisabled
? 'Disabled. Unselect checks without a "Run" action to enable the button.'
: ''}
?has-tooltip=${runButtonDisabled}
>
<gr-button
class="font-normal"
link
?disabled=${runButtonDisabled}
@click=${() => {
actions.forEach(action => {
if (!action) return;
this.getChecksModel().triggerAction(
action,
undefined,
'run-selected'
);
});
this.reporting.reportInteraction(
Interaction.CHECKS_RUNS_SELECTED_TRIGGERED
);
}}
>Run Selected</gr-button
>
</gr-tooltip-content>
`;
}
private renderCollapseButton() {
return html`
<gr-tooltip-content
has-tooltip
title=${this.collapsed ? 'Expand runs panel' : 'Collapse runs panel'}
>
<gr-button
link
class="expandButton"
role="switch"
aria-checked=${this.collapsed ? 'true' : 'false'}
aria-label=${this.collapsed
? 'Expand runs panel'
: 'Collapse runs panel'}
@click=${this.toggleCollapsed}
><iron-icon
class="expandIcon"
icon=${this.collapsed
? 'gr-icons:chevron-right'
: 'gr-icons:chevron-left'}
></iron-icon>
</gr-button>
</gr-tooltip-content>
`;
}
private toggleCollapsed() {
this.collapsed = !this.collapsed;
this.reporting.reportInteraction(Interaction.CHECKS_RUNS_PANEL_TOGGLE, {
collapsed: this.collapsed,
});
}
onInput() {
assertIsDefined(this.filterInput, 'filter <input> element');
this.reporting.reportInteraction(
Interaction.CHECKS_RUN_FILTER_CHANGED,
{},
{deduping: Deduping.EVENT_ONCE_PER_CHANGE}
);
if (this.filterInput.value) {
this.filterRegExp = new RegExp(this.filterInput.value, 'i');
} else {
this.filterRegExp = undefined;
}
}
toggle(
plugin: string,
runs: CheckRun[],
actions: Action[] = [],
links: Link[] = [],
summaryMessage: string | undefined = undefined
) {
const newRuns = this.runs.includes(runs[0]) ? [] : runs;
this.getChecksModel().updateStateSetResults(
plugin,
newRuns,
actions,
links,
summaryMessage,
ChecksPatchset.LATEST
);
}
renderSection(status: RunStatus) {
const runs = this.runs
.filter(r => r.isLatestAttempt)
.filter(
r =>
r.status === status ||
(status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
)
.filter(r => !this.filterRegExp || this.filterRegExp.test(r.checkName))
.sort(compareByWorstCategory);
if (runs.length === 0) return;
const expanded = this.isSectionExpanded.get(status) ?? true;
const expandedClass = expanded ? 'expanded' : 'collapsed';
const icon = expanded ? 'gr-icons:expand-less' : 'gr-icons:expand-more';
let header = headerForStatus(status);
if (runs.some(r => r.status === RunStatus.SCHEDULED)) {
header = `${header} / ${headerForStatus(RunStatus.SCHEDULED)}`;
}
return html`
<div class="${status.toLowerCase()} ${expandedClass}">
<div class="sectionHeader" @click=${() => this.toggleExpanded(status)}>
<iron-icon class="expandIcon" icon=${icon}></iron-icon>
<h3 class="heading-3">${header}</h3>
</div>
<div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
</div>
`;
}
toggleExpanded(status: RunStatus) {
const expanded = this.isSectionExpanded.get(status) ?? true;
this.isSectionExpanded.set(status, !expanded);
this.reporting.reportInteraction(Interaction.CHECKS_RUN_SECTION_TOGGLE, {
status,
expanded: !expanded,
});
this.requestUpdate();
}
renderRun(run: CheckRun) {
const selectedRun = this.selectedRuns.includes(run.checkName);
const selectedAttempt = this.selectedAttempts.get(run.checkName);
const deselected = !selectedRun && this.selectedRuns.length > 0;
return html`<gr-checks-run
.run=${run}
.selected=${selectedRun}
.selectedAttempt=${selectedAttempt}
.deselected=${deselected}
></gr-checks-run>`;
}
showFilter(): boolean {
return this.runs.length > 10 || !!this.filterRegExp;
}
renderFakeControls() {
if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return;
return html`
<div class="testing">
<div>Toggle fake runs by clicking buttons:</div>
<gr-button link @click=${() => clearAllFakeRuns(this.getChecksModel())}
>none</gr-button
>
<gr-button
link
@click=${() =>
this.toggle('f0', [fakeRun0], fakeActions, fakeLinks, 'ETA: 1 min')}
>0</gr-button
>
<gr-button link @click=${() => this.toggle('f1', [fakeRun1])}
>1</gr-button
>
<gr-button link @click=${() => this.toggle('f2', [fakeRun2])}
>2</gr-button
>
<gr-button link @click=${() => this.toggle('f3', [fakeRun3])}
>3</gr-button
>
<gr-button link @click="${() => this.toggle('f4', fakeRun4Att)}}"
>4</gr-button
>
<gr-button link @click=${() => this.toggle('f5', [fakeRun5])}
>5</gr-button
>
<gr-button link @click=${() => setAllFakeRuns(this.getChecksModel())}
>all</gr-button
>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-checks-run': GrChecksRun;
'gr-checks-runs': GrChecksRuns;
}
}