blob: 7975e3519258089922ca108abf3f2bdf37d7ff5b [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 '@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, when } from 'mobx';
import '../../components/test_count_indicator';
import '../../components/status_bar';
import '../../components/tab_bar';
import { MiloBaseElement } from '../../components/milo_base';
import { TabDef } from '../../components/tab_bar';
import { AppState, consumeAppState } from '../../context/app_state';
import { BuildState, provideBuildState } from '../../context/build_state';
import { InvocationState, provideInvocationState, QueryInvocationError } from '../../context/invocation_state';
import { consumeConfigsStore, DEFAULT_USER_CONFIGS, UserConfigs, UserConfigsStore } from '../../context/user_configs';
import { GA_ACTIONS, GA_CATEGORIES, trackEvent } from '../../libs/analytics_utils';
import { getLegacyURLPathForBuild, getURLPathForBuilder, getURLPathForProject } from '../../libs/build_utils';
import { BUILD_STATUS_CLASS_MAP, BUILD_STATUS_COLOR_MAP, BUILD_STATUS_DISPLAY_MAP } from '../../libs/constants';
import { consumer, provider } from '../../libs/context';
import { errorHandler, forwardWithoutMsg, reportRenderError } from '../../libs/error_handler';
import { displayDuration, LONG_TIME_FORMAT } from '../../libs/time_utils';
import { LoadTestVariantsError } from '../../models/test_loader';
import { NOT_FOUND_URL, router } from '../../routes';
import { BuilderID, BuildStatus, TEST_PRESENTATION_KEY } from '../../services/buildbucket';
import colorClasses from '../../styles/color_classes.css';
import commonStyle from '../../styles/common_style.css';
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']),
]);
function retryWithoutComputedInvId(err: ErrorEvent, ele: BuildPageElement) {
if (err.error instanceof LoadTestVariantsError) {
// Ignore request using the old invocation ID.
if (!err.error.req.invocations.includes(`invocations/${ele.buildState.invocationId}`)) {
err.stopPropagation();
return false;
}
// Old builds don't support computed invocation ID.
// Disable it and try again.
if (ele.buildState.useComputedInvId && !err.error.req.pageToken) {
ele.buildState.useComputedInvId = false;
err.stopPropagation();
return false;
}
} else if (err.error instanceof QueryInvocationError) {
// Ignore request using the old invocation ID.
if (err.error.invId !== ele.buildState.invocationId) {
err.stopPropagation();
return false;
}
// Old builds don't support computed invocation ID.
// Disable it and try again.
if (ele.buildState.useComputedInvId) {
ele.buildState.useComputedInvId = false;
err.stopPropagation();
return false;
}
}
return forwardWithoutMsg(err, ele);
}
/**
* 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.
*/
@customElement('milo-build-page')
@errorHandler(retryWithoutComputedInvId)
@provider
@consumer
export class BuildPageElement extends MiloBaseElement implements BeforeEnterObserver {
@observable.ref
@consumeAppState()
appState!: AppState;
@observable.ref
@consumeConfigsStore()
configsStore!: UserConfigsStore;
@observable.ref
@provideBuildState({ global: true })
buildState!: BuildState;
@observable.ref
@provideInvocationState({ global: true })
invocationState!: InvocationState;
@observable private readonly uncommittedConfigs: UserConfigs = merge({}, DEFAULT_USER_CONFIGS);
// The page is visited via a short link.
// The page will be redirected to the long link after the build is fetched.
private isShortLink = false;
private urlSuffix = '';
// builderParam is only set when the page visited via a full link.
@observable.ref private builderIdParam?: BuilderID;
@observable.ref private buildNumOrIdParam = '';
@computed private get buildNumOrId() {
return this.buildState.build?.buildNumOrId || this.buildNumOrIdParam;
}
@computed private get legacyUrl() {
return getLegacyURLPathForBuild(this.builderIdParam!, this.buildNumOrId);
}
onBeforeEnter(location: RouterLocation, cmd: PreventAndRedirectCommands) {
const buildId = location.params['build_id'];
const path = location.params['path'];
if (typeof buildId === 'string' && path instanceof Array) {
this.isShortLink = true;
this.buildNumOrIdParam = ('b' + buildId) as string;
this.urlSuffix = '/' + path.join('/') + location.search + location.hash;
return;
}
this.isShortLink = false;
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.builderIdParam = {
project: project as string,
bucket: bucket as string,
builder: builder as string,
};
this.buildNumOrIdParam = buildNumOrId as string;
return;
}
@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';
}
@computed private get documentTitle() {
const status = this.buildState.build?.status;
const statusDisplay = status ? BUILD_STATUS_DISPLAY_MAP[status] : 'loading';
return `${statusDisplay} - ${this.builderIdParam?.builder || ''} ${this.buildNumOrId}`;
}
connectedCallback() {
super.connectedCallback();
if (!this.isShortLink) {
trackEvent(GA_CATEGORIES.NEW_BUILD_PAGE, GA_ACTIONS.PAGE_VISITED, window.location.href);
}
this.appState.hasSettingsDialog++;
this.addDisposer(
reaction(
() => [this.appState],
() => {
this.buildState?.dispose();
this.buildState = new BuildState(this.appState);
this.buildState.builderIdParam = this.builderIdParam;
this.buildState.buildNumOrIdParam = this.buildNumOrIdParam;
// Emulate @property() update.
this.updated(new Map([['buildState', this.buildState]]));
},
{ fireImmediately: true }
)
);
this.addDisposer(() => this.buildState.dispose());
this.addDisposer(
reaction(
() => this.appState,
(appState) => {
this.invocationState?.dispose();
this.invocationState = new InvocationState(appState);
// Emulate @property() update.
this.updated(new Map([['invocationState', this.invocationState]]));
},
{ fireImmediately: true }
)
);
this.addDisposer(() => this.invocationState.dispose());
this.addDisposer(
autorun(() => {
this.invocationState.invocationId = this.buildState.invocationId;
this.invocationState.isComputedInvId = this.buildState.useComputedInvId;
this.invocationState.presentationConfig =
this.buildState.build?.output?.properties?.[TEST_PRESENTATION_KEY] ||
this.buildState.build?.input?.properties?.[TEST_PRESENTATION_KEY] ||
{};
this.invocationState.warning = this.buildState.build?.buildOrStepInfraFailed
? 'Test results displayed here are likely incomplete because some steps have infra failed.'
: '';
})
);
if (this.isShortLink) {
// Redirect to the long link after the build is fetched.
this.addDisposer(
when(
() => this.buildState.build !== null,
() => {
const build = this.buildState.build!;
if (build.number !== undefined) {
this.appState.setBuildId(build.builder, build.number, build.id);
}
const buildUrl = router.urlForName('build', {
project: build.builder.project,
bucket: build.builder.bucket,
builder: build.builder.builder,
build_num_or_id: build.buildNumOrId,
});
Router.go(buildUrl + this.urlSuffix);
}
)
);
// Skip rendering-related reactions.
return;
}
this.addDisposer(
reaction(
() => this.faviconUrl,
(faviconUrl) => document.getElementById('favicon')?.setAttribute('href', faviconUrl),
{ fireImmediately: true }
)
);
this.addDisposer(
reaction(
() => this.documentTitle,
(title) => (document.title = title),
{ fireImmediately: true }
)
);
// Sync uncommitted configs with committed configs.
this.addDisposer(
reaction(
() => merge({}, this.configsStore.userConfigs),
(committedConfig) => merge(this.uncommittedConfigs, committedConfig),
{ fireImmediately: true }
)
);
}
disconnectedCallback() {
this.appState.hasSettingsDialog--;
super.disconnectedCallback();
}
@computed get tabDefs(): TabDef[] {
const params = {
project: this.builderIdParam!.project,
bucket: this.builderIdParam!.bucket,
builder: this.builderIdParam!.builder,
build_num_or_id: this.buildNumOrIdParam,
};
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.invocationState.hasInvocation
? []
: [
{
id: 'test-results',
label: 'Test Results',
href: router.urlForName('build-test-results', params),
slotName: 'test-count-indicator',
},
]),
{
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 || 'unknown'
}`;
case BuildStatus.Failure:
case BuildStatus.InfraFailure:
case BuildStatus.Success:
return `after ${displayDuration(build.endTime!.diff(build.startTime || build.createTime))}`;
default:
return '';
}
})()}
`;
}
protected render = reportRenderError.bind(this)(() => {
if (this.isShortLink) {
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);
}
// Reset uncommitted configs.
merge(this.uncommittedConfigs, this.configsStore.userConfigs);
this.appState.showSettingsDialog = false;
}}
>
<table>
<tr>
<td>Default tab:</td>
<td>
<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>
</td>
</tr>
<mwc-button slot="primaryAction" dialogAction="save" dense unelevated>Save</mwc-button>
<mwc-button slot="secondaryAction" dialogAction="dismiss">Cancel</mwc-button>
</table>
</mwc-dialog>
<div id="build-summary">
<div id="build-id">
<span id="build-id-label">Build </span>
<a href=${getURLPathForProject(this.builderIdParam!.project)}>${this.builderIdParam!.project}</a>
<span>&nbsp;/&nbsp;</span>
<span>${this.builderIdParam!.bucket}</span>
<span>&nbsp;/&nbsp;</span>
<a href=${getURLPathForBuilder(this.builderIdParam!)}>${this.builderIdParam!.builder}</a>
<span>&nbsp;/&nbsp;</span>
<span>${this.buildNumOrId}</span>
</div>
${this.buildState.customBugLink === null
? html``
: html`
<div class="delimiter"></div>
<a href=${this.buildState.customBugLink} target="_blank">File a bug</a>
`}
${this.appState.redirectSw === null
? html``
: html`
<div class="delimiter"></div>
<a
@click=${(e: MouseEvent) => {
const switchVerTemporarily = e.metaKey || e.shiftKey || e.ctrlKey || e.altKey;
trackEvent(
GA_CATEGORIES.LEGACY_BUILD_PAGE,
switchVerTemporarily ? GA_ACTIONS.SWITCH_VERSION_TEMP : GA_ACTIONS.SWITCH_VERSION,
window.location.href
);
if (switchVerTemporarily) {
return;
}
const expires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toUTCString();
document.cookie = `showNewBuildPage=false; expires=${expires}; path=/`;
this.appState.redirectSw?.unregister();
}}
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}
></milo-status-bar>
<milo-tab-bar .tabs=${this.tabDefs} .selectedTabId=${this.appState.selectedTabId}>
<milo-test-count-indicator slot="test-count-indicator"></milo-test-count-indicator>
</milo-tab-bar>
<slot></slot>
`;
});
static styles = [
commonStyle,
colorClasses,
css`
#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;
}
milo-tab-bar {
margin: 0 10px;
padding-top: 10px;
}
milo-test-count-indicator {
margin-right: -13px;
}
#settings-dialog {
--mdc-dialog-min-width: 600px;
}
#default-tab-selector {
display: inline-block;
margin-left: 10px;
padding: 0.375rem 0.75rem;
font-size: 1rem;
line-height: 1.5;
background-clip: padding-box;
border: 1px solid var(--divider-color);
border-radius: 0.25rem;
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
}
`,
];
}