1// Copyright (C) 2018 The Android Open Source Project
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//      http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15import * as m from 'mithril';
16
17import {assertExists, assertTrue} from '../base/logging';
18import {Actions} from '../common/actions';
19import {TRACE_SUFFIX} from '../common/constants';
20import {QueryResponse} from '../common/queries';
21import {EngineMode, TraceArrayBufferSource} from '../common/state';
22import * as version from '../gen/perfetto_version';
23
24import {Animation} from './animation';
25import {copyToClipboard} from './clipboard';
26import {globals} from './globals';
27import {toggleHelp} from './help_modal';
28import {
29  isLegacyTrace,
30  openFileWithLegacyTraceViewer,
31} from './legacy_trace_viewer';
32import {showModal} from './modal';
33import {isDownloadable, isShareable} from './trace_attrs';
34
35const ALL_PROCESSES_QUERY = 'select name, pid from process order by name;';
36
37const CPU_TIME_FOR_PROCESSES = `
38select
39  process.name,
40  sum(dur)/1e9 as cpu_sec
41from sched
42join thread using(utid)
43join process using(upid)
44group by upid
45order by cpu_sec desc
46limit 100;`;
47
48const CYCLES_PER_P_STATE_PER_CPU = `
49select
50  cpu,
51  freq,
52  dur,
53  sum(dur * freq)/1e6 as mcycles
54from (
55  select
56    cpu,
57    value as freq,
58    lead(ts) over (partition by cpu order by ts) - ts as dur
59  from counter
60  inner join cpu_counter_track on counter.track_id = cpu_counter_track.id
61  where name = 'cpufreq'
62) group by cpu, freq
63order by mcycles desc limit 32;`;
64
65const CPU_TIME_BY_CPU_BY_PROCESS = `
66select
67  process.name as process,
68  thread.name as thread,
69  cpu,
70  sum(dur) / 1e9 as cpu_sec
71from sched
72inner join thread using(utid)
73inner join process using(upid)
74group by utid, cpu
75order by cpu_sec desc
76limit 30;`;
77
78const HEAP_GRAPH_BYTES_PER_TYPE = `
79select
80  o.upid,
81  o.graph_sample_ts,
82  c.name,
83  sum(o.self_size) as total_self_size
84from heap_graph_object o join heap_graph_class c on o.type_id = c.id
85group by
86 o.upid,
87 o.graph_sample_ts,
88 c.name
89order by total_self_size desc
90limit 100;`;
91
92const SQL_STATS = `
93with first as (select started as ts from sqlstats limit 1)
94select query,
95    round((max(ended - started, 0))/1e6) as runtime_ms,
96    round((max(started - queued, 0))/1e6) as latency_ms,
97    round((started - first.ts)/1e6) as t_start_ms
98from sqlstats, first
99order by started desc`;
100
101let lastTabTitle = '';
102
103function createCannedQuery(query: string): (_: Event) => void {
104  return (e: Event) => {
105    e.preventDefault();
106    globals.dispatch(Actions.executeQuery({
107      engineId: '0',
108      queryId: 'command',
109      query,
110    }));
111  };
112}
113
114function showDebugTrack(): (_: Event) => void {
115  return (e: Event) => {
116    e.preventDefault();
117    globals.dispatch(Actions.addDebugTrack({
118      engineId: Object.keys(globals.state.engines)[0],
119      name: 'Debug Slices',
120    }));
121  };
122}
123
124const EXAMPLE_ANDROID_TRACE_URL =
125    'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s';
126
127const EXAMPLE_CHROME_TRACE_URL =
128    'https://storage.googleapis.com/perfetto-misc/example_chrome_trace_4s_1.json';
129
130const SECTIONS = [
131  {
132    title: 'Navigation',
133    summary: 'Open or record a new trace',
134    expanded: true,
135    items: [
136      {t: 'Open trace file', a: popupFileSelectionDialog, i: 'folder_open'},
137      {
138        t: 'Open with legacy UI',
139        a: popupFileSelectionDialogOldUI,
140        i: 'filter_none'
141      },
142      {t: 'Record new trace', a: navigateRecord, i: 'fiber_smart_record'},
143    ],
144  },
145  {
146    title: 'Current Trace',
147    summary: 'Actions on the current trace',
148    expanded: true,
149    hideIfNoTraceLoaded: true,
150    appendOpenedTraceTitle: true,
151    items: [
152      {t: 'Show timeline', a: navigateViewer, i: 'line_style'},
153      {
154        t: 'Share',
155        a: shareTrace,
156        i: 'share',
157        internalUserOnly: true,
158      },
159      {
160        t: 'Download',
161        a: downloadTrace,
162        i: 'file_download',
163        checkDownloadDisabled: true,
164      },
165      {t: 'Legacy UI', a: openCurrentTraceWithOldUI, i: 'filter_none'},
166      {t: 'Query (SQL)', a: navigateAnalyze, i: 'control_camera'},
167      {t: 'Metrics', a: navigateMetrics, i: 'speed'},
168      {t: 'Info and stats', a: navigateInfo, i: 'info'},
169    ],
170  },
171  {
172    title: 'Example Traces',
173    expanded: true,
174    summary: 'Open an example trace',
175    items: [
176      {
177        t: 'Open Android example',
178        a: openTraceUrl(EXAMPLE_ANDROID_TRACE_URL),
179        i: 'description'
180      },
181      {
182        t: 'Open Chrome example',
183        a: openTraceUrl(EXAMPLE_CHROME_TRACE_URL),
184        i: 'description'
185      },
186    ],
187  },
188  {
189    title: 'Sample queries',
190    summary: 'Compute summary statistics',
191    items: [
192      {
193        t: 'Show Debug Track',
194        a: showDebugTrack(),
195        i: 'view_day',
196      },
197      {
198        t: 'All Processes',
199        a: createCannedQuery(ALL_PROCESSES_QUERY),
200        i: 'search',
201      },
202      {
203        t: 'CPU Time by process',
204        a: createCannedQuery(CPU_TIME_FOR_PROCESSES),
205        i: 'search',
206      },
207      {
208        t: 'Cycles by p-state by CPU',
209        a: createCannedQuery(CYCLES_PER_P_STATE_PER_CPU),
210        i: 'search',
211      },
212      {
213        t: 'CPU Time by CPU by process',
214        a: createCannedQuery(CPU_TIME_BY_CPU_BY_PROCESS),
215        i: 'search',
216      },
217      {
218        t: 'Heap Graph: Bytes per type',
219        a: createCannedQuery(HEAP_GRAPH_BYTES_PER_TYPE),
220        i: 'search',
221      },
222      {
223        t: 'Debug SQL performance',
224        a: createCannedQuery(SQL_STATS),
225        i: 'bug_report',
226      },
227    ],
228  },
229  {
230    title: 'Support',
231    summary: 'Documentation & Bugs',
232    items: [
233      {
234        t: 'Controls',
235        a: openHelp,
236        i: 'help',
237      },
238      {
239        t: 'Documentation',
240        a: 'https://perfetto.dev',
241        i: 'find_in_page',
242      },
243      {
244        t: 'Report a bug',
245        a: 'https://goto.google.com/perfetto-ui-bug',
246        i: 'bug_report',
247      },
248    ],
249  },
250
251];
252
253const vidSection = {
254  title: 'Video',
255  summary: 'Open a screen recording',
256  expanded: true,
257  items: [
258    {t: 'Open video file', a: popupVideoSelectionDialog, i: 'folder_open'},
259  ],
260};
261
262function openHelp(e: Event) {
263  e.preventDefault();
264  toggleHelp();
265}
266
267function getFileElement(): HTMLInputElement {
268  return document.querySelector('input[type=file]')! as HTMLInputElement;
269}
270
271function popupFileSelectionDialog(e: Event) {
272  e.preventDefault();
273  delete getFileElement().dataset['useCatapultLegacyUi'];
274  delete getFileElement().dataset['video'];
275  getFileElement().click();
276}
277
278function popupFileSelectionDialogOldUI(e: Event) {
279  e.preventDefault();
280  delete getFileElement().dataset['video'];
281  getFileElement().dataset['useCatapultLegacyUi'] = '1';
282  getFileElement().click();
283}
284
285function openCurrentTraceWithOldUI(e: Event) {
286  e.preventDefault();
287  console.assert(isTraceLoaded());
288  globals.logging.logEvent('Trace Actions', 'Open current trace in legacy UI');
289  if (!isTraceLoaded) return;
290  const engine = Object.values(globals.state.engines)[0];
291  const src = engine.source;
292  if (src.type === 'ARRAY_BUFFER') {
293    openInOldUIWithSizeCheck(new Blob([src.buffer]));
294  } else if (src.type === 'FILE') {
295    openInOldUIWithSizeCheck(src.file);
296  } else if (src.type === 'URL') {
297    m.request({
298       method: 'GET',
299       url: src.url,
300       // TODO(hjd): Once mithril is updated we can use responseType here rather
301       // than using config and remove the extract below.
302       config: xhr => {
303         xhr.responseType = 'blob';
304         xhr.onprogress = progress => {
305           const percent = (100 * progress.loaded / progress.total).toFixed(1);
306           globals.dispatch(Actions.updateStatus({
307             msg: `Downloading trace ${percent}%`,
308             timestamp: Date.now() / 1000,
309           }));
310         };
311       },
312       extract: xhr => {
313         return xhr.response;
314       }
315     }).then(result => {
316      openInOldUIWithSizeCheck(result as Blob);
317    });
318  } else {
319    throw new Error(`Loading to catapult from source with type ${src.type}`);
320  }
321}
322
323function isTraceLoaded(): boolean {
324  const engine = Object.values(globals.state.engines)[0];
325  return engine !== undefined;
326}
327
328function popupVideoSelectionDialog(e: Event) {
329  e.preventDefault();
330  delete getFileElement().dataset['useCatapultLegacyUi'];
331  getFileElement().dataset['video'] = '1';
332  getFileElement().click();
333}
334
335function openTraceUrl(url: string): (e: Event) => void {
336  return e => {
337    globals.logging.logEvent('Trace Actions', 'Open example trace');
338    e.preventDefault();
339    globals.frontendLocalState.localOnlyMode = false;
340    globals.dispatch(Actions.openTraceFromUrl({url}));
341  };
342}
343
344function onInputElementFileSelectionChanged(e: Event) {
345  if (!(e.target instanceof HTMLInputElement)) {
346    throw new Error('Not an input element');
347  }
348  if (!e.target.files) return;
349  const file = e.target.files[0];
350  // Reset the value so onchange will be fired with the same file.
351  e.target.value = '';
352
353  globals.frontendLocalState.localOnlyMode = false;
354
355  if (e.target.dataset['useCatapultLegacyUi'] === '1') {
356    openWithLegacyUi(file);
357    return;
358  }
359
360  if (e.target.dataset['video'] === '1') {
361    // TODO(hjd): Update this to use a controller and publish.
362    globals.dispatch(Actions.executeQuery({
363      engineId: '0', queryId: 'command',
364      query: `select ts from slices where name = 'first_frame' union ` +
365             `select start_ts from trace_bounds`}));
366    setTimeout(() => {
367      const resp = globals.queryResults.get('command') as QueryResponse;
368      // First value is screenrecord trace event timestamp
369      // and second value is trace boundary's start timestamp
370      const offset = (Number(resp.rows[1]['ts'].toString()) -
371                      Number(resp.rows[0]['ts'].toString())) /
372          1e9;
373      globals.queryResults.delete('command');
374      globals.rafScheduler.scheduleFullRedraw();
375      globals.dispatch(Actions.deleteQuery({queryId: 'command'}));
376      globals.dispatch(Actions.setVideoOffset({offset}));
377    }, 1000);
378    globals.dispatch(Actions.openVideoFromFile({file}));
379    return;
380  }
381  globals.logging.logEvent('Trace Actions', 'Open trace from file');
382  globals.dispatch(Actions.openTraceFromFile({file}));
383}
384
385async function openWithLegacyUi(file: File) {
386  // Switch back to the old catapult UI.
387  globals.logging.logEvent('Trace Actions', 'Open trace in Legacy UI');
388  if (await isLegacyTrace(file)) {
389    openFileWithLegacyTraceViewer(file);
390    return;
391  }
392  openInOldUIWithSizeCheck(file);
393}
394
395function openInOldUIWithSizeCheck(trace: Blob) {
396  // Perfetto traces smaller than 50mb can be safely opened in the legacy UI.
397  if (trace.size < 1024 * 1024 * 50) {
398    globals.dispatch(Actions.convertTraceToJson({file: trace}));
399    return;
400  }
401
402  // Give the user the option to truncate larger perfetto traces.
403  const size = Math.round(trace.size / (1024 * 1024));
404  showModal({
405    title: 'Legacy UI may fail to open this trace',
406    content:
407        m('div',
408          m('p',
409            `This trace is ${size}mb, opening it in the legacy UI ` +
410                `may fail.`),
411          m('p',
412            'More options can be found at ',
413            m('a',
414              {
415                href: 'https://goto.google.com/opening-large-traces',
416                target: '_blank'
417              },
418              'go/opening-large-traces'),
419            '.')),
420    buttons: [
421      {
422        text: 'Open full trace (not recommended)',
423        primary: false,
424        id: 'open',
425        action: () => {
426          globals.dispatch(Actions.convertTraceToJson({file: trace}));
427        }
428      },
429      {
430        text: 'Open beginning of trace',
431        primary: true,
432        id: 'truncate-start',
433        action: () => {
434          globals.dispatch(
435              Actions.convertTraceToJson({file: trace, truncate: 'start'}));
436        }
437      },
438      {
439        text: 'Open end of trace',
440        primary: true,
441        id: 'truncate-end',
442        action: () => {
443          globals.dispatch(
444              Actions.convertTraceToJson({file: trace, truncate: 'end'}));
445        }
446      }
447
448    ]
449  });
450  return;
451}
452
453function navigateRecord(e: Event) {
454  e.preventDefault();
455  globals.dispatch(Actions.navigate({route: '/record'}));
456}
457
458function navigateAnalyze(e: Event) {
459  e.preventDefault();
460  globals.dispatch(Actions.navigate({route: '/query'}));
461}
462
463function navigateMetrics(e: Event) {
464  e.preventDefault();
465  globals.dispatch(Actions.navigate({route: '/metrics'}));
466}
467
468function navigateInfo(e: Event) {
469  e.preventDefault();
470  globals.dispatch(Actions.navigate({route: '/info'}));
471}
472
473function navigateViewer(e: Event) {
474  e.preventDefault();
475  globals.dispatch(Actions.navigate({route: '/viewer'}));
476}
477
478function shareTrace(e: Event) {
479  e.preventDefault();
480  const engine = assertExists(Object.values(globals.state.engines)[0]);
481  const traceUrl = (engine.source as (TraceArrayBufferSource)).url || '';
482
483  // If the trace is not shareable (has been pushed via postMessage()) but has
484  // a url, create a pseudo-permalink by echoing back the URL.
485  if (!isShareable()) {
486    const msg =
487        [m('p',
488           'This trace was opened by an external site and as such cannot ' +
489               'be re-shared preserving the UI state.')];
490    if (traceUrl) {
491      msg.push(m('p', 'By using the URL below you can open this trace again.'));
492      msg.push(m('p', 'Clicking will copy the URL into the clipboard.'));
493      msg.push(createTraceLink(traceUrl, traceUrl));
494    }
495
496    showModal({
497      title: 'Cannot create permalink from external trace',
498      content: m('div', msg),
499      buttons: []
500    });
501    return;
502  }
503
504  if (!isShareable() || !isTraceLoaded()) return;
505
506  const result = confirm(
507      `Upload the trace and generate a permalink. ` +
508      `The trace will be accessible by anybody with the permalink.`);
509  if (result) {
510    globals.logging.logEvent('Trace Actions', 'Create permalink');
511    globals.dispatch(Actions.createPermalink({isRecordingConfig: false}));
512  }
513}
514
515function downloadTrace(e: Event) {
516  e.preventDefault();
517  if (!isDownloadable() || !isTraceLoaded()) return;
518  globals.logging.logEvent('Trace Actions', 'Download trace');
519
520  const engine = Object.values(globals.state.engines)[0];
521  if (!engine) return;
522  let url = '';
523  let fileName = `trace${TRACE_SUFFIX}`;
524  const src = engine.source;
525  if (src.type === 'URL') {
526    url = src.url;
527    fileName = url.split('/').slice(-1)[0];
528  } else if (src.type === 'ARRAY_BUFFER') {
529    const blob = new Blob([src.buffer], {type: 'application/octet-stream'});
530    if (src.fileName) {
531      fileName = src.fileName;
532    }
533    url = URL.createObjectURL(blob);
534  } else if (src.type === 'FILE') {
535    const file = src.file;
536    url = URL.createObjectURL(file);
537    fileName = file.name;
538  } else {
539    throw new Error(`Download from ${JSON.stringify(src)} is not supported`);
540  }
541
542  const a = document.createElement('a');
543  a.href = url;
544  a.download = fileName;
545  a.target = '_blank';
546  document.body.appendChild(a);
547  a.click();
548  document.body.removeChild(a);
549  URL.revokeObjectURL(url);
550}
551
552
553const EngineRPCWidget: m.Component = {
554  view() {
555    let cssClass = '';
556    let title = 'Number of pending SQL queries';
557    let label: string;
558    let failed = false;
559    let mode: EngineMode|undefined;
560
561    // We are assuming we have at most one engine here.
562    const engines = Object.values(globals.state.engines);
563    assertTrue(engines.length <= 1);
564    for (const engine of engines) {
565      mode = engine.mode;
566      if (engine.failed !== undefined) {
567        cssClass += '.red';
568        title = 'Query engine crashed\n' + engine.failed;
569        failed = true;
570      }
571    }
572
573    // If we don't have an engine yet, guess what will be the mode that will
574    // be used next time we'll create one. Even if we guess it wrong (somehow
575    // trace_controller.ts takes a different decision later, e.g. because the
576    // RPC server is shut down after we load the UI and cached httpRpcState)
577    // this will eventually become  consistent once the engine is created.
578    if (mode === undefined) {
579      if (globals.frontendLocalState.httpRpcState.connected &&
580          globals.state.newEngineMode === 'USE_HTTP_RPC_IF_AVAILABLE') {
581        mode = 'HTTP_RPC';
582      } else {
583        mode = 'WASM';
584      }
585    }
586
587    if (mode === 'HTTP_RPC') {
588      cssClass += '.green';
589      label = 'RPC';
590      title += '\n(Query engine: native accelerator over HTTP+RPC)';
591    } else {
592      label = 'WSM';
593      title += '\n(Query engine: built-in WASM)';
594    }
595
596    return m(
597        `.dbg-info-square${cssClass}`,
598        {title},
599        m('div', label),
600        m('div', `${failed ? 'FAIL' : globals.numQueuedQueries}`));
601  }
602};
603
604const ServiceWorkerWidget: m.Component = {
605  view() {
606    let cssClass = '';
607    let title = 'Service Worker: ';
608    let label = 'N/A';
609    const ctl = globals.serviceWorkerController;
610    if ((!('serviceWorker' in navigator))) {
611      label = 'N/A';
612      title += 'not supported by the browser (requires HTTPS)';
613    } else if (ctl.bypassed) {
614      label = 'OFF';
615      cssClass = '.red';
616      title += 'Bypassed, using live network. Double-click to re-enable';
617    } else if (ctl.installing) {
618      label = 'UPD';
619      cssClass = '.amber';
620      title += 'Installing / updating ...';
621    } else if (!navigator.serviceWorker.controller) {
622      label = 'N/A';
623      title += 'Not available, using network';
624    } else {
625      label = 'ON';
626      cssClass = '.green';
627      title += 'Serving from cache. Ready for offline use';
628    }
629
630    const toggle = async () => {
631      if (globals.serviceWorkerController.bypassed) {
632        globals.serviceWorkerController.setBypass(false);
633        return;
634      }
635      showModal({
636        title: 'Disable service worker?',
637        content: m(
638            'div',
639            m('p', `If you continue the service worker will be disabled until
640                      manually re-enabled.`),
641            m('p', `All future requests will be served from the network and the
642                    UI won't be available offline.`),
643            m('p', `You should do this only if you are debugging the UI
644                    or if you are experiencing caching-related problems.`),
645            m('p', `Disabling will cause a refresh of the UI, the current state
646                    will be lost.`),
647            ),
648        buttons: [
649          {
650            text: 'Disable and reload',
651            primary: true,
652            id: 'sw-bypass-enable',
653            action: () => {
654              globals.serviceWorkerController.setBypass(true).then(
655                  () => location.reload());
656            }
657          },
658          {
659            text: 'Cancel',
660            primary: false,
661            id: 'sw-bypass-cancel',
662            action: () => {}
663          }
664        ]
665      });
666    };
667
668    return m(
669        `.dbg-info-square${cssClass}`,
670        {title, ondblclick: toggle},
671        m('div', 'SW'),
672        m('div', label));
673  }
674};
675
676const SidebarFooter: m.Component = {
677  view() {
678    return m(
679        '.sidebar-footer',
680        m('button',
681          {
682            onclick: () => globals.frontendLocalState.togglePerfDebug(),
683          },
684          m('i.material-icons',
685            {title: 'Toggle Perf Debug Mode'},
686            'assessment')),
687        m(EngineRPCWidget),
688        m(ServiceWorkerWidget),
689        m(
690            '.version',
691            m('a',
692              {
693                href: `https://github.com/google/perfetto/tree/${
694                    version.SCM_REVISION}/ui`,
695                title: `Channel: ${globals.channel}`,
696                target: '_blank',
697              },
698              `${version.VERSION}`),
699            ),
700    );
701  }
702};
703
704
705export class Sidebar implements m.ClassComponent {
706  private _redrawWhileAnimating =
707      new Animation(() => globals.rafScheduler.scheduleFullRedraw());
708  view() {
709    const vdomSections = [];
710    for (const section of SECTIONS) {
711      if (section.hideIfNoTraceLoaded && !isTraceLoaded()) continue;
712      const vdomItems = [];
713      for (const item of section.items) {
714        let css = '';
715        let attrs = {
716          onclick: typeof item.a === 'function' ? item.a : null,
717          href: typeof item.a === 'string' ? item.a : '#',
718          target: typeof item.a === 'string' ? '_blank' : null,
719          disabled: false,
720        };
721        if (item.a === openCurrentTraceWithOldUI &&
722            globals.state.traceConversionInProgress) {
723          attrs.onclick = e => e.preventDefault();
724          css = '.pending';
725        }
726        if ((item as {internalUserOnly: boolean}).internalUserOnly === true) {
727          if (!globals.isInternalUser) continue;
728        }
729        if (!isDownloadable() && item.hasOwnProperty('checkDownloadDisabled')) {
730          attrs = {
731            onclick: e => {
732              e.preventDefault();
733              alert('Can not download external trace.');
734            },
735            href: '#',
736            target: null,
737            disabled: true,
738          };
739        }
740        vdomItems.push(m(
741            'li', m(`a${css}`, attrs, m('i.material-icons', item.i), item.t)));
742      }
743      if (section.appendOpenedTraceTitle) {
744        const engines = Object.values(globals.state.engines);
745        if (engines.length === 1) {
746          let traceTitle = '';
747          let traceUrl = '';
748          switch (engines[0].source.type) {
749            case 'FILE':
750              // Split on both \ and / (because C:\Windows\paths\are\like\this).
751              traceTitle = engines[0].source.file.name.split(/[/\\]/).pop()!;
752              const fileSizeMB = Math.ceil(engines[0].source.file.size / 1e6);
753              traceTitle += ` (${fileSizeMB} MB)`;
754              break;
755            case 'URL':
756              traceUrl = engines[0].source.url;
757              traceTitle = traceUrl.split('/').pop()!;
758              break;
759            case 'ARRAY_BUFFER':
760              traceTitle = engines[0].source.title;
761              traceUrl = engines[0].source.url || '';
762              break;
763            case 'HTTP_RPC':
764              traceTitle = 'External trace (RPC)';
765              break;
766            default:
767              break;
768          }
769          if (traceTitle !== '') {
770            const tabTitle = `${traceTitle} - Perfetto UI`;
771            if (tabTitle !== lastTabTitle) {
772              document.title = lastTabTitle = tabTitle;
773            }
774            vdomItems.unshift(m('li', createTraceLink(traceTitle, traceUrl)));
775          }
776        }
777      }
778      vdomSections.push(
779          m(`section${section.expanded ? '.expanded' : ''}`,
780            m('.section-header',
781              {
782                onclick: () => {
783                  section.expanded = !section.expanded;
784                  globals.rafScheduler.scheduleFullRedraw();
785                }
786              },
787              m('h1', {title: section.summary}, section.title),
788              m('h2', section.summary)),
789            m('.section-content', m('ul', vdomItems))));
790    }
791    if (globals.state.videoEnabled) {
792      const videoVdomItems = [];
793      for (const item of vidSection.items) {
794        videoVdomItems.push(
795          m('li',
796            m(`a`,
797              {
798                onclick: typeof item.a === 'function' ? item.a : null,
799                href: typeof item.a === 'string' ? item.a : '#',
800              },
801              m('i.material-icons', item.i),
802              item.t)));
803      }
804      vdomSections.push(
805        m(`section${vidSection.expanded ? '.expanded' : ''}`,
806          m('.section-header',
807            {
808              onclick: () => {
809                vidSection.expanded = !vidSection.expanded;
810                globals.rafScheduler.scheduleFullRedraw();
811              }
812            },
813            m('h1', vidSection.title),
814            m('h2', vidSection.summary), ),
815          m('.section-content', m('ul', videoVdomItems))));
816    }
817    return m(
818        'nav.sidebar',
819        {
820          class: globals.frontendLocalState.sidebarVisible ? 'show-sidebar' :
821                                                             'hide-sidebar',
822          // 150 here matches --sidebar-timing in the css.
823          ontransitionstart: () => this._redrawWhileAnimating.start(150),
824          ontransitionend: () => this._redrawWhileAnimating.stop(),
825        },
826        m(
827            `header.${globals.channel}`,
828            m(`img[src=${globals.root}assets/brand.png].brand`),
829            m('button.sidebar-button',
830              {
831                onclick: () => {
832                  globals.frontendLocalState.toggleSidebar();
833                },
834              },
835              m('i.material-icons',
836                {
837                  title: globals.frontendLocalState.sidebarVisible ?
838                      'Hide menu' :
839                      'Show menu',
840                },
841                'menu')),
842            ),
843        m('input[type=file]', {onchange: onInputElementFileSelectionChanged}),
844        m('.sidebar-scroll',
845          m(
846              '.sidebar-scroll-container',
847              ...vdomSections,
848              m(SidebarFooter),
849              )),
850    );
851  }
852}
853
854function createTraceLink(title: string, url: string) {
855  if (url === '') {
856    return m('a.trace-file-name', title);
857  }
858  const linkProps = {
859    href: url,
860    title: 'Click to copy the URL',
861    target: '_blank',
862    onclick: (e: Event) => {
863      e.preventDefault();
864      copyToClipboard(url);
865      globals.dispatch(Actions.updateStatus({
866        msg: 'Link copied into the clipboard',
867        timestamp: Date.now() / 1000,
868      }));
869    },
870  };
871  return m('a.trace-file-name', linkProps, title);
872}
873