| // 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 {getCurrentChannel} from '../core/channels'; |
| import {TRACE_SUFFIX} from '../public/trace'; |
| import { |
| disableMetatracingAndGetTrace, |
| enableMetatracing, |
| isMetatracingEnabled, |
| } from '../core/metatracing'; |
| import {Engine, EngineMode} from '../trace_processor/engine'; |
| import {featureFlags} from '../core/feature_flags'; |
| import {raf} from '../core/raf_scheduler'; |
| import {SCM_REVISION, VERSION} from '../gen/perfetto_version'; |
| import {showModal} from '../widgets/modal'; |
| import {Animation} from './animation'; |
| import {downloadData, downloadUrl} from './download_utils'; |
| import {globals} from './globals'; |
| import {toggleHelp} from './help_modal'; |
| import {shareTrace} from './trace_share_utils'; |
| import { |
| convertTraceToJsonAndDownload, |
| convertTraceToSystraceAndDownload, |
| } from './trace_converter'; |
| import {openInOldUIWithSizeCheck} from './legacy_trace_viewer'; |
| import {SIDEBAR_SECTIONS, SidebarSections} from '../public/sidebar'; |
| import {AppImpl} from '../core/app_impl'; |
| import {Trace} from '../public/trace'; |
| import {OptionalTraceImplAttrs, TraceImpl} from '../core/trace_impl'; |
| import {Command} from '../public/command'; |
| import {SidebarMenuItemInternal} from '../core/sidebar_manager'; |
| import {exists, getOrCreate} from '../base/utils'; |
| import {copyToClipboard} from '../base/clipboard'; |
| import {classNames} from '../base/classnames'; |
| import {formatHotkey} from '../base/hotkeys'; |
| import {assetSrc} from '../base/assets'; |
| |
| const GITILES_URL = |
| 'https://android.googlesource.com/platform/external/perfetto'; |
| |
| 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, |
| }); |
| |
| function shouldShowHiringBanner(): boolean { |
| return globals.isInternalUser && HIRING_BANNER_FLAG.get(); |
| } |
| |
| async function openCurrentTraceWithOldUI(trace: Trace): Promise<void> { |
| AppImpl.instance.analytics.logEvent( |
| 'Trace Actions', |
| 'Open current trace in legacy UI', |
| ); |
| const file = await trace.getTraceFile(); |
| await openInOldUIWithSizeCheck(file); |
| } |
| |
| async function convertTraceToSystrace(trace: Trace): Promise<void> { |
| AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .systrace'); |
| const file = await trace.getTraceFile(); |
| await convertTraceToSystraceAndDownload(file); |
| } |
| |
| async function convertTraceToJson(trace: Trace): Promise<void> { |
| AppImpl.instance.analytics.logEvent('Trace Actions', 'Convert to .json'); |
| const file = await trace.getTraceFile(); |
| await convertTraceToJsonAndDownload(file); |
| } |
| |
| function downloadTrace(trace: TraceImpl) { |
| if (!trace.traceInfo.downloadable) return; |
| AppImpl.instance.analytics.logEvent('Trace Actions', 'Download trace'); |
| |
| let url = ''; |
| let fileName = `trace${TRACE_SUFFIX}`; |
| const src = trace.traceInfo.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 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 || |
| AppImpl.instance.trace?.engine.mode === 'HTTP_RPC' |
| ); |
| } |
| |
| function recordMetatrace(engine: Engine) { |
| AppImpl.instance.analytics.logEvent('Trace Actions', 'Record metatrace'); |
| |
| 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 toggleMetatrace(e: Engine) { |
| return isMetatracingEnabled() ? finaliseMetatrace(e) : recordMetatrace(e); |
| } |
| |
| async function finaliseMetatrace(engine: Engine) { |
| AppImpl.instance.analytics.logEvent('Trace Actions', 'Finalise metatrace'); |
| |
| const jsEvents = disableMetatracingAndGetTrace(); |
| |
| const result = await engine.stopAndGetMetatrace(); |
| if (result.error.length !== 0) { |
| throw new Error(`Failed to read metatrace: ${result.error}`); |
| } |
| |
| downloadData('metatrace', result.metatrace, jsEvents); |
| } |
| |
| class EngineRPCWidget implements m.ClassComponent<OptionalTraceImplAttrs> { |
| view({attrs}: m.CVnode<OptionalTraceImplAttrs>) { |
| let cssClass = ''; |
| let title = 'Number of pending SQL queries'; |
| let label: string; |
| let failed = false; |
| let mode: EngineMode | undefined; |
| |
| const engine = attrs.trace?.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 ( |
| AppImpl.instance.httpRpc.httpRpcAvailable && |
| AppImpl.instance.httpRpc.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)'; |
| } |
| |
| const numReqs = attrs.trace?.engine.numRequestsPending ?? 0; |
| return m( |
| `.dbg-info-square${cssClass}`, |
| {title}, |
| m('div', label), |
| m('div', `${failed ? 'FAIL' : numReqs}`), |
| ); |
| } |
| } |
| |
| const ServiceWorkerWidget: m.Component = { |
| view() { |
| let cssClass = ''; |
| let title = 'Service Worker: '; |
| let label = 'N/A'; |
| const ctl = AppImpl.instance.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 (ctl.bypassed) { |
| ctl.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: () => ctl.setBypass(true).then(() => location.reload()), |
| }, |
| {text: 'Cancel'}, |
| ], |
| }); |
| }; |
| |
| return m( |
| `.dbg-info-square${cssClass}`, |
| {title, ondblclick: toggle}, |
| m('div', 'SW'), |
| m('div', label), |
| ); |
| }, |
| }; |
| |
| class SidebarFooter implements m.ClassComponent<OptionalTraceImplAttrs> { |
| view({attrs}: m.CVnode<OptionalTraceImplAttrs>) { |
| return m( |
| '.sidebar-footer', |
| m(EngineRPCWidget, attrs), |
| m(ServiceWorkerWidget), |
| m( |
| '.version', |
| m( |
| 'a', |
| { |
| href: `${GITILES_URL}/+/${SCM_REVISION}/ui`, |
| title: `Channel: ${getCurrentChannel()}`, |
| target: '_blank', |
| }, |
| VERSION, |
| ), |
| ), |
| ); |
| } |
| } |
| |
| 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<OptionalTraceImplAttrs> { |
| private _redrawWhileAnimating = new Animation(() => |
| raf.scheduleFullRedraw('force'), |
| ); |
| private _asyncJobPending = new Set<string>(); |
| private _sectionExpanded = new Map<string, boolean>(); |
| |
| constructor() { |
| registerMenuItems(); |
| } |
| |
| view({attrs}: m.CVnode<OptionalTraceImplAttrs>) { |
| const sidebar = AppImpl.instance.sidebar; |
| if (!sidebar.enabled) return null; |
| return m( |
| 'nav.sidebar', |
| { |
| class: sidebar.visible ? 'show-sidebar' : 'hide-sidebar', |
| // 150 here matches --sidebar-timing in the css. |
| // TODO(hjd): Should link to the CSS variable. |
| ontransitionstart: (e: TransitionEvent) => { |
| if (e.target !== e.currentTarget) return; |
| this._redrawWhileAnimating.start(150); |
| }, |
| ontransitionend: (e: TransitionEvent) => { |
| if (e.target !== e.currentTarget) return; |
| this._redrawWhileAnimating.stop(); |
| }, |
| }, |
| shouldShowHiringBanner() ? m(HiringBanner) : null, |
| m( |
| `header.${getCurrentChannel()}`, |
| m(`img[src=${assetSrc('assets/brand.png')}].brand`), |
| m( |
| 'button.sidebar-button', |
| { |
| onclick: () => sidebar.toggleVisibility(), |
| }, |
| m( |
| 'i.material-icons', |
| { |
| title: sidebar.visible ? 'Hide menu' : 'Show menu', |
| }, |
| 'menu', |
| ), |
| ), |
| ), |
| m( |
| '.sidebar-scroll', |
| m( |
| '.sidebar-scroll-container', |
| ...(Object.keys(SIDEBAR_SECTIONS) as SidebarSections[]).map((s) => |
| this.renderSection(s), |
| ), |
| m(SidebarFooter, attrs), |
| ), |
| ), |
| ); |
| } |
| |
| private renderSection(sectionId: SidebarSections) { |
| const section = SIDEBAR_SECTIONS[sectionId]; |
| const menuItems = AppImpl.instance.sidebar.menuItems |
| .valuesAsArray() |
| .filter((item) => item.section === sectionId) |
| .sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)) |
| .map((item) => this.renderItem(item)); |
| |
| // Don't render empty sections. |
| if (menuItems.length === 0) return undefined; |
| |
| const expanded = getOrCreate(this._sectionExpanded, sectionId, () => true); |
| return m( |
| `section${expanded ? '.expanded' : ''}`, |
| m( |
| '.section-header', |
| { |
| onclick: () => { |
| this._sectionExpanded.set(sectionId, !expanded); |
| raf.scheduleFullRedraw(); |
| }, |
| }, |
| m('h1', {title: section.title}, section.title), |
| m('h2', section.summary), |
| ), |
| m('.section-content', m('ul', menuItems)), |
| ); |
| } |
| |
| private renderItem(item: SidebarMenuItemInternal): m.Child { |
| let href = '#'; |
| let disabled = false; |
| let target = null; |
| let command: Command | undefined = undefined; |
| let tooltip = valueOrCallback(item.tooltip); |
| let onclick: (() => unknown | Promise<unknown>) | undefined = undefined; |
| const commandId = 'commandId' in item ? item.commandId : undefined; |
| const action = 'action' in item ? item.action : undefined; |
| let text = valueOrCallback(item.text); |
| const disabReason: boolean | string | undefined = valueOrCallback( |
| item.disabled, |
| ); |
| |
| if (disabReason === true || typeof disabReason === 'string') { |
| disabled = true; |
| onclick = () => typeof disabReason === 'string' && alert(disabReason); |
| } else if (action !== undefined) { |
| onclick = action; |
| } else if (commandId !== undefined) { |
| const cmdMgr = AppImpl.instance.commands; |
| command = cmdMgr.hasCommand(commandId ?? '') |
| ? cmdMgr.getCommand(commandId) |
| : undefined; |
| if (command === undefined) { |
| disabled = true; |
| } else { |
| text = text !== undefined ? text : command.name; |
| if (command.defaultHotkey !== undefined) { |
| tooltip = |
| `${tooltip ?? command.name}` + |
| ` [${formatHotkey(command.defaultHotkey)}]`; |
| } |
| onclick = () => cmdMgr.runCommand(commandId); |
| } |
| } |
| |
| // This is not an else if because in some rare cases the user might want |
| // to have both an href and onclick, with different behaviors. The only case |
| // today is the trace name / URL, where we want the URL in the href to |
| // support right-click -> copy URL, but the onclick does copyToClipboard(). |
| if ('href' in item && item.href !== undefined) { |
| href = item.href; |
| target = href.startsWith('#') ? null : '_blank'; |
| } |
| return m( |
| 'li', |
| m( |
| 'a', |
| { |
| className: classNames( |
| valueOrCallback(item.cssClass), |
| this._asyncJobPending.has(item.id) && 'pending', |
| ), |
| onclick: onclick && this.wrapClickHandler(item.id, onclick), |
| href, |
| target, |
| disabled, |
| title: tooltip, |
| }, |
| exists(item.icon) && m('i.material-icons', valueOrCallback(item.icon)), |
| text, |
| ), |
| ); |
| } |
| |
| // Creates the onClick handlers for the items which provided a function in the |
| // `action` member. The function can be either sync or async. |
| // What we want to achieve here is the following: |
| // - If the action is async (returns a Promise), we want to render a spinner, |
| // next to the menu item, until the promise is resolved. |
| // - [Minor] we want to call e.preventDefault() to override the behaviour of |
| // the <a href='#'> which gets rendered for accessibility reasons. |
| private wrapClickHandler(itemId: string, itemAction: Function) { |
| return (e: Event) => { |
| e.preventDefault(); // Make the <a href="#"> a no-op. |
| const res = itemAction(); |
| if (!(res instanceof Promise)) return; |
| if (this._asyncJobPending.has(itemId)) { |
| return; // Don't queue up another action if not yet finished. |
| } |
| this._asyncJobPending.add(itemId); |
| raf.scheduleFullRedraw(); |
| res.finally(() => { |
| this._asyncJobPending.delete(itemId); |
| raf.scheduleFullRedraw('force'); |
| }); |
| }; |
| } |
| } |
| |
| // TODO(primiano): The registrations below should be moved to dedicated |
| // plugins (most of this really belongs to core_plugins/commads/index.ts). |
| // For now i'm keeping everything here as splitting these require moving some |
| // functions like share_trace() out of core, splitting out permalink, etc. |
| |
| let globalItemsRegistered = false; |
| const traceItemsRegistered = new WeakSet<TraceImpl>(); |
| |
| function registerMenuItems() { |
| if (!globalItemsRegistered) { |
| globalItemsRegistered = true; |
| registerGlobalSidebarEntries(); |
| } |
| const trace = AppImpl.instance.trace; |
| if (trace !== undefined && !traceItemsRegistered.has(trace)) { |
| traceItemsRegistered.add(trace); |
| registerTraceMenuItems(trace); |
| } |
| } |
| |
| function registerGlobalSidebarEntries() { |
| const app = AppImpl.instance; |
| // TODO(primiano): The Open file / Open with legacy entries are registered by |
| // the 'perfetto.CoreCommands' plugins. Make things consistent. |
| app.sidebar.addMenuItem({ |
| section: 'support', |
| text: 'Keyboard shortcuts', |
| action: toggleHelp, |
| icon: 'help', |
| }); |
| app.sidebar.addMenuItem({ |
| section: 'support', |
| text: 'Documentation', |
| href: 'https://perfetto.dev/docs', |
| icon: 'find_in_page', |
| }); |
| app.sidebar.addMenuItem({ |
| section: 'support', |
| sortOrder: 4, |
| text: 'Report a bug', |
| href: getBugReportUrl(), |
| icon: 'bug_report', |
| }); |
| } |
| |
| function registerTraceMenuItems(trace: TraceImpl) { |
| const downloadDisabled = trace.traceInfo.downloadable |
| ? false |
| : 'Cannot download external trace'; |
| |
| const traceTitle = trace?.traceInfo.traceTitle; |
| traceTitle && |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| text: traceTitle, |
| href: trace.traceInfo.traceUrl, |
| action: () => copyToClipboard(trace.traceInfo.traceUrl), |
| tooltip: 'Click to copy the URL', |
| cssClass: 'trace-file-name', |
| }); |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| text: 'Show timeline', |
| href: '#!/viewer', |
| icon: 'line_style', |
| }); |
| globals.isInternalUser && |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| text: 'Share', |
| action: async () => await shareTrace(trace), |
| icon: 'share', |
| }); |
| trace.sidebar.addMenuItem({ |
| section: 'current_trace', |
| text: 'Download', |
| action: () => downloadTrace(trace), |
| icon: 'file_download', |
| disabled: downloadDisabled, |
| }); |
| trace.sidebar.addMenuItem({ |
| section: 'convert_trace', |
| text: 'Switch to legacy UI', |
| action: async () => await openCurrentTraceWithOldUI(trace), |
| icon: 'filter_none', |
| disabled: downloadDisabled, |
| }); |
| trace.sidebar.addMenuItem({ |
| section: 'convert_trace', |
| text: 'Convert to .json', |
| action: async () => await convertTraceToJson(trace), |
| icon: 'file_download', |
| disabled: downloadDisabled, |
| }); |
| trace.traceInfo.hasFtrace && |
| trace.sidebar.addMenuItem({ |
| section: 'convert_trace', |
| text: 'Convert to .systrace', |
| action: async () => await convertTraceToSystrace(trace), |
| icon: 'file_download', |
| disabled: downloadDisabled, |
| }); |
| trace.sidebar.addMenuItem({ |
| section: 'support', |
| sortOrder: 5, |
| text: () => |
| isMetatracingEnabled() ? 'Finalize metatrace' : 'Record metatrace', |
| action: () => toggleMetatrace(trace.engine), |
| icon: () => (isMetatracingEnabled() ? 'download' : 'fiber_smart_record'), |
| }); |
| } |
| |
| // Used to deal with fields like the entry name, which can be either a direct |
| // string or a callback that returns the string. |
| function valueOrCallback<T>(value: T | (() => T)): T; |
| function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined; |
| function valueOrCallback<T>(value: T | (() => T) | undefined): T | undefined { |
| if (value === undefined) return undefined; |
| return value instanceof Function ? value() : value; |
| } |