blob: 09a25586b52c73d1b66a331aa05331badd7be229 [file] [log] [blame]
/**
* @license
* Copyright 2020 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../shared/gr-icon/gr-icon';
import {classMap} from 'lit/directives/class-map.js';
import './gr-hovercard-run';
import {css, html, LitElement, nothing, PropertyValues} from 'lit';
import {customElement, property, query, state} from 'lit/decorators.js';
import './gr-checks-attempt';
import {Action, Link, RunStatus} from '../../api/checks';
import {sharedStyles} from '../../styles/shared-styles';
import {
ALL_ATTEMPTS,
AttemptChoice,
attemptChoiceLabel,
LATEST_ATTEMPT,
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 {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';
import {when} from 'lit/directives/when.js';
@customElement('gr-checks-run')
export class GrChecksRun extends LitElement {
static override get styles() {
return [
sharedStyles,
css`
:host {
display: block;
--thick-border: 6px;
}
:host([condensed]) .eta,
:host([condensed]) .middle,
:host([condensed]) .right {
display: none;
}
:host([condensed]) * {
pointer-events: none;
}
.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 {
border-left: var(--thick-border) solid var(--info-foreground);
}
.chip.check_circle {
border-left: var(--thick-border) solid var(--success-foreground);
}
.chip.timelapse,
.chip.pending_actions {
border-left: var(--thick-border) solid var(--border-color);
}
.chip.placeholder {
border-left: var(--thick-border) solid var(--border-color);
}
.chip.placeholder gr-icon {
display: none;
}
gr-icon.error {
color: var(--error-foreground);
}
gr-icon.warning {
color: var(--warning-foreground);
}
gr-icon.info {
color: var(--info-foreground);
}
gr-icon.check_circle {
color: var(--success-foreground);
}
:host(:not([condensed])) 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 gr-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;
@state()
selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
@property({attribute: false})
deselected = false;
@property({type: Boolean})
condensed = false;
@state()
shouldRender = false;
private readonly reporting = getAppContext().reportingService;
private getChecksModel = resolve(this, checksModelToken);
constructor() {
super();
subscribe(
this,
() => this.getChecksModel().checksSelectedAttemptNumber$,
x => (this.selectedAttempt = x)
);
}
override firstUpdated() {
assertIsDefined(this.chipElement, 'chip element');
whenVisible(this.chipElement, () => (this.shouldRender = true), 200);
}
override render() {
if (!this.shouldRender) return html`<div class="chip">Loading ...</div>`;
const icon = iconForRun(this.run);
const classes = {
chip: true,
[icon.name]: 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" tabindex="0">
<gr-hovercard-run .run=${this.run}></gr-hovercard-run>
${this.renderFilterIcon()}
<gr-icon
class=${icon.name}
icon=${icon.name}
?filled=${icon.filled}
></gr-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.renderAttempt({attempt: LATEST_ATTEMPT})}
${this.renderAttempt({attempt: ALL_ATTEMPTS})}
${this.run.attemptDetails.map(a => this.renderAttempt(a))}
</div>
`;
}
renderAttempt(detail: AttemptDetail) {
const attempt = detail.attempt ?? 0;
const checkNameId = charsOnly(this.run.checkName).toLowerCase();
const id = `attempt-${detail.attempt}`;
const icon = detail.icon ?? {name: ''};
const wasNotRun =
icon?.name === iconFor(RunStatus.RUNNABLE)?.name &&
attempt !== LATEST_ATTEMPT &&
attempt !== ALL_ATTEMPTS;
const selected = this.selectedAttempt === attempt;
return html`<div class="attemptDetail">
<input
type="radio"
id=${id}
name=${`${checkNameId}-attempt-choice`}
.checked=${selected}
?disabled=${!selected && wasNotRun}
@change=${() => this.handleAttemptChange(attempt)}
/>
<gr-icon
icon=${icon.name}
class=${icon.name}
?filled=${icon.filled}
></gr-icon>
<label for=${id}>
${attemptChoiceLabel(attempt)}${wasNotRun ? ' (not run)' : ''}
</label>
</div>`;
}
handleAttemptChange(attempt: AttemptChoice) {
this.getChecksModel().updateStateSetAttempt(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}
><gr-icon
icon="open_in_new"
class="statusLinkIcon"
aria-label="external link to run status details"
></gr-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`<gr-icon icon="filter_alt" filled class="filter"></gr-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`
<gr-icon
icon=${icon.name}
class=${icon.name}
?filled=${icon.filled}
></gr-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.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
e.stopPropagation();
fireRunSelected(this, this.run.checkName);
}
}
@customElement('gr-checks-runs')
export class GrChecksRuns extends LitElement {
@query('#filterInput')
filterInput?: HTMLInputElement;
@state()
filterRegExp = '';
@property({attribute: false})
runs: CheckRun[] = [];
@property({type: Boolean, reflect: true})
collapsed = false;
@property({attribute: false})
selectedRuns: string[] = [];
@state()
selectedAttempt: AttemptChoice = LATEST_ATTEMPT;
@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)
);
subscribe(
this,
() => this.getChecksModel().checksSelectedAttemptNumber$,
x => (this.selectedAttempt = x)
);
subscribe(
this,
() => this.getChecksModel().runFilterRegexp$,
x => (this.filterRegExp = x)
);
this.addEventListener('click', () => {
if (this.collapsed) this.toggleCollapsed();
});
}
static override get styles() {
return [
sharedStyles,
fontStyles,
css`
:host {
display: block;
}
:host(:not([collapsed])) {
width: 20%;
padding: var(--spacing-l) var(--spacing-xl) var(--spacing-xl)
var(--spacing-xl);
}
:host([collapsed]) {
width: 90px;
padding: var(--spacing-l) var(--spacing-l) var(--spacing-xl)
var(--spacing-l);
max-height: 600px;
overflow: hidden;
}
:host([collapsed]) * {
pointer-events: none;
}
:host([collapsed]:hover) {
cursor: pointer;
}
.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 .expandButton {
margin-right: calc(0px - var(--spacing-m));
}
:host([collapsed]:hover) .expandButton {
background: var(--gray-background-hover);
border-radius: var(--border-radius);
}
.sectionHeader {
padding-top: var(--spacing-l);
text-transform: capitalize;
cursor: default;
}
:host([collapsed]) .sectionHeader {
cursor: pointer;
}
.sectionHeader h3 {
display: inline-block;
}
:host(:not([collapsed])) .collapsed .sectionRuns {
display: none;
}
:host(:not([collapsed])) .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 gr-icon {
color: var(--error-foreground);
margin-right: var(--spacing-m);
}
.login {
background: var(--info-background);
}
.login gr-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);
if (changedProperties.has('tabState') && this.tabState) {
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() {
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()}
.value=${this.filterRegExp}
@input=${this.onInput}
/>
${this.renderSection(RunStatus.RUNNING)}
${this.renderSection(RunStatus.COMPLETED)}
${this.renderSection(RunStatus.RUNNABLE)} ${this.renderFakeControls()}
`;
}
private renderZeroState() {
if (this.collapsed) return;
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]) => {
const msg = this.collapsed
? 'Error'
: `Error while fetching results for ${plugin}:<br />${message}`;
return html`
<div class="error">
<div class="left">
<gr-icon icon="error" filled></gr-icon>
</div>
<div class="right">
<div class="message">${msg}</div>
</div>
</div>
`;
});
}
private renderSignIn() {
if (!this.loginCallback) return;
const message = this.collapsed
? 'Sign in'
: 'Sign in to Checks Plugin to see runs and results';
return html`
<div class="login">
<div>
<gr-icon icon="info"></gr-icon>
${message}
</div>
<div class="buttonRow">
<gr-button @click=${this.loginCallback} link>Sign in</gr-button>
</div>
</div>
`;
}
private renderTitleButtons() {
if (this.collapsed) return;
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 font-normal"
role="switch"
aria-checked=${this.collapsed ? 'true' : 'false'}
aria-label=${this.collapsed
? 'Expand runs panel'
: 'Collapse runs panel'}
@click=${this.toggleCollapsed}
>
<div>
<gr-icon
icon=${this.collapsed ? 'chevron_right' : 'chevron_left'}
class="expandIcon"
>
</gr-icon>
</div>
</gr-button>
</gr-tooltip-content>
`;
}
private toggleCollapsed(event?: Event) {
if (event) event.stopPropagation();
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}
);
const value = this.filterInput.value;
this.getChecksModel().updateStateSetRunFilter(value ?? '');
}
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 regExp = new RegExp(this.filterRegExp, 'i');
const runs = this.runs
.filter(r => r.isLatestAttempt)
.filter(
r =>
r.status === status ||
(status === RunStatus.RUNNING && r.status === RunStatus.SCHEDULED)
)
.filter(r => regExp.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 ? 'expand_less' : 'expand_more';
let header = headerForStatus(status);
if (runs.some(r => r.status === RunStatus.SCHEDULED)) {
header = `${header} / ${headerForStatus(RunStatus.SCHEDULED)}`;
}
const count = when(!this.collapsed, () => html` (${runs.length})`);
const grIcon = when(
!this.collapsed,
() => html`<gr-icon icon=${icon} class="expandIcon"></gr-icon>`
);
return html`
<div class="${status.toLowerCase()} ${expandedClass}">
<div class="sectionHeader" @click=${() => this.toggleExpanded(status)}>
${grIcon}
<h3 class="heading-3">${header}${count}</h3>
</div>
<div class="sectionRuns">${runs.map(run => this.renderRun(run))}</div>
</div>
`;
}
toggleExpanded(status: RunStatus) {
if (this.collapsed) return;
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 deselected = !selectedRun && this.selectedRuns.length > 0;
return html`<gr-checks-run
.run=${run}
?condensed=${this.collapsed}
.selected=${selectedRun}
.deselected=${deselected}
></gr-checks-run>`;
}
showFilter(): boolean {
if (this.collapsed) return false;
return this.runs.length > 10 || !!this.filterRegExp;
}
renderFakeControls() {
if (!this.flagService.isEnabled(KnownExperimentId.CHECKS_DEVELOPER)) return;
if (this.collapsed) 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;
}
}