chrome-devtools-frontend
Version:
Chrome DevTools UI
375 lines (336 loc) • 12.9 kB
text/typescript
// Copyright 2021 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import * as i18n from '../../core/i18n/i18n.js';
import * as SDK from '../../core/sdk/sdk.js';
import * as Protocol from '../../generated/protocol.js';
import * as UI from '../../ui/legacy/legacy.js';
import {html, type LitTemplate, nothing, render} from '../../ui/lit/lit.js';
import * as VisualLogging from '../../ui/visual_logging/visual_logging.js';
import serviceWorkerUpdateCycleViewStyles from './serviceWorkerUpdateCycleView.css.js';
const UIStrings = {
/**
* @description Text in Indexed DBViews of the Application panel
*/
version: 'Version',
/**
* @description Table heading for Service Workers update information. Update is a noun.
*/
updateActivity: 'Update Activity',
/**
* @description Title for the timeline tab.
*/
timeline: 'Timeline',
/**
* @description Text in Service Workers Update Life Cycle
* @example {2} PH1
*/
startTimeS: 'Start time: {PH1}',
/**
* @description Text for end time of an event
* @example {2} PH1
*/
endTimeS: 'End time: {PH1}',
} as const;
const str_ = i18n.i18n.registerUIStrings('panels/application/ServiceWorkerUpdateCycleView.ts', UIStrings);
const i18nString = i18n.i18n.getLocalizedString.bind(undefined, str_);
export interface ViewInput {
timeRanges: ServiceWorkerUpdateRange[];
expandedRows: Set<string>;
onFocus: (event: Event) => void;
onKeydown: (event: Event, key: string) => void;
onClick: (event: Event, key: string) => void;
}
export type View = (input: ViewInput, output: unknown, target: HTMLElement) => void;
export const DEFAULT_VIEW: View = (input, _output, target) => {
let tableRows: LitTemplate|typeof nothing = nothing;
if (input.timeRanges.length > 0) {
const startTimes = input.timeRanges.map(r => r.start);
const endTimes = input.timeRanges.map(r => r.end);
const startTime = startTimes.reduce((a, b) => Math.min(a, b));
const endTime = endTimes.reduce((a, b) => Math.max(a, b));
const scale = 100 / (endTime - startTime);
tableRows = html`${input.timeRanges.map(range => {
const phaseName = range.phase;
const left = (scale * (range.start - startTime));
const right = (scale * (endTime - range.end));
const key = `${range.id}-${range.phase}`;
const expanded = input.expandedRows.has(key);
// clang-format off
return html`
<tr class="service-worker-update-timeline" jslog=${VisualLogging.treeItem('update-timeline').track({
click: true,
resize: true,
keydown: 'ArrowLeft|ArrowRight|ArrowUp|ArrowDown|Enter|Space',
})}>
<td class="service-worker-update-timing-bar-clickable" tabindex="0" role="switch"
aria-checked=${expanded ? 'true' : 'false'}
=${input.onFocus}
=${(e: Event) => input.onKeydown(e, key)}
=${(e: Event) => input.onClick(e, key)}
jslog=${VisualLogging.expand('timing-info').track({click: true})}>
#${range.id}
</td>
<td>${phaseName}</td>
<td>
<div class="service-worker-update-timing-row">
<span class="service-worker-update-timing-bar ${phaseName.toLowerCase()}"
style="left: ${left}%; right: ${right}%;">\u200B</span>
</div>
</td>
</tr>
<tr class="service-worker-update-timing-bar-details ${expanded ? 'service-worker-update-timing-bar-details-expanded' : 'service-worker-update-timing-bar-details-collapsed'}" tabindex="0">
<td colspan="3"><span>${i18nString(UIStrings.startTimeS, {PH1: new Date(range.start).toISOString()})}</span></td>
</tr>
<tr class="service-worker-update-timing-bar-details ${expanded ? 'service-worker-update-timing-bar-details-expanded' : 'service-worker-update-timing-bar-details-collapsed'}" tabindex="0">
<td colspan="3"><span>${i18nString(UIStrings.endTimeS, {PH1: new Date(range.end).toISOString()})}</span></td>
</tr>
`;
// clang-format on
})}`;
}
// clang-format off
render(html`
<style>${serviceWorkerUpdateCycleViewStyles}</style>
<table class="service-worker-update-timing-table" jslog=${VisualLogging.tree('update-timing-table')}>
<tr class="service-worker-update-timing-table-header">
<td>${i18nString(UIStrings.version)}</td>
<td>${i18nString(UIStrings.updateActivity)}</td>
<td>${i18nString(UIStrings.timeline)}</td>
</tr>
${tableRows}
</table>
`, target);
// clang-format on
};
export class ServiceWorkerUpdateCycleView extends UI.Widget.Widget {
#registration?: SDK.ServiceWorkerManager.ServiceWorkerRegistration;
private rows: HTMLTableRowElement[];
private selectedRowIndex: number;
private expandedRows = new Set<string>();
#view: View;
constructor(element?: HTMLElement, view = DEFAULT_VIEW) {
super(element);
this.#view = view;
this.rows = [];
this.selectedRowIndex = -1;
}
set registration(registration: SDK.ServiceWorkerManager.ServiceWorkerRegistration|undefined) {
this.#registration = registration;
this.requestUpdate();
}
get registration(): SDK.ServiceWorkerManager.ServiceWorkerRegistration|undefined {
return this.#registration;
}
set registrationFingerprint(_fingerprint: symbol|undefined) {
this.requestUpdate();
}
calculateServiceWorkerUpdateRanges(): ServiceWorkerUpdateRange[] {
function addRange(ranges: ServiceWorkerUpdateRange[], range: ServiceWorkerUpdateRange): void {
if (range.start < Number.MAX_VALUE && range.start <= range.end) {
ranges.push(range);
}
}
/**
* Add ranges representing Install, Wait or Activate of a sw version represented by id.
*/
function addNormalizedRanges(
ranges: ServiceWorkerUpdateRange[], id: string, startInstallTime: number, endInstallTime: number,
startActivateTime: number, endActivateTime: number,
status: Protocol.ServiceWorker.ServiceWorkerVersionStatus): void {
addRange(ranges, {id, phase: ServiceWorkerUpdateNames.INSTALL, start: startInstallTime, end: endInstallTime});
if (status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activating ||
status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activated ||
status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Redundant) {
addRange(ranges, {
id,
phase: ServiceWorkerUpdateNames.WAIT,
start: endInstallTime,
end: startActivateTime,
});
addRange(
ranges, {id, phase: ServiceWorkerUpdateNames.ACTIVATE, start: startActivateTime, end: endActivateTime});
}
}
function rangesForVersion(version: SDK.ServiceWorkerManager.ServiceWorkerVersion): ServiceWorkerUpdateRange[] {
let state: SDK.ServiceWorkerManager.ServiceWorkerVersionState|null = version.currentState;
let endActivateTime = 0;
let beginActivateTime = 0;
let endInstallTime = 0;
let beginInstallTime = 0;
const currentStatus = state.status;
if (currentStatus === Protocol.ServiceWorker.ServiceWorkerVersionStatus.New) {
return [];
}
while (state) {
// find the earliest timestamp of different stage on record.
if (state.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activated) {
endActivateTime = state.lastUpdatedTimestamp;
} else if (state.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Activating) {
if (endActivateTime === 0) {
endActivateTime = state.lastUpdatedTimestamp;
}
beginActivateTime = state.lastUpdatedTimestamp;
} else if (state.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installed) {
endInstallTime = state.lastUpdatedTimestamp;
} else if (state.status === Protocol.ServiceWorker.ServiceWorkerVersionStatus.Installing) {
if (endInstallTime === 0) {
endInstallTime = state.lastUpdatedTimestamp;
}
beginInstallTime = state.lastUpdatedTimestamp;
}
state = state.previousState;
}
const ranges: ServiceWorkerUpdateRange[] = [];
addNormalizedRanges(
ranges, version.id, beginInstallTime, endInstallTime, beginActivateTime, endActivateTime, currentStatus);
return ranges;
}
if (!this.#registration) {
return [];
}
const versions = this.#registration.versionsByMode();
const modes = [
SDK.ServiceWorkerManager.ServiceWorkerVersion.Modes.ACTIVE,
SDK.ServiceWorkerManager.ServiceWorkerVersion.Modes.WAITING,
SDK.ServiceWorkerManager.ServiceWorkerVersion.Modes.INSTALLING,
SDK.ServiceWorkerManager.ServiceWorkerVersion.Modes.REDUNDANT,
];
for (const mode of modes) {
const version = versions.get(mode);
if (version) {
const ranges = rangesForVersion(version);
return ranges;
}
}
return [];
}
override performUpdate(): void {
const timeRanges = this.calculateServiceWorkerUpdateRanges();
const input: ViewInput = {
timeRanges,
expandedRows: this.expandedRows,
onFocus: this.onFocus.bind(this),
onKeydown: this.onKeydown.bind(this),
onClick: this.onClick.bind(this),
};
this.#view(input, this, this.contentElement);
this.rows =
Array.from(this.contentElement.querySelectorAll<HTMLTableRowElement>('.service-worker-update-timeline'));
if (this.selectedRowIndex >= this.rows.length) {
this.selectedRowIndex = -1;
}
}
private toggle(key: string, expanded: boolean): void {
if (expanded) {
this.expandedRows.delete(key);
} else {
this.expandedRows.add(key);
}
this.requestUpdate();
}
private onFocus(event: Event): void {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
const tr = target.parentElement as HTMLTableRowElement | null;
if (!tr) {
return;
}
this.selectedRowIndex = this.rows.indexOf(tr);
}
private onKeydown(event: Event, key: string): void {
if (!event.target) {
return;
}
const keyboardEvent = event as KeyboardEvent;
const expanded = this.expandedRows.has(key);
if (keyboardEvent.key === 'Enter' || keyboardEvent.key === ' ') {
this.toggle(key, expanded);
event.preventDefault();
return;
}
if ((!expanded && keyboardEvent.key === 'ArrowRight') || (expanded && keyboardEvent.key === 'ArrowLeft')) {
this.toggle(key, expanded);
event.preventDefault();
return;
}
if (keyboardEvent.key === 'ArrowDown') {
if (this.selectedRowIndex >= 0) {
this.selectNextRow();
} else {
this.selectFirstRow();
}
event.preventDefault();
}
if (keyboardEvent.key === 'ArrowUp') {
if (this.selectedRowIndex >= 0) {
this.selectPreviousRow();
} else {
this.selectLastRow();
}
event.preventDefault();
}
}
private focusRow(row: HTMLTableRowElement): void {
row.cells[0].focus();
}
private blurRow(row: HTMLTableRowElement): void {
row.cells[0].blur();
}
private selectFirstRow(): void {
if (this.rows.length === 0) {
return;
}
this.selectedRowIndex = 0;
this.focusRow(this.rows[0]);
}
private selectLastRow(): void {
if (this.rows.length === 0) {
return;
}
this.selectedRowIndex = this.rows.length - 1;
this.focusRow(this.rows[this.selectedRowIndex]);
}
private selectNextRow(): void {
if (this.rows.length === 0) {
return;
}
const previousRowIndex = this.selectedRowIndex;
this.selectedRowIndex++;
if (this.selectedRowIndex >= this.rows.length) {
this.selectedRowIndex = 0;
}
this.blurRow(this.rows[previousRowIndex]);
this.focusRow(this.rows[this.selectedRowIndex]);
}
private selectPreviousRow(): void {
if (this.rows.length === 0) {
return;
}
const previousRowIndex = this.selectedRowIndex;
this.selectedRowIndex--;
if (this.selectedRowIndex < 0) {
this.selectedRowIndex = this.rows.length - 1;
}
this.blurRow(this.rows[previousRowIndex]);
this.focusRow(this.rows[this.selectedRowIndex]);
}
private onClick(event: Event, key: string): void {
const expanded = this.expandedRows.has(key);
this.toggle(key, expanded);
event.preventDefault();
}
}
export const enum ServiceWorkerUpdateNames {
INSTALL = 'Install',
WAIT = 'Wait',
ACTIVATE = 'Activate',
}
export interface ServiceWorkerUpdateRange {
id: string;
phase: ServiceWorkerUpdateNames;
start: number;
end: number;
}