Merge "tp: migrate RuntimeTableFunction away from SqliteTable" into main
diff --git a/Android.bp b/Android.bp
index 9bc3137..d43bcb7 100644
--- a/Android.bp
+++ b/Android.bp
@@ -12852,6 +12852,7 @@
     srcs: [
         "src/trace_redaction/build_timeline.cc",
         "src/trace_redaction/filter_ftrace_using_allowlist.cc",
+        "src/trace_redaction/filter_print_events.cc",
         "src/trace_redaction/filter_sched_waking_events.cc",
         "src/trace_redaction/find_package_uid.cc",
         "src/trace_redaction/optimize_timeline.cc",
@@ -12861,6 +12862,7 @@
         "src/trace_redaction/prune_package_list.cc",
         "src/trace_redaction/redact_sched_switch.cc",
         "src/trace_redaction/scrub_ftrace_events.cc",
+        "src/trace_redaction/scrub_process_stats.cc",
         "src/trace_redaction/scrub_process_trees.cc",
         "src/trace_redaction/scrub_task_rename.cc",
         "src/trace_redaction/scrub_trace_packet.cc",
diff --git a/src/trace_redaction/BUILD.gn b/src/trace_redaction/BUILD.gn
index f908e96..e4b35a1 100644
--- a/src/trace_redaction/BUILD.gn
+++ b/src/trace_redaction/BUILD.gn
@@ -32,6 +32,8 @@
     "build_timeline.h",
     "filter_ftrace_using_allowlist.cc",
     "filter_ftrace_using_allowlist.h",
+    "filter_print_events.cc",
+    "filter_print_events.h",
     "filter_sched_waking_events.cc",
     "filter_sched_waking_events.h",
     "find_package_uid.cc",
@@ -50,6 +52,8 @@
     "redact_sched_switch.h",
     "scrub_ftrace_events.cc",
     "scrub_ftrace_events.h",
+    "scrub_process_stats.cc",
+    "scrub_process_stats.h",
     "scrub_process_trees.cc",
     "scrub_process_trees.h",
     "scrub_task_rename.cc",
@@ -82,6 +86,7 @@
     "filter_sched_waking_events_integrationtest.cc",
     "redact_sched_switch_integrationtest.cc",
     "scrub_ftrace_events_integrationtest.cc",
+    "scrub_process_stats_integrationtest.cc",
     "scrub_process_trees_integrationtest.cc",
     "scrub_task_rename_integrationtest.cc",
     "trace_redaction_integration_fixture.cc",
diff --git a/src/trace_redaction/filter_ftrace_using_allowlist_integrationtest.cc b/src/trace_redaction/filter_ftrace_using_allowlist_integrationtest.cc
index c0abf3f..89946ae 100644
--- a/src/trace_redaction/filter_ftrace_using_allowlist_integrationtest.cc
+++ b/src/trace_redaction/filter_ftrace_using_allowlist_integrationtest.cc
@@ -170,6 +170,7 @@
   ASSERT_TRUE(events.count(protos::pbzero::FtraceEvent::kTimestampFieldNumber));
 
   // These are events.
+  ASSERT_TRUE(events.count(protos::pbzero::FtraceEvent::kPrintFieldNumber));
   ASSERT_TRUE(
       events.count(protos::pbzero::FtraceEvent::kCpuFrequencyFieldNumber));
   ASSERT_TRUE(events.count(protos::pbzero::FtraceEvent::kCpuIdleFieldNumber));
@@ -197,7 +198,6 @@
   // These are events.
   ASSERT_FALSE(
       events.count(protos::pbzero::FtraceEvent::kOomScoreAdjUpdateFieldNumber));
-  ASSERT_FALSE(events.count(protos::pbzero::FtraceEvent::kPrintFieldNumber));
   ASSERT_FALSE(
       events.count(protos::pbzero::FtraceEvent::kSchedProcessExitFieldNumber));
   ASSERT_FALSE(
diff --git a/src/trace_redaction/filter_print_events.cc b/src/trace_redaction/filter_print_events.cc
new file mode 100644
index 0000000..de0c952
--- /dev/null
+++ b/src/trace_redaction/filter_print_events.cc
@@ -0,0 +1,63 @@
+/*
+ * 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_redaction/filter_print_events.h"
+
+#include "perfetto/base/logging.h"
+#include "perfetto/base/status.h"
+
+#include "protos/perfetto/trace/ftrace/ftrace_event.pbzero.h"
+#include "protos/perfetto/trace/ftrace/ftrace_event_bundle.pbzero.h"
+
+namespace perfetto::trace_redaction {
+
+base::Status FilterPrintEvents::VerifyContext(const Context& context) const {
+  if (!context.package_uid.has_value()) {
+    return base::ErrStatus("FilterPrintEvents: missing packet uid.");
+  }
+
+  if (!context.timeline) {
+    return base::ErrStatus("FilterPrintEvents: missing timeline.");
+  }
+
+  return base::OkStatus();
+}
+
+bool FilterPrintEvents::KeepEvent(const Context& context,
+                                  protozero::ConstBytes bytes) const {
+  PERFETTO_DCHECK(context.timeline);
+  PERFETTO_DCHECK(context.package_uid.has_value());
+
+  const auto* timeline = context.timeline.get();
+  auto package_uid = context.package_uid;
+
+  protozero::ProtoDecoder event(bytes);
+
+  // This is not a print packet. Keep the packet.
+  if (event.FindField(protos::pbzero::FtraceEvent::kPrintFieldNumber).valid()) {
+    return true;
+  }
+
+  auto time =
+      event.FindField(protos::pbzero::FtraceEvent::kTimestampFieldNumber);
+  auto pid = event.FindField(protos::pbzero::FtraceEvent::kPidFieldNumber);
+
+  // Pid + Time --> UID, if the uid matches the target package, keep the event.
+  return pid.valid() && time.valid() &&
+         timeline->Search(time.as_uint64(), pid.as_int32()).uid == package_uid;
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/filter_print_events.h b/src/trace_redaction/filter_print_events.h
new file mode 100644
index 0000000..36ef92b
--- /dev/null
+++ b/src/trace_redaction/filter_print_events.h
@@ -0,0 +1,46 @@
+/*
+ * 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.
+ */
+
+#ifndef SRC_TRACE_REDACTION_FILTER_PRINT_EVENTS_H_
+#define SRC_TRACE_REDACTION_FILTER_PRINT_EVENTS_H_
+
+#include "perfetto/protozero/field.h"
+#include "src/trace_redaction/scrub_ftrace_events.h"
+#include "src/trace_redaction/trace_redaction_framework.h"
+
+namespace perfetto::trace_redaction {
+
+// event {
+//   timestamp: 6702093749982230
+//   pid: 7947                    <-- target
+//   print {
+//     buf: "B|7105|virtual void
+//     swappy::ChoreographerThread::onChoreographer()\n"
+//   }
+// }
+//
+// If the target pid doesn't belong to the target package (context.package_uid),
+// then the event will be marked as "don't keep".
+class FilterPrintEvents : public FtraceEventFilter {
+ public:
+  base::Status VerifyContext(const Context& context) const override;
+  bool KeepEvent(const Context& context,
+                 protozero::ConstBytes bytes) const override;
+};
+
+}  // namespace perfetto::trace_redaction
+
+#endif  // SRC_TRACE_REDACTION_FILTER_PRINT_EVENTS_H_
diff --git a/src/trace_redaction/main.cc b/src/trace_redaction/main.cc
index c0061b6..4620ed2 100644
--- a/src/trace_redaction/main.cc
+++ b/src/trace_redaction/main.cc
@@ -18,6 +18,7 @@
 #include "perfetto/base/status.h"
 #include "src/trace_redaction/build_timeline.h"
 #include "src/trace_redaction/filter_ftrace_using_allowlist.h"
+#include "src/trace_redaction/filter_print_events.h"
 #include "src/trace_redaction/filter_sched_waking_events.h"
 #include "src/trace_redaction/find_package_uid.h"
 #include "src/trace_redaction/optimize_timeline.h"
@@ -25,6 +26,7 @@
 #include "src/trace_redaction/prune_package_list.h"
 #include "src/trace_redaction/redact_sched_switch.h"
 #include "src/trace_redaction/scrub_ftrace_events.h"
+#include "src/trace_redaction/scrub_process_stats.h"
 #include "src/trace_redaction/scrub_process_trees.h"
 #include "src/trace_redaction/scrub_task_rename.h"
 #include "src/trace_redaction/scrub_trace_packet.h"
@@ -55,11 +57,13 @@
   // number of events they need to iterate over.
   auto scrub_ftrace_events = redactor.emplace_transform<ScrubFtraceEvents>();
   scrub_ftrace_events->emplace_back<FilterFtraceUsingAllowlist>();
+  scrub_ftrace_events->emplace_back<FilterPrintEvents>();
   scrub_ftrace_events->emplace_back<FilterSchedWakingEvents>();
 
   redactor.emplace_transform<ScrubProcessTrees>();
   redactor.emplace_transform<ScrubTaskRename>();
   redactor.emplace_transform<RedactSchedSwitch>();
+  redactor.emplace_transform<ScrubProcessStats>();
 
   Context context;
   context.package_name = package_name;
diff --git a/src/trace_redaction/populate_allow_lists.cc b/src/trace_redaction/populate_allow_lists.cc
index 2ba81b6..7a5b48a 100644
--- a/src/trace_redaction/populate_allow_lists.cc
+++ b/src/trace_redaction/populate_allow_lists.cc
@@ -77,6 +77,7 @@
       protos::pbzero::FtraceEvent::kIonBufferDestroyFieldNumber,
       protos::pbzero::FtraceEvent::kDmaHeapStatFieldNumber,
       protos::pbzero::FtraceEvent::kRssStatThrottledFieldNumber,
+      protos::pbzero::FtraceEvent::kPrintFieldNumber,
   };
 
   // TODO: Some ftrace fields should be retained, but they carry too much risk
diff --git a/src/trace_redaction/scrub_process_stats.cc b/src/trace_redaction/scrub_process_stats.cc
new file mode 100644
index 0000000..991c02f
--- /dev/null
+++ b/src/trace_redaction/scrub_process_stats.cc
@@ -0,0 +1,102 @@
+/*
+ * 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_redaction/scrub_process_stats.h"
+
+#include <string>
+
+#include "perfetto/base/status.h"
+#include "perfetto/protozero/field.h"
+#include "perfetto/protozero/scattered_heap_buffer.h"
+#include "protos/perfetto/trace/ps/process_stats.pbzero.h"
+#include "src/trace_redaction/proto_util.h"
+#include "src/trace_redaction/trace_redaction_framework.h"
+
+namespace perfetto::trace_redaction {
+
+base::Status ScrubProcessStats::Transform(const Context& context,
+                                          std::string* packet) const {
+  if (!context.package_uid.has_value()) {
+    return base::ErrStatus("FilterProcessStats: missing package uid.");
+  }
+
+  if (!context.timeline) {
+    return base::ErrStatus("FilterProcessStats: missing timeline.");
+  }
+
+  protozero::ProtoDecoder packet_decoder(*packet);
+
+  // Very few packets will have process stats. It's best to avoid
+  // reserialization whenever possible.
+  if (!packet_decoder
+           .FindField(protos::pbzero::TracePacket::kProcessStatsFieldNumber)
+           .valid()) {
+    return base::OkStatus();
+  }
+
+  protozero::HeapBuffered<protos::pbzero::TracePacket> message;
+
+  // TODO(vaage): Add primitive to drop all packets that don't have a
+  // timestamp, allowing all other packets assume there are timestamps.
+  auto time_field = packet_decoder.FindField(
+      protos::pbzero::TracePacket::kTimestampFieldNumber);
+  PERFETTO_DCHECK(time_field.valid());
+  auto time = time_field.as_uint64();
+
+  auto* timeline = context.timeline.get();
+  auto uid = context.package_uid.value();
+
+  for (auto packet_field = packet_decoder.ReadField(); packet_field.valid();
+       packet_field = packet_decoder.ReadField()) {
+    if (packet_field.id() !=
+        protos::pbzero::TracePacket::kProcessStatsFieldNumber) {
+      proto_util::AppendField(packet_field, message.get());
+      continue;
+    }
+
+    auto process_stats = std::move(packet_field);
+    protozero::ProtoDecoder process_stats_decoder(process_stats.as_bytes());
+
+    auto* process_stats_message = message->set_process_stats();
+
+    for (auto process_stats_field = process_stats_decoder.ReadField();
+         process_stats_field.valid();
+         process_stats_field = process_stats_decoder.ReadField()) {
+      bool keep_field;
+
+      if (process_stats_field.id() ==
+          protos::pbzero::ProcessStats::kProcessesFieldNumber) {
+        protozero::ProtoDecoder process_decoder(process_stats_field.as_bytes());
+        auto pid = process_decoder.FindField(
+            protos::pbzero::ProcessStats::Process::kPidFieldNumber);
+        keep_field =
+            pid.valid() && timeline->Search(time, pid.as_int32()).uid == uid;
+      } else {
+        keep_field = true;
+      }
+
+      if (keep_field) {
+        proto_util::AppendField(process_stats_field, process_stats_message);
+      }
+    }
+  }
+
+  packet->assign(message.SerializeAsString());
+
+  return base::OkStatus();
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/src/trace_redaction/scrub_process_stats.h b/src/trace_redaction/scrub_process_stats.h
new file mode 100644
index 0000000..99b6697
--- /dev/null
+++ b/src/trace_redaction/scrub_process_stats.h
@@ -0,0 +1,32 @@
+/*
+ * 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.
+ */
+
+#ifndef SRC_TRACE_REDACTION_SCRUB_PROCESS_STATS_H_
+#define SRC_TRACE_REDACTION_SCRUB_PROCESS_STATS_H_
+
+#include "src/trace_redaction/trace_redaction_framework.h"
+
+namespace perfetto::trace_redaction {
+
+class ScrubProcessStats : public TransformPrimitive {
+ public:
+  base::Status Transform(const Context& context,
+                         std::string* packet) const override;
+};
+
+}  // namespace perfetto::trace_redaction
+
+#endif  // SRC_TRACE_REDACTION_SCRUB_PROCESS_STATS_H_
diff --git a/src/trace_redaction/scrub_process_stats_integrationtest.cc b/src/trace_redaction/scrub_process_stats_integrationtest.cc
new file mode 100644
index 0000000..01c61b1
--- /dev/null
+++ b/src/trace_redaction/scrub_process_stats_integrationtest.cc
@@ -0,0 +1,142 @@
+/*
+ * 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 <cstdint>
+#include <string>
+
+#include "perfetto/base/status.h"
+#include "src/base/test/status_matchers.h"
+#include "src/trace_redaction/build_timeline.h"
+#include "src/trace_redaction/optimize_timeline.h"
+#include "src/trace_redaction/scrub_process_stats.h"
+#include "src/trace_redaction/trace_redaction_framework.h"
+#include "src/trace_redaction/trace_redaction_integration_fixture.h"
+#include "src/trace_redaction/trace_redactor.h"
+#include "test/gtest_and_gmock.h"
+
+#include "protos/perfetto/trace/ps/process_stats.pbzero.h"
+#include "protos/perfetto/trace/trace.pbzero.h"
+#include "protos/perfetto/trace/trace_packet.pbzero.h"
+
+namespace perfetto::trace_redaction {
+
+class ScrubProcessStatsTest : public testing::Test,
+                              protected TraceRedactionIntegrationFixure {
+ protected:
+  void SetUp() override {
+    trace_redactor()->emplace_collect<BuildTimeline>();
+    trace_redactor()->emplace_build<OptimizeTimeline>();
+    trace_redactor()->emplace_transform<ScrubProcessStats>();
+
+    // Package "com.Unity.com.unity.multiplayer.samples.coop";
+    context()->package_uid = 10252;
+  }
+
+  // Gets pids from all process_stats messages in the trace (bytes).
+  base::FlatSet<int32_t> GetAllPids(const std::string& bytes) const {
+    base::FlatSet<int32_t> pids;
+
+    protos::pbzero::Trace::Decoder decoder(bytes);
+
+    for (auto packet = decoder.packet(); packet; ++packet) {
+      protos::pbzero::TracePacket::Decoder trace_packet(packet->as_bytes());
+
+      if (!trace_packet.has_process_stats()) {
+        continue;
+      }
+
+      protos::pbzero::ProcessStats::Decoder process_stats(
+          trace_packet.process_stats());
+
+      for (auto process = process_stats.processes(); process; ++process) {
+        protos::pbzero::ProcessStats::Process::Decoder p(process->as_bytes());
+        PERFETTO_DCHECK(p.has_pid());
+        pids.insert(p.pid());
+      }
+    }
+
+    return pids;
+  }
+};
+
+// This test is a canary for changes to the test data. If the test data was to
+// change, every test in this file would fail.
+//
+//  SELECT DISTINCT pid
+//  FROM process
+//  WHERE upid IN (
+//    SELECT DISTINCT upid
+//    FROM counter
+//      JOIN process_counter_track ON counter.track_id=process_counter_track.id
+//    WHERE name!='oom_score_adj'
+//  )
+//  ORDER BY pid
+//
+//  NOTE: WHERE name!='oom_score_adj' is used because there are two sources for
+//  oom_score_adj values and we only want process stats here.
+TEST_F(ScrubProcessStatsTest, VerifyTraceStats) {
+  base::FlatSet<int32_t> expected = {
+      1,     578,   581,   696,   697,   698,   699,   700,   701,   704,
+      709,   710,   718,   728,   749,   750,   751,   752,   756,   760,
+      761,   762,   873,   874,   892,   1046,  1047,  1073,  1074,  1091,
+      1092,  1093,  1101,  1103,  1104,  1105,  1106,  1107,  1110,  1111,
+      1112,  1113,  1115,  1116,  1118,  1119,  1120,  1121,  1123,  1124,
+      1125,  1126,  1127,  1129,  1130,  1131,  1133,  1140,  1145,  1146,
+      1147,  1151,  1159,  1163,  1164,  1165,  1166,  1167,  1168,  1175,
+      1177,  1205,  1206,  1235,  1237,  1238,  1248,  1251,  1254,  1255,
+      1295,  1296,  1298,  1300,  1301,  1303,  1304,  1312,  1317,  1325,
+      1339,  1340,  1363,  1374,  1379,  1383,  1388,  1392,  1408,  1409,
+      1410,  1413,  1422,  1426,  1427,  1428,  1429,  1433,  1436,  1448,
+      1450,  1451,  1744,  1774,  1781,  1814,  2262,  2268,  2286,  2392,
+      2456,  2502,  2510,  2518,  2528,  2569,  3171,  3195,  3262,  3286,
+      3310,  3338,  3442,  3955,  4386,  4759,  5935,  6034,  6062,  6167,
+      6547,  6573,  6720,  6721,  6725,  6944,  6984,  7105,  7207,  7557,
+      7636,  7786,  7874,  7958,  7960,  7967,  15449, 15685, 15697, 16453,
+      19683, 21124, 21839, 23150, 23307, 23876, 24317, 25017, 25126, 25450,
+      25474, 27271, 30604, 32289,
+  };
+
+  auto original = LoadOriginal();
+  ASSERT_OK(original) << original.status().c_message();
+
+  auto actual = GetAllPids(*original);
+
+  for (auto pid : expected) {
+    ASSERT_TRUE(actual.count(pid))
+        << "pid " << pid << " was not found in the trace";
+  }
+
+  for (auto pid : actual) {
+    ASSERT_TRUE(expected.count(pid))
+        << "pid " << pid << " was found in the trace";
+  }
+}
+
+// Package name: "com.Unity.com.unity.multiplayer.samples.coop"
+// Package pid: 7105
+TEST_F(ScrubProcessStatsTest, OnlyKeepsStatsForPackage) {
+  auto result = Redact();
+  ASSERT_OK(result) << result.c_message();
+
+  auto redacted = LoadRedacted();
+  ASSERT_OK(redacted) << redacted.status().c_message();
+
+  auto actual = GetAllPids(*redacted);
+  ASSERT_EQ(actual.size(), 1u);
+  ASSERT_TRUE(actual.count(7105));
+}
+
+}  // namespace perfetto::trace_redaction
diff --git a/ui/src/assets/details.scss b/ui/src/assets/details.scss
index f3e529a..d09690a 100644
--- a/ui/src/assets/details.scss
+++ b/ui/src/assets/details.scss
@@ -173,43 +173,6 @@
         width: 50%;
       }
     }
-    &.flamegraph-profile {
-      display: flex;
-      justify-content: space-between;
-      align-content: center;
-      height: 30px;
-      padding: 0;
-      font-size: 12px;
-      * {
-        align-self: center;
-      }
-      .options {
-        display: inline-flex;
-        justify-content: space-around;
-      }
-      .details {
-        display: inline-flex;
-        justify-content: flex-end;
-      }
-      .title {
-        justify-self: start;
-        margin-left: 5px;
-        font-size: 14px;
-        margin-right: 10px;
-      }
-      .time {
-        justify-self: end;
-        margin-right: 10px;
-      }
-      .selected {
-        justify-self: end;
-        margin-right: 10px;
-        white-space: nowrap;
-        overflow: hidden;
-        text-overflow: ellipsis;
-        width: 200px;
-      }
-    }
   }
 
   table {
@@ -709,3 +672,35 @@
 .pf-noselection {
   height: 100%;
 }
+
+.flamegraph-profile {
+  height: 100%;
+  // This is required to position locally-scoped (i.e. non-full-screen) modal
+  // dialogs within the panel, as they use position: absolute.
+  position: relative;
+
+  .time {
+    justify-self: end;
+    margin-right: 10px;
+  }
+  .selected {
+    justify-self: end;
+    margin-right: 10px;
+    white-space: nowrap;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    width: 200px;
+  }
+  .flamegraph-content {
+    overflow: auto;
+    height: 100%;
+
+    .loading-container {
+      font-size: larger;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      height: 100%;
+    }
+  }
+}
diff --git a/ui/src/controller/flamegraph_controller.ts b/ui/src/controller/flamegraph_controller.ts
index a10252c..b1c00a4 100644
--- a/ui/src/controller/flamegraph_controller.ts
+++ b/ui/src/controller/flamegraph_controller.ts
@@ -300,6 +300,7 @@
         await this.args.engine.query(`select value from stats
        where severity = 'error' and name = 'heap_graph_non_finalized_graph'`)
       ).firstRow({value: NUM}).value > 0;
+    flamegraphDetails.graphLoading = false;
     publishFlamegraphDetails(flamegraphDetails);
   }
 
@@ -317,8 +318,10 @@
     if (this.flamegraphDatasets.has(key)) {
       currentData = this.flamegraphDatasets.get(key)!;
     } else {
-      // TODO(b/330703412): Show loading state.
-
+      publishFlamegraphDetails({
+        ...globals.flamegraphDetails,
+        graphLoading: true,
+      });
       // Collecting data for drawing flamegraph for selected profile.
       // Data needs to be in following format:
       // id, name, parent_id, depth, total_size
diff --git a/ui/src/frontend/flamegraph_panel.ts b/ui/src/frontend/flamegraph_panel.ts
index fea7fb2..0f4cba2 100644
--- a/ui/src/frontend/flamegraph_panel.ts
+++ b/ui/src/frontend/flamegraph_panel.ts
@@ -30,6 +30,8 @@
 import {Icon} from '../widgets/icon';
 import {Modal, ModalAttrs} from '../widgets/modal';
 import {Popup} from '../widgets/popup';
+import {EmptyState} from '../widgets/empty_state';
+import {Spinner} from '../widgets/spinner';
 
 import {Flamegraph, NodeRendering} from './flamegraph';
 import {globals} from './globals';
@@ -37,7 +39,9 @@
 import {Router} from './router';
 import {getCurrentTrace} from './sidebar';
 import {convertTraceToPprofAndDownload} from './trace_converter';
+import {ButtonBar} from '../widgets/button';
 import {DurationWidget} from './widgets/duration';
+import {DetailsShell} from '../widgets/details_shell';
 
 const HEADER_HEIGHT = 30;
 
@@ -90,33 +94,31 @@
         ? this.flamegraph.getHeight() + HEADER_HEIGHT
         : 0;
       return m(
-        '.details-panel',
+        '.flamegraph-profile',
         this.maybeShowModal(flamegraphDetails.graphIncomplete),
         m(
-          '.details-panel-heading.flamegraph-profile',
-          {onclick: (e: MouseEvent) => e.stopPropagation()},
-          [
-            m('div.options', [
-              m(
-                'div.title',
-                this.getTitle(),
-                this.profileType === ProfileType.MIXED_HEAP_PROFILE &&
+          DetailsShell,
+          {
+            fillParent: true,
+            title: m(
+              'div.title',
+              this.getTitle(),
+              this.profileType === ProfileType.MIXED_HEAP_PROFILE &&
+                m(
+                  Popup,
+                  {
+                    trigger: m(Icon, {icon: 'warning'}),
+                  },
                   m(
-                    Popup,
-                    {
-                      trigger: m(Icon, {icon: 'warning'}),
-                    },
-                    m(
-                      '',
-                      {style: {width: '300px'}},
-                      'This is a mixed java/native heap profile, free()s are not visualized. To visualize free()s, remove "all_heaps: true" from the config.',
-                    ),
+                    '',
+                    {style: {width: '300px'}},
+                    'This is a mixed java/native heap profile, free()s are not visualized. To visualize free()s, remove "all_heaps: true" from the config.',
                   ),
-                ':',
-              ),
-              this.getViewingOptionButtons(),
-            ]),
-            m('div.details', [
+                ),
+              ':',
+            ),
+            description: this.getViewingOptionButtons(),
+            buttons: [
               m(
                 'div.selected',
                 `Selected function: ${toSelectedCallsite(
@@ -145,23 +147,39 @@
                     this.downloadPprof();
                   },
                 }),
-            ]),
-          ],
+            ],
+          },
+          m(
+            '.flamegraph-content',
+            flamegraphDetails.graphLoading
+              ? m(
+                  '.loading-container',
+                  m(
+                    EmptyState,
+                    {
+                      icon: 'bar_chart',
+                      title: 'Computing graph ...',
+                      className: 'flamegraph-loading',
+                    },
+                    m(Spinner, {easing: true}),
+                  ),
+                )
+              : m(`canvas[ref=canvas]`, {
+                  style: `height:${height}px; width:100%`,
+                  onmousemove: (e: MouseEvent) => {
+                    const {offsetX, offsetY} = e;
+                    this.onMouseMove({x: offsetX, y: offsetY});
+                  },
+                  onmouseout: () => {
+                    this.onMouseOut();
+                  },
+                  onclick: (e: MouseEvent) => {
+                    const {offsetX, offsetY} = e;
+                    this.onMouseClick({x: offsetX, y: offsetY});
+                  },
+                }),
+          ),
         ),
-        m(`canvas[ref=canvas]`, {
-          style: `height:${height}px; width:100%`,
-          onmousemove: (e: MouseEvent) => {
-            const {offsetX, offsetY} = e;
-            this.onMouseMove({x: offsetX, y: offsetY});
-          },
-          onmouseout: () => {
-            this.onMouseOut();
-          },
-          onclick: (e: MouseEvent) => {
-            const {offsetX, offsetY} = e;
-            this.onMouseClick({x: offsetX, y: offsetY});
-          },
-        }),
       );
     } else {
       return m(
@@ -260,7 +278,7 @@
 
   getViewingOptionButtons(): m.Children {
     return m(
-      'div',
+      ButtonBar,
       ...FlamegraphDetailsPanel.selectViewingOptions(
         assertExists(this.profileType),
       ),
diff --git a/ui/src/frontend/globals.ts b/ui/src/frontend/globals.ts
index 1af1399..5cecd7e 100644
--- a/ui/src/frontend/globals.ts
+++ b/ui/src/frontend/globals.ts
@@ -160,6 +160,8 @@
   // When heap_graph_non_finalized_graph has a count >0, we mark the graph
   // as incomplete.
   graphIncomplete?: boolean;
+  // About to show a new graph whose data is not ready yet.
+  graphLoading?: boolean;
 }
 
 export interface CpuProfileDetails {