blob: c6ca0247a2d1f5b841b1c6548d764cb387e74dcd [file] [log] [blame]
// Copyright 2020 The LUCI Authors.
//
// 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 { MobxLitElement } from '@adobe/lit-mobx';
import { GrpcError, RpcCode } from '@chopsui/prpc-client';
import '@material/mwc-button';
import '@material/mwc-dialog';
import '@material/mwc-icon';
import { BeforeEnterObserver, PreventAndRedirectCommands, Router, RouterLocation } from '@vaadin/router';
import { css, customElement, html } from 'lit-element';
import merge from 'lodash-es/merge';
import { autorun, computed, observable, reaction } from 'mobx';
import { REJECTED } from 'mobx-utils';
import '../../components/status_bar';
import '../../components/tab_bar';
import { TabDef } from '../../components/tab_bar';
import { AppState, consumeAppState } from '../../context/app_state';
import { BuildState, provideBuildState } from '../../context/build_state';
import { InvocationState, provideInvocationState } from '../../context/invocation_state';
import { consumeConfigsStore, DEFAULT_USER_CONFIGS, UserConfigs, UserConfigsStore } from '../../context/user_configs';
import { getGitilesRepoURL, getLegacyURLForBuild, getURLForBuilder, getURLForProject } from '../../libs/build_utils';
import { BUILD_STATUS_CLASS_MAP, BUILD_STATUS_COLOR_MAP, BUILD_STATUS_DISPLAY_MAP } from '../../libs/constants';
import { displayDuration, LONG_TIME_FORMAT } from '../../libs/time_utils';
import { genFeedbackUrl } from '../../libs/utils';
import { NOT_FOUND_URL, router } from '../../routes';
import { BuilderID, BuildStatus } from '../../services/buildbucket';
const STATUS_FAVICON_MAP = Object.freeze({
[BuildStatus.Scheduled]: 'gray',
[BuildStatus.Started]: 'yellow',
[BuildStatus.Success]: 'green',
[BuildStatus.Failure]: 'red',
[BuildStatus.InfraFailure]: 'purple',
[BuildStatus.Canceled]: 'teal',
});
// An array of [buildTabName, buildTabLabel] tuples.
// Use an array of tuples instead of an Object to ensure order.
const TAB_NAME_LABEL_TUPLES = Object.freeze([
Object.freeze(['build-overview', 'Overview']),
Object.freeze(['build-test-results', 'Test Results']),
Object.freeze(['build-steps', 'Steps & Logs']),
Object.freeze(['build-related-builds', 'Related Builds']),
Object.freeze(['build-timeline', 'Timeline']),
Object.freeze(['build-blamelist', 'Blamelist']),
]);
/**
* Main build page.
* Reads project, bucket, builder and build from URL params.
* If any of the parameters are not provided, redirects to '/not-found'.
* If build is not a number, shows an error.
*/
export class BuildPageElement extends MobxLitElement implements BeforeEnterObserver {
@observable.ref appState!: AppState;
@observable.ref configsStore!: UserConfigsStore;
@observable.ref buildState!: BuildState;
@observable.ref invocationState!: InvocationState;
// Set to true when testing.
// Otherwise this.render() will throw due to missing initialization steps in
// this.onBeforeEnter.
@observable.ref prerender = false;
@observable private readonly uncommittedConfigs: UserConfigs = merge({}, DEFAULT_USER_CONFIGS);
@observable.ref private showFeedbackDialog = false;
private builder!: BuilderID;
private buildNumOrId = '';
private get legacyUrl() {
return getLegacyURLForBuild(this.builder, this.buildNumOrId);
}
onBeforeEnter(location: RouterLocation, cmd: PreventAndRedirectCommands) {
const project = location.params['project'];
const bucket = location.params['bucket'];
const builder = location.params['builder'];
const buildNumOrId = location.params['build_num_or_id'];
if ([project, bucket, builder, buildNumOrId].some((param) => typeof param !== 'string')) {
return cmd.redirect(NOT_FOUND_URL);
}
this.builder = {
project: project as string,
bucket: bucket as string,
builder: builder as string,
};
this.buildNumOrId = buildNumOrId as string;
document.title = `${builder} ${buildNumOrId}`;
return;
}
private disposers: Array<() => void> = [];
@computed private get faviconUrl() {
if (this.buildState.build) {
return `/static/common/favicon/${STATUS_FAVICON_MAP[this.buildState.build.status]}-32.png`;
}
return `/static/common/favicon/milo-32.png`;
}
connectedCallback() {
super.connectedCallback();
this.appState.hasSettingsDialog = true;
this.disposers.push(reaction(
() => [this.appState],
() => {
this.buildState?.dispose();
this.buildState = new BuildState(this.appState);
this.buildState.builder = this.builder;
this.buildState.buildNumOrId = this.buildNumOrId;
// Emulate @property() update.
this.updated(new Map([['buildState', this.buildState]]));
},
{fireImmediately: true},
));
this.disposers.push(() => this.buildState.dispose());
this.disposers.push(autorun(
() => {
if (this.buildState.build$.state !== REJECTED) {
return;
}
const err = this.buildState.build$.value as GrpcError;
// If the build is not found and the user is not logged in, redirect
// them to the login page.
if (err.code === RpcCode.NOT_FOUND && this.appState.accessToken === '') {
Router.go(`${router.urlForName('login')}?${new URLSearchParams([['redirect', window.location.href]])}`);
return;
}
this.dispatchEvent(new ErrorEvent('error', {
message: this.buildState.build$.value.toString(),
composed: true,
bubbles: true,
}));
},
));
this.disposers.push(reaction(
() => this.appState,
(appState) => {
this.invocationState?.dispose();
this.invocationState = new InvocationState(appState);
this.invocationState.invocationId = this.buildState.invocationId;
// Emulate @property() update.
this.updated(new Map([['invocationState', this.invocationState]]));
},
{fireImmediately: true},
));
this.disposers.push(() => this.invocationState.dispose());
this.disposers.push(reaction(
() => this.buildState.invocationId,
(invId) => this.invocationState.invocationId = invId,
{fireImmediately: true},
));
this.disposers.push(reaction(
() => this.invocationState.invocation$.state,
() => {
if (this.invocationState.invocation$.state !== REJECTED) {
return;
}
this.dispatchEvent(new ErrorEvent('error', {
message: this.invocationState.invocation$.value.toString(),
composed: true,
bubbles: true,
}));
},
));
this.disposers.push(autorun(() => {
const build = this.buildState.build;
if (!build) {
return;
}
// If the build has only succeeded steps, show all steps in the steps tab by
// default (even if the user's preference is to hide succeeded steps).
if (build.rootSteps.every((s) => s.status === BuildStatus.Success)) {
this.configsStore.userConfigs.steps.showSucceededSteps = true;
}
// If the input gitiles commit is in the blamelist pins, select it.
// Otherwise, select the first blamelist pin.
const buildInputCommitRepo = build.input.gitilesCommit
? getGitilesRepoURL(build.input.gitilesCommit)
: null;
let selectedBlamelistPinIndex = build.blamelistPins
.findIndex((pin) => getGitilesRepoURL(pin) === buildInputCommitRepo) || 0;
if (selectedBlamelistPinIndex === -1) {
selectedBlamelistPinIndex = 0;
}
this.appState.selectedBlamelistPinIndex = selectedBlamelistPinIndex;
}));
this.disposers.push((reaction(
() => this.faviconUrl,
(faviconUrl) => document.getElementById('favicon')?.setAttribute('href', faviconUrl),
{fireImmediately: true},
)));
// Sync uncommitted configs with committed configs.
this.disposers.push(reaction(
() => merge({}, this.configsStore.userConfigs),
(committedConfig) => merge(this.uncommittedConfigs, committedConfig),
{fireImmediately: true},
));
}
disconnectedCallback() {
this.appState.hasSettingsDialog = false;
for (const disposer of this.disposers) {
disposer();
}
super.disconnectedCallback();
}
@computed get hasInvocation() {
return this.invocationState.invocationId !== '';
}
@computed get tabDefs(): TabDef[] {
const params = {
'project': this.builder.project,
'bucket': this.builder.bucket,
'builder': this.builder.builder,
'build_num_or_id': this.buildNumOrId,
};
return [
{
id: 'overview',
label: 'Overview',
href: router.urlForName('build-overview', params),
},
// TODO(crbug/1128097): display test-results tab unconditionally once
// Foundation team is ready for ResultDB integration with other LUCI
// projects.
...!this.hasInvocation ? [] : [{
id: 'test-results',
label: 'Test Results',
href: router.urlForName('build-test-results', params),
}],
{
id: 'steps',
label: 'Steps & Logs',
href: router.urlForName('build-steps', params),
},
{
id: 'related-builds',
label: 'Related Builds',
href: router.urlForName('build-related-builds', params),
},
{
id: 'timeline',
label: 'Timeline',
href: router.urlForName('build-timeline', params),
},
{
id: 'blamelist',
label: 'Blamelist',
href: router.urlForName('build-blamelist', params),
},
];
}
@computed private get statusBarColor() {
const build = this.buildState.build;
return build ? BUILD_STATUS_COLOR_MAP[build.status] : 'var(--active-color)';
}
private renderBuildStatus() {
const build = this.buildState.build;
if (!build) {
return html``;
}
return html`
<i class="status ${BUILD_STATUS_CLASS_MAP[build.status]}">
${BUILD_STATUS_DISPLAY_MAP[build.status] || 'unknown status'}
</i>
${(() => { switch (build.status) {
case BuildStatus.Scheduled:
return `since ${build.createTime.toFormat(LONG_TIME_FORMAT)}`;
case BuildStatus.Started:
return `since ${build.startTime!.toFormat(LONG_TIME_FORMAT)}`;
case BuildStatus.Canceled:
return `after ${displayDuration(build.endTime!.diff(build.createTime))} by ${build.canceledBy}`;
case BuildStatus.Failure:
case BuildStatus.InfraFailure:
case BuildStatus.Success:
return `after ${displayDuration(build.endTime!.diff(build.startTime || build.createTime))}`;
default:
return '';
}})()}
`;
}
protected render() {
if (this.prerender) {
return html``;
}
return html`
<mwc-dialog
id="settings-dialog"
heading="Settings"
?open=${this.appState.showSettingsDialog}
@closed=${(event: CustomEvent<{action: string}>) => {
if (event.detail.action === 'save') {
merge(this.configsStore.userConfigs, this.uncommittedConfigs);
this.configsStore.save();
}
// Reset uncommitted configs.
merge(this.uncommittedConfigs, this.configsStore.userConfigs);
this.appState.showSettingsDialog = false;
}}
>
<label for="default-tab-selector">Default tab:</label>
<select
id="default-tab-selector"
@change=${(e: InputEvent) => this.uncommittedConfigs.defaultBuildPageTabName = (e.target as HTMLOptionElement).value}
>
${TAB_NAME_LABEL_TUPLES.map(([tabName, label]) => html`
<option
value=${tabName}
?selected=${tabName === this.uncommittedConfigs.defaultBuildPageTabName}
>${label}</option>
`)}
</select>
<mwc-button slot="primaryAction" dialogAction="save" dense unelevated>Save</mwc-button>
<mwc-button slot="secondaryAction" dialogAction="dismiss">Cancel</mwc-button>
</mwc-dialog>
<mwc-dialog
id="feedback-dialog"
heading="Tell Us What's Missing"
?open=${this.showFeedbackDialog}
@closed=${() => {
const noFeedbackEle = this.shadowRoot!.getElementById('no-feedback-prompt')! as HTMLInputElement;
if (noFeedbackEle.checked) {
this.configsStore.userConfigs.askForFeedback = false;
this.configsStore.save();
}
this.showFeedbackDialog = false;
window.open(this.legacyUrl, '_self');
}}
>
<div>
We'd love to make the new build page work better for everyone.<br>
Please take a moment to give us feedback before switching back to the old build page.
</div>
<br>
<input type="checkbox" id="no-feedback-prompt">
<label for="no-feedback-prompt">Don't show again</label>
<mwc-button
slot="primaryAction"
dense
unelevated
@click=${() => window.open(genFeedbackUrl())}
>Open Feedback Page</mwc-button>
<mwc-button slot="secondaryAction" dialogAction="dismiss">Proceed to legacy page</mwc-button>
</mwc-dialog>
<div id="build-summary">
<div id="build-id">
<span id="build-id-label">Build </span>
<a href=${getURLForProject(this.builder.project)}>${this.builder.project}</a>
<span>/</span>
<span>${this.builder.bucket}</span>
<span>/</span>
<a href=${getURLForBuilder(this.builder)}>${this.builder.builder}</a>
<span>/</span>
<span>${this.buildNumOrId}</span>
</div>
<div class="delimiter"></div>
<a
@click=${(e: Event) => {
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `showNewBuildPage=false; expires=${expires}; path=/`;
if (this.configsStore.userConfigs.askForFeedback) {
this.showFeedbackDialog = true;
e.preventDefault();
}
}}
href=${this.legacyUrl}
>Switch to the legacy build page</a>
<div id="build-status">${this.renderBuildStatus()}</div>
</div>
<milo-status-bar
.components=${[{color: this.statusBarColor, weight: 1}]}
.loading=${this.buildState.build$.state === 'pending'}
></milo-status-bar>
<milo-tab-bar
.tabs=${this.tabDefs}
.selectedTabId=${this.appState.selectedTabId}
></milo-tab-bar>
<slot></slot>
`;
}
static styles = css`
:host {
height: calc(100vh - var(--header-height));
display: grid;
grid-template-rows: repeat(5, auto) 1fr;
}
#build-summary {
background-color: var(--block-background-color);
padding: 6px 16px;
font-family: "Google Sans", "Helvetica Neue", sans-serif;
font-size: 14px;
display: flex;
}
#build-id {
flex: 0 auto;
font-size: 0px;
}
#build-id > * {
font-size: 14px;
}
#build-id-label {
color: var(--light-text-color);
}
#build-status {
margin-left: auto;
flex: 0 auto;
}
.delimiter {
border-left: 1px solid var(--divider-color);
width: 1px;
margin-left: 10px;
margin-right: 10px;
}
#status {
font-weight: 500;
}
.status.scheduled {
color: var(--scheduled-color);
}
.status.started {
color: var(--started-color);
}
.status.success {
color: var(--success-color);
}
.status.failure {
color: var(--failure-color);
}
.status.infra-failure {
color: var(--critical-failure-color);
}
.status.canceled {
color: var(--canceled-color);
}
milo-tab-bar {
margin: 0 10px;
padding-top: 10px;
}
#settings-dialog {
--mdc-dialog-min-width: 600px;
}
#default-tab-selector {
display: inline-block;
margin-left: 10px;
padding: .375rem .75rem;
font-size: 1rem;
line-height: 1.5;
background-clip: padding-box;
border: 1px solid var(--divider-color);
border-radius: .25rem;
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
}
`;
}
customElement('milo-build-page')(
provideInvocationState(
provideBuildState(
consumeConfigsStore(
consumeAppState(
BuildPageElement,
),
),
),
),
);