// Copyright (C) 2018 The Android Open Source Project // // 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 m from 'mithril'; import {assertExists, assertTrue} from '../base/logging'; import {Actions} from '../common/actions'; import {getCurrentChannel} from '../common/channels'; import {TRACE_SUFFIX} from '../common/constants'; import {ConversionJobStatus} from '../common/conversion_jobs'; import {Engine} from '../common/engine'; import {featureFlags} from '../common/feature_flags'; import { disableMetatracingAndGetTrace, enableMetatracing, isMetatracingEnabled, } from '../common/metatracing'; import {EngineMode, TraceArrayBufferSource} from '../common/state'; import {SCM_REVISION, VERSION} from '../gen/perfetto_version'; import {Animation} from './animation'; import {onClickCopy} from './clipboard'; import {downloadData, downloadUrl} from './download_utils'; import {globals} from './globals'; import {toggleHelp} from './help_modal'; import { isLegacyTrace, openFileWithLegacyTraceViewer, } from './legacy_trace_viewer'; import {showModal} from './modal'; import {runQueryInNewTab} from './query_result_tab'; import {Router} from './router'; import {isDownloadable, isShareable} from './trace_attrs'; import { convertToJson, convertTraceToJsonAndDownload, convertTraceToSystraceAndDownload, } from './trace_converter'; const ALL_PROCESSES_QUERY = 'select name, pid from process order by name;'; const CPU_TIME_FOR_PROCESSES = ` select process.name, sum(dur)/1e9 as cpu_sec from sched join thread using(utid) join process using(upid) group by upid order by cpu_sec desc limit 100;`; const CYCLES_PER_P_STATE_PER_CPU = ` select cpu, freq, dur, sum(dur * freq)/1e6 as mcycles from ( select cpu, value as freq, lead(ts) over (partition by cpu order by ts) - ts as dur from counter inner join cpu_counter_track on counter.track_id = cpu_counter_track.id where name = 'cpufreq' ) group by cpu, freq order by mcycles desc limit 32;`; const CPU_TIME_BY_CPU_BY_PROCESS = ` select process.name as process, thread.name as thread, cpu, sum(dur) / 1e9 as cpu_sec from sched inner join thread using(utid) inner join process using(upid) group by utid, cpu order by cpu_sec desc limit 30;`; const HEAP_GRAPH_BYTES_PER_TYPE = ` select o.upid, o.graph_sample_ts, c.name, sum(o.self_size) as total_self_size from heap_graph_object o join heap_graph_class c on o.type_id = c.id group by o.upid, o.graph_sample_ts, c.name order by total_self_size desc limit 100;`; const SQL_STATS = ` with first as (select started as ts from sqlstats limit 1) select round((max(ended - started, 0))/1e6) as runtime_ms, round((started - first.ts)/1e6) as t_start_ms, query from sqlstats, first order by started desc`; const GITILES_URL = 'https://android.googlesource.com/platform/external/perfetto'; let lastTabTitle = ''; function getBugReportUrl(): string { if (globals.isInternalUser) { return 'https://goto.google.com/perfetto-ui-bug'; } else { return 'https://github.com/google/perfetto/issues/new'; } } const HIRING_BANNER_FLAG = featureFlags.register({ id: 'showHiringBanner', name: 'Show hiring banner', description: 'Show the "We\'re hiring" banner link in the side bar.', defaultValue: false, }); const WIDGETS_PAGE_IN_NAV_FLAG = featureFlags.register({ id: 'showWidgetsPageInNav', name: 'Show widgets page', description: 'Show a link to the widgets page in the side bar.', defaultValue: false, }); function shouldShowHiringBanner(): boolean { return globals.isInternalUser && HIRING_BANNER_FLAG.get(); } function createCannedQuery(query: string, title: string): (_: Event) => void { return (e: Event) => { e.preventDefault(); runQueryInNewTab(query, title); }; } const EXAMPLE_ANDROID_TRACE_URL = 'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s'; const EXAMPLE_CHROME_TRACE_URL = 'https://storage.googleapis.com/perfetto-misc/chrome_example_wikipedia.perfetto_trace.gz'; interface SectionItem { t: string; a: string|((e: Event) => void); i: string; isPending?: () => boolean; isVisible?: () => boolean; internalUserOnly?: boolean; checkDownloadDisabled?: boolean; checkMetatracingEnabled?: boolean; checkMetatracingDisabled?: boolean; } interface Section { title: string; summary: string; items: SectionItem[]; expanded?: boolean; hideIfNoTraceLoaded?: boolean; appendOpenedTraceTitle?: boolean; } const SECTIONS: Section[] = [ { title: 'Navigation', summary: 'Open or record a new trace', expanded: true, items: [ {t: 'Open trace file', a: popupFileSelectionDialog, i: 'folder_open'}, { t: 'Open with legacy UI', a: popupFileSelectionDialogOldUI, i: 'filter_none', }, {t: 'Record new trace', a: navigateRecord, i: 'fiber_smart_record'}, { t: 'Widgets', a: navigateWidgets, i: 'widgets', isVisible: () => WIDGETS_PAGE_IN_NAV_FLAG.get(), }, ], }, { title: 'Current Trace', summary: 'Actions on the current trace', expanded: true, hideIfNoTraceLoaded: true, appendOpenedTraceTitle: true, items: [ {t: 'Show timeline', a: navigateViewer, i: 'line_style'}, { t: 'Share', a: shareTrace, i: 'share', internalUserOnly: true, isPending: () => globals.getConversionJobStatus('create_permalink') === ConversionJobStatus.InProgress, }, { t: 'Download', a: downloadTrace, i: 'file_download', checkDownloadDisabled: true, }, {t: 'Query (SQL)', a: navigateAnalyze, i: 'control_camera'}, {t: 'Metrics', a: navigateMetrics, i: 'speed'}, {t: 'Info and stats', a: navigateInfo, i: 'info'}, ], }, { title: 'Convert trace', summary: 'Convert to other formats', expanded: true, hideIfNoTraceLoaded: true, items: [ { t: 'Switch to legacy UI', a: openCurrentTraceWithOldUI, i: 'filter_none', isPending: () => globals.getConversionJobStatus('open_in_legacy') === ConversionJobStatus.InProgress, }, { t: 'Convert to .json', a: convertTraceToJson, i: 'file_download', isPending: () => globals.getConversionJobStatus('convert_json') === ConversionJobStatus.InProgress, checkDownloadDisabled: true, }, { t: 'Convert to .systrace', a: convertTraceToSystrace, i: 'file_download', isVisible: () => globals.hasFtrace, isPending: () => globals.getConversionJobStatus('convert_systrace') === ConversionJobStatus.InProgress, checkDownloadDisabled: true, }, ], }, { title: 'Example Traces', expanded: true, summary: 'Open an example trace', items: [ { t: 'Open Android example', a: openTraceUrl(EXAMPLE_ANDROID_TRACE_URL), i: 'description', }, { t: 'Open Chrome example', a: openTraceUrl(EXAMPLE_CHROME_TRACE_URL), i: 'description', }, ], }, { title: 'Support', expanded: true, summary: 'Documentation & Bugs', items: [ {t: 'Keyboard shortcuts', a: openHelp, i: 'help'}, {t: 'Documentation', a: 'https://perfetto.dev/docs', i: 'find_in_page'}, {t: 'Flags', a: navigateFlags, i: 'emoji_flags'}, { t: 'Report a bug', a: () => window.open(getBugReportUrl()), i: 'bug_report', }, ], }, { title: 'Sample queries', summary: 'Compute summary statistics', items: [ { t: 'Record metatrace', a: recordMetatrace, i: 'fiber_smart_record', checkMetatracingDisabled: true, }, { t: 'Finalise metatrace', a: finaliseMetatrace, i: 'file_download', checkMetatracingEnabled: true, }, { t: 'All Processes', a: createCannedQuery(ALL_PROCESSES_QUERY, 'All Processes'), i: 'search', }, { t: 'CPU Time by process', a: createCannedQuery(CPU_TIME_FOR_PROCESSES, 'CPU Time by process'), i: 'search', }, { t: 'Cycles by p-state by CPU', a: createCannedQuery( CYCLES_PER_P_STATE_PER_CPU, 'Cycles by p-state by CPU'), i: 'search', }, { t: 'CPU Time by CPU by process', a: createCannedQuery( CPU_TIME_BY_CPU_BY_PROCESS, 'CPU Time by CPU by process'), i: 'search', }, { t: 'Heap Graph: Bytes per type', a: createCannedQuery( HEAP_GRAPH_BYTES_PER_TYPE, 'Heap Graph: Bytes per type'), i: 'search', }, { t: 'Debug SQL performance', a: createCannedQuery(SQL_STATS, 'Recent SQL queries'), i: 'bug_report', }, ], }, ]; function openHelp(e: Event) { e.preventDefault(); toggleHelp(); } function getFileElement(): HTMLInputElement { return assertExists( document.querySelector('input[type=file]')); } function popupFileSelectionDialog(e: Event) { e.preventDefault(); delete getFileElement().dataset['useCatapultLegacyUi']; getFileElement().click(); } function popupFileSelectionDialogOldUI(e: Event) { e.preventDefault(); getFileElement().dataset['useCatapultLegacyUi'] = '1'; getFileElement().click(); } function downloadTraceFromUrl(url: string): Promise { return m.request({ method: 'GET', url, // TODO(hjd): Once mithril is updated we can use responseType here rather // than using config and remove the extract below. config: (xhr) => { xhr.responseType = 'blob'; xhr.onprogress = (progress) => { const percent = (100 * progress.loaded / progress.total).toFixed(1); globals.dispatch(Actions.updateStatus({ msg: `Downloading trace ${percent}%`, timestamp: Date.now() / 1000, })); }; }, extract: (xhr) => { return xhr.response; }, }); } export async function getCurrentTrace(): Promise { // Caller must check engine exists. const engine = assertExists(globals.getCurrentEngine()); const src = engine.source; if (src.type === 'ARRAY_BUFFER') { return new Blob([src.buffer]); } else if (src.type === 'FILE') { return src.file; } else if (src.type === 'URL') { return downloadTraceFromUrl(src.url); } else { throw new Error(`Loading to catapult from source with type ${src.type}`); } } function openCurrentTraceWithOldUI(e: Event) { e.preventDefault(); assertTrue(isTraceLoaded()); globals.logging.logEvent('Trace Actions', 'Open current trace in legacy UI'); if (!isTraceLoaded) return; getCurrentTrace() .then((file) => { openInOldUIWithSizeCheck(file); }) .catch((error) => { throw new Error(`Failed to get current trace ${error}`); }); } function convertTraceToSystrace(e: Event) { e.preventDefault(); assertTrue(isTraceLoaded()); globals.logging.logEvent('Trace Actions', 'Convert to .systrace'); if (!isTraceLoaded) return; getCurrentTrace() .then((file) => { convertTraceToSystraceAndDownload(file); }) .catch((error) => { throw new Error(`Failed to get current trace ${error}`); }); } function convertTraceToJson(e: Event) { e.preventDefault(); assertTrue(isTraceLoaded()); globals.logging.logEvent('Trace Actions', 'Convert to .json'); if (!isTraceLoaded) return; getCurrentTrace() .then((file) => { convertTraceToJsonAndDownload(file); }) .catch((error) => { throw new Error(`Failed to get current trace ${error}`); }); } export function isTraceLoaded(): boolean { return globals.getCurrentEngine() !== undefined; } function openTraceUrl(url: string): (e: Event) => void { return (e) => { globals.logging.logEvent('Trace Actions', 'Open example trace'); e.preventDefault(); globals.dispatch(Actions.openTraceFromUrl({url})); }; } function onInputElementFileSelectionChanged(e: Event) { if (!(e.target instanceof HTMLInputElement)) { throw new Error('Not an input element'); } if (!e.target.files) return; const file = e.target.files[0]; // Reset the value so onchange will be fired with the same file. e.target.value = ''; if (e.target.dataset['useCatapultLegacyUi'] === '1') { openWithLegacyUi(file); return; } globals.logging.logEvent('Trace Actions', 'Open trace from file'); globals.dispatch(Actions.openTraceFromFile({file})); } async function openWithLegacyUi(file: File) { // Switch back to the old catapult UI. globals.logging.logEvent('Trace Actions', 'Open trace in Legacy UI'); if (await isLegacyTrace(file)) { openFileWithLegacyTraceViewer(file); return; } openInOldUIWithSizeCheck(file); } function openInOldUIWithSizeCheck(trace: Blob) { // Perfetto traces smaller than 50mb can be safely opened in the legacy UI. if (trace.size < 1024 * 1024 * 50) { convertToJson(trace); return; } // Give the user the option to truncate larger perfetto traces. const size = Math.round(trace.size / (1024 * 1024)); showModal({ title: 'Legacy UI may fail to open this trace', content: m('div', m('p', `This trace is ${size}mb, opening it in the legacy UI ` + `may fail.`), m('p', 'More options can be found at ', m('a', { href: 'https://goto.google.com/opening-large-traces', target: '_blank', }, 'go/opening-large-traces'), '.')), buttons: [ { text: 'Open full trace (not recommended)', action: () => convertToJson(trace), }, { text: 'Open beginning of trace', action: () => convertToJson(trace, /* truncate*/ 'start'), }, { text: 'Open end of trace', primary: true, action: () => convertToJson(trace, /* truncate*/ 'end'), }, ], }); return; } function navigateRecord(e: Event) { e.preventDefault(); Router.navigate('#!/record'); } function navigateWidgets(e: Event) { e.preventDefault(); Router.navigate('#!/widgets'); } function navigateAnalyze(e: Event) { e.preventDefault(); Router.navigate('#!/query'); } function navigateFlags(e: Event) { e.preventDefault(); Router.navigate('#!/flags'); } function navigateMetrics(e: Event) { e.preventDefault(); Router.navigate('#!/metrics'); } function navigateInfo(e: Event) { e.preventDefault(); Router.navigate('#!/info'); } function navigateViewer(e: Event) { e.preventDefault(); Router.navigate('#!/viewer'); } function shareTrace(e: Event) { e.preventDefault(); const engine = assertExists(globals.getCurrentEngine()); const traceUrl = (engine.source as (TraceArrayBufferSource)).url || ''; // If the trace is not shareable (has been pushed via postMessage()) but has // a url, create a pseudo-permalink by echoing back the URL. if (!isShareable()) { const msg = [m('p', 'This trace was opened by an external site and as such cannot ' + 'be re-shared preserving the UI state.')]; if (traceUrl) { msg.push(m('p', 'By using the URL below you can open this trace again.')); msg.push(m('p', 'Clicking will copy the URL into the clipboard.')); msg.push(createTraceLink(traceUrl, traceUrl)); } showModal({ title: 'Cannot create permalink from external trace', content: m('div', msg), }); return; } if (!isShareable() || !isTraceLoaded()) return; const result = confirm( `Upload UI state and generate a permalink. ` + `The trace will be accessible by anybody with the permalink.`); if (result) { globals.logging.logEvent('Trace Actions', 'Create permalink'); globals.dispatch(Actions.createPermalink({isRecordingConfig: false})); } } function downloadTrace(e: Event) { e.preventDefault(); if (!isDownloadable() || !isTraceLoaded()) return; globals.logging.logEvent('Trace Actions', 'Download trace'); const engine = globals.getCurrentEngine(); if (!engine) return; let url = ''; let fileName = `trace${TRACE_SUFFIX}`; const src = engine.source; if (src.type === 'URL') { url = src.url; fileName = url.split('/').slice(-1)[0]; } else if (src.type === 'ARRAY_BUFFER') { const blob = new Blob([src.buffer], {type: 'application/octet-stream'}); const inputFileName = window.prompt('Please enter a name for your file or leave blank'); if (inputFileName) { fileName = `${inputFileName}.perfetto_trace.gz`; } else if (src.fileName) { fileName = src.fileName; } url = URL.createObjectURL(blob); } else if (src.type === 'FILE') { const file = src.file; url = URL.createObjectURL(file); fileName = file.name; } else { throw new Error(`Download from ${JSON.stringify(src)} is not supported`); } downloadUrl(fileName, url); } function getCurrentEngine(): Engine|undefined { const engineId = globals.getCurrentEngine()?.id; if (engineId === undefined) return undefined; return globals.engines.get(engineId); } function highPrecisionTimersAvailable(): boolean { // High precision timers are available either when the page is cross-origin // isolated or when the trace processor is a standalone binary. return window.crossOriginIsolated || globals.getCurrentEngine()?.mode === 'HTTP_RPC'; } function recordMetatrace(e: Event) { e.preventDefault(); globals.logging.logEvent('Trace Actions', 'Record metatrace'); const engine = getCurrentEngine(); if (!engine) return; if (!highPrecisionTimersAvailable()) { const PROMPT = `High-precision timers are not available to WASM trace processor yet. Modern browsers restrict high-precision timers to cross-origin-isolated pages. As Perfetto UI needs to open traces via postMessage, it can't be cross-origin isolated until browsers ship support for 'Cross-origin-opener-policy: restrict-properties'. Do you still want to record a metatrace? Note that events under timer precision (1ms) will dropped. Alternatively, connect to a trace_processor_shell --httpd instance. `; showModal({ title: `Trace processor doesn't have high-precision timers`, content: m('.modal-pre', PROMPT), buttons: [ { text: 'YES, record metatrace', primary: true, action: () => { enableMetatracing(); engine.enableMetatrace(); }, }, { text: 'NO, cancel', }, ], }); } else { engine.enableMetatrace(); } } async function finaliseMetatrace(e: Event) { e.preventDefault(); globals.logging.logEvent('Trace Actions', 'Finalise metatrace'); const jsEvents = disableMetatracingAndGetTrace(); const engine = getCurrentEngine(); if (!engine) return; const result = await engine.stopAndGetMetatrace(); if (result.error.length !== 0) { throw new Error(`Failed to read metatrace: ${result.error}`); } downloadData('metatrace', result.metatrace, jsEvents); } const EngineRPCWidget: m.Component = { view() { let cssClass = ''; let title = 'Number of pending SQL queries'; let label: string; let failed = false; let mode: EngineMode|undefined; const engine = globals.state.engine; if (engine !== undefined) { mode = engine.mode; if (engine.failed !== undefined) { cssClass += '.red'; title = 'Query engine crashed\n' + engine.failed; failed = true; } } // If we don't have an engine yet, guess what will be the mode that will // be used next time we'll create one. Even if we guess it wrong (somehow // trace_controller.ts takes a different decision later, e.g. because the // RPC server is shut down after we load the UI and cached httpRpcState) // this will eventually become consistent once the engine is created. if (mode === undefined) { if (globals.frontendLocalState.httpRpcState.connected && globals.state.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE') { mode = 'HTTP_RPC'; } else { mode = 'WASM'; } } if (mode === 'HTTP_RPC') { cssClass += '.green'; label = 'RPC'; title += '\n(Query engine: native accelerator over HTTP+RPC)'; } else { label = 'WSM'; title += '\n(Query engine: built-in WASM)'; } return m( `.dbg-info-square${cssClass}`, {title}, m('div', label), m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`)); }, }; const ServiceWorkerWidget: m.Component = { view() { let cssClass = ''; let title = 'Service Worker: '; let label = 'N/A'; const ctl = globals.serviceWorkerController; if ((!('serviceWorker' in navigator))) { label = 'N/A'; title += 'not supported by the browser (requires HTTPS)'; } else if (ctl.bypassed) { label = 'OFF'; cssClass = '.red'; title += 'Bypassed, using live network. Double-click to re-enable'; } else if (ctl.installing) { label = 'UPD'; cssClass = '.amber'; title += 'Installing / updating ...'; } else if (!navigator.serviceWorker.controller) { label = 'N/A'; title += 'Not available, using network'; } else { label = 'ON'; cssClass = '.green'; title += 'Serving from cache. Ready for offline use'; } const toggle = async () => { if (globals.serviceWorkerController.bypassed) { globals.serviceWorkerController.setBypass(false); return; } showModal({ title: 'Disable service worker?', content: m( 'div', m('p', `If you continue the service worker will be disabled until manually re-enabled.`), m('p', `All future requests will be served from the network and the UI won't be available offline.`), m('p', `You should do this only if you are debugging the UI or if you are experiencing caching-related problems.`), m('p', `Disabling will cause a refresh of the UI, the current state will be lost.`), ), buttons: [ { text: 'Disable and reload', primary: true, action: () => { globals.serviceWorkerController.setBypass(true).then( () => location.reload()); }, }, {text: 'Cancel'}, ], }); }; return m( `.dbg-info-square${cssClass}`, {title, ondblclick: toggle}, m('div', 'SW'), m('div', label)); }, }; const SidebarFooter: m.Component = { view() { return m( '.sidebar-footer', m('button', { onclick: () => globals.dispatch(Actions.togglePerfDebug({})), }, m('i.material-icons', {title: 'Toggle Perf Debug Mode'}, 'assessment')), m(EngineRPCWidget), m(ServiceWorkerWidget), m( '.version', m('a', { href: `${GITILES_URL}/+/${SCM_REVISION}/ui`, title: `Channel: ${getCurrentChannel()}`, target: '_blank', }, `${VERSION.substr(0, 11)}`), ), ); }, }; class HiringBanner implements m.ClassComponent { view() { return m( '.hiring-banner', m('a', { href: 'http://go/perfetto-open-roles', target: '_blank', }, 'We\'re hiring!')); } } export class Sidebar implements m.ClassComponent { private _redrawWhileAnimating = new Animation(() => globals.rafScheduler.scheduleFullRedraw()); view() { if (globals.hideSidebar) return null; const vdomSections = []; for (const section of SECTIONS) { if (section.hideIfNoTraceLoaded && !isTraceLoaded()) continue; const vdomItems = []; for (const item of section.items) { if (item.isVisible !== undefined && !item.isVisible()) { continue; } let css = ''; let attrs = { onclick: typeof item.a === 'function' ? item.a : null, href: typeof item.a === 'string' ? item.a : '#', target: typeof item.a === 'string' ? '_blank' : null, disabled: false, id: item.t.toLowerCase().replace(/[^\w]/g, '_'), }; if (item.isPending && item.isPending()) { attrs.onclick = (e) => e.preventDefault(); css = '.pending'; } if (item.internalUserOnly && !globals.isInternalUser) { continue; } if (item.checkMetatracingEnabled || item.checkMetatracingDisabled) { if (item.checkMetatracingEnabled === true && !isMetatracingEnabled()) { continue; } if (item.checkMetatracingDisabled === true && isMetatracingEnabled()) { continue; } if (item.checkMetatracingDisabled && !highPrecisionTimersAvailable()) { attrs.disabled = true; } } if (item.checkDownloadDisabled && !isDownloadable()) { attrs = { onclick: (e) => { e.preventDefault(); alert('Can not download external trace.'); }, href: '#', target: null, disabled: true, id: '', }; } vdomItems.push(m( 'li', m(`a${css}`, attrs, m('i.material-icons', item.i), item.t))); } if (section.appendOpenedTraceTitle) { const engine = globals.state.engine; if (engine !== undefined) { let traceTitle = ''; let traceUrl = ''; switch (engine.source.type) { case 'FILE': // Split on both \ and / (because C:\Windows\paths\are\like\this). traceTitle = engine.source.file.name.split(/[/\\]/).pop()!; const fileSizeMB = Math.ceil(engine.source.file.size / 1e6); traceTitle += ` (${fileSizeMB} MB)`; break; case 'URL': traceUrl = engine.source.url; traceTitle = traceUrl.split('/').pop()!; break; case 'ARRAY_BUFFER': traceTitle = engine.source.title; traceUrl = engine.source.url || ''; const arrayBufferSizeMB = Math.ceil(engine.source.buffer.byteLength / 1e6); traceTitle += ` (${arrayBufferSizeMB} MB)`; break; case 'HTTP_RPC': traceTitle = 'External trace (RPC)'; break; default: break; } if (traceTitle !== '') { const tabTitle = `${traceTitle} - Perfetto UI`; if (tabTitle !== lastTabTitle) { document.title = lastTabTitle = tabTitle; } vdomItems.unshift(m('li', createTraceLink(traceTitle, traceUrl))); } } } vdomSections.push( m(`section${section.expanded ? '.expanded' : ''}`, m('.section-header', { onclick: () => { section.expanded = !section.expanded; globals.rafScheduler.scheduleFullRedraw(); }, }, m('h1', {title: section.summary}, section.title), m('h2', section.summary)), m('.section-content', m('ul', vdomItems)))); } return m( 'nav.sidebar', { class: globals.state.sidebarVisible ? 'show-sidebar' : 'hide-sidebar', // 150 here matches --sidebar-timing in the css. // TODO(hjd): Should link to the CSS variable. ontransitionstart: () => this._redrawWhileAnimating.start(150), ontransitionend: () => this._redrawWhileAnimating.stop(), }, shouldShowHiringBanner() ? m(HiringBanner) : null, m( `header.${getCurrentChannel()}`, m(`img[src=${globals.root}assets/brand.png].brand`), m('button.sidebar-button', { onclick: () => { globals.dispatch(Actions.toggleSidebar({})); }, }, m('i.material-icons', { title: globals.state.sidebarVisible ? 'Hide menu' : 'Show menu', }, 'menu')), ), m('input.trace_file[type=file]', {onchange: onInputElementFileSelectionChanged}), m('.sidebar-scroll', m( '.sidebar-scroll-container', ...vdomSections, m(SidebarFooter), )), ); } } function createTraceLink(title: string, url: string) { if (url === '') { return m('a.trace-file-name', title); } const linkProps = { href: url, title: 'Click to copy the URL', target: '_blank', onclick: onClickCopy(url), }; return m('a.trace-file-name', linkProps, title); }