Merge "ui: Don't show reload toast"
diff --git a/src/base/paged_memory_unittest.cc b/src/base/paged_memory_unittest.cc
index 8a94941..fd2790d 100644
--- a/src/base/paged_memory_unittest.cc
+++ b/src/base/paged_memory_unittest.cc
@@ -164,7 +164,7 @@
 #endif  // ADDRESS_SANITIZER
 
 TEST(PagedMemoryTest, GuardRegions) {
-  const size_t kSize = 4096;
+  const size_t kSize = GetSysPageSize();
   PagedMemory mem = PagedMemory::Allocate(kSize);
   ASSERT_TRUE(mem.IsValid());
   volatile char* raw = reinterpret_cast<char*>(mem.Get());
diff --git a/src/ipc/buffered_frame_deserializer.cc b/src/ipc/buffered_frame_deserializer.cc
index 94d5a82..d893dfb 100644
--- a/src/ipc/buffered_frame_deserializer.cc
+++ b/src/ipc/buffered_frame_deserializer.cc
@@ -39,7 +39,7 @@
 BufferedFrameDeserializer::BufferedFrameDeserializer(size_t max_capacity)
     : capacity_(max_capacity) {
   PERFETTO_CHECK(max_capacity % base::GetSysPageSize() == 0);
-  PERFETTO_CHECK(max_capacity > base::GetSysPageSize());
+  PERFETTO_CHECK(max_capacity >= base::GetSysPageSize());
 }
 
 BufferedFrameDeserializer::~BufferedFrameDeserializer() = default;
diff --git a/src/trace_processor/export_json.cc b/src/trace_processor/export_json.cc
index 5dfac9d..560d477 100644
--- a/src/trace_processor/export_json.cc
+++ b/src/trace_processor/export_json.cc
@@ -984,6 +984,7 @@
                                                 SliceId slice_id,
                                                 std::string name,
                                                 std::string cat,
+                                                Json::Value args,
                                                 bool flow_begin) {
     const auto& slices = storage_->slice_table();
     const auto& thread_tracks = storage_->thread_track_table();
@@ -1012,6 +1013,7 @@
     if (!flow_begin) {
       event["bp"] = "e";
     }
+    event["args"] = std::move(args);
     return std::move(event);
   }
 
@@ -1025,10 +1027,14 @@
 
       std::string cat;
       std::string name;
+      auto args = args_builder_.GetArgs(arg_set_id);
       if (arg_set_id != kInvalidArgSetId) {
-        auto args = args_builder_.GetArgs(arg_set_id);
         cat = args["cat"].asString();
         name = args["name"].asString();
+        // Don't export these args since they are only used for this export and
+        // weren't part of the original event.
+        args.removeMember("name");
+        args.removeMember("cat");
       } else {
         auto opt_slice_out_idx = slice_table.id().IndexOf(slice_out);
         PERFETTO_DCHECK(opt_slice_out_idx.has_value());
@@ -1038,10 +1044,10 @@
         name = GetNonNullString(storage_, name_id);
       }
 
-      auto out_event =
-          CreateFlowEventV1(i, slice_out, name, cat, /* flow_begin = */ true);
-      auto in_event =
-          CreateFlowEventV1(i, slice_in, name, cat, /* flow_begin = */ false);
+      auto out_event = CreateFlowEventV1(i, slice_out, name, cat, args,
+                                         /* flow_begin = */ true);
+      auto in_event = CreateFlowEventV1(i, slice_in, name, cat, std::move(args),
+                                        /* flow_begin = */ false);
 
       if (out_event && in_event) {
         writer_.WriteCommonEvent(out_event.value());
diff --git a/src/trace_processor/trace_processor_impl.cc b/src/trace_processor/trace_processor_impl.cc
index d4f55d0..5ca9bbe 100644
--- a/src/trace_processor/trace_processor_impl.cc
+++ b/src/trace_processor/trace_processor_impl.cc
@@ -152,6 +152,17 @@
     PERFETTO_ELOG("Error initializing: %s", error);
     sqlite3_free(error);
   }
+  // This is a table intended to be used for metric debugging/developing. Data
+  // in the table is shown specially in the UI, and users can insert rows into
+  // this table to draw more things.
+  sqlite3_exec(db,
+               "CREATE TABLE debug_slices (id BIG INT, name STRING, ts BIG INT,"
+               "dur BIG INT, depth BIG INT)",
+               0, 0, &error);
+  if (error) {
+    PERFETTO_ELOG("Error initializing: %s", error);
+    sqlite3_free(error);
+  }
 
   // Initialize the bounds table with some data so even before parsing any data,
   // we still have a valid table.
diff --git a/tools/busy_threads/busy_threads.cc b/tools/busy_threads/busy_threads.cc
index 72b13dc..10f57f6 100644
--- a/tools/busy_threads/busy_threads.cc
+++ b/tools/busy_threads/busy_threads.cc
@@ -23,6 +23,7 @@
 
 #include "perfetto/base/logging.h"
 #include "perfetto/base/time.h"
+#include "perfetto/ext/base/scoped_file.h"
 
 #define PERFETTO_HAVE_PTHREADS                \
   (PERFETTO_BUILDFLAG(PERFETTO_OS_LINUX) ||   \
@@ -51,12 +52,13 @@
 void PrintUsage(const char* bin_name) {
 #if PERFETTO_HAVE_PTHREADS
   PERFETTO_ELOG(
-      "Usage: %s --threads=N --period_us=N --duty_cycle=[1-100] "
+      "Usage: %s [--background] --threads=N --period_us=N --duty_cycle=[1-100] "
       "[--thread_names=N]",
       bin_name);
 #else
-  PERFETTO_ELOG("Usage: %s --threads=N --period_us=N --duty_cycle=[1-100]",
-                bin_name);
+  PERFETTO_ELOG(
+      "Usage: %s [--background] --threads=N --period_us=N --duty_cycle=[1-100]",
+      bin_name);
 #endif
 }
 
@@ -92,15 +94,17 @@
 }
 
 int BusyThreadsMain(int argc, char** argv) {
+  bool background = false;
   int64_t num_threads = -1;
   int64_t period_us = -1;
   int64_t duty_cycle = -1;
   uint32_t thread_name_count = 0;
 
   static struct option long_options[] = {
+    {"background", no_argument, nullptr, 'd'},
     {"threads", required_argument, nullptr, 't'},
     {"period_us", required_argument, nullptr, 'p'},
-    {"duty_cycle", required_argument, nullptr, 'd'},
+    {"duty_cycle", required_argument, nullptr, 'c'},
 #if PERFETTO_HAVE_PTHREADS
     {"thread_names", required_argument, nullptr, 'r'},
 #endif
@@ -110,13 +114,16 @@
   int c;
   while ((c = getopt_long(argc, argv, "", long_options, &option_index)) != -1) {
     switch (c) {
+      case 'd':
+        background = true;
+        break;
       case 't':
         num_threads = atol(optarg);
         break;
       case 'p':
         period_us = atol(optarg);
         break;
-      case 'd':
+      case 'c':
         duty_cycle = atol(optarg);
         break;
 #if PERFETTO_HAVE_PTHREADS
@@ -134,6 +141,30 @@
     return 1;
   }
 
+  if (background) {
+    pid_t pid;
+    switch (pid = fork()) {
+      case -1:
+        PERFETTO_FATAL("fork");
+      case 0: {
+        PERFETTO_CHECK(setsid() != -1);
+        base::ignore_result(chdir("/"));
+        base::ScopedFile null = base::OpenFile("/dev/null", O_RDONLY);
+        PERFETTO_CHECK(null);
+        PERFETTO_CHECK(dup2(*null, STDIN_FILENO) != -1);
+        PERFETTO_CHECK(dup2(*null, STDOUT_FILENO) != -1);
+        PERFETTO_CHECK(dup2(*null, STDERR_FILENO) != -1);
+        // Do not accidentally close stdin/stdout/stderr.
+        if (*null <= 2)
+          null.release();
+        break;
+      }
+      default:
+        printf("%d\n", pid);
+        exit(0);
+    }
+  }
+
   int64_t busy_us =
       static_cast<int64_t>(static_cast<double>(period_us) *
                            (static_cast<double>(duty_cycle) / 100.0));
diff --git a/ui/index.html b/ui/index.html
index 70c765a..b214653 100644
--- a/ui/index.html
+++ b/ui/index.html
@@ -4,7 +4,8 @@
   <title>Perfetto UI</title>
   <!-- See b/149573396 for CSP rationale. -->
   <!-- TODO(b/121211019): remove script-src-elem rule once fixed. -->
-  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src-elem 'self' https://*.google.com https://*.googleusercontent.com https://www.googletagmanager.com https://www.google-analytics.com 'sha256-eYlPNiizBKy/rhHAaz06RXrXVsKmBN6tTFYwmJTvcwc='; object-src 'none'; connect-src 'self' http://127.0.0.1:9001 https://www.google-analytics.com https://*.googleapis.com; img-src 'self' https://www.google-analytics.com; navigate-to https://*.perfetto.dev;">  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
+  <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src-elem 'self' https://*.google.com https://*.googleusercontent.com https://www.googletagmanager.com https://www.google-analytics.com 'sha256-eYlPNiizBKy/rhHAaz06RXrXVsKmBN6tTFYwmJTvcwc='; object-src 'none'; connect-src 'self' http://127.0.0.1:9001 https://www.google-analytics.com https://*.googleapis.com blob: data:; img-src 'self' https://www.google-analytics.com; navigate-to https://*.perfetto.dev;">
+  <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0" name="viewport" />
   <link href="perfetto.css" rel="stylesheet">
   <link rel="icon" type="image/png" href="assets/favicon.png">
   <!-- Global site tag (gtag.js) - Google Analytics -->
diff --git a/ui/src/assets/common.scss b/ui/src/assets/common.scss
index c926284..f6f5fa5 100644
--- a/ui/src/assets/common.scss
+++ b/ui/src/assets/common.scss
@@ -650,3 +650,7 @@
     cursor: pointer;
   }
 }
+
+.disallow-selection {
+  user-select: none;
+}
diff --git a/ui/src/common/actions.ts b/ui/src/common/actions.ts
index 2625b95..9441418 100644
--- a/ui/src/common/actions.ts
+++ b/ui/src/common/actions.ts
@@ -17,6 +17,7 @@
 import {assertExists} from '../base/logging';
 import {randomColor} from '../common/colorizer';
 import {ConvertTrace, ConvertTraceToPprof} from '../controller/trace_converter';
+import {DEBUG_SLICE_TRACK_KIND} from '../tracks/debug_slices/common';
 
 import {DEFAULT_VIEWING_OPTION} from './flamegraph_util';
 import {
@@ -197,6 +198,34 @@
     };
   },
 
+  addDebugTrack(state: StateDraft, args: {engineId: string, name: string}):
+      void {
+        if (state.debugTrackId !== undefined) return;
+        const trackId = `${state.nextId++}`;
+        state.debugTrackId = trackId;
+        this.addTrack(state, {
+          id: trackId,
+          engineId: args.engineId,
+          kind: DEBUG_SLICE_TRACK_KIND,
+          name: args.name,
+          trackGroup: SCROLLING_TRACK_GROUP,
+          config: {
+            maxDepth: 1,
+          }
+        });
+        this.toggleTrackPinned(state, {trackId});
+      },
+
+  removeDebugTrack(state: StateDraft, _: {}): void {
+    const {debugTrackId} = state;
+    if (debugTrackId === undefined) return;
+    delete state.tracks[debugTrackId];
+    state.scrollingTracks =
+        state.scrollingTracks.filter(id => id !== debugTrackId);
+    state.pinnedTracks = state.pinnedTracks.filter(id => id !== debugTrackId);
+    state.debugTrackId = undefined;
+  },
+
   updateAggregateSorting(
       state: StateDraft, args: {id: string, column: string}) {
     let prefs = state.aggregatePreferences[args.id];
@@ -298,6 +327,14 @@
         trackGroup.collapsed = !trackGroup.collapsed;
       },
 
+  requestTrackReload(state: StateDraft, _: {}) {
+    if (state.lastTrackReloadRequest) {
+      state.lastTrackReloadRequest++;
+    } else {
+      state.lastTrackReloadRequest = 1;
+    }
+  },
+
   setEngineReady(
       state: StateDraft,
       args: {engineId: string; ready: boolean, mode: EngineMode}): void {
diff --git a/ui/src/common/state.ts b/ui/src/common/state.ts
index b053df4..f2dc00f 100644
--- a/ui/src/common/state.ts
+++ b/ui/src/common/state.ts
@@ -274,6 +274,8 @@
   visibleTracks: string[];
   scrollingTracks: string[];
   pinnedTracks: string[];
+  debugTrackId?: string;
+  lastTrackReloadRequest?: number;
   queries: ObjectById<QueryConfig>;
   permalink: PermalinkConfig;
   notes: ObjectById<Note|AreaNote>;
diff --git a/ui/src/controller/permalink_controller.ts b/ui/src/controller/permalink_controller.ts
index 039498c..4f955af 100644
--- a/ui/src/controller/permalink_controller.ts
+++ b/ui/src/controller/permalink_controller.ts
@@ -123,6 +123,12 @@
   private static async loadState(id: string): Promise<State|RecordConfig> {
     const url = `https://storage.googleapis.com/${BUCKET_NAME}/${id}`;
     const response = await fetch(url);
+    if (!response.ok) {
+      throw new Error(
+          `Could not fetch permalink.\n` +
+          `Are you sure the id (${id}) is correct?\n` +
+          `URL: ${url}`);
+    }
     const text = await response.text();
     const stateHash = await toSha256(text);
     const state = JSON.parse(text);
diff --git a/ui/src/controller/track_controller.ts b/ui/src/controller/track_controller.ts
index 381ebc8..ff27d17 100644
--- a/ui/src/controller/track_controller.ts
+++ b/ui/src/controller/track_controller.ts
@@ -41,6 +41,7 @@
   private requestingData = false;
   private queuedRequest = false;
   private isSetup = false;
+  private lastReloadHandled = 0;
 
   // We choose 100000 as the table size to cache as this is roughly the point
   // where SQLite sorts start to become expensive.
@@ -60,6 +61,10 @@
   // to be performed before the first onBoundsChange invcation.
   async onSetup() {}
 
+  // Can be overriden by the track implementation to allow some one-off work
+  // when requested reload (e.g. recalculating height).
+  async onReload() {}
+
   // Must be overridden by the track implementation. Is invoked when the track
   // frontend runs out of cached data. The derived track controller is expected
   // to publish new track data in response to this call.
@@ -113,8 +118,19 @@
     return result;
   }
 
+  private shouldReload(): boolean {
+    const {lastTrackReloadRequest} = globals.state;
+    return !!lastTrackReloadRequest &&
+        this.lastReloadHandled < lastTrackReloadRequest;
+  }
+
+  private markReloadHandled() {
+    this.lastReloadHandled = globals.state.lastTrackReloadRequest || 0;
+  }
+
   shouldRequestData(traceTime: TraceTime): boolean {
     if (this.data === undefined) return true;
+    if (this.shouldReload()) return true;
 
     // If at the limit only request more data if the view has moved.
     const atLimit = this.data.length === LIMIT;
@@ -227,11 +243,13 @@
         this.queuedRequest = true;
       } else {
         this.requestingData = true;
-        let setupPromise = Promise.resolve();
+        let promise = Promise.resolve();
         if (!this.isSetup) {
-          setupPromise = this.onSetup();
+          promise = this.onSetup();
+        } else if (this.shouldReload()) {
+          promise = this.onReload().then(() => this.markReloadHandled());
         }
-        setupPromise
+        promise
             .then(() => {
               this.isSetup = true;
               return this.onBoundsChange(
diff --git a/ui/src/frontend/android_bug_tool.ts b/ui/src/frontend/android_bug_tool.ts
new file mode 100644
index 0000000..ff63f58
--- /dev/null
+++ b/ui/src/frontend/android_bug_tool.ts
@@ -0,0 +1,67 @@
+import {defer} from '../base/deferred';
+
+enum WebContentScriptMessageType {
+  UNKNOWN,
+  CONVERT_OBJECT_URL,
+  CONVERT_OBJECT_URL_RESPONSE,
+}
+
+const ANDROID_BUG_TOOL_EXTENSION_ID = 'mbbaofdfoekifkfpgehgffcpagbbjkmj';
+
+interface Attachment {
+  name: string;
+  objectUrl: string;
+  restrictionSeverity: number;
+}
+
+interface ConvertObjectUrlResponse {
+  action: WebContentScriptMessageType.CONVERT_OBJECT_URL_RESPONSE;
+  attachments: Attachment[];
+  issueAccessLevel: string;
+  issueId: string;
+  issueTitle: string;
+}
+
+export interface TraceFromBuganizer {
+  issueId: string;
+  issueTitle: string;
+  file: File;
+}
+
+export function loadAndroidBugToolInfo(): Promise<TraceFromBuganizer> {
+  const deferred = defer<TraceFromBuganizer>();
+
+  // Request to convert the blob object url "blob:chrome-extension://xxx"
+  // the chrome extension has to a web downloadable url "blob:http://xxx".
+  chrome.runtime.sendMessage(
+      ANDROID_BUG_TOOL_EXTENSION_ID,
+      {action: WebContentScriptMessageType.CONVERT_OBJECT_URL},
+      async (response: ConvertObjectUrlResponse) => {
+        switch (response.action) {
+          case WebContentScriptMessageType.CONVERT_OBJECT_URL_RESPONSE:
+          if (response.attachments?.length > 0) {
+            const filesBlobPromises =
+                response.attachments.map(async attachment => {
+                  const fileQueryResponse = await fetch(attachment.objectUrl);
+                  const blob = await fileQueryResponse.blob();
+                  // Note: The blob's media type is always set to "image/png".
+                  // Clone blob to clear media type.
+                  return new File([blob], attachment.name);
+                });
+            const files = await Promise.all(filesBlobPromises);
+            deferred.resolve({
+              issueId: response.issueId,
+              issueTitle: response.issueTitle,
+              file: files[0],
+            });
+          } else {
+            throw new Error('Got no attachements from extension');
+          }
+          break;
+          default:
+            throw new Error(`Received unhandled response code (${
+                response.action}) from extension.`);
+        }
+      });
+  return deferred;
+}
diff --git a/ui/src/frontend/error_dialog.ts b/ui/src/frontend/error_dialog.ts
index 1c44b98..211d6e5 100644
--- a/ui/src/frontend/error_dialog.ts
+++ b/ui/src/frontend/error_dialog.ts
@@ -85,25 +85,20 @@
     shareTraceSection.push(
         m(`input[type=checkbox]`, {
           checked,
-          oninput: m.withAttr(
-              'checked',
-              value => {
-                checked = value;
-                if (value && engine.source.type === 'FILE') {
-                  saveTrace(engine.source.file).then((url) => {
-                    const errMessage = createErrorMessage(errLog, checked, url);
-                    renderModal(
-                        errTitle,
-                        errMessage,
-                        userDescription,
-                        shareTraceSection);
-                    return;
-                  });
-                }
-                const errMessage = createErrorMessage(errLog, checked);
+          oninput: (ev: InputEvent) => {
+            checked = (ev.target as HTMLInputElement).checked;
+            if (checked && engine.source.type === 'FILE') {
+              saveTrace(engine.source.file).then(url => {
+                const errMessage = createErrorMessage(errLog, checked, url);
                 renderModal(
                     errTitle, errMessage, userDescription, shareTraceSection);
-              })
+                return;
+              });
+            }
+            const errMessage = createErrorMessage(errLog, checked);
+            renderModal(
+                errTitle, errMessage, userDescription, shareTraceSection);
+          },
         }),
         m('span', `Check this box to share the current trace for debugging 
      purposes.`),
@@ -134,11 +129,9 @@
           m('textarea.modal-textarea', {
             rows: 3,
             maxlength: 1000,
-            oninput: m.withAttr(
-                'value',
-                v => {
-                  userDescription = v;
-                })
+            oninput: (ev: InputEvent) => {
+              userDescription = (ev.target as HTMLTextAreaElement).value;
+            },
           }),
           shareTraceSection),
     buttons: [
diff --git a/ui/src/frontend/frontend_local_state.ts b/ui/src/frontend/frontend_local_state.ts
index e3809a5..519b74b 100644
--- a/ui/src/frontend/frontend_local_state.ts
+++ b/ui/src/frontend/frontend_local_state.ts
@@ -170,7 +170,6 @@
 
   // Called when beginning a canvas redraw.
   clearVisibleTracks() {
-    this.prevVisibleTracks = new Set(this.visibleTracks);
     this.visibleTracks.clear();
   }
 
@@ -181,6 +180,7 @@
             value => this.visibleTracks.has(value))) {
       globals.dispatch(
           Actions.setVisibleTracks({tracks: Array.from(this.visibleTracks)}));
+      this.prevVisibleTracks = new Set(this.visibleTracks);
     }
   }
 
diff --git a/ui/src/frontend/index.ts b/ui/src/frontend/index.ts
index 7ff1840..6d51c73 100644
--- a/ui/src/frontend/index.ts
+++ b/ui/src/frontend/index.ts
@@ -31,6 +31,7 @@
 import {CurrentSearchResults, SearchSummary} from '../common/search_data';
 
 import {AnalyzePage} from './analyze_page';
+import {loadAndroidBugToolInfo} from './android_bug_tool';
 import {maybeShowErrorDialog} from './error_dialog';
 import {
   CounterDetails,
@@ -49,6 +50,7 @@
 import {RecordPage, updateAvailableAdbDevices} from './record_page';
 import {Router} from './router';
 import {CheckHttpRpcConnection} from './rpc_http_dialog';
+import {taskTracker} from './task_tracker';
 import {TraceInfoPage} from './trace_info_page';
 import {ViewerPage} from './viewer_page';
 
@@ -318,14 +320,32 @@
   // /?s=xxxx for permalinks.
   const stateHash = Router.param('s');
   const urlHash = Router.param('url');
-  if (stateHash) {
+  const androidBugTool = Router.param('openFromAndroidBugTool');
+  if (typeof stateHash === 'string' && stateHash) {
     globals.dispatch(Actions.loadPermalink({
       hash: stateHash,
     }));
-  } else if (urlHash) {
+  } else if (typeof urlHash === 'string' && urlHash) {
     globals.dispatch(Actions.openTraceFromUrl({
       url: urlHash,
     }));
+  } else if (androidBugTool) {
+    // TODO(hjd): Unify updateStatus and TaskTracker
+    globals.dispatch(Actions.updateStatus({
+      msg: 'Loading trace from ABT extension',
+      timestamp: Date.now() / 1000
+    }));
+    const loadInfo = loadAndroidBugToolInfo();
+    taskTracker.trackPromise(loadInfo, 'Loading trace from ABT extension');
+    loadInfo
+        .then(info => {
+          globals.dispatch(Actions.openTraceFromFile({
+            file: info.file,
+          }));
+        })
+        .catch(e => {
+          console.error(e);
+        });
   }
 
   // Prevent pinch zoom.
diff --git a/ui/src/frontend/notes_panel.ts b/ui/src/frontend/notes_panel.ts
index 0aae5a9..4e1365d 100644
--- a/ui/src/frontend/notes_panel.ts
+++ b/ui/src/frontend/notes_panel.ts
@@ -273,25 +273,23 @@
               e.stopImmediatePropagation();
             },
             value: note.text,
-            onchange: m.withAttr(
-                'value',
-                newText => {
-                  globals.dispatch(Actions.changeNoteText({
-                    id: attrs.id,
-                    newText,
-                  }));
-                }),
+            onchange: (e: InputEvent) => {
+              const newText = (e.target as HTMLInputElement).value;
+              globals.dispatch(Actions.changeNoteText({
+                id: attrs.id,
+                newText,
+              }));
+            },
           }),
           m('span.color-change', `Change color: `, m('input[type=color]', {
               value: note.color,
-              onchange: m.withAttr(
-                  'value',
-                  newColor => {
-                    globals.dispatch(Actions.changeNoteColor({
-                      id: attrs.id,
-                      newColor,
-                    }));
-                  }),
+              onchange: (e: Event) => {
+                const newColor = (e.target as HTMLInputElement).value;
+                globals.dispatch(Actions.changeNoteColor({
+                  id: attrs.id,
+                  newColor,
+                }));
+              },
             })),
           m('button',
             {
diff --git a/ui/src/frontend/pages.ts b/ui/src/frontend/pages.ts
index 3091c5e..c595420 100644
--- a/ui/src/frontend/pages.ts
+++ b/ui/src/frontend/pages.ts
@@ -32,7 +32,7 @@
       {
         onclick: () => globals.dispatch(Actions.clearPermalink({})),
       },
-      m('i.material-icons', 'close')),
+      m('i.material-icons.disallow-selection', 'close')),
   ]);
 }
 
diff --git a/ui/src/frontend/panel_container.ts b/ui/src/frontend/panel_container.ts
index 4de44aa..23bcec7 100644
--- a/ui/src/frontend/panel_container.ts
+++ b/ui/src/frontend/panel_container.ts
@@ -218,7 +218,9 @@
   view({attrs}: m.CVnode<Attrs>) {
     this.attrs = attrs;
     const renderPanel = (panel: m.Vnode) => perfDebug() ?
-        m('.panel', panel, m('.debug-panel-border')) :
+        m('.panel',
+          {key: panel.key},
+          [panel, m('.debug-panel-border', {key: 'debug-panel-border'})]) :
         m('.panel', {key: panel.key}, panel);
 
     return [
diff --git a/ui/src/frontend/record_page.ts b/ui/src/frontend/record_page.ts
index 62f728b..b6683f3 100644
--- a/ui/src/frontend/record_page.ts
+++ b/ui/src/frontend/record_page.ts
@@ -121,15 +121,14 @@
   const recButton = (mode: RecordMode, title: string, img: string) => {
     const checkboxArgs = {
       checked: cfg.mode === mode,
-      onchange: m.withAttr(
-          'checked',
-          (checked: boolean) => {
-            if (!checked) return;
-            const traceCfg = produce(globals.state.recordConfig, draft => {
-              draft.mode = mode;
-            });
-            globals.dispatch(Actions.setRecordConfig({config: traceCfg}));
-          })
+      onchange: (e: InputEvent) => {
+        const checked = (e.target as HTMLInputElement).checked;
+        if (!checked) return;
+        const traceCfg = produce(globals.state.recordConfig, draft => {
+          draft.mode = mode;
+        });
+        globals.dispatch(Actions.setRecordConfig({config: traceCfg}));
+      },
     };
     return m(
         `label${cfg.mode === mode ? '.selected' : ''}`,
@@ -801,7 +800,9 @@
           m('select',
             {
               selectedIndex,
-              onchange: m.withAttr('value', onTargetChange),
+              onchange: (e: Event) => {
+                onTargetChange((e.target as HTMLSelectElement).value);
+              },
               onupdate: (select) => {
                 // Work around mithril bug
                 // (https://github.com/MithrilJS/mithril.js/issues/2107): We may
@@ -1311,7 +1312,8 @@
     };
 
     const pages: m.Children = [];
-    let routePage = Router.param('p');
+    const routePageParam = Router.param('p');
+    let routePage = typeof routePageParam === 'string' ? routePageParam : '';
     if (!Object.keys(SECTIONS).includes(routePage)) {
       routePage = 'buffers';
     }
diff --git a/ui/src/frontend/record_widgets.ts b/ui/src/frontend/record_widgets.ts
index 4d55409..9558919 100644
--- a/ui/src/frontend/record_widgets.ts
+++ b/ui/src/frontend/record_widgets.ts
@@ -74,8 +74,12 @@
           onclick: () => onToggle(!enabled),
         }),
         m('label',
-          m(`input[type=checkbox]`,
-            {checked: enabled, oninput: m.withAttr('checked', onToggle)}),
+          m(`input[type=checkbox]`, {
+            checked: enabled,
+            oninput: (e: InputEvent) => {
+              onToggle((e.target as HTMLInputElement).checked);
+            },
+          }),
           m('span', attrs.title)),
         m('div', m('div', attrs.descr), m('.probe-config', children)));
   }
@@ -140,13 +144,17 @@
         type: 'text',
         pattern: '(0[0-9]|1[0-9]|2[0-3])(:[0-5][0-9]){2}',  // hh:mm:ss
         value: new Date(val).toISOString().substr(11, 8),
-        oninput: m.withAttr('value', v => this.onTimeValueChange(attrs, v))
+        oninput: (e: InputEvent) => {
+          this.onTimeValueChange(attrs, (e.target as HTMLInputElement).value);
+        },
       };
     } else {
       spinnerCfg = {
         type: 'number',
         value: val,
-        oninput: m.withAttr('value', v => this.onValueChange(attrs, v))
+        oninput: (e: InputEvent) => {
+          this.onTimeValueChange(attrs, (e.target as HTMLInputElement).value);
+        },
       };
     }
     return m(
@@ -156,7 +164,11 @@
         attrs.icon !== undefined ? m('i.material-icons', attrs.icon) : [],
         m(`input[id="${id}"][type=range][min=0][max=${maxIdx}][value=${idx}]
         ${disabled ? '[disabled]' : ''}`,
-          {oninput: m.withAttr('value', v => this.onSliderChange(attrs, v))}),
+          {
+            oninput: (e: InputEvent) => {
+              this.onSliderChange(attrs, +(e.target as HTMLInputElement).value);
+            },
+          }),
         m(`input.spinner[min=${min !== undefined ? min : 1}][for=${id}]`,
           spinnerCfg),
         m('.unit', attrs.unit));
diff --git a/ui/src/frontend/sidebar.ts b/ui/src/frontend/sidebar.ts
index a6addae..31bdbe1 100644
--- a/ui/src/frontend/sidebar.ts
+++ b/ui/src/frontend/sidebar.ts
@@ -112,6 +112,16 @@
   };
 }
 
+function showDebugTrack(): (_: Event) => void {
+  return (e: Event) => {
+    e.preventDefault();
+    globals.dispatch(Actions.addDebugTrack({
+      engineId: Object.keys(globals.state.engines)[0],
+      name: 'Debug Slices',
+    }));
+  };
+}
+
 const EXAMPLE_ANDROID_TRACE_URL =
     'https://storage.googleapis.com/perfetto-misc/example_android_trace_15s';
 
@@ -181,6 +191,11 @@
     summary: 'Compute summary statistics',
     items: [
       {
+        t: 'Show Debug Track',
+        a: showDebugTrack(),
+        i: 'view_day',
+      },
+      {
         t: 'All Processes',
         a: createCannedQuery(ALL_PROCESSES_QUERY),
         i: 'search',
diff --git a/ui/src/frontend/task_tracker.ts b/ui/src/frontend/task_tracker.ts
new file mode 100644
index 0000000..ea795f7
--- /dev/null
+++ b/ui/src/frontend/task_tracker.ts
@@ -0,0 +1,50 @@
+interface PromiseInfo {
+  startTimeMs: number;
+  message: string;
+}
+
+export class TaskTracker {
+  private promisesSeen: number;
+  private promisesRejected: number;
+  private promisesFulfilled: number;
+  private promiseInfo: Map<Promise<unknown>, PromiseInfo>;
+
+  constructor() {
+    this.promisesSeen = 0;
+    this.promisesRejected = 0;
+    this.promisesFulfilled = 0;
+    this.promiseInfo = new Map();
+  }
+
+  trackPromise(promise: Promise<unknown>, message: string): void {
+    this.promiseInfo.set(promise, {
+      startTimeMs: (new Date()).getMilliseconds(),
+      message,
+    });
+    this.promisesSeen += 1;
+    promise.then(() => {
+      this.promisesFulfilled += 1;
+    }).catch(() => {
+      this.promisesRejected += 1;
+    }).finally(() => {
+      this.promiseInfo.delete(promise);
+    });
+  }
+
+  hasPendingTasks(): boolean {
+    return this.promisesSeen > (this.promisesFulfilled + this.promisesRejected);
+  }
+
+  progressMessage(): string|undefined {
+    const {value} = this.promiseInfo.values().next();
+    if (value === undefined) {
+      return value;
+    } else {
+      const nowMs = (new Date()).getMilliseconds();
+      const runtimeSeconds = Math.round((nowMs - value.startTimeMs) / 1000);
+      return `${value.message} (${runtimeSeconds}s)`;
+    }
+  }
+}
+
+export const taskTracker = new TaskTracker();
diff --git a/ui/src/frontend/task_tracker_unittest.ts b/ui/src/frontend/task_tracker_unittest.ts
new file mode 100644
index 0000000..f869843
--- /dev/null
+++ b/ui/src/frontend/task_tracker_unittest.ts
@@ -0,0 +1,34 @@
+// Copyright (C) 2020 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 {defer} from '../base/deferred';
+import {TaskTracker} from './task_tracker';
+
+test('it starts with no pending tasks', () => {
+  const tracker = new TaskTracker();
+  expect(tracker.hasPendingTasks()).toEqual(false);
+  expect(tracker.progressMessage()).toEqual(undefined);
+});
+
+test('it knows if a task is pending', () => {
+  const tracker = new TaskTracker();
+  const deferred = defer();
+
+  tracker.trackPromise(deferred, "Some task");
+
+  expect(tracker.hasPendingTasks()).toEqual(true);
+  expect(tracker.progressMessage()).toEqual("Some task (0s)");
+  deferred.resolve();
+});
+
diff --git a/ui/src/frontend/topbar.ts b/ui/src/frontend/topbar.ts
index 02d29c9..7bac826 100644
--- a/ui/src/frontend/topbar.ts
+++ b/ui/src/frontend/topbar.ts
@@ -19,6 +19,7 @@
 
 import {globals} from './globals';
 import {executeSearch} from './search_handler';
+import {taskTracker} from './task_tracker';
 
 const SEARCH = Symbol('search');
 const COMMAND = Symbol('command');
@@ -110,17 +111,16 @@
         `.omnibox${commandMode ? '.command-mode' : ''}`,
         m('input', {
           placeholder: PLACEHOLDER[mode],
-          oninput: m.withAttr(
-              'value',
-              v => {
-                globals.frontendLocalState.setOmnibox(
-                    v, commandMode ? 'COMMAND' : 'SEARCH');
-                if (mode === SEARCH) {
-                  globals.frontendLocalState.setSearchIndex(-1);
-                  displayStepThrough = v.length >= 4;
-                  globals.rafScheduler.scheduleFullRedraw();
-                }
-              }),
+          oninput: (e: InputEvent) => {
+            const value = (e.target as HTMLInputElement).value;
+            globals.frontendLocalState.setOmnibox(
+                value, commandMode ? 'COMMAND' : 'SEARCH');
+            if (mode === SEARCH) {
+              globals.frontendLocalState.setSearchIndex(-1);
+              displayStepThrough = value.length >= 4;
+              globals.rafScheduler.scheduleFullRedraw();
+            }
+          },
           value: globals.frontendLocalState.omnibox,
         }),
         displayStepThrough ?
@@ -179,7 +179,7 @@
     if (this.progressBar === undefined) return;
     const engine: EngineConfig = globals.state.engines['0'];
     if ((engine !== undefined && !engine.ready) ||
-        globals.numQueuedQueries > 0) {
+        globals.numQueuedQueries > 0 || taskTracker.hasPendingTasks()) {
       this.progressBar.classList.add('progress-anim');
     } else {
       this.progressBar.classList.remove('progress-anim');
diff --git a/ui/src/frontend/track.ts b/ui/src/frontend/track.ts
index c5af2f2..4b53508 100644
--- a/ui/src/frontend/track.ts
+++ b/ui/src/frontend/track.ts
@@ -47,11 +47,18 @@
  * The abstract class that needs to be implemented by all tracks.
  */
 export abstract class Track<Config = {}, Data extends TrackData = TrackData> {
-  constructor(protected trackState: TrackState) {}
+  private trackId: string;
+  constructor(trackState: TrackState) {
+    this.trackId = trackState.id;
+  }
   protected abstract renderCanvas(ctx: CanvasRenderingContext2D): void;
 
+  protected get trackState(): TrackState {
+    return globals.state.tracks[this.trackId];
+  }
+
   get config(): Config {
-    return this.trackState.config as Config;
+    return globals.state.tracks[this.trackId].config as Config;
   }
 
   data(): Data|undefined {
diff --git a/ui/src/tracks/all_controller.ts b/ui/src/tracks/all_controller.ts
index 3d86dc5..116c1b9 100644
--- a/ui/src/tracks/all_controller.ts
+++ b/ui/src/tracks/all_controller.ts
@@ -25,3 +25,4 @@
 import './process_summary/controller';
 import './thread_state/controller';
 import './async_slices/controller';
+import './debug_slices/controller';
diff --git a/ui/src/tracks/all_frontend.ts b/ui/src/tracks/all_frontend.ts
index bfe9f26..6311137 100644
--- a/ui/src/tracks/all_frontend.ts
+++ b/ui/src/tracks/all_frontend.ts
@@ -25,3 +25,4 @@
 import './process_summary/frontend';
 import './thread_state/frontend';
 import './async_slices/frontend';
+import './debug_slices/frontend';
diff --git a/ui/src/tracks/debug_slices/common.ts b/ui/src/tracks/debug_slices/common.ts
new file mode 100644
index 0000000..9625d54
--- /dev/null
+++ b/ui/src/tracks/debug_slices/common.ts
@@ -0,0 +1,22 @@
+// Copyright (C) 2020 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 {Data as ChromeSlicesData} from '../chrome_slices/common';
+
+export const DEBUG_SLICE_TRACK_KIND = 'DebugSliceTrack';
+
+export interface Config {
+  maxDepth: number;
+}
+
+export {Data} from '../chrome_slices/common';
diff --git a/ui/src/tracks/debug_slices/controller.ts b/ui/src/tracks/debug_slices/controller.ts
new file mode 100644
index 0000000..7fab628
--- /dev/null
+++ b/ui/src/tracks/debug_slices/controller.ts
@@ -0,0 +1,97 @@
+// Copyright (C) 2020 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 {assertTrue} from '../../base/logging';
+import {Actions} from '../../common/actions';
+import {fromNs, toNs} from '../../common/time';
+import {globals} from '../../controller/globals';
+import {
+  TrackController,
+  trackControllerRegistry,
+} from '../../controller/track_controller';
+
+import {Config, Data, DEBUG_SLICE_TRACK_KIND} from './common';
+
+class DebugSliceTrackController extends TrackController<Config, Data> {
+  static readonly kind = DEBUG_SLICE_TRACK_KIND;
+
+  async onReload() {
+    const rawResult = await this.query(`select max(depth) from debug_slices`);
+    const maxDepth =
+        (rawResult.numRecords === 0) ? 1 : rawResult.columns[0].longValues![0];
+    globals.dispatch(
+        Actions.updateTrackConfig({id: this.trackId, config: {maxDepth}}));
+  }
+
+  async onBoundsChange(start: number, end: number, resolution: number):
+      Promise<Data> {
+    const rawResult = await this.query(
+        `select id, name, ts, dur, depth from debug_slices where
+        (ts + dur) >= ${toNs(start)} and ts <= ${toNs(end)}`);
+
+    assertTrue(rawResult.columns.length === 5);
+    const [idCol, nameCol, tsCol, durCol, depthCol] = rawResult.columns;
+    const idValues = idCol.longValues! || idCol.doubleValues!;
+    const tsValues = tsCol.longValues! || tsCol.doubleValues!;
+    const durValues = durCol.longValues! || durCol.doubleValues!;
+
+    const numRows = rawResult.numRecords;
+    const slices: Data = {
+      start,
+      end,
+      resolution,
+      length: numRows,
+      strings: [],
+      sliceIds: new Float64Array(numRows),
+      starts: new Float64Array(numRows),
+      ends: new Float64Array(numRows),
+      depths: new Uint16Array(numRows),
+      titles: new Uint16Array(numRows),
+      isInstant: new Uint16Array(numRows),
+    };
+
+    const stringIndexes = new Map<string, number>();
+    function internString(str: string) {
+      let idx = stringIndexes.get(str);
+      if (idx !== undefined) return idx;
+      idx = slices.strings.length;
+      slices.strings.push(str);
+      stringIndexes.set(str, idx);
+      return idx;
+    }
+
+    for (let i = 0; i < rawResult.numRecords; i++) {
+      let sliceStart: number, sliceEnd: number;
+      if (tsCol.isNulls![i] || durCol.isNulls![i]) {
+        sliceStart = sliceEnd = -1;
+      } else {
+        sliceStart = tsValues[i];
+        const sliceDur = durValues[i];
+        sliceEnd = sliceStart + sliceDur;
+      }
+      slices.sliceIds[i] = idCol.isNulls![i] ? -1 : idValues[i];
+      slices.starts[i] = fromNs(sliceStart);
+      slices.ends[i] = fromNs(sliceEnd);
+      slices.depths[i] = depthCol.isNulls![i] ? 0 : depthCol.longValues![i];
+      const sliceName =
+          nameCol.isNulls![i] ? '[null]' : nameCol.stringValues![i];
+      slices.titles[i] = internString(sliceName);
+      slices.isInstant[i] = 0;
+    }
+
+    return slices;
+  }
+}
+
+trackControllerRegistry.register(DebugSliceTrackController);
diff --git a/ui/src/tracks/debug_slices/frontend.ts b/ui/src/tracks/debug_slices/frontend.ts
new file mode 100644
index 0000000..bdda433
--- /dev/null
+++ b/ui/src/tracks/debug_slices/frontend.ts
@@ -0,0 +1,55 @@
+// Copyright (C) 2020 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 * as m from 'mithril';
+
+import {Actions} from '../../common/actions';
+import {TrackState} from '../../common/state';
+import {globals} from '../../frontend/globals';
+import {Track} from '../../frontend/track';
+import {TrackButton, TrackButtonAttrs} from '../../frontend/track_panel';
+import {trackRegistry} from '../../frontend/track_registry';
+import {ChromeSliceTrack} from '../chrome_slices/frontend';
+
+import {DEBUG_SLICE_TRACK_KIND} from './common';
+
+export class DebugSliceTrack extends ChromeSliceTrack {
+  static readonly kind = DEBUG_SLICE_TRACK_KIND;
+  static create(trackState: TrackState): Track {
+    return new DebugSliceTrack(trackState);
+  }
+
+  getTrackShellButtons(): Array<m.Vnode<TrackButtonAttrs>> {
+    const buttons: Array<m.Vnode<TrackButtonAttrs>> = [];
+    buttons.push(m(TrackButton, {
+      action: () => {
+        globals.dispatch(Actions.requestTrackReload({}));
+      },
+      i: 'refresh',
+      tooltip: 'Refresh tracks',
+      showButton: true,
+    }));
+    buttons.push(m(TrackButton, {
+      action: () => {
+        globals.dispatch(Actions.removeDebugTrack({}));
+      },
+      i: 'close',
+      tooltip: 'Close',
+      showButton: true,
+    }));
+    return buttons;
+  }
+}
+
+trackRegistry.register(DebugSliceTrack);