Merge "Update chrome_input_with_frame_view.pftrace.sh256 to point to the most current file." into main
diff --git a/Android.bp b/Android.bp
index e435f99..94b84ff 100644
--- a/Android.bp
+++ b/Android.bp
@@ -5323,6 +5323,7 @@
 genrule {
     name: "perfetto_protos_perfetto_metrics_webview_descriptor",
     srcs: [
+        ":libprotobuf-internal-descriptor-proto",
         "protos/perfetto/metrics/android/ad_services_metric.proto",
         "protos/perfetto/metrics/android/android_blocking_call.proto",
         "protos/perfetto/metrics/android/android_blocking_calls_cuj_metric.proto",
@@ -5388,7 +5389,7 @@
     tools: [
         "aprotoc",
     ],
-    cmd: "mkdir -p $(genDir)/external/perfetto/ && $(location aprotoc) --proto_path=external/perfetto --descriptor_set_out=$(out) $(in)",
+    cmd: "mkdir -p $(genDir)/external/perfetto/ && $(location aprotoc) --proto_path=external/perfetto --proto_path=external/protobuf/src --descriptor_set_out=$(out) $(in)",
     out: [
         "perfetto_protos_perfetto_metrics_webview_descriptor.bin",
     ],
@@ -11347,6 +11348,7 @@
     srcs: [
         "src/trace_processor/db/column/arrangement_overlay_unittest.cc",
         "src/trace_processor/db/column/dense_null_overlay_unittest.cc",
+        "src/trace_processor/db/column/fake_storage_unittest.cc",
         "src/trace_processor/db/column/id_storage_unittest.cc",
         "src/trace_processor/db/column/null_overlay_unittest.cc",
         "src/trace_processor/db/column/numeric_storage_unittest.cc",
diff --git a/CHANGELOG b/CHANGELOG
index b600600..99ff035 100644
--- a/CHANGELOG
+++ b/CHANGELOG
@@ -4,7 +4,18 @@
   Trace Processor:
     *
   UI:
-    *
+    * Add tracks to the list of searchable items.
+    * Use mipmaps to improve track query performance on large traces.
+    * Fix slow scrolling bug in ftrace explorer tab on low DPI machines.
+    * Overhaul track decider queries to improve trace load times.
+    * Add track
+    * Tidy up command names & remove some example ones.
+    * Remove arg auto-completion in pivot table.
+    * Show dominator tree views by default.
+    * Fix counter event selection off-by-one error.
+    * Add viewport control to the plugin API.
+    * Sticky track titles to improve track button accessibility in tall tracks.
+    * A handful of small bugfixes.
   SDK:
     * The TRACE_EVENT macro used to reject `const char *` event names: either
       `StaticString` or `DynamicString` needed to be specified. In the last year
diff --git a/gn/proto_library.gni b/gn/proto_library.gni
index f69d3ce..a7a77c6 100644
--- a/gn/proto_library.gni
+++ b/gn/proto_library.gni
@@ -371,7 +371,7 @@
 
         metadata = {
           proto_library_sources = invoker.sources
-          import_dirs = import_dirs_
+          proto_import_dirs = import_dirs_
           exports = get_path_info(public_deps_, "abspath")
         }
         forward_variables_from(invoker, vars_to_forward)
diff --git a/gn/standalone/proto_library.gni b/gn/standalone/proto_library.gni
index 07b8140..1a23a97 100644
--- a/gn/standalone/proto_library.gni
+++ b/gn/standalone/proto_library.gni
@@ -170,6 +170,10 @@
       ]
     }
 
+    metadata = {
+      proto_import_dirs = import_dirs
+    }
+
     if (generate_cc) {
       cc_generator_options_ = ""
       if (defined(invoker.cc_generator_options)) {
diff --git a/src/trace_processor/db/column/BUILD.gn b/src/trace_processor/db/column/BUILD.gn
index 74d1611..d53e7ae 100644
--- a/src/trace_processor/db/column/BUILD.gn
+++ b/src/trace_processor/db/column/BUILD.gn
@@ -75,6 +75,7 @@
   sources = [
     "arrangement_overlay_unittest.cc",
     "dense_null_overlay_unittest.cc",
+    "fake_storage_unittest.cc",
     "id_storage_unittest.cc",
     "null_overlay_unittest.cc",
     "numeric_storage_unittest.cc",
diff --git a/src/trace_processor/db/column/fake_storage.cc b/src/trace_processor/db/column/fake_storage.cc
index f587c77..babfb7c 100644
--- a/src/trace_processor/db/column/fake_storage.cc
+++ b/src/trace_processor/db/column/fake_storage.cc
@@ -41,6 +41,7 @@
 SingleSearchResult FakeStorageChain::SingleSearch(FilterOp,
                                                   SqlValue,
                                                   uint32_t i) const {
+  PERFETTO_CHECK(i < size_);
   switch (strategy_) {
     case kAll:
       return SingleSearchResult::kMatch;
@@ -115,37 +116,37 @@
     FilterOp,
     SqlValue,
     const OrderedIndices& indices) const {
-  if (strategy_ == kAll) {
-    return {0, indices.size};
-  }
-
-  if (strategy_ == kNone) {
-    return {};
-  }
-
-  if (strategy_ == kRange) {
-    // We are looking at intersection of |range_| and |indices_|.
-    const uint32_t* first_in_range = std::partition_point(
-        indices.data, indices.data + indices.size,
-        [this](uint32_t i) { return !range_.Contains(i); });
-    const uint32_t* first_outside_range =
-        std::partition_point(first_in_range, indices.data + indices.size,
-                             [this](uint32_t i) { return range_.Contains(i); });
-    return {static_cast<uint32_t>(std::distance(indices.data, first_in_range)),
-            static_cast<uint32_t>(
-                std::distance(indices.data, first_outside_range))};
-  }
-
-  PERFETTO_DCHECK(strategy_ == kBitVector);
-  // We are looking at intersection of |range_| and |bit_vector_|.
-  const uint32_t* first_set = std::partition_point(
-      indices.data, indices.data + indices.size,
-      [this](uint32_t i) { return !bit_vector_.IsSet(i); });
-  const uint32_t* first_non_set =
-      std::partition_point(first_set, indices.data + indices.size,
-                           [this](uint32_t i) { return bit_vector_.IsSet(i); });
-  return {static_cast<uint32_t>(std::distance(indices.data, first_set)),
+  switch (strategy_) {
+    case kAll:
+      return {0, indices.size};
+    case kNone:
+      return {};
+    case kRange: {
+      // We are looking at intersection of |range_| and |indices_|.
+      const uint32_t* first_in_range = std::partition_point(
+          indices.data, indices.data + indices.size,
+          [this](uint32_t i) { return !range_.Contains(i); });
+      const uint32_t* first_outside_range = std::partition_point(
+          first_in_range, indices.data + indices.size,
+          [this](uint32_t i) { return range_.Contains(i); });
+      return {
+          static_cast<uint32_t>(std::distance(indices.data, first_in_range)),
+          static_cast<uint32_t>(
+              std::distance(indices.data, first_outside_range))};
+    }
+    case kBitVector:
+      // We are looking at intersection of |range_| and |bit_vector_|.
+      const uint32_t* first_set = std::partition_point(
+          indices.data, indices.data + indices.size,
+          [this](uint32_t i) { return !bit_vector_.IsSet(i); });
+      const uint32_t* first_non_set = std::partition_point(
+          first_set, indices.data + indices.size,
+          [this](uint32_t i) { return bit_vector_.IsSet(i); });
+      return {
+          static_cast<uint32_t>(std::distance(indices.data, first_set)),
           static_cast<uint32_t>(std::distance(indices.data, first_non_set))};
+  }
+  PERFETTO_FATAL("For GCC");
 }
 
 void FakeStorageChain::StableSort(SortToken*, SortToken*, SortDirection) const {
diff --git a/src/trace_processor/db/column/fake_storage_unittest.cc b/src/trace_processor/db/column/fake_storage_unittest.cc
new file mode 100644
index 0000000..0bc0211
--- /dev/null
+++ b/src/trace_processor/db/column/fake_storage_unittest.cc
@@ -0,0 +1,210 @@
+/*
+ * Copyright (C) 2024 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.
+ */
+#include "src/trace_processor/db/column/fake_storage.h"
+
+#include <cstdint>
+#include <limits>
+#include <vector>
+
+#include "perfetto/trace_processor/basic_types.h"
+#include "src/trace_processor/containers/bit_vector.h"
+#include "src/trace_processor/db/column/data_layer.h"
+#include "src/trace_processor/db/column/types.h"
+#include "src/trace_processor/db/column/utils.h"
+#include "test/gtest_and_gmock.h"
+
+namespace perfetto::trace_processor {
+
+inline bool operator==(const Range& a, const Range& b) {
+  return std::tie(a.start, a.end) == std::tie(b.start, b.end);
+}
+
+inline bool operator==(const BitVector& a, const BitVector& b) {
+  return a.size() == b.size() && a.CountSetBits() == b.CountSetBits();
+}
+
+namespace column {
+namespace {
+
+using testing::ElementsAre;
+using testing::IsEmpty;
+
+using Indices = DataLayerChain::Indices;
+using OrderedIndices = DataLayerChain::OrderedIndices;
+
+TEST(FakeStorage, ValidateSearchConstraints) {
+  {
+    // All passes
+    auto fake = FakeStorageChain::SearchAll(10);
+    EXPECT_EQ(fake->ValidateSearchConstraints(FilterOp::kEq, SqlValue()),
+              SearchValidationResult::kOk);
+  }
+  {
+    // None passes
+    auto fake = FakeStorageChain::SearchNone(10);
+    EXPECT_EQ(fake->ValidateSearchConstraints(FilterOp::kEq, SqlValue()),
+              SearchValidationResult::kOk);
+  }
+  {
+    // Index vector
+    auto fake =
+        FakeStorageChain::SearchSubset(5, std::vector<uint32_t>{1, 2, 3, 4, 5});
+    EXPECT_EQ(fake->ValidateSearchConstraints(FilterOp::kEq, SqlValue()),
+              SearchValidationResult::kOk);
+  }
+  {
+    // BitVector
+    auto fake = FakeStorageChain::SearchSubset(5, BitVector{0, 1, 0, 1, 0});
+    EXPECT_EQ(fake->ValidateSearchConstraints(FilterOp::kEq, SqlValue()),
+              SearchValidationResult::kOk);
+  }
+  {
+    // Range
+    auto fake = FakeStorageChain::SearchSubset(5, Range(1, 4));
+    EXPECT_EQ(fake->ValidateSearchConstraints(FilterOp::kEq, SqlValue()),
+              SearchValidationResult::kOk);
+  }
+}
+
+TEST(FakeStorage, SingleSearch) {
+  {
+    // All passes
+    auto fake = FakeStorageChain::SearchAll(10);
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 5u),
+              SingleSearchResult::kMatch);
+  }
+  {
+    // None passes
+    auto fake = FakeStorageChain::SearchNone(10);
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 5u),
+              SingleSearchResult::kNoMatch);
+  }
+  {
+    // Index vector
+    auto fake =
+        FakeStorageChain::SearchSubset(5, std::vector<uint32_t>{1, 2, 3, 4, 5});
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 0u),
+              SingleSearchResult::kNoMatch);
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 1u),
+              SingleSearchResult::kMatch);
+  }
+  {
+    // BitVector
+    auto fake = FakeStorageChain::SearchSubset(5, BitVector{0, 1, 0, 1, 0});
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 0),
+              SingleSearchResult::kNoMatch);
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 1u),
+              SingleSearchResult::kMatch);
+  }
+  {
+    // Range
+    auto fake = FakeStorageChain::SearchSubset(5, Range(1, 4));
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 0),
+              SingleSearchResult::kNoMatch);
+    EXPECT_EQ(fake->SingleSearch(FilterOp::kEq, SqlValue(), 1u),
+              SingleSearchResult::kMatch);
+  }
+}
+
+TEST(FakeStorage, IndexSearchValidated) {
+  {
+    // All passes
+    Indices indices = Indices::CreateWithIndexPayloadForTesting(
+        {1u, 0u, 3u}, Indices::State::kNonmonotonic);
+    auto fake = FakeStorageChain::SearchAll(5);
+    fake->IndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    ASSERT_THAT(utils::ExtractPayloadForTesting(indices), ElementsAre(0, 1, 2));
+  }
+  {
+    // None passes
+    Indices indices = Indices::CreateWithIndexPayloadForTesting(
+        {1u, 0u, 3u}, Indices::State::kNonmonotonic);
+    auto fake = FakeStorageChain::SearchNone(5);
+    fake->IndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    EXPECT_TRUE(utils::ExtractPayloadForTesting(indices).empty());
+  }
+  {
+    // BitVector
+    Indices indices = Indices::CreateWithIndexPayloadForTesting(
+        {1u, 0u, 3u}, Indices::State::kNonmonotonic);
+    auto fake = FakeStorageChain::SearchSubset(5, BitVector{0, 1, 0, 1, 0});
+    fake->IndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    ASSERT_THAT(utils::ExtractPayloadForTesting(indices), ElementsAre(0, 2));
+  }
+  {
+    // Index vector
+    Indices indices = Indices::CreateWithIndexPayloadForTesting(
+        {1u, 0u, 3u}, Indices::State::kNonmonotonic);
+    auto fake =
+        FakeStorageChain::SearchSubset(5, std::vector<uint32_t>{1, 2, 3});
+    fake->IndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    ASSERT_THAT(utils::ExtractPayloadForTesting(indices), ElementsAre(0, 2));
+  }
+  {
+    // Range
+    Indices indices = Indices::CreateWithIndexPayloadForTesting(
+        {1u, 0u, 3u}, Indices::State::kNonmonotonic);
+    auto fake = FakeStorageChain::SearchSubset(5, Range(1, 4));
+    fake->IndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    ASSERT_THAT(utils::ExtractPayloadForTesting(indices), ElementsAre(0, 2));
+  }
+}
+
+TEST(FakeStorage, OrderedIndexSearchValidated) {
+  std::vector<uint32_t> table_idx{4, 3, 2, 1};
+  OrderedIndices indices{table_idx.data(), uint32_t(table_idx.size()),
+                         Indices::State::kNonmonotonic};
+  {
+    // All passes
+    auto fake = FakeStorageChain::SearchAll(5);
+    Range ret =
+        fake->OrderedIndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    EXPECT_EQ(ret, Range(0, 4));
+  }
+  {
+    // None passes
+    auto fake = FakeStorageChain::SearchNone(5);
+    Range ret =
+        fake->OrderedIndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    EXPECT_EQ(ret, Range(0, 0));
+  }
+  {
+    // BitVector
+    auto fake = FakeStorageChain::SearchSubset(5, BitVector{0, 0, 1, 1, 1});
+    Range ret =
+        fake->OrderedIndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    EXPECT_EQ(ret, Range(0, 3));
+  }
+  {
+    // Index vector
+    auto fake =
+        FakeStorageChain::SearchSubset(5, std::vector<uint32_t>{1, 2, 3});
+    Range ret =
+        fake->OrderedIndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    EXPECT_EQ(ret, Range(1, 4));
+  }
+  {
+    // Range
+    auto fake = FakeStorageChain::SearchSubset(5, Range(1, 4));
+    Range ret =
+        fake->OrderedIndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
+    EXPECT_EQ(ret, Range(1, 4));
+  }
+}
+
+}  // namespace
+}  // namespace column
+}  // namespace perfetto::trace_processor
diff --git a/src/trace_processor/db/column/range_overlay_unittest.cc b/src/trace_processor/db/column/range_overlay_unittest.cc
index a965be3..240e998 100644
--- a/src/trace_processor/db/column/range_overlay_unittest.cc
+++ b/src/trace_processor/db/column/range_overlay_unittest.cc
@@ -95,14 +95,17 @@
 TEST(RangeOverlay, IndexSearch) {
   auto fake =
       FakeStorageChain::SearchSubset(8, BitVector({0, 1, 0, 1, 0, 1, 0, 0}));
+
+  // {true, false}
   Range range(3, 5);
   RangeOverlay storage(&range);
   auto chain = storage.MakeChain(std::move(fake));
 
+  // {true, false, true}
   Indices indices = Indices::CreateWithIndexPayloadForTesting(
-      {1u, 0u, 3u}, Indices::State::kNonmonotonic);
+      {0, 1, 0}, Indices::State::kNonmonotonic);
   chain->IndexSearch(FilterOp::kGe, SqlValue::Long(0u), indices);
-  ASSERT_THAT(utils::ExtractPayloadForTesting(indices), ElementsAre(1u));
+  ASSERT_THAT(utils::ExtractPayloadForTesting(indices), ElementsAre(0, 2));
 }
 
 TEST(RangeOverlay, StableSort) {
diff --git a/tools/gen_android_bp b/tools/gen_android_bp
index d71b0dc..c8a2b1a 100755
--- a/tools/gen_android_bp
+++ b/tools/gen_android_bp
@@ -768,7 +768,6 @@
   # The .proto filegroup will be added to `tool_files` of rdeps so that the
   # genrules can be sandboxed.
 
-  tool_files = set()
   for proto_dep in target.proto_deps().union(target.transitive_proto_deps()):
     tool_files.add(":" + label_to_module_name(proto_dep.name))
 
diff --git a/tools/gen_tp_table_headers.py b/tools/gen_tp_table_headers.py
index f15245e..91e4cdb 100755
--- a/tools/gen_tp_table_headers.py
+++ b/tools/gen_tp_table_headers.py
@@ -72,7 +72,8 @@
   ]
   headers: Dict[str, Header] = {}
   for table in parse_tables_from_modules(modules):
-    input_path = os.path.relpath(table.table.python_module, ROOT_DIR)
+    raw_path = table.table.python_module
+    input_path = raw_path[raw_path.rfind('/src') + 1:]
     header = headers.get(input_path, Header([]))
     header.tables.append(table)
     headers[input_path] = header
diff --git a/tools/gn_utils.py b/tools/gn_utils.py
index d0417d7..904760e 100644
--- a/tools/gn_utils.py
+++ b/tools/gn_utils.py
@@ -529,9 +529,8 @@
     return metadata.get('exports', [])
 
   def get_proto_paths(self, proto_desc):
-    # import_dirs in metadata will be available for source_set targets.
     metadata = proto_desc.get('metadata', {})
-    return metadata.get('import_dirs', [])
+    return metadata.get('proto_import_dirs', [])
 
   def get_proto_target_type(self, target: Target
                            ) -> Tuple[Optional[str], Optional[Dict]]:
diff --git a/ui/src/assets/details.scss b/ui/src/assets/details.scss
index d09690a..ff66708 100644
--- a/ui/src/assets/details.scss
+++ b/ui/src/assets/details.scss
@@ -446,112 +446,46 @@
   header.stale {
     color: grey;
   }
+}
 
-  .rows {
-    position: relative;
-    direction: ltr;
-    width: 100%;
+.pf-ftrace-explorer {
+  height: 100%;
+  font-size: 11px;
+  font-family: var(--monospace-font);
 
-    .row {
-      @include transition();
-      position: absolute;
-      width: 100%;
-      height: 20px;
-      line-height: 20px;
-      background-color: hsl(214, 22%, 100%);
+  .colour {
+    display: inline-block;
+    height: 10px;
+    width: 10px;
+    margin-right: 4px;
+  }
+}
 
-      &.D {
-        color: hsl(122, 20%, 40%);
-      }
-      &.V {
-        color: hsl(122, 20%, 30%);
-      }
-      &.I {
-        color: hsl(0, 0%, 20%);
-      }
-      &.W {
-        color: hsl(45, 60%, 45%);
-      }
-      &.E {
-        color: hsl(4, 90%, 58%);
-      }
-      &.F {
-        color: hsl(291, 64%, 42%);
-      }
-      &.stale {
-        color: #aaa;
-      }
-      &:nth-child(even) {
-        background-color: hsl(214, 22%, 95%);
-      }
-      &:hover {
-        background-color: $table-hover-color;
-      }
-      .cell {
-        font-size: 11px;
-        font-family: var(--monospace-font);
-        white-space: nowrap;
-        overflow: scroll;
-        padding-left: 10px;
-        padding-right: 10px;
-        display: inline-block;
-        &:first-child {
-          padding-left: 5px;
-        }
-        &:last-child {
-          padding-right: 5px;
-        }
-        &:only-child {
-          width: 100%;
-        }
+.pf-android-logs-table {
+  height: 100%;
+  font-size: 11px;
+  font-family: var(--monospace-font);
 
-        // The following children will be used as columns in the table showing
-        // Android logs.
-
-        // 1.Timestamp
-        &:nth-child(1) {
-          width: 7rem;
-          text-overflow: clip;
-          text-align: right;
-        }
-        // 2.Level
-        &:nth-child(2) {
-          width: 4rem;
-        }
-        // 3.Tag
-        &:nth-child(3) {
-          width: 13rem;
-        }
-
-        &.with-process {
-          // 4.Process name
-          &:nth-child(4) {
-            width: 18rem;
-          }
-          // 5.Message - a long string, will take most of the display space.
-          &:nth-child(5) {
-            width: calc(100% - 42rem);
-          }
-        }
-
-        &.no-process {
-          // 4.Message - a long string, will take most of the display space.
-          &:nth-child(4) {
-            width: calc(100% - 24rem);
-          }
-        }
-
-        &.row-header {
-          text-align: left;
-          font-weight: bold;
-          font-size: 13px;
-        }
-
-        &.row-header:first-child {
-          padding-left: 15px;
-        }
-      }
-    }
+  .D {
+    color: hsl(122, 20%, 40%);
+  }
+  .V {
+    color: hsl(122, 20%, 30%);
+  }
+  .I {
+    color: hsl(0, 0%, 20%);
+  }
+  .W {
+    color: hsl(45, 60%, 45%);
+  }
+  .E {
+    color: hsl(4, 90%, 58%);
+  }
+  .F {
+    color: hsl(291, 64%, 42%);
+  }
+  .pf-highlighted {
+    background: #d2efe0;
   }
 }
 
@@ -559,109 +493,6 @@
   margin: 10px;
 }
 
-.ftrace-panel {
-  display: contents;
-
-  .sticky {
-    position: sticky;
-    top: 0;
-    left: 0;
-    z-index: 1;
-    background-color: white;
-    color: #3c4b5d;
-    padding: 5px 10px;
-    display: grid;
-    grid-template-columns: auto auto;
-    justify-content: space-between;
-  }
-
-  .ftrace-rows-label {
-    display: flex;
-    align-items: center;
-  }
-
-  header.stale {
-    color: grey;
-  }
-
-  .rows {
-    position: relative;
-    direction: ltr;
-    min-width: 100%;
-    font-size: 12px;
-
-    .row {
-      @include transition();
-      position: absolute;
-      min-width: 100%;
-      line-height: 20px;
-      background-color: hsl(214, 22%, 100%);
-      white-space: nowrap;
-
-      &:nth-child(even) {
-        background-color: hsl(214, 22%, 95%);
-      }
-
-      &:hover {
-        background-color: $table-hover-color;
-      }
-
-      .cell {
-        font-family: var(--monospace-font);
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        margin-right: 8px;
-        display: inline-block;
-
-        .colour {
-          display: inline-block;
-          height: 10px;
-          width: 10px;
-          margin-right: 4px;
-        }
-
-        &:first-child {
-          margin-left: 8px;
-        }
-
-        &:last-child {
-          margin-right: 8px;
-        }
-
-        &:only-child {
-          width: 100%;
-        }
-
-        // Timestamp
-        &:nth-child(1) {
-          width: 13em;
-          // text-align: right;
-        }
-
-        // Name
-        &:nth-child(2) {
-          width: 24em;
-        }
-
-        // CPU
-        &:nth-child(3) {
-          width: 3em;
-        }
-
-        // Process
-        &:nth-child(4) {
-          width: 24em;
-        }
-
-        &.row-header {
-          font-weight: bold;
-        }
-      }
-    }
-  }
-}
-
 .screenshot-panel {
   height: 100%;
   img {
diff --git a/ui/src/assets/perfetto.scss b/ui/src/assets/perfetto.scss
index 8bb1cdc..567deae 100644
--- a/ui/src/assets/perfetto.scss
+++ b/ui/src/assets/perfetto.scss
@@ -56,3 +56,4 @@
 @import "widgets/hotkey";
 @import "widgets/text_paragraph";
 @import "widgets/treetable";
+@import "widgets/virtual_table";
diff --git a/ui/src/assets/widgets/virtual_table.scss b/ui/src/assets/widgets/virtual_table.scss
new file mode 100644
index 0000000..acd22d5
--- /dev/null
+++ b/ui/src/assets/widgets/virtual_table.scss
@@ -0,0 +1,89 @@
+// Copyright (C) 2024 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.
+
+@use "sass:math";
+@import "theme";
+
+// Adding these to a new layer makes other rules take precedence
+@layer widgets {
+  .pf-vtable {
+    overflow: auto;
+    font-family: $pf-font;
+    position: relative;
+    background: white; // Performance tweak - see b/335451611
+
+    .pf-vtable-content {
+      display: inline-flex;
+      flex-direction: column;
+      min-width: 100%;
+
+      .pf-vtable-header {
+        font-weight: bold;
+        position: sticky;
+        top: 0;
+        z-index: 1;
+        background: white;
+        white-space: nowrap;
+        padding-inline: 4px;
+
+        // A shadow improves distinction between header and content
+        box-shadow: #0001 0px 0px 8px;
+      }
+
+      .pf-vtable-data {
+        white-space: nowrap;
+        overflow: hidden;
+        text-overflow: ellipsis;
+        margin-right: 8px;
+        display: inline-block;
+      }
+
+      .pf-vtable-slider {
+        overflow: hidden;
+
+        // Necessary trig because we have a 45deg stripes
+        $pattern-density: 1px * math.sqrt(2);
+        $pattern-col: #ddd;
+        overflow: hidden;
+
+        background: repeating-linear-gradient(
+          -45deg,
+          $pattern-col,
+          $pattern-col $pattern-density,
+          white $pattern-density,
+          white $pattern-density * 2
+        );
+
+        .pf-vtable-puck {
+          .pf-vtable-row {
+            white-space: nowrap;
+            padding-inline: 4px;
+
+            &:nth-child(odd) {
+              background-color: hsl(214, 22%, 95%);
+            }
+
+            &:nth-child(even) {
+              background-color: white;
+            }
+
+            &:hover {
+              background-color: $table-hover-color;
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/ui/src/base/geom.ts b/ui/src/base/geom.ts
index f569400..5a20023 100644
--- a/ui/src/base/geom.ts
+++ b/ui/src/base/geom.ts
@@ -24,6 +24,11 @@
   readonly height: number;
 }
 
+export interface Vector {
+  readonly x: number;
+  readonly y: number;
+}
+
 export function intersectRects(a: Rect, b: Rect): Rect {
   return {
     top: Math.max(a.top, b.top),
@@ -57,3 +62,28 @@
     height: r.bottom - r.top,
   };
 }
+
+/**
+ * Return true if rect a contains rect b.
+ *
+ * @param a A rect.
+ * @param b Another rect.
+ * @returns True if rect a contains rect b, false otherwise.
+ */
+export function containsRect(a: Rect, b: Rect): boolean {
+  return !(
+    b.top < a.top ||
+    b.bottom > a.bottom ||
+    b.left < a.left ||
+    b.right > a.right
+  );
+}
+
+export function translateRect(a: Rect, b: Vector): Rect {
+  return {
+    top: a.top + b.y,
+    left: a.left + b.x,
+    bottom: a.bottom + b.y,
+    right: a.right + b.x,
+  };
+}
diff --git a/ui/src/common/flamegraph_util.ts b/ui/src/common/flamegraph_util.ts
index 0817ebf..acf2ee8 100644
--- a/ui/src/common/flamegraph_util.ts
+++ b/ui/src/common/flamegraph_util.ts
@@ -24,7 +24,7 @@
   id: 'showHeapGraphDominatorTree',
   name: 'Show heap graph dominator tree',
   description: 'Show dominated size and objects tabs in Java heap graph view.',
-  defaultValue: false,
+  defaultValue: true,
 });
 
 export function viewingOptions(profileType: ProfileType): Array<ViewingOption> {
diff --git a/ui/src/common/plugins.ts b/ui/src/common/plugins.ts
index ba60270..0750e2e 100644
--- a/ui/src/common/plugins.ts
+++ b/ui/src/common/plugins.ts
@@ -299,11 +299,20 @@
     },
 
     get tracks(): TrackRef[] {
-      return Object.values(globals.state.tracks).map((trackState) => {
+      const tracks = Object.values(globals.state.tracks);
+      const pinnedTracks = globals.state.pinnedTracks;
+      const groups = globals.state.trackGroups;
+      return tracks.map((trackState) => {
+        const group = trackState.trackGroup
+          ? groups[trackState.trackGroup]
+          : undefined;
         return {
           displayName: trackState.name,
           uri: trackState.uri,
           params: trackState.params,
+          key: trackState.key,
+          groupName: group?.name,
+          isPinned: pinnedTracks.includes(trackState.key),
         };
       });
     },
diff --git a/ui/src/core/default_plugins.ts b/ui/src/core/default_plugins.ts
index 583e6bc..a9f8395 100644
--- a/ui/src/core/default_plugins.ts
+++ b/ui/src/core/default_plugins.ts
@@ -32,6 +32,7 @@
   'dev.perfetto.BookmarkletApi',
   'dev.perfetto.CoreCommands',
   'dev.perfetto.LargeScreensPerf',
+  'dev.perfetto.RestorePinnedTrack',
   'perfetto.AndroidLog',
   'perfetto.Annotation',
   'perfetto.AsyncSlices',
@@ -48,7 +49,6 @@
   'perfetto.Frames',
   'perfetto.FtraceRaw',
   'perfetto.HeapProfile',
-  'perfetto.NullTrack',
   'perfetto.PerfSamplesProfile',
   'perfetto.PivotTable',
   'perfetto.ProcessSummary',
diff --git a/ui/src/frontend/base_counter_track.ts b/ui/src/frontend/base_counter_track.ts
index caf2ef6..91b0939 100644
--- a/ui/src/frontend/base_counter_track.ts
+++ b/ui/src/frontend/base_counter_track.ts
@@ -30,6 +30,7 @@
 import {NewTrackArgs} from './track';
 import {CacheKey} from '../core/timeline_cache';
 import {featureFlags} from '../core/feature_flags';
+import {uuidv4Sql} from '../base/uuid';
 
 export const COUNTER_DEBUG_MENU_ITEMS = featureFlags.register({
   id: 'counterDebugMenuItems',
@@ -195,6 +196,7 @@
 export abstract class BaseCounterTrack implements Track {
   protected engine: EngineProxy;
   protected trackKey: string;
+  protected trackUuid = uuidv4Sql();
 
   // This is the over-skirted cached bounds:
   private countersKey: CacheKey = CacheKey.zero();
@@ -256,7 +258,7 @@
 
   constructor(args: BaseCounterTrackArgs) {
     this.engine = args.engine;
-    this.trackKey = args.trackKey.replaceAll('-', '_');
+    this.trackKey = args.trackKey;
     this.defaultOptions = args.options ?? {};
   }
 
@@ -449,6 +451,33 @@
 
   async onCreate(): Promise<void> {
     this.initState = await this.onInit();
+
+    const displayValueQuery = await this.engine.query(`
+        create virtual table ${this.getTableName()}
+        using __intrinsic_counter_mipmap((
+          SELECT
+            ts,
+            ${this.getValueExpression()} as value
+          FROM (${this.getSqlSource()})
+        ));
+
+        select
+          min_value as minDisplayValue,
+          max_value as maxDisplayValue
+        from ${this.getTableName()}(
+          trace_start(), trace_end(), trace_dur()
+        );
+      `);
+
+    const {minDisplayValue, maxDisplayValue} = displayValueQuery.firstRow({
+      minDisplayValue: NUM,
+      maxDisplayValue: NUM,
+    });
+
+    this.limits = {
+      minDisplayValue,
+      maxDisplayValue,
+    };
   }
 
   async onUpdate(): Promise<void> {
@@ -691,11 +720,14 @@
     this.hover = undefined;
   }
 
-  onDestroy(): void {
+  async onDestroy(): Promise<void> {
     if (this.initState) {
       this.initState.dispose();
       this.initState = undefined;
     }
+    if (this.engine.isAlive) {
+      await this.engine.query(`drop table if exists ${this.getTableName()}`);
+    }
   }
 
   // Compute the range of values to display and range label.
@@ -811,37 +843,11 @@
     }
   }
 
+  private getTableName(): string {
+    return `counter_${this.trackUuid}`;
+  }
+
   private async maybeRequestData(rawCountersKey: CacheKey) {
-    let limits = this.limits;
-    if (limits === undefined) {
-      const displayValueQuery = await this.engine.query(`
-        drop table if exists counter_${this.trackKey};
-
-        create virtual table counter_${this.trackKey}
-        using __intrinsic_counter_mipmap((
-          SELECT
-            ts,
-            ${this.getValueExpression()} as value
-          FROM (${this.getSqlSource()})
-        ));
-
-        select
-          min_value as minDisplayValue,
-          max_value as maxDisplayValue
-        from counter_${this.trackKey}(
-          trace_start(), trace_end(), trace_dur()
-        );
-      `);
-      const {minDisplayValue, maxDisplayValue} = displayValueQuery.firstRow({
-        minDisplayValue: NUM,
-        maxDisplayValue: NUM,
-      });
-      limits = this.limits = {
-        minDisplayValue,
-        maxDisplayValue,
-      };
-    }
-
     if (rawCountersKey.isCoveredBy(this.countersKey)) {
       return; // We have the data already, no need to re-query.
     }
@@ -859,7 +865,7 @@
         max_value as maxDisplayValue,
         last_ts as ts,
         last_value as lastDisplayValue
-      FROM counter_${this.trackKey}(
+      FROM ${this.getTableName()}(
         ${countersKey.start},
         ${countersKey.end},
         ${countersKey.bucketSize}
diff --git a/ui/src/frontend/base_slice_track.ts b/ui/src/frontend/base_slice_track.ts
index ca4fc41..e4b03a2 100644
--- a/ui/src/frontend/base_slice_track.ts
+++ b/ui/src/frontend/base_slice_track.ts
@@ -41,6 +41,7 @@
 import {DEFAULT_SLICE_LAYOUT, SliceLayout} from './slice_layout';
 import {NewTrackArgs} from './track';
 import {BUCKETS_PER_PIXEL, CacheKey} from '../core/timeline_cache';
+import {uuidv4Sql} from '../base/uuid';
 
 // The common class that underpins all tracks drawing slices.
 
@@ -174,6 +175,7 @@
   protected sliceLayout: SliceLayout = {...DEFAULT_SLICE_LAYOUT};
   protected engine: EngineProxy;
   protected trackKey: string;
+  protected trackUuid = uuidv4Sql();
 
   // This is the over-skirted cached bounds:
   private slicesKey: CacheKey = CacheKey.zero();
@@ -307,6 +309,10 @@
     return `${size}px Roboto Condensed`;
   }
 
+  private getTableName(): string {
+    return `slice_${this.trackUuid}`;
+  }
+
   async onCreate(): Promise<void> {
     this.initState = await this.onInit();
 
@@ -357,7 +363,7 @@
     this.incomplete = incomplete;
 
     await this.engine.query(`
-      create virtual table slice_${this.trackKey}
+      create virtual table ${this.getTableName()}
       using __intrinsic_slice_mipmap((
         select id, ts, dur, ${this.depthColumn()}
         from (${this.getSqlSource()})
@@ -654,7 +660,9 @@
       this.initState.dispose();
       this.initState = undefined;
     }
-    await this.engine.execute(`drop table slice_${this.trackKey}`);
+    if (this.engine.isAlive) {
+      await this.engine.execute(`drop table ${this.getTableName()}`);
+    }
   }
 
   // This method figures out if the visible window is outside the bounds of
@@ -681,7 +689,7 @@
         s.id,
         z.depth
         ${extraCols ? ',' + extraCols : ''}
-      FROM slice_${this.trackKey}(
+      FROM ${this.getTableName()}(
         ${slicesKey.start},
         ${slicesKey.end},
         ${slicesKey.bucketSize}
diff --git a/ui/src/frontend/css_constants.ts b/ui/src/frontend/css_constants.ts
index 1a37c1e..f756136 100644
--- a/ui/src/frontend/css_constants.ts
+++ b/ui/src/frontend/css_constants.ts
@@ -23,7 +23,6 @@
 export let SELECTION_FILL_COLOR = '#8398e64d';
 export let OVERVIEW_TIMELINE_NON_VISIBLE_COLOR = '#c8c8c8cc';
 export let DEFAULT_DETAILS_CONTENT_HEIGHT = 280;
-export const SELECTED_LOG_ROWS_COLOR = '#D2EFE0';
 export let BACKGROUND_COLOR = '#ffffff';
 export let FOREGROUND_COLOR = '#222';
 export let COLLAPSED_BACKGROUND = '#ffffff';
diff --git a/ui/src/frontend/simple_counter_track.ts b/ui/src/frontend/simple_counter_track.ts
index 084c14f..361480b 100644
--- a/ui/src/frontend/simple_counter_track.ts
+++ b/ui/src/frontend/simple_counter_track.ts
@@ -17,6 +17,7 @@
 import {BaseCounterTrack, CounterOptions} from './base_counter_track';
 import {CounterColumns, SqlDataSource} from './debug_tracks';
 import {Disposable, DisposableCallback} from '../base/disposable';
+import {uuidv4Sql} from '../base/uuid';
 
 export type SimpleCounterTrackConfig = {
   data: SqlDataSource;
@@ -39,7 +40,7 @@
       options: config.options,
     });
     this.config = config;
-    this.sqlTableName = `__simple_counter_${this.trackKey}`;
+    this.sqlTableName = `__simple_counter_${uuidv4Sql()}`;
   }
 
   async onInit(): Promise<Disposable> {
@@ -74,7 +75,7 @@
 
   private async dropTrackTable(): Promise<void> {
     if (this.engine.isAlive) {
-      this.engine.query(`drop table if exists ${this.sqlTableName}`);
+      await this.engine.query(`drop table if exists ${this.sqlTableName}`);
     }
   }
 }
diff --git a/ui/src/frontend/widgets_page.ts b/ui/src/frontend/widgets_page.ts
index ea80972..65fb04e 100644
--- a/ui/src/frontend/widgets_page.ts
+++ b/ui/src/frontend/widgets_page.ts
@@ -50,6 +50,11 @@
 import {TableShowcase} from './tables/table_showcase';
 import {TreeTable, TreeTableAttrs} from './widgets/treetable';
 import {Intent} from '../widgets/common';
+import {
+  VirtualTable,
+  VirtualTableAttrs,
+  VirtualTableRow,
+} from '../widgets/virtual_table';
 
 const DATA_ENGLISH_LETTER_FREQUENCY = {
   table: [
@@ -569,6 +574,11 @@
   },
 ];
 
+let virtualTableData: {offset: number; rows: VirtualTableRow[]} = {
+  offset: 0,
+  rows: [],
+};
+
 export const WidgetsPage = createPage({
   view() {
     return m(
@@ -1154,10 +1164,38 @@
           return m(TreeTable<File>, attrs);
         },
       }),
+      m(WidgetShowcase, {
+        label: 'VirtualTable',
+        description: `Virtualized table for efficient rendering of large datasets`,
+        renderWidget: () => {
+          const attrs: VirtualTableAttrs = {
+            columns: [
+              {header: 'x', width: '4em'},
+              {header: 'x^2', width: '8em'},
+            ],
+            rows: virtualTableData.rows,
+            firstRowOffset: virtualTableData.offset,
+            rowHeight: 20,
+            numRows: 500_000,
+            style: {height: '200px'},
+            onReload: (rowOffset, rowCount) => {
+              const rows = [];
+              for (let i = rowOffset; i < rowOffset + rowCount; i++) {
+                rows.push({id: i, cells: [i, i ** 2]});
+              }
+              virtualTableData = {
+                offset: rowOffset,
+                rows,
+              };
+              raf.scheduleFullRedraw();
+            },
+          };
+          return m(VirtualTable, attrs);
+        },
+      }),
     );
   },
 });
-
 class ModalShowcase implements m.ClassComponent {
   private static counter = 0;
 
diff --git a/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts b/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
index 2c13c76..166a007 100644
--- a/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
+++ b/ui/src/plugins/dev.perfetto.AndroidCujs/index.ts
@@ -64,15 +64,17 @@
       sf_callback_missed_frames,
       hwui_callback_missed_frames,
       cuj_layer.layer_name,
-      cuj.ts,
-      cuj.dur,
+      /* Boundaries table doesn't contain ts and dur when a CUJ didn't complete successfully.
+        In that case we still want to show that it was canceled, so let's take the slice timestamps. */
+      CASE WHEN boundaries.ts IS NOT NULL THEN boundaries.ts ELSE cuj.ts END AS ts,
+      CASE WHEN boundaries.dur IS NOT NULL THEN boundaries.dur ELSE cuj.dur END AS dur,
       cuj.track_id,
       cuj.slice_id
     FROM slice AS cuj
-           JOIN process_track AS pt
-                ON cuj.track_id = pt.id
+           JOIN process_track AS pt ON cuj.track_id = pt.id
            LEFT JOIN android_jank_cuj jc
                      ON pt.upid = jc.upid AND cuj.name = jc.cuj_slice_name AND cuj.ts = jc.ts
+           LEFT JOIN android_jank_cuj_main_thread_cuj_boundary boundaries using (cuj_id)
            LEFT JOIN android_jank_cuj_layer_name cuj_layer USING (cuj_id)
            LEFT JOIN android_jank_cuj_counter_metrics USING (cuj_id)
     WHERE cuj.name GLOB 'J<*>'
@@ -140,7 +142,7 @@
             },
             'Jank CUJs',
             {ts: 'ts', dur: 'dur', name: 'name'},
-            [],
+            JANK_COLUMNS,
           );
         });
       },
diff --git a/ui/src/plugins/dev.perfetto.RestorePinnedTracks/OWNERS b/ui/src/plugins/dev.perfetto.RestorePinnedTracks/OWNERS
new file mode 100644
index 0000000..987684d
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.RestorePinnedTracks/OWNERS
@@ -0,0 +1,2 @@
+nicomazz@google.com
+nickchameyev@google.com
diff --git a/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts b/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts
new file mode 100644
index 0000000..81036db
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.RestorePinnedTracks/index.ts
@@ -0,0 +1,135 @@
+// Copyright (C) 2024 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 {
+  Plugin,
+  PluginContext,
+  PluginContextTrace,
+  PluginDescriptor,
+  TrackRef,
+} from '../../public';
+
+const PLUGIN_ID = 'dev.perfetto.RestorePinnedTrack';
+const SAVED_TRACKS_KEY = `${PLUGIN_ID}#savedPerfettoTracks`;
+
+/**
+ * Fuzzy save and restore of pinned tracks.
+ *
+ * Tries to persist pinned tracks. Uses full string matching between track name
+ * and group name. When no match is found for a saved track, it tries again
+ * without numbers.
+ */
+class RestorePinnedTrack implements Plugin {
+  onActivate(_ctx: PluginContext): void {}
+
+  private ctx!: PluginContextTrace;
+
+  async onTraceLoad(ctx: PluginContextTrace): Promise<void> {
+    this.ctx = ctx;
+    ctx.registerCommand({
+      id: `${PLUGIN_ID}#save`,
+      name: 'Save: Pinned tracks',
+      callback: () => {
+        this.saveTracks();
+      },
+    });
+    ctx.registerCommand({
+      id: `${PLUGIN_ID}#restore`,
+      name: 'Restore: Pinned tracks',
+      callback: () => {
+        this.restoreTracks();
+      },
+    });
+  }
+
+  private saveTracks() {
+    const pinnedTracks = this.ctx.timeline.tracks.filter(
+      (trackRef) => trackRef.isPinned,
+    );
+    const tracksToSave: SavedPinnedTrack[] = pinnedTracks.map((trackRef) => ({
+      groupName: trackRef.groupName,
+      trackName: trackRef.displayName,
+    }));
+    window.localStorage.setItem(SAVED_TRACKS_KEY, JSON.stringify(tracksToSave));
+  }
+
+  private restoreTracks() {
+    const savedTracks = window.localStorage.getItem(SAVED_TRACKS_KEY);
+    if (!savedTracks) {
+      alert('No saved tracks. Use the Save command first');
+      return;
+    }
+    const tracksToRestore: SavedPinnedTrack[] = JSON.parse(savedTracks);
+    const tracks: TrackRef[] = this.ctx.timeline.tracks;
+    tracksToRestore.forEach((trackToRestore) => {
+      // Check for an exact match
+      const exactMatch = tracks.find((track) => {
+        return (
+          track.key &&
+          trackToRestore.trackName === track.displayName &&
+          trackToRestore.groupName === track.groupName
+        );
+      });
+
+      if (exactMatch) {
+        this.ctx.timeline.pinTrack(exactMatch.key!);
+      } else {
+        // We attempt a match after removing numbers to potentially pin a
+        // "similar" track from a different trace. Removing numbers allows
+        // flexibility; for instance, with multiple 'sysui' processes (e.g.
+        // track group name: "com.android.systemui 123") without this approach,
+        // any could be mistakenly pinned. The goal is to restore specific
+        // tracks within the same trace, ensuring that a previously pinned track
+        // is pinned again.
+        // If the specific process with that PID is unavailable, pinning any
+        // other process matching the package name is attempted.
+        const fuzzyMatch = tracks.find((track) => {
+          return (
+            track.key &&
+            this.removeNumbers(trackToRestore.trackName) ===
+              this.removeNumbers(track.displayName) &&
+            this.removeNumbers(trackToRestore.groupName) ===
+              this.removeNumbers(track.groupName)
+          );
+        });
+
+        if (fuzzyMatch) {
+          this.ctx.timeline.pinTrack(fuzzyMatch.key!);
+        } else {
+          console.warn(
+            '[RestorePinnedTracks] No track found that matches',
+            trackToRestore,
+          );
+        }
+      }
+    });
+  }
+
+  private removeNumbers(inputString?: string): string | undefined {
+    return inputString?.replace(/\d+/g, '');
+  }
+}
+
+interface SavedPinnedTrack {
+  // Optional: group name for the track. Usually matches with process name.
+  groupName?: string;
+
+  // Track name to restore.
+  trackName: string;
+}
+
+export const plugin: PluginDescriptor = {
+  pluginId: PLUGIN_ID,
+  plugin: RestorePinnedTrack,
+};
diff --git a/ui/src/public/index.ts b/ui/src/public/index.ts
index ece6ea0..f3e4338 100644
--- a/ui/src/public/index.ts
+++ b/ui/src/public/index.ts
@@ -482,6 +482,12 @@
 
   // Optional: Add tracks to a group with this name.
   groupName?: string;
+
+  // Optional: Track key
+  key?: string;
+
+  // Optional: Whether the track is pinned
+  isPinned?: boolean;
 }
 
 // A predicate for selecting a subset of tracks.
diff --git a/ui/src/tracks/android_log/logs_panel.ts b/ui/src/tracks/android_log/logs_panel.ts
index 51e0889..3970190 100644
--- a/ui/src/tracks/android_log/logs_panel.ts
+++ b/ui/src/tracks/android_log/logs_panel.ts
@@ -18,12 +18,10 @@
 import {Actions} from '../../common/actions';
 import {raf} from '../../core/raf_scheduler';
 import {DetailsShell} from '../../widgets/details_shell';
-import {VirtualScrollContainer} from '../../widgets/virtual_scroll_container';
 
-import {SELECTED_LOG_ROWS_COLOR} from '../../frontend/css_constants';
 import {globals} from '../../frontend/globals';
 import {Timestamp} from '../../frontend/widgets/timestamp';
-import {createStore, EngineProxy, LONG, NUM, Store, STR} from '../../public';
+import {EngineProxy, LONG, NUM, NUM_NULL, Store, STR} from '../../public';
 import {Monitor} from '../../base/monitor';
 import {AsyncLimiter} from '../../base/async_limiter';
 import {escapeGlob, escapeQuery} from '../../trace_processor/query_utils';
@@ -31,6 +29,8 @@
 import {Button} from '../../widgets/button';
 import {TextInput} from '../../widgets/text_input';
 import {Intent} from '../../widgets/common';
+import {VirtualTable, VirtualTableRow} from '../../widgets/virtual_table';
+import {classNames} from '../../base/classnames';
 
 const ROW_H = 20;
 
@@ -63,15 +63,12 @@
 }
 
 export class LogPanel implements m.ClassComponent<LogPanelAttrs> {
-  private readonly SKIRT_SIZE = 50;
   private entries?: LogEntries;
-  private isStale = true;
-  private viewportBounds = {top: 0, bottom: 0};
 
-  private readonly paginationStore = createStore<Pagination>({
+  private pagination: Pagination = {
     offset: 0,
     count: 0,
-  });
+  };
   private readonly rowsMonitor: Monitor;
   private readonly filterMonitor: Monitor;
   private readonly queryLimiter = new AsyncLimiter();
@@ -81,7 +78,6 @@
       () => attrs.filterStore.state,
       () => globals.state.frontendLocalState.visibleState.start,
       () => globals.state.frontendLocalState.visibleState.end,
-      () => this.paginationStore.state,
     ]);
 
     this.filterMonitor = new Monitor([() => attrs.filterStore.state]);
@@ -89,148 +85,104 @@
 
   view({attrs}: m.CVnode<LogPanelAttrs>) {
     if (this.rowsMonitor.ifStateChanged()) {
-      this.queryLimiter.schedule(async () => {
-        this.isStale = true;
-        raf.scheduleFullRedraw();
-
-        const visibleState = globals.state.frontendLocalState.visibleState;
-        const visibleSpan = new TimeSpan(visibleState.start, visibleState.end);
-
-        if (this.filterMonitor.ifStateChanged()) {
-          await updateLogView(attrs.engine, attrs.filterStore.state);
-        }
-
-        this.entries = await updateLogEntries(
-          attrs.engine,
-          visibleSpan,
-          this.paginationStore.state,
-        );
-
-        raf.scheduleFullRedraw();
-        this.isStale = false;
-      });
+      this.reloadData(attrs);
     }
 
     const hasProcessNames =
       this.entries &&
       this.entries.processName.filter((name) => name).length > 0;
+    const totalEvents = this.entries?.totalEvents ?? 0;
 
-    const rows: m.Children = [];
-    rows.push(
-      m(
-        `.row`,
-        m('.cell.row-header', 'Timestamp'),
-        m('.cell.row-header', 'Level'),
-        m('.cell.row-header', 'Tag'),
-        hasProcessNames
-          ? m('.cell.with-process.row-header', 'Process name')
-          : undefined,
-        hasProcessNames
-          ? m('.cell.with-process.row-header', 'Message')
-          : m('.cell.no-process.row-header', 'Message'),
-        m('br'),
-      ),
-    );
-    if (this.entries) {
-      const offset = this.entries.offset;
-      const timestamps = this.entries.timestamps;
-      const priorities = this.entries.priorities;
-      const tags = this.entries.tags;
-      const messages = this.entries.messages;
-      const processNames = this.entries.processName;
-      const totalEvents = this.entries.totalEvents;
-
-      for (let i = 0; i < this.entries.timestamps.length; i++) {
-        const priorityLetter = LOG_PRIORITIES[priorities[i]][0];
-        const ts = timestamps[i];
-        const prioClass = priorityLetter || '';
-        const style: {top: string; backgroundColor?: string} = {
-          // 1.5 is for the width of the header
-          top: `${(offset + i + 1.5) * ROW_H}px`,
-        };
-        if (this.entries.isHighlighted[i]) {
-          style.backgroundColor = SELECTED_LOG_ROWS_COLOR;
-        }
-
-        rows.push(
-          m(
-            `.row.${prioClass}`,
-            {
-              class: this.isStale ? 'stale' : '',
-              style,
-              onmouseover: () => {
-                globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
-              },
-              onmouseout: () => {
-                globals.dispatch(
-                  Actions.setHoverCursorTimestamp({ts: Time.INVALID}),
-                );
-              },
-            },
-            m('.cell', m(Timestamp, {ts})),
-            m('.cell', priorityLetter || '?'),
-            m('.cell', tags[i]),
-            hasProcessNames
-              ? m('.cell.with-process', processNames[i])
-              : undefined,
-            hasProcessNames
-              ? m('.cell.with-process', messages[i])
-              : m('.cell.no-process', messages[i]),
-            m('br'),
-          ),
-        );
-      }
-
-      return m(
-        DetailsShell,
-        {
-          title: 'Android Logs',
-          description: `[${this.viewportBounds.top}, ${this.viewportBounds.bottom}] / ${totalEvents}`,
-          buttons: m(LogsFilters, {store: attrs.filterStore}),
+    return m(
+      DetailsShell,
+      {
+        title: 'Android Logs',
+        description: `Total messages: ${totalEvents}`,
+        buttons: m(LogsFilters, {store: attrs.filterStore}),
+      },
+      m(VirtualTable, {
+        className: 'pf-android-logs-table',
+        columns: [
+          {header: 'Timestamp', width: '7rem'},
+          {header: 'Level', width: '4rem'},
+          {header: 'Tag', width: '13rem'},
+          ...(hasProcessNames ? [{header: 'Process', width: '18rem'}] : []),
+          {header: 'Message', width: '42rem'},
+        ],
+        rows: this.renderRows(hasProcessNames),
+        firstRowOffset: this.entries?.offset ?? 0,
+        numRows: this.entries?.totalEvents ?? 0,
+        rowHeight: ROW_H,
+        onReload: (offset, count) => {
+          this.pagination = {offset, count};
+          this.reloadData(attrs);
         },
-        m(
-          VirtualScrollContainer,
-          {
-            onScroll: (scrollContainer: HTMLElement) => {
-              this.recomputeVisibleRowsAndUpdate(scrollContainer);
-              raf.scheduleFullRedraw();
-            },
-          },
-          m(
-            '.log-panel',
-            m('.rows', {style: {height: `${totalEvents * ROW_H}px`}}, rows),
-          ),
-        ),
-      );
-    }
-
-    return null;
+        onRowHover: (id) => {
+          const timestamp = this.entries?.timestamps[id];
+          if (timestamp !== undefined) {
+            globals.dispatch(Actions.setHoverCursorTimestamp({ts: timestamp}));
+          }
+        },
+        onRowOut: () => {
+          globals.dispatch(Actions.setHoverCursorTimestamp({ts: Time.INVALID}));
+        },
+      }),
+    );
   }
 
-  recomputeVisibleRowsAndUpdate(scrollContainer: HTMLElement) {
-    const viewportTop = Math.floor(scrollContainer.scrollTop / ROW_H);
-    const viewportHeight = Math.ceil(scrollContainer.clientHeight / ROW_H);
-    const viewportBottom = viewportTop + viewportHeight;
+  private reloadData(attrs: LogPanelAttrs) {
+    this.queryLimiter.schedule(async () => {
+      const visibleState = globals.state.frontendLocalState.visibleState;
+      const visibleSpan = new TimeSpan(visibleState.start, visibleState.end);
 
-    this.viewportBounds = {
-      top: viewportTop,
-      bottom: viewportBottom,
-    };
+      if (this.filterMonitor.ifStateChanged()) {
+        await updateLogView(attrs.engine, attrs.filterStore.state);
+      }
 
-    const curPage = this.paginationStore.state;
+      this.entries = await updateLogEntries(
+        attrs.engine,
+        visibleSpan,
+        this.pagination,
+      );
 
-    if (
-      viewportTop < curPage.offset ||
-      viewportBottom >= curPage.offset + curPage.count
-    ) {
-      this.paginationStore.edit((draft) => {
-        const offset = Math.max(0, viewportTop - this.SKIRT_SIZE);
-        // Make it even so alternating coloured rows line up
-        const offsetEven = Math.floor(offset / 2) * 2;
-        draft.offset = offsetEven;
-        draft.count = viewportHeight + this.SKIRT_SIZE * 2;
+      raf.scheduleFullRedraw();
+    });
+  }
+
+  private renderRows(hasProcessNames: boolean | undefined): VirtualTableRow[] {
+    if (!this.entries) {
+      return [];
+    }
+
+    const timestamps = this.entries.timestamps;
+    const priorities = this.entries.priorities;
+    const tags = this.entries.tags;
+    const messages = this.entries.messages;
+    const processNames = this.entries.processName;
+
+    const rows: VirtualTableRow[] = [];
+    for (let i = 0; i < this.entries.timestamps.length; i++) {
+      const priorityLetter = LOG_PRIORITIES[priorities[i]][0];
+      const ts = timestamps[i];
+      const prioClass = priorityLetter || '';
+
+      rows.push({
+        id: i,
+        className: classNames(
+          prioClass,
+          this.entries.isHighlighted[i] && 'pf-highlighted',
+        ),
+        cells: [
+          m(Timestamp, {ts}),
+          priorityLetter || '?',
+          tags[i],
+          ...(hasProcessNames ? [processNames[i]] : []),
+          messages[i],
+        ],
       });
     }
+
+    return rows;
   }
 }
 
@@ -460,7 +412,7 @@
     prio: NUM,
     tag: STR,
     msg: STR,
-    isMsgHighlighted: NUM,
+    isMsgHighlighted: NUM_NULL,
     isProcessHighlighted: NUM,
     processName: STR,
   });
diff --git a/ui/src/tracks/cpu_freq/index.ts b/ui/src/tracks/cpu_freq/index.ts
index 92e30f9..b5fa5fa 100644
--- a/ui/src/tracks/cpu_freq/index.ts
+++ b/ui/src/tracks/cpu_freq/index.ts
@@ -31,6 +31,7 @@
   Track,
 } from '../../public';
 import {LONG, NUM, NUM_NULL} from '../../trace_processor/query_result';
+import {uuidv4Sql} from '../../base/uuid';
 
 export const CPU_FREQ_TRACK_KIND = 'CpuFreqTrack';
 
@@ -63,30 +64,29 @@
 
   private engine: EngineProxy;
   private config: Config;
-  private trackKey: string;
+  private trackUuid = uuidv4Sql();
 
-  constructor(config: Config, engine: EngineProxy, trackKey: string) {
+  constructor(config: Config, engine: EngineProxy) {
     this.config = config;
     this.engine = engine;
-    this.trackKey = trackKey.split('-').join('_');
   }
 
   async onCreate() {
     if (this.config.idleTrackId === undefined) {
       await this.engine.execute(`
-        create view raw_freq_idle_${this.trackKey} as
+        create view raw_freq_idle_${this.trackUuid} as
         select ts, dur, value as freqValue, -1 as idleValue
         from experimental_counter_dur c
         where track_id = ${this.config.freqTrackId}
       `);
     } else {
       await this.engine.execute(`
-        create view raw_freq_${this.trackKey} as
+        create view raw_freq_${this.trackUuid} as
         select ts, dur, value as freqValue
         from experimental_counter_dur c
         where track_id = ${this.config.freqTrackId};
 
-        create view raw_idle_${this.trackKey} as
+        create view raw_idle_${this.trackUuid} as
         select
           ts,
           dur,
@@ -94,22 +94,22 @@
         from experimental_counter_dur c
         where track_id = ${this.config.idleTrackId};
 
-        create virtual table raw_freq_idle_${this.trackKey}
-        using span_join(raw_freq_${this.trackKey}, raw_idle_${this.trackKey});
+        create virtual table raw_freq_idle_${this.trackUuid}
+        using span_join(raw_freq_${this.trackUuid}, raw_idle_${this.trackUuid});
       `);
     }
 
     await this.engine.execute(`
-      create virtual table cpu_freq_${this.trackKey}
+      create virtual table cpu_freq_${this.trackUuid}
       using __intrinsic_counter_mipmap((
         select ts, freqValue as value
-        from raw_freq_idle_${this.trackKey}
+        from raw_freq_idle_${this.trackUuid}
       ));
 
-      create virtual table cpu_idle_${this.trackKey}
+      create virtual table cpu_idle_${this.trackUuid}
       using __intrinsic_counter_mipmap((
         select ts, idleValue as value
-        from raw_freq_idle_${this.trackKey}
+        from raw_freq_idle_${this.trackUuid}
       ));
     `);
   }
@@ -120,11 +120,11 @@
 
   async onDestroy(): Promise<void> {
     if (this.engine.isAlive) {
-      await this.engine.query(`drop table cpu_freq_${this.trackKey}`);
-      await this.engine.query(`drop table cpu_idle_${this.trackKey}`);
-      await this.engine.query(`drop table raw_freq_idle_${this.trackKey}`);
-      await this.engine.query(`drop view if exists raw_freq_${this.trackKey}`);
-      await this.engine.query(`drop view if exists raw_idle_${this.trackKey}`);
+      await this.engine.query(`drop table cpu_freq_${this.trackUuid}`);
+      await this.engine.query(`drop table cpu_idle_${this.trackUuid}`);
+      await this.engine.query(`drop table raw_freq_idle_${this.trackUuid}`);
+      await this.engine.query(`drop view if exists raw_freq_${this.trackUuid}`);
+      await this.engine.query(`drop view if exists raw_idle_${this.trackUuid}`);
     }
   }
 
@@ -143,7 +143,7 @@
         max_value as maxFreq,
         last_ts as ts,
         last_value as lastFreq
-      FROM cpu_freq_${this.trackKey}(
+      FROM cpu_freq_${this.trackUuid}(
         ${start},
         ${end},
         ${resolution}
@@ -151,7 +151,7 @@
     `);
     const idleResult = await this.engine.query(`
       SELECT last_value as lastIdle
-      FROM cpu_idle_${this.trackKey}(
+      FROM cpu_idle_${this.trackUuid}(
         ${start},
         ${end},
         ${resolution}
@@ -450,7 +450,7 @@
           displayName: `Cpu ${cpu} Frequency`,
           kind: CPU_FREQ_TRACK_KIND,
           cpu,
-          trackFactory: (c) => new CpuFreqTrack(config, ctx.engine, c.trackKey),
+          trackFactory: () => new CpuFreqTrack(config, ctx.engine),
         });
       }
     }
diff --git a/ui/src/tracks/cpu_slices/index.ts b/ui/src/tracks/cpu_slices/index.ts
index 552092c..5c042dc 100644
--- a/ui/src/tracks/cpu_slices/index.ts
+++ b/ui/src/tracks/cpu_slices/index.ts
@@ -40,6 +40,7 @@
   Track,
 } from '../../public';
 import {LONG, NUM, STR_NULL} from '../../trace_processor/query_result';
+import {uuidv4Sql} from '../../base/uuid';
 
 export const CPU_SLICE_TRACK_KIND = 'CpuSliceTrack';
 
@@ -69,6 +70,7 @@
   private engine: EngineProxy;
   private cpu: number;
   private trackKey: string;
+  private trackUuid = uuidv4Sql();
 
   constructor(engine: EngineProxy, trackKey: string, cpu: number) {
     this.engine = engine;
@@ -78,7 +80,7 @@
 
   async onCreate() {
     await this.engine.query(`
-      create virtual table cpu_slice_${this.trackKey}
+      create virtual table cpu_slice_${this.trackUuid}
       using __intrinsic_slice_mipmap((
         select
           id,
@@ -116,7 +118,7 @@
         s.id,
         s.dur = -1 as isIncomplete,
         ifnull(s.priority < 100, 0) as isRealtime
-      from cpu_slice_${this.trackKey}(${start}, ${end}, ${resolution}) z
+      from cpu_slice_${this.trackUuid}(${start}, ${end}, ${resolution}) z
       cross join sched s using (id)
     `);
 
@@ -165,7 +167,7 @@
   async onDestroy() {
     if (this.engine.isAlive) {
       await this.engine.query(
-        `drop table if exists cpu_slice_${this.trackKey}`,
+        `drop table if exists cpu_slice_${this.trackUuid}`,
       );
     }
     this.fetcher.dispose();
diff --git a/ui/src/tracks/ftrace/ftrace_explorer.ts b/ui/src/tracks/ftrace/ftrace_explorer.ts
index 9481b86..5e7eb32 100644
--- a/ui/src/tracks/ftrace/ftrace_explorer.ts
+++ b/ui/src/tracks/ftrace/ftrace_explorer.ts
@@ -24,27 +24,18 @@
   PopupMultiSelect,
 } from '../../widgets/multiselect';
 import {PopupPosition} from '../../widgets/popup';
-import {VirtualScrollContainer} from '../../widgets/virtual_scroll_container';
 
 import {globals} from '../../frontend/globals';
 import {Timestamp} from '../../frontend/widgets/timestamp';
 import {FtraceFilter, FtraceStat} from './common';
-import {
-  createStore,
-  EngineProxy,
-  LONG,
-  NUM,
-  Store,
-  STR,
-  STR_NULL,
-} from '../../public';
+import {EngineProxy, LONG, NUM, Store, STR, STR_NULL} from '../../public';
 import {raf} from '../../core/raf_scheduler';
 import {AsyncLimiter} from '../../base/async_limiter';
 import {Monitor} from '../../base/monitor';
 import {Button} from '../../widgets/button';
+import {VirtualTable, VirtualTableRow} from '../../widgets/virtual_table';
 
 const ROW_H = 20;
-const PAGE_SIZE = 250;
 
 interface FtraceExplorerAttrs {
   cache: FtraceExplorerCache;
@@ -69,8 +60,8 @@
 }
 
 interface Pagination {
-  page: number;
-  pageCount: number;
+  offset: number;
+  count: number;
 }
 
 export interface FtraceExplorerCache {
@@ -104,10 +95,10 @@
 }
 
 export class FtraceExplorer implements m.ClassComponent<FtraceExplorerAttrs> {
-  private readonly paginationStore = createStore<Pagination>({
-    page: 0,
-    pageCount: 0,
-  });
+  private pagination: Pagination = {
+    offset: 0,
+    count: 0,
+  };
   private readonly monitor: Monitor;
   private readonly queryLimiter = new AsyncLimiter();
 
@@ -119,7 +110,6 @@
       () => globals.state.frontendLocalState.visibleState.start,
       () => globals.state.frontendLocalState.visibleState.end,
       () => attrs.filterStore.state,
-      () => this.paginationStore.state,
     ]);
 
     if (attrs.cache.state === 'blank') {
@@ -136,60 +126,85 @@
   }
 
   view({attrs}: m.CVnode<FtraceExplorerAttrs>) {
-    this.monitor.ifStateChanged(() =>
-      this.queryLimiter.schedule(async () => {
-        this.data = await lookupFtraceEvents(
-          attrs.engine,
-          this.paginationStore.state.page * PAGE_SIZE,
-          this.paginationStore.state.pageCount * PAGE_SIZE,
-          attrs.filterStore.state,
-        );
-        raf.scheduleFullRedraw();
-      }),
-    );
+    this.monitor.ifStateChanged(() => {
+      this.reloadData(attrs);
+    });
 
     return m(
       DetailsShell,
       {
         title: this.renderTitle(),
         buttons: this.renderFilterPanel(attrs),
+        fillParent: true,
       },
-      m(
-        VirtualScrollContainer,
-        {
-          onScroll: this.onScroll.bind(this),
+      m(VirtualTable, {
+        className: 'pf-ftrace-explorer',
+        columns: [
+          {header: 'ID', width: '5em'},
+          {header: 'Timestamp', width: '13em'},
+          {header: 'Name', width: '24em'},
+          {header: 'CPU', width: '3em'},
+          {header: 'Process', width: '24em'},
+          {header: 'Args', width: '200em'},
+        ],
+        firstRowOffset: this.data?.offset ?? 0,
+        numRows: this.data?.numEvents ?? 0,
+        rowHeight: ROW_H,
+        rows: this.renderData(),
+        onReload: (offset, count) => {
+          this.pagination = {offset, count};
+          this.reloadData(attrs);
         },
-        m('.ftrace-panel', this.renderRows()),
-      ),
+        onRowHover: this.onRowOver.bind(this),
+        onRowOut: this.onRowOut.bind(this),
+      }),
     );
   }
 
-  onScroll(scrollContainer: HTMLElement) {
-    const paginationState = this.paginationStore.state;
-    const prevPage = paginationState.page;
-    const prevPageCount = paginationState.pageCount;
-
-    const visibleRowOffset = Math.floor(scrollContainer.scrollTop / ROW_H);
-    const visibleRowCount = Math.ceil(scrollContainer.clientHeight / ROW_H);
-
-    // Work out which "page" we're on
-    const page = Math.max(0, Math.floor(visibleRowOffset / PAGE_SIZE) - 1);
-    const pageCount = Math.ceil(visibleRowCount / PAGE_SIZE) + 2;
-
-    if (page !== prevPage || pageCount !== prevPageCount) {
-      this.paginationStore.edit((draft) => {
-        draft.page = page;
-        draft.pageCount = pageCount;
-      });
+  private reloadData(attrs: FtraceExplorerAttrs): void {
+    this.queryLimiter.schedule(async () => {
+      this.data = await lookupFtraceEvents(
+        attrs.engine,
+        this.pagination.offset,
+        this.pagination.count,
+        attrs.filterStore.state,
+      );
       raf.scheduleFullRedraw();
+    });
+  }
+
+  private renderData(): VirtualTableRow[] {
+    if (!this.data) {
+      return [];
+    }
+
+    return this.data.events.map((event) => {
+      const {ts, name, cpu, process, args, id} = event;
+      const timestamp = m(Timestamp, {ts});
+      const color = colorForFtrace(name).base.cssString;
+
+      return {
+        id,
+        cells: [
+          id,
+          timestamp,
+          m('', m('span.colour', {style: {background: color}}), name),
+          cpu,
+          process,
+          args,
+        ],
+      };
+    });
+  }
+
+  private onRowOver(id: number) {
+    const event = this.data?.events.find((event) => event.id === id);
+    if (event) {
+      globals.dispatch(Actions.setHoverCursorTimestamp({ts: event.ts}));
     }
   }
 
-  onRowOver(ts: time) {
-    globals.dispatch(Actions.setHoverCursorTimestamp({ts}));
-  }
-
-  onRowOut() {
+  private onRowOut() {
     globals.dispatch(Actions.setHoverCursorTimestamp({ts: Time.INVALID}));
   }
 
@@ -242,55 +257,6 @@
       },
     });
   }
-
-  // Render all the rows including the first title row
-  private renderRows() {
-    const data = this.data;
-    const rows: m.Children = [];
-
-    rows.push(
-      m(
-        `.row`,
-        m('.cell.row-header', 'Timestamp'),
-        m('.cell.row-header', 'Name'),
-        m('.cell.row-header', 'CPU'),
-        m('.cell.row-header', 'Process'),
-        m('.cell.row-header', 'Args'),
-      ),
-    );
-
-    if (data) {
-      const {events, offset, numEvents} = data;
-      for (let i = 0; i < events.length; i++) {
-        const {ts, name, cpu, process, args} = events[i];
-
-        const timestamp = m(Timestamp, {ts});
-
-        const rank = i + offset;
-
-        const color = colorForFtrace(name).base.cssString;
-
-        rows.push(
-          m(
-            `.row`,
-            {
-              style: {top: `${(rank + 1.0) * ROW_H}px`},
-              onmouseover: this.onRowOver.bind(this, ts),
-              onmouseout: this.onRowOut.bind(this),
-            },
-            m('.cell', timestamp),
-            m('.cell', m('span.colour', {style: {background: color}}), name),
-            m('.cell', cpu),
-            m('.cell', process),
-            m('.cell', args),
-          ),
-        );
-      }
-      return m('.rows', {style: {height: `${numEvents * ROW_H}px`}}, rows);
-    } else {
-      return m('.rows', rows);
-    }
-  }
 }
 
 async function lookupFtraceEvents(
diff --git a/ui/src/tracks/process_summary/index.ts b/ui/src/tracks/process_summary/index.ts
index 37ac68f..ea6845f 100644
--- a/ui/src/tracks/process_summary/index.ts
+++ b/ui/src/tracks/process_summary/index.ts
@@ -103,8 +103,8 @@
           tags: {
             isDebuggable,
           },
-          trackFactory: ({trackKey}) => {
-            return new ProcessSchedulingTrack(ctx.engine, trackKey, config);
+          trackFactory: () => {
+            return new ProcessSchedulingTrack(ctx.engine, config);
           },
         });
       } else {
diff --git a/ui/src/tracks/process_summary/process_scheduling_track.ts b/ui/src/tracks/process_summary/process_scheduling_track.ts
index 5905de2..9725c29 100644
--- a/ui/src/tracks/process_summary/process_scheduling_track.ts
+++ b/ui/src/tracks/process_summary/process_scheduling_track.ts
@@ -27,6 +27,7 @@
 import {PanelSize} from '../../frontend/panel';
 import {EngineProxy, Track} from '../../public';
 import {LONG, NUM, QueryResult} from '../../trace_processor/query_result';
+import {uuidv4Sql} from '../../base/uuid';
 
 export const PROCESS_SCHEDULING_TRACK_KIND = 'ProcessSchedulingTrack';
 
@@ -57,13 +58,12 @@
   private fetcher = new TimelineFetcher(this.onBoundsChange.bind(this));
   private maxCpu = 0;
   private engine: EngineProxy;
-  private trackKey: string;
+  private trackUuid = uuidv4Sql();
   private config: Config;
 
-  constructor(engine: EngineProxy, trackKey: string, config: Config) {
+  constructor(engine: EngineProxy, config: Config) {
     this.engine = engine;
     this.config = config;
-    this.trackKey = trackKey.split('-').join('_');
   }
 
   async onCreate(): Promise<void> {
@@ -75,7 +75,7 @@
 
     if (this.config.upid !== null) {
       await this.engine.query(`
-        create virtual table process_scheduling_${this.trackKey}
+        create virtual table process_scheduling_${this.trackUuid}
         using __intrinsic_slice_mipmap((
           select
             id,
@@ -95,7 +95,7 @@
     } else {
       assertExists(this.config.utid);
       await this.engine.query(`
-        create virtual table process_scheduling_${this.trackKey}
+        create virtual table process_scheduling_${this.trackUuid}
         using __intrinsic_slice_mipmap((
           select
             id,
@@ -121,7 +121,7 @@
     this.fetcher.dispose();
     if (this.engine.isAlive) {
       await this.engine.query(`
-        drop table process_scheduling_${this.trackKey}
+        drop table process_scheduling_${this.trackUuid}
       `);
     }
   }
@@ -182,7 +182,7 @@
         s.id,
         z.depth as cpu,
         utid
-      from process_scheduling_${this.trackKey}(
+      from process_scheduling_${this.trackUuid}(
         ${start}, ${end}, ${bucketSize}
       ) z
       cross join sched s using (id)
diff --git a/ui/src/widgets/virtual_scroll_container.ts b/ui/src/widgets/virtual_scroll_container.ts
deleted file mode 100644
index f6bd052..0000000
--- a/ui/src/widgets/virtual_scroll_container.ts
+++ /dev/null
@@ -1,49 +0,0 @@
-// Copyright (C) 2023 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 {findRef, toHTMLElement} from '../base/dom_utils';
-
-interface VirtualScrollContainerAttrs {
-  // Called when the scrolling element is created, updates, or scrolls.
-  onScroll?: (dom: HTMLElement) => void;
-}
-
-export class VirtualScrollContainer
-  implements m.ClassComponent<VirtualScrollContainerAttrs>
-{
-  private readonly REF = 'virtual-scroll-container';
-  view({attrs, children}: m.Vnode<VirtualScrollContainerAttrs>) {
-    const {onScroll = () => {}} = attrs;
-
-    return m(
-      '.pf-virtual-scroll-container',
-      {
-        ref: this.REF,
-        onscroll: (e: Event) => onScroll(e.target as HTMLElement),
-      },
-      children,
-    );
-  }
-
-  oncreate({dom, attrs}: m.VnodeDOM<VirtualScrollContainerAttrs, this>) {
-    const {onScroll = () => {}} = attrs;
-
-    const element = findRef(dom, this.REF);
-    if (element) {
-      onScroll(toHTMLElement(element));
-    }
-  }
-}
diff --git a/ui/src/widgets/virtual_scroll_helper.ts b/ui/src/widgets/virtual_scroll_helper.ts
new file mode 100644
index 0000000..4fbe5c1
--- /dev/null
+++ b/ui/src/widgets/virtual_scroll_helper.ts
@@ -0,0 +1,150 @@
+// Copyright (C) 2024 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 {Trash} from '../base/disposable';
+import * as Geometry from '../base/geom';
+
+export interface VirtualScrollHelperOpts {
+  overdrawPx: number;
+
+  // How close we can get to undrawn regions before updating
+  tolerancePx: number;
+
+  callback: (r: Geometry.Rect) => void;
+}
+
+export interface Data {
+  opts: VirtualScrollHelperOpts;
+  rect?: Geometry.Rect;
+}
+
+export class VirtualScrollHelper {
+  private readonly _trash = new Trash();
+  private readonly _data: Data[] = [];
+
+  constructor(
+    sliderElement: HTMLElement,
+    containerElement: Element,
+    opts: VirtualScrollHelperOpts[] = [],
+  ) {
+    this._data = opts.map((opts) => {
+      return {opts};
+    });
+
+    const recalculateRects = () => {
+      this._data.forEach((data) =>
+        recalculatePuckRect(sliderElement, containerElement, data),
+      );
+    };
+
+    containerElement.addEventListener('scroll', recalculateRects, {
+      passive: true,
+    });
+    this._trash.addCallback(() =>
+      containerElement.removeEventListener('scroll', recalculateRects),
+    );
+
+    // Resize observer callbacks are called once immediately
+    const resizeObserver = new ResizeObserver(() => {
+      recalculateRects();
+    });
+
+    resizeObserver.observe(containerElement);
+    resizeObserver.observe(sliderElement);
+    this._trash.addCallback(() => {
+      resizeObserver.disconnect();
+    });
+  }
+
+  dispose() {
+    this._trash.dispose();
+  }
+}
+
+function recalculatePuckRect(
+  sliderElement: HTMLElement,
+  containerElement: Element,
+  data: Data,
+): void {
+  const {tolerancePx, overdrawPx, callback} = data.opts;
+  if (!data.rect) {
+    const targetPuckRect = getTargetPuckRect(
+      sliderElement,
+      containerElement,
+      overdrawPx,
+    );
+    callback(targetPuckRect);
+    data.rect = targetPuckRect;
+  } else {
+    const viewportRect = containerElement.getBoundingClientRect();
+
+    // Expand the viewportRect by the tolerance
+    const viewportExpandedRect = Geometry.expandRect(viewportRect, tolerancePx);
+
+    const sliderClientRect = sliderElement.getBoundingClientRect();
+    const viewportClamped = Geometry.intersectRects(
+      viewportExpandedRect,
+      sliderClientRect,
+    );
+
+    // Translate the puck rect into client space (currently in slider space)
+    const puckClientRect = Geometry.translateRect(data.rect, {
+      x: sliderClientRect.x,
+      y: sliderClientRect.y,
+    });
+
+    // Check if the tolerance rect entirely contains the expanded viewport rect
+    // If not, request an update
+    if (!Geometry.containsRect(puckClientRect, viewportClamped)) {
+      const targetPuckRect = getTargetPuckRect(
+        sliderElement,
+        containerElement,
+        overdrawPx,
+      );
+      callback(targetPuckRect);
+      data.rect = targetPuckRect;
+    }
+  }
+}
+
+// Returns what the puck rect should look like
+function getTargetPuckRect(
+  sliderElement: HTMLElement,
+  containerElement: Element,
+  overdrawPx: number,
+) {
+  const sliderElementRect = sliderElement.getBoundingClientRect();
+  const containerRect = containerElement.getBoundingClientRect();
+
+  // Calculate the intersection of the container's viewport and the target
+  const intersection = Geometry.intersectRects(
+    containerRect,
+    sliderElementRect,
+  );
+
+  // Pad the intersection by the overdraw amount
+  const intersectionExpanded = Geometry.expandRect(intersection, overdrawPx);
+
+  // Intersect with the original target rect unless we want to avoid resizes
+  const targetRect = Geometry.intersectRects(
+    intersectionExpanded,
+    sliderElementRect,
+  );
+
+  return Geometry.rebaseRect(
+    targetRect,
+    sliderElementRect.x,
+    sliderElementRect.y,
+  );
+}
diff --git a/ui/src/widgets/virtual_table.ts b/ui/src/widgets/virtual_table.ts
new file mode 100644
index 0000000..0b97b96
--- /dev/null
+++ b/ui/src/widgets/virtual_table.ts
@@ -0,0 +1,262 @@
+// Copyright (C) 2024 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 {Trash} from '../base/disposable';
+import {findRef, toHTMLElement} from '../base/dom_utils';
+import {Rect} from '../base/geom';
+import {assertExists} from '../base/logging';
+import {Style} from './common';
+import {scheduleFullRedraw} from './raf';
+import {VirtualScrollHelper} from './virtual_scroll_helper';
+
+/**
+ * The |VirtualTable| widget can be useful when attempting to render a large
+ * amount of tabular data - i.e. dumping the entire contents of a database
+ * table.
+ *
+ * A naive approach would be to load the entire dataset from the table and
+ * render it into the DOM. However, this has a number of disadvantages:
+ * - The query could potentially be very slow on large enough datasets.
+ * - The amount of data pulled could be larger than the available memory.
+ * - Rendering thousands of DOM elements using Mithril can get be slow.
+ * - Asking the browser to create and update thousands of elements on the DOM
+ *   can also be slow.
+ *
+ * This implementation takes advantage of the fact that computer monitors are
+ * only so tall, so most will only be able to display a small subset of rows at
+ * a given time, and the user will have to scroll to reveal more data.
+ *
+ * Thus, this widgets operates in such a way as to only render the DOM elements
+ * that are visible within the given scrolling container's viewport. To avoid
+ * spamming render updates, we render a few more rows above and below the
+ * current viewport, and only trigger an update once the user scrolls too close
+ * to the edge of the rendered data. These margins and tolerances are
+ * configurable with the |renderOverdrawPx| and |renderTolerancePx| attributes.
+ *
+ * When it comes to loading data, it's often more performant to run fewer large
+ * queries compared to more frequent smaller queries. Running a new query every
+ * time we want to update the DOM is usually too frequent, and results in
+ * flickering as the data is usually not loaded at the time the relevant row
+ * scrolls into view.
+ *
+ * Thus, this implementation employs two sets of limits, one to refresh the DOM
+ * and one larger one to re-query the data. The latter may be configured using
+ * the |queryOverdrawPx| and |queryTolerancePx| attributes.
+ *
+ * The smaller DOM refreshes and handled internally, but the user must be called
+ * to invoke a new query update. When new data is required, the |onReload|
+ * callback is called with the row offset and count.
+ *
+ * The data must be passed in the |data| attribute which contains the offset of
+ * the currently loaded data and a number of rows.
+ *
+ * Row and column content is flexible as m.Children are accepted and passed
+ * straight to mithril.
+ *
+ * The widget is quite opinionated in terms of its styling, but the entire
+ * widget and each row may be tweaked using |className| and |style| attributes
+ * which behave in the same way as they do on other Mithril components.
+ */
+
+export interface VirtualTableAttrs {
+  // A list of columns containing the header row content and column widths
+  columns: VirtualTableColumn[];
+
+  // Row height in px (each row must have the same height)
+  rowHeight: number;
+
+  // Offset of the first row
+  firstRowOffset: number;
+
+  // Total number of rows
+  numRows: number;
+
+  // The row data to render
+  rows: VirtualTableRow[];
+
+  // Optional: Called when we need to reload data
+  onReload?: (rowOffset: number, rowCount: number) => void;
+
+  // Additional class name applied to the table container element
+  className?: string;
+
+  // Additional styles applied to the table container element
+  style?: Style;
+
+  // Optional: Called when a row is hovered, passing the hovered row's id
+  onRowHover?: (id: number) => void;
+
+  // Optional: Called when a row is un-hovered, passing the un-hovered row's id
+  onRowOut?: (id: number) => void;
+
+  // Optional: Number of pixels equivalent of rows to overdraw above and below
+  // the viewport
+  // Defaults to a sensible value
+  renderOverdrawPx?: number;
+
+  // Optional: How close we can get to the edge before triggering a DOM redraw
+  // Defaults to a sensible value
+  renderTolerancePx?: number;
+
+  // Optional: Number of pixels equivalent of rows to query above and below the
+  // viewport
+  // Defaults to a sensible value
+  queryOverdrawPx?: number;
+
+  // Optional: How close we can get to the edge if the loaded data before we
+  // trigger another query
+  // Defaults to a sensible value
+  queryTolerancePx?: number;
+}
+
+export interface VirtualTableColumn {
+  // Content to render in the header row
+  header: m.Children;
+
+  // CSS width e.g. 12px, 4em, etc...
+  width: string;
+}
+
+export interface VirtualTableRow {
+  // Id for this row (must be unique within this dataset)
+  // Used for callbacks and as a Mithril key.
+  id: number;
+
+  // Data for each column in this row - must match number of elements in columns
+  cells: m.Children[];
+
+  // Optional: Additional class name applied to the row element
+  className?: string;
+}
+
+export class VirtualTable implements m.ClassComponent<VirtualTableAttrs> {
+  private readonly CONTAINER_REF = 'CONTAINER';
+  private readonly SLIDER_REF = 'SLIDER';
+  private readonly trash = new Trash();
+  private renderBounds = {rowStart: 0, rowEnd: 0};
+
+  view({attrs}: m.Vnode<VirtualTableAttrs>): m.Children {
+    const {columns, className, numRows, rowHeight, style} = attrs;
+    return m(
+      '.pf-vtable',
+      {className, style, ref: this.CONTAINER_REF},
+      m(
+        '.pf-vtable-content',
+        m(
+          '.pf-vtable-header',
+          columns.map((col) =>
+            m('.pf-vtable-data', {style: {width: col.width}}, col.header),
+          ),
+        ),
+        m(
+          '.pf-vtable-slider',
+          {ref: this.SLIDER_REF, style: {height: `${rowHeight * numRows}px`}},
+          m(
+            '.pf-vtable-puck',
+            {
+              style: {
+                transform: `translateY(${
+                  this.renderBounds.rowStart * rowHeight
+                }px)`,
+              },
+            },
+            this.renderContent(attrs),
+          ),
+        ),
+      ),
+    );
+  }
+
+  private renderContent(attrs: VirtualTableAttrs): m.Children {
+    const rows: m.ChildArray = [];
+    for (
+      let i = this.renderBounds.rowStart;
+      i < this.renderBounds.rowEnd;
+      ++i
+    ) {
+      rows.push(this.renderRow(attrs, i));
+    }
+    return rows;
+  }
+
+  private renderRow(attrs: VirtualTableAttrs, i: number): m.Children {
+    const {rows, firstRowOffset, rowHeight, columns, onRowHover, onRowOut} =
+      attrs;
+    if (i >= firstRowOffset && i < firstRowOffset + rows.length) {
+      // Render the row...
+      const index = i - firstRowOffset;
+      const rowData = rows[index];
+      return m(
+        '.pf-vtable-row',
+        {
+          className: rowData.className,
+          style: {height: `${rowHeight}px`},
+          onmouseover: () => {
+            onRowHover?.(rowData.id);
+          },
+          onmouseout: () => {
+            onRowOut?.(rowData.id);
+          },
+        },
+        rowData.cells.map((data, colIndex) =>
+          m('.pf-vtable-data', {style: {width: columns[colIndex].width}}, data),
+        ),
+      );
+    } else {
+      // Render a placeholder div with the same height as a row but a
+      // transparent background
+      return m('', {style: {height: `${rowHeight}px`}});
+    }
+  }
+
+  oncreate({dom, attrs}: m.VnodeDOM<VirtualTableAttrs>) {
+    const {
+      renderOverdrawPx = 200,
+      renderTolerancePx = 100,
+      queryOverdrawPx = 10_000,
+      queryTolerancePx = 5_000,
+    } = attrs;
+
+    const sliderEl = toHTMLElement(assertExists(findRef(dom, this.SLIDER_REF)));
+    const containerEl = assertExists(findRef(dom, this.CONTAINER_REF));
+    const virtualScrollHelper = new VirtualScrollHelper(sliderEl, containerEl, [
+      {
+        overdrawPx: renderOverdrawPx,
+        tolerancePx: renderTolerancePx,
+        callback: ({top, bottom}: Rect) => {
+          const height = bottom - top;
+          const rowStart = Math.floor(top / attrs.rowHeight / 2) * 2;
+          const rowCount = Math.ceil(height / attrs.rowHeight / 2) * 2;
+          this.renderBounds = {rowStart, rowEnd: rowStart + rowCount};
+          scheduleFullRedraw();
+        },
+      },
+      {
+        overdrawPx: queryOverdrawPx,
+        tolerancePx: queryTolerancePx,
+        callback: ({top, bottom}: Rect) => {
+          const rowStart = Math.floor(top / attrs.rowHeight / 2) * 2;
+          const rowEnd = Math.ceil(bottom / attrs.rowHeight);
+          attrs.onReload?.(rowStart, rowEnd - rowStart);
+        },
+      },
+    ]);
+    this.trash.add(virtualScrollHelper);
+  }
+
+  onremove(_: m.VnodeDOM<VirtualTableAttrs>) {
+    this.trash.dispose();
+  }
+}