blob: 9aa837dda6d25c4b4cd42d44d69b4d13e065d9ab [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../shared/gr-icon/gr-icon';
import {fontStyles} from '../../styles/gr-font-styles';
import {customElement, property} from 'lit/decorators.js';
import './gr-checks-action';
import {CheckRun} from '../../models/checks/checks-model';
import {
AttemptDetail,
ChecksIcon,
iconFor,
runActions,
worstCategory,
} from '../../models/checks/checks-util';
import {durationString, fromNow} from '../../utils/date-util';
import {RunStatus} from '../../api/checks';
import {ordinal} from '../../utils/string-util';
import {HovercardMixin} from '../../mixins/hovercard-mixin/hovercard-mixin';
import {css, html, LitElement} from 'lit';
import {checksStyles} from './gr-checks-styles';
// This avoids JSC_DYNAMIC_EXTENDS_WITHOUT_JSDOC closure compiler error.
const base = HovercardMixin(LitElement);
@customElement('gr-hovercard-run')
export class GrHovercardRun extends base {
@property({type: Object})
run?: CheckRun;
static override get styles() {
return [
fontStyles,
checksStyles,
base.styles || [],
css`
#container {
min-width: 356px;
max-width: 356px;
padding: var(--spacing-xl) 0 var(--spacing-m) 0;
}
.row {
display: flex;
margin-top: var(--spacing-s);
}
.attempts.row {
flex-wrap: wrap;
}
.chipRow {
display: flex;
margin-top: var(--spacing-s);
}
.chip {
background: var(--gray-background);
color: var(--gray-foreground);
border-radius: 20px;
padding: var(--spacing-xs) var(--spacing-m) var(--spacing-xs)
var(--spacing-s);
}
.title {
color: var(--deemphasized-text-color);
margin-right: var(--spacing-m);
}
div.section {
margin: 0 var(--spacing-xl) var(--spacing-m) var(--spacing-xl);
display: flex;
}
div.sectionIcon {
flex: 0 0 30px;
}
div.chip gr-icon {
font-size: 16px;
/* Positioning of a 16px icon in the middle of a 20px line. */
position: relative;
top: 2px;
}
div.sectionIcon gr-icon {
position: relative;
top: 2px;
font-size: 20px;
}
div.sectionIcon gr-icon.small {
position: relative;
top: 6px;
font-size: 16px;
}
div.sectionContent gr-icon.link {
color: var(--link-color);
}
div.sectionContent .attemptIcon gr-icon,
div.sectionContent gr-icon.small {
font-size: 16px;
margin-right: var(--spacing-s);
/* Positioning of a 16px icon in the middle of a 20px line. */
position: relative;
top: 2px;
}
div.sectionContent .attemptIcon gr-icon {
margin-right: 0;
}
.attemptIcon,
.attemptNumber {
margin-right: var(--spacing-s);
color: var(--deemphasized-text-color);
text-align: center;
width: 24px;
font-size: var(--font-size-small);
}
div.action {
border-top: 1px solid var(--border-color);
margin-top: var(--spacing-m);
padding: var(--spacing-m) var(--spacing-xl) 0;
}
`,
];
}
override render() {
if (!this.run) return '';
const icon = this.computeIcon();
const chipIcon = this.computeChipIcon();
return html`
<div id="container" role="tooltip" tabindex="-1">
<div class="section">
<div
?hidden=${!this.run || this.run.status === RunStatus.RUNNABLE}
class="chipRow"
>
<div class="chip">
<gr-icon
icon=${chipIcon.name}
?filled=${chipIcon.filled}
></gr-icon>
<span>${this.run.status}</span>
</div>
</div>
</div>
<div class="section">
<div class="sectionIcon" ?hidden=${icon.name.length === 0}>
<gr-icon
icon=${icon.name}
class=${icon.name}
?filled=${icon.filled}
></gr-icon>
</div>
<div class="sectionContent">
<h3 class="name heading-3">
<span>${this.run.checkName}</span>
</h3>
</div>
</div>
${this.renderStatusSection()} ${this.renderAttemptSection()}
${this.renderTimestampSection()} ${this.renderDescriptionSection()}
${this.renderActions()}
</div>
`;
}
private renderStatusSection() {
if (!this.run || (!this.run.statusLink && !this.run.statusDescription))
return;
return html`
<div class="section">
<div class="sectionIcon">
<gr-icon icon="info" class="small"></gr-icon>
</div>
<div class="sectionContent">
${this.run.statusLink
? html` <div class="row">
<div class="title">Status</div>
<div>
<a href=${this.run.statusLink} target="_blank"
><gr-icon
icon="open_in_new"
aria-label="external link to check status"
class="small link"
></gr-icon
>${this.computeHostName(this.run.statusLink)}
</a>
</div>
</div>`
: ''}
${this.run.statusDescription
? html` <div class="row">
<div class="title">Message</div>
<div>${this.run.statusDescription}</div>
</div>`
: ''}
</div>
</div>
`;
}
private renderAttemptSection() {
if (this.hideAttempts()) return;
const attempts = this.computeAttempts();
return html`
<div class="section">
<div class="sectionIcon">
<gr-icon icon="arrow_forward" class="small"></gr-icon>
</div>
<div class="sectionContent">
<div class="attempts row">
<div class="title">Attempt</div>
${attempts.map(a => this.renderAttempt(a))}
</div>
</div>
</div>
`;
}
private renderAttempt(attempt: AttemptDetail) {
const attemptNumber = attempt.attempt;
const icon = attempt.icon ?? {name: ''};
if (attemptNumber !== undefined && typeof attemptNumber !== 'number') {
return;
}
return html`
<div>
<div class="attemptIcon">
<gr-icon class=${icon.name} icon=${icon.name} ?filled=${icon.filled}>
</gr-icon>
</div>
<div class="attemptNumber">${ordinal(attemptNumber)}</div>
</div>
`;
}
private renderTimestampSection() {
if (
!this.run ||
(!this.run.startedTimestamp &&
!this.run.scheduledTimestamp &&
!this.run.finishedTimestamp)
)
return;
const scheduled =
this.run.scheduledTimestamp && !this.run.startedTimestamp
? html`<div class="row">
<div class="title">Scheduled</div>
<div>${fromNow(this.run.scheduledTimestamp)}</div>
</div>`
: '';
const started = this.run.startedTimestamp
? html`<div class="row">
<div class="title">Started</div>
<div>${fromNow(this.run.startedTimestamp)}</div>
</div>`
: '';
const finished =
this.run.finishedTimestamp && this.run.status === RunStatus.COMPLETED
? html`<div class="row">
<div class="title">Ended</div>
<div>${fromNow(this.run.finishedTimestamp)}</div>
</div>`
: '';
const completed =
this.run.startedTimestamp &&
this.run.finishedTimestamp &&
this.run.status === RunStatus.COMPLETED
? html`<div class="row">
<div class="title">Completion</div>
<div>
${durationString(
this.run.startedTimestamp,
this.run.finishedTimestamp,
true
)}
</div>
</div>`
: '';
const eta =
this.run.finishedTimestamp && this.run.status === RunStatus.RUNNING
? html`<div class="row">
<div class="title">ETA</div>
<div>
${durationString(new Date(), this.run.finishedTimestamp, true)}
</div>
</div>`
: '';
return html`
<div class="section">
<div class="sectionIcon">
<gr-icon icon="schedule" class="small"></gr-icon>
</div>
<div class="sectionContent">
${scheduled} ${started} ${finished} ${completed} ${eta}
</div>
</div>
`;
}
private renderDescriptionSection() {
if (!this.run || (!this.run.checkLink && !this.run.checkDescription))
return;
return html`
<div class="section">
<div class="sectionIcon">
<gr-icon icon="link" class="small"></gr-icon>
</div>
<div class="sectionContent">
${this.run.checkDescription
? html` <div class="row">
<div class="title">Description</div>
<div>${this.run.checkDescription}</div>
</div>`
: ''}
${this.run.checkLink
? html` <div class="row">
<div class="title">Documentation</div>
<div>
<a href=${this.run.checkLink} target="_blank"
><gr-icon
icon="open_in_new"
aria-label="external link to check documentation"
class="small link"
></gr-icon
>${this.computeHostName(this.run.checkLink)}
</a>
</div>
</div>`
: ''}
</div>
</div>
`;
}
private renderActions() {
const actions = runActions(this.run);
return actions.map(
action =>
html`
<div class="action">
<gr-checks-action
context="hovercard"
.eventTarget=${this._target}
.action=${action}
></gr-checks-action>
</div>
`
);
}
computeIcon(): ChecksIcon {
if (!this.run) return {name: ''};
const category = worstCategory(this.run);
if (category) return iconFor(category);
return this.run.status === RunStatus.COMPLETED
? iconFor(RunStatus.COMPLETED)
: {name: ''};
}
computeAttempts(): AttemptDetail[] {
const details: AttemptDetail[] = this.run?.attemptDetails ?? [];
const more: AttemptDetail[] =
details.length > 7
? [{icon: {name: 'more_horiz'}, attempt: undefined}]
: [];
return [...more, ...details.slice(-7)];
}
private computeChipIcon(): ChecksIcon {
if (this.run?.status === RunStatus.COMPLETED) {
return {name: 'check'};
}
if (this.run?.status === RunStatus.RUNNING) {
return iconFor(RunStatus.RUNNING);
}
if (this.run?.status === RunStatus.SCHEDULED) {
return iconFor(RunStatus.SCHEDULED);
}
return {name: ''};
}
private computeHostName(link?: string) {
return link ? new URL(link).hostname : '';
}
private hideAttempts() {
const attemptCount = this.run?.attemptDetails?.length;
return attemptCount === undefined || attemptCount < 2;
}
}
declare global {
interface HTMLElementTagNameMap {
'gr-hovercard-run': GrHovercardRun;
}
}