blob: 5db82f6341bb6e3cbc4ee01202f7b9e7fab33964 [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 {
axisBottom,
axisLeft,
axisTop,
BaseType,
scaleLinear,
scaleTime,
select as d3Select,
Selection,
timeMillisecond,
} from 'd3';
import { css, customElement, html, property } from 'lit-element';
import { render } from 'lit-html';
import { DateTime } from 'luxon';
import { autorun, observable } from 'mobx';
import '../../components/dot_spinner';
import { MiloBaseElement } from '../../components/milo_base';
import { HideTooltipEventDetail, ShowTooltipEventDetail } from '../../components/tooltip';
import { AppState, consumeAppState } from '../../context/app_state';
import { BuildState, consumeBuildState } from '../../context/build_state';
import { GA_ACTIONS, GA_CATEGORIES, trackEvent } from '../../libs/analytics_utils';
import { BUILD_STATUS_CLASS_MAP } from '../../libs/constants';
import { consumer } from '../../libs/context';
import { errorHandler, forwardWithoutMsg, reportError, reportRenderError } from '../../libs/error_handler';
import { enumerate } from '../../libs/iter_utils';
import { displayDuration, NUMERIC_TIME_FORMAT } from '../../libs/time_utils';
import { StepExt } from '../../models/step_ext';
import commonStyle from '../../styles/common_style.css';
const MARGIN = 10;
const TOP_AXIS_HEIGHT = 35;
const BOTTOM_AXIS_HEIGHT = 25;
const BORDER_SIZE = 1;
const HALF_BORDER_SIZE = BORDER_SIZE / 2;
const ROW_HEIGHT = 30;
const STEP_HEIGHT = 24;
const STEP_MARGIN = (ROW_HEIGHT - STEP_HEIGHT) / 2 - HALF_BORDER_SIZE;
const STEP_EXTRA_WIDTH = 2;
const TEXT_HEIGHT = 10;
const STEP_TEXT_OFFSET = ROW_HEIGHT / 2 + TEXT_HEIGHT / 2;
const TEXT_MARGIN = 10;
const SIDE_PANEL_WIDTH = 400;
const MIN_GRAPH_WIDTH = 500 + SIDE_PANEL_WIDTH;
const SIDE_PANEL_RECT_WIDTH = SIDE_PANEL_WIDTH - STEP_MARGIN * 2 - BORDER_SIZE * 2;
const STEP_IDENT = 15;
const LIST_ITEM_WIDTH = SIDE_PANEL_RECT_WIDTH - TEXT_MARGIN * 2;
const LIST_ITEM_HEIGHT = 16;
const LIST_ITEM_X_OFFSET = STEP_MARGIN + TEXT_MARGIN + BORDER_SIZE;
const LIST_ITEM_Y_OFFSET = STEP_MARGIN + (STEP_HEIGHT - LIST_ITEM_HEIGHT) / 2;
const V_GRID_LINE_MAX_GAP = 80;
const PREDEFINED_TIME_INTERVALS = [
// Values that can divide 1 day.
86400000, // 24hr
43200000, // 12hr
28800000, // 8hr
// Values that can divide 12 hours.
21600000, // 6hr
14400000, // 4hr
10800000, // 3hr
7200000, // 2hr
3600000, // 1hr
// Values that can divide 1 hour.
1800000, // 30min
1200000, // 20min
900000, // 15min
600000, // 10min
// Values that can divide 15 minutes.
300000, // 5min
180000, // 3min
120000, // 2min
60000, // 1min
// Values that can divide 1 minute.
30000, // 30s
20000, // 20s
15000, // 15s
10000, // 10s
// Values that can divide 15 seconds.
5000, // 5s
3000, // 3s
2000, // 2s
1000, // 1s
];
/**
* A utility function that helps assigning appropriate list numbers to steps.
* For example, if a step is the 1st child of the 2nd root step, the list number
* would be '2.1. '.
*
* @param rootSteps a list of root steps.
* @yields A tuple consist of the list number and the step.
*/
export function* traverseStepList(rootSteps: readonly StepExt[], prefix = ''): IterableIterator<[string, StepExt]> {
for (const step of rootSteps.values()) {
const listNum = prefix + (step.index + 1) + '.';
yield [listNum, step];
yield* traverseStepList(step.children, listNum);
}
}
@customElement('milo-timeline-tab')
@errorHandler(forwardWithoutMsg)
@consumer
export class TimelineTabElement extends MiloBaseElement {
@observable.ref
@consumeAppState()
appState!: AppState;
@observable.ref
@consumeBuildState()
buildState!: BuildState;
@observable.ref private totalWidth!: number;
@observable.ref private bodyWidth!: number;
// Don't set them as observable. When render methods update them, we don't
// want autorun to trigger this.renderTimeline() again.
@property() private headerEle!: HTMLDivElement;
@property() private footerEle!: HTMLDivElement;
@property() private sidePanelEle!: HTMLDivElement;
@property() private bodyEle!: HTMLDivElement;
// Properties shared between render methods.
private bodyHeight!: number;
private scaleTime!: d3.ScaleTime<number, number, never>;
private scaleStep!: d3.ScaleLinear<number, number, never>;
private timeInterval!: d3.TimeInterval;
private readonly nowTimestamp = Date.now();
private readonly now = DateTime.fromMillis(this.nowTimestamp);
private relativeTimeText!: Selection<SVGTextElement, unknown, null, undefined>;
connectedCallback() {
super.connectedCallback();
this.appState.selectedTabId = 'timeline';
trackEvent(GA_CATEGORIES.TIMELINE_TAB, GA_ACTIONS.TAB_VISITED, window.location.href);
const syncWidth = () => {
this.totalWidth = Math.max(window.innerWidth - 2 * MARGIN, MIN_GRAPH_WIDTH);
this.bodyWidth = this.totalWidth - SIDE_PANEL_WIDTH;
};
window.addEventListener('resize', syncWidth);
this.addDisposer(() => window.removeEventListener('resize', syncWidth));
syncWidth();
this.addDisposer(autorun(() => this.renderTimeline()));
}
protected render = reportRenderError.bind(this)(() => {
if (!this.buildState.build) {
return html`<div id="load">Loading <milo-dot-spinner></milo-load-spinner></div>`;
}
if (this.buildState.build.steps.length === 0) {
return html`<div id="no-steps">No steps were run.</div>`;
}
return html`<div id="timeline">${this.sidePanelEle}${this.headerEle}${this.bodyEle}${this.footerEle}</div>`;
});
private renderTimeline = reportError.bind(this)(() => {
const build = this.buildState.build;
if (!build || !build.startTime || build.steps.length === 0) {
return;
}
const startTime = build.startTime.toMillis();
const endTime = build.endTime?.toMillis() || this.nowTimestamp;
this.bodyHeight = build.steps.length * ROW_HEIGHT - BORDER_SIZE;
const padding = Math.ceil(((endTime - startTime) * STEP_EXTRA_WIDTH) / this.bodyWidth) / 2;
// Calc attributes shared among components.
this.scaleTime = scaleTime()
// Add a bit of padding to ensure everything renders in the viewport.
.domain([startTime - padding, endTime + padding])
// Ensure the right border is rendered within the viewport, while the left
// border overlaps with the right border of the side-panel.
.range([-HALF_BORDER_SIZE, this.bodyWidth - HALF_BORDER_SIZE]);
this.scaleStep = scaleLinear()
.domain([0, build.steps.length])
// Ensure the top and bottom borders are not rendered.
.range([-HALF_BORDER_SIZE, this.bodyHeight + HALF_BORDER_SIZE]);
const maxInterval = (endTime - startTime + 2 * padding) / (this.bodyWidth / V_GRID_LINE_MAX_GAP);
// Assign a default value here to make TSC happy.
let interval = PREDEFINED_TIME_INTERVALS[0];
// Find the largest interval that is no larger than the maximum interval.
// Use linear search because the array is relatively short.
for (const predefined of PREDEFINED_TIME_INTERVALS) {
interval = predefined;
if (maxInterval >= predefined) {
break;
}
}
this.timeInterval = timeMillisecond.every(interval)!;
// Render each component.
this.renderHeader();
this.renderFooter();
this.renderSidePanel();
this.renderBody();
});
private renderHeader() {
const build = this.buildState.build!;
this.headerEle = document.createElement('div');
const svg = d3Select(this.headerEle)
.attr('id', 'header')
.append('svg')
.attr('viewport', `0 0 ${this.totalWidth} ${TOP_AXIS_HEIGHT}`);
svg
.append('text')
.attr('x', TEXT_MARGIN)
.attr('y', TOP_AXIS_HEIGHT - TEXT_MARGIN / 2)
.attr('font-weight', '500')
.text('Build Start Time: ' + build.startTime!.toFormat(NUMERIC_TIME_FORMAT));
const headerRootGroup = svg
.append('g')
.attr('transform', `translate(${SIDE_PANEL_WIDTH}, ${TOP_AXIS_HEIGHT - HALF_BORDER_SIZE})`);
const topAxis = axisTop(this.scaleTime).ticks(this.timeInterval);
headerRootGroup.call(topAxis);
this.relativeTimeText = headerRootGroup
.append('text')
.style('opacity', 0)
.attr('id', 'relative-time')
.attr('fill', 'red')
.attr('y', -TEXT_HEIGHT - TEXT_MARGIN)
.attr('text-anchor', 'end');
// Top border for the side panel.
headerRootGroup.append('line').attr('x1', -SIDE_PANEL_WIDTH).attr('stroke', 'var(--default-text-color)');
}
private renderFooter() {
const build = this.buildState.build!;
this.footerEle = document.createElement('div');
const svg = d3Select(this.footerEle)
.attr('id', 'footer')
.append('svg')
.attr('viewport', `0 0 ${this.totalWidth} ${BOTTOM_AXIS_HEIGHT}`);
if (build.endTime) {
svg
.append('text')
.attr('x', TEXT_MARGIN)
.attr('y', TEXT_HEIGHT + TEXT_MARGIN / 2)
.attr('font-weight', '500')
.text('Build End Time: ' + build.endTime.toFormat(NUMERIC_TIME_FORMAT));
}
const footerRootGroup = svg.append('g').attr('transform', `translate(${SIDE_PANEL_WIDTH}, ${HALF_BORDER_SIZE})`);
const bottomAxis = axisBottom(this.scaleTime).ticks(this.timeInterval);
footerRootGroup.call(bottomAxis);
// Bottom border for the side panel.
footerRootGroup.append('line').attr('x1', -SIDE_PANEL_WIDTH).attr('stroke', 'var(--default-text-color)');
}
private renderSidePanel() {
const build = this.buildState.build!;
this.sidePanelEle = document.createElement('div');
const svg = d3Select(this.sidePanelEle)
.style('width', SIDE_PANEL_WIDTH + 'px')
.style('height', this.bodyHeight + 'px')
.attr('id', 'side-panel')
.append('svg')
.attr('viewport', `0 0 ${SIDE_PANEL_WIDTH} ${this.bodyHeight}`);
// Grid lines
const horizontalGridLines = axisLeft(this.scaleStep)
.ticks(build.steps.length)
.tickFormat(() => '')
.tickSize(-SIDE_PANEL_WIDTH)
.tickFormat(() => '');
svg.append('g').attr('class', 'grid').call(horizontalGridLines);
for (const [i, [listNum, step]] of enumerate(traverseStepList(build.rootSteps))) {
const stepGroup = svg
.append('g')
.attr('class', BUILD_STATUS_CLASS_MAP[step.status])
.attr('transform', `translate(0, ${i * ROW_HEIGHT})`);
const rect = stepGroup
.append('rect')
.attr('x', STEP_MARGIN + BORDER_SIZE)
.attr('y', STEP_MARGIN)
.attr('width', SIDE_PANEL_RECT_WIDTH)
.attr('height', STEP_HEIGHT);
this.installStepInteractionHandlers(rect, step);
const listItem = stepGroup
.append('foreignObject')
.attr('class', 'not-intractable')
.attr('x', LIST_ITEM_X_OFFSET + step.depth * STEP_IDENT)
.attr('y', LIST_ITEM_Y_OFFSET)
.attr('height', STEP_HEIGHT - LIST_ITEM_Y_OFFSET)
.attr('width', LIST_ITEM_WIDTH);
listItem.append('xhtml:span').text(listNum + ' ');
const stepText = listItem.append('xhtml:span').text(step.selfName);
if (step.logs?.[0].viewUrl) {
stepText.attr('class', 'hyperlink');
}
}
// Left border.
svg
.append('line')
.attr('x1', HALF_BORDER_SIZE)
.attr('x2', HALF_BORDER_SIZE)
.attr('y2', this.bodyHeight)
.attr('stroke', 'var(--default-text-color)');
// Right border.
svg
.append('line')
.attr('x1', SIDE_PANEL_WIDTH - HALF_BORDER_SIZE)
.attr('x2', SIDE_PANEL_WIDTH - HALF_BORDER_SIZE)
.attr('y2', this.bodyHeight)
.attr('stroke', 'var(--default-text-color)');
}
private renderBody() {
const build = this.buildState.build!;
this.bodyEle = document.createElement('div');
const svg = d3Select(this.bodyEle)
.attr('id', 'body')
.style('width', this.bodyWidth + 'px')
.style('height', this.bodyHeight + 'px')
.append('svg')
.attr('viewport', `0 0 ${this.bodyWidth} ${this.bodyHeight}`);
// Grid lines
const verticalGridLines = axisTop(this.scaleTime)
.ticks(this.timeInterval)
.tickSize(-this.bodyHeight)
.tickFormat(() => '');
svg.append('g').attr('class', 'grid').call(verticalGridLines);
const horizontalGridLines = axisLeft(this.scaleStep)
.ticks(build.steps.length)
.tickFormat(() => '')
.tickSize(-this.bodyWidth)
.tickFormat(() => '');
svg.append('g').attr('class', 'grid').call(horizontalGridLines);
for (const [i, [listNum, step]] of enumerate(traverseStepList(build.rootSteps))) {
const start = this.scaleTime(step.startTime?.toMillis() || this.nowTimestamp);
const end = this.scaleTime(step.endTime?.toMillis() || this.nowTimestamp);
const stepGroup = svg
.append('g')
.attr('class', BUILD_STATUS_CLASS_MAP[step.status])
.attr('transform', `translate(${start}, ${i * ROW_HEIGHT})`);
// Add extra width so tiny steps are visible.
const width = end - start + STEP_EXTRA_WIDTH;
stepGroup
.append('rect')
.attr('x', -STEP_EXTRA_WIDTH / 2)
.attr('y', STEP_MARGIN)
.attr('width', width)
.attr('height', STEP_HEIGHT);
const isWide = width > this.bodyWidth * 0.33;
const nearEnd = end > this.bodyWidth * 0.66;
const stepText = stepGroup
.append('text')
.attr('text-anchor', isWide || !nearEnd ? 'start' : 'end')
.attr('x', isWide ? TEXT_MARGIN : nearEnd ? -TEXT_MARGIN : width + TEXT_MARGIN)
.attr('y', STEP_TEXT_OFFSET)
.text(listNum + ' ' + step.selfName);
// Wail until the next event cycle so stepText is rendered when we call
// this.getBBox();
window.setTimeout(() => {
// Rebind this so we can access it in the function below.
const timelineTab = this; // eslint-disable-line @typescript-eslint/no-this-alias
stepText.each(function () {
const textBBox = this.getBBox();
const x1 = Math.min(textBBox.x, -STEP_EXTRA_WIDTH / 2);
const x2 = Math.max(textBBox.x + textBBox.width, STEP_MARGIN + width);
// This makes the step text easier to interact with.
const eventTargetRect = stepGroup
.append('rect')
.attr('x', x1)
.attr('y', STEP_MARGIN)
.attr('width', x2 - x1)
.attr('height', STEP_HEIGHT)
.attr('class', 'invisible');
timelineTab.installStepInteractionHandlers(eventTargetRect, step);
});
}, 10);
}
const yRuler = svg
.append('line')
.style('opacity', 0)
.attr('stroke', 'red')
.attr('pointer-events', 'none')
.attr('y1', 0)
.attr('y2', this.bodyHeight);
let svgBox: DOMRect | null = null;
svg.on('mouseover', () => {
this.relativeTimeText.style('opacity', 1);
yRuler.style('opacity', 1);
});
svg.on('mouseout', () => {
this.relativeTimeText.style('opacity', 0);
yRuler.style('opacity', 0);
});
svg.on('mousemove', (e: MouseEvent) => {
if (svgBox === null) {
svgBox = svg.node()!.getBoundingClientRect();
}
const x = e.pageX - svgBox.x;
yRuler.attr('x1', x);
yRuler.attr('x2', x);
const time = DateTime.fromJSDate(this.scaleTime.invert(x));
const duration = time.diff(build.startTime!);
this.relativeTimeText.attr('x', x);
this.relativeTimeText.text(displayDuration(duration) + ' since build start');
});
// Right border.
svg
.append('line')
.attr('x1', this.bodyWidth - HALF_BORDER_SIZE)
.attr('x2', this.bodyWidth - HALF_BORDER_SIZE)
.attr('y2', this.bodyHeight)
.attr('stroke', 'var(--default-text-color)');
}
/**
* Installs handlers for interacting with a step object.
*/
private installStepInteractionHandlers<T extends BaseType>(
ele: Selection<T, unknown, null, undefined>,
step: StepExt
) {
const logUrl = step.logs?.[0].viewUrl;
if (logUrl) {
ele.attr('class', ele.attr('class') + ' clickable').on('click', (e: MouseEvent) => {
e.stopPropagation();
window.open(logUrl, '_blank');
});
}
ele
.on('mouseover', (e: MouseEvent) => {
const tooltip = document.createElement('div');
render(
html`
<table>
<tr>
<td colspan="2">${logUrl ? 'Click to open associated log.' : html`<b>No associated log.</b>`}</td>
</tr>
<tr>
<td>Started:</td>
<td>
${(step.startTime || this.now).toFormat(NUMERIC_TIME_FORMAT)}
(after ${displayDuration((step.startTime || this.now).diff(this.buildState.build!.startTime!))})
</td>
</tr>
<tr>
<td>Ended:</td>
<td>${
step.endTime
? step.endTime.toFormat(NUMERIC_TIME_FORMAT) +
` (after ${displayDuration(step.endTime.diff(this.buildState.build!.startTime!))})`
: 'N/A'
}</td>
</tr>
<tr>
<td>Duration:</td>
<td>${displayDuration(step.duration)}</td>
</tr>
</div>
`,
tooltip
);
window.dispatchEvent(
new CustomEvent<ShowTooltipEventDetail>('show-tooltip', {
detail: {
tooltip,
targetRect: (e.target as HTMLElement).getBoundingClientRect(),
gapSize: 5,
},
})
);
})
.on('mouseout', () => {
window.dispatchEvent(new CustomEvent<HideTooltipEventDetail>('hide-tooltip', { detail: { delay: 0 } }));
});
}
static styles = [
commonStyle,
css`
:host {
display: block;
margin: ${MARGIN}px;
}
#load {
color: var(--active-text-color);
}
#timeline {
display: grid;
grid-template-rows: ${TOP_AXIS_HEIGHT}px 1fr ${BOTTOM_AXIS_HEIGHT}px;
grid-template-columns: ${SIDE_PANEL_WIDTH}px 1fr;
grid-template-areas:
'header header'
'side-panel body'
'footer footer';
margin-top: ${-MARGIN}px;
}
#header {
grid-area: header;
position: sticky;
top: 0;
background: white;
z-index: 2;
}
#footer {
grid-area: footer;
position: sticky;
bottom: 0;
background: white;
z-index: 2;
}
#side-panel {
grid-area: side-panel;
z-index: 1;
font-weight: 500;
}
#body {
grid-area: body;
}
#body path.domain {
stroke: none;
}
svg {
width: 100%;
height: 100%;
}
text {
fill: var(--default-text-color);
}
#relative-time {
fill: red;
}
.grid line {
stroke: var(--divider-color);
}
.clickable {
cursor: pointer;
}
.not-intractable {
pointer-events: none;
}
.hyperlink {
text-decoration: underline;
}
.scheduled > rect {
stroke: var(--scheduled-color);
fill: var(--scheduled-bg-color);
}
.started > rect {
stroke: var(--started-color);
fill: var(--started-bg-color);
}
.success > rect {
stroke: var(--success-color);
fill: var(--success-bg-color);
}
.failure > rect {
stroke: var(--failure-color);
fill: var(--failure-bg-color);
}
.infra-failure > rect {
stroke: var(--critical-failure-color);
fill: var(--critical-failure-bg-color);
}
.canceled > rect {
stroke: var(--canceled-color);
fill: var(--canceled-bg-color);
}
.invisible {
opacity: 0;
}
`,
];
}