From b9eab60befbd1aab0036c8e574a574a6d37ac78a Mon Sep 17 00:00:00 2001
From: Zack Asofsky
Date: Fri, 5 Jun 2026 09:27:11 -0400
Subject: [PATCH] Add summary columns, column toggles, and per-snapshot report
drawer to multi-report
The multi-report command rendered only Asset Bundle / Serialized File / PMR
metrics and offered no high-level memory picture or way to drill into a single
snapshot. This turns it into a triage dashboard:
- Summary columns: per snapshot, show the Allocated Memory Distribution
resident breakdown (Total, Native, Managed, Executables & Mapped, Graphics,
Untracked) read from each database's summary_metrics table. Resident columns
are visible by default; the committed/allocated equivalents are rendered but
hidden behind a toggle. Categories with no measurable resident size
(Graphics, Untracked) and databases exported before summary_metrics existed
render N/A.
- Column show/hide toolbar: checkboxes toggle each column group on and off;
group-header and session-header colspans recompute live, and within-session
sorting is unaffected.
- Per-snapshot report drawer: a full single-snapshot report is generated for
each database and a row click opens it in an inline slide-in iframe drawer,
with an "open in new tab" pop-out fallback. Add --no-reports for the faster
table-only output.
Reuse the existing summary_metrics read by extracting SummaryMetricsDbReader
(shared by the summary command), and reuse ReportBuilder/ReportRenderer for the
per-snapshot reports. Refactor MultiSnapshotHtmlRenderer to a column-descriptor
model so the wider, toggleable table stays maintainable. Make the single-report
Contents nav responsive so it no longer overlaps content when viewed in the
narrow drawer iframe.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
Cli/CliOptions.cs | 1 +
Cli/CommandLineBuilder.cs | 6 +
Cli/Program.cs | 1 +
.../MultiSnapshotHtmlRenderer.cs | 425 ++++++++++++++----
.../MultiSnapshotReportBuilder.cs | 12 +-
.../MultiSnapshotReportModel.cs | 9 +
.../MultiSnapshotReportRunner.cs | 136 +++++-
Core/Report/ReportRenderer.cs | 6 +
Core/Report/SummaryMetricsDbReader.cs | 72 +++
Core/Report/SummaryReportRunner.cs | 54 +--
10 files changed, 565 insertions(+), 157 deletions(-)
create mode 100644 Core/Report/SummaryMetricsDbReader.cs
diff --git a/Cli/CliOptions.cs b/Cli/CliOptions.cs
index 8f42194..9f03884 100644
--- a/Cli/CliOptions.cs
+++ b/Cli/CliOptions.cs
@@ -30,6 +30,7 @@ internal sealed class CliOptions
public bool ContinueOnError { get; set; } = true;
public string MultiReportDirectory { get; set; } = string.Empty;
public string? MultiReportFilter { get; set; }
+ public bool MultiReportNoReports { get; set; }
public string GoldenPath { get; set; } = string.Empty;
public string? ValidationOutputPath { get; set; }
public string SummaryInputPath { get; set; } = string.Empty;
diff --git a/Cli/CommandLineBuilder.cs b/Cli/CommandLineBuilder.cs
index c439583..d8036d4 100644
--- a/Cli/CommandLineBuilder.cs
+++ b/Cli/CommandLineBuilder.cs
@@ -243,11 +243,16 @@ public static RootCommand Build(
{
Description = "Print progress and timings.",
};
+ var multiNoReportsOpt = new Option("--no-reports")
+ {
+ Description = "Skip generating per-snapshot drill-down reports (faster; rows are not clickable).",
+ };
multiReportCmd.Add(filterOpt);
multiReportCmd.Add(multiOutOpt);
multiReportCmd.Add(multiTitleOpt);
multiReportCmd.Add(multiVerboseOpt);
+ multiReportCmd.Add(multiNoReportsOpt);
multiReportCmd.SetAction((ParseResult parseResult) =>
{
@@ -267,6 +272,7 @@ public static RootCommand Build(
ReportOutputPath = string.IsNullOrWhiteSpace(outPath) ? null : ExpandPath(outPath!),
ReportTitle = parseResult.GetValue(multiTitleOpt)!,
Verbose = parseResult.GetValue(multiVerboseOpt),
+ MultiReportNoReports = parseResult.GetValue(multiNoReportsOpt),
};
return runMultiReport(options);
});
diff --git a/Cli/Program.cs b/Cli/Program.cs
index 29eac6c..9f34c29 100644
--- a/Cli/Program.cs
+++ b/Cli/Program.cs
@@ -92,6 +92,7 @@ private static int RunMultiReport(CliOptions options)
NameFilter = options.MultiReportFilter,
ReportOutputPath = options.ReportOutputPath,
ReportTitle = options.ReportTitle,
+ GenerateReports = !options.MultiReportNoReports,
};
var progress = new ConsoleProgress(options.Verbose);
return MultiSnapshotReportRunner.Run(multiOptions, progress);
diff --git a/Core/Report/MultiSnapshotReport/MultiSnapshotHtmlRenderer.cs b/Core/Report/MultiSnapshotReport/MultiSnapshotHtmlRenderer.cs
index f052daf..6aac292 100644
--- a/Core/Report/MultiSnapshotReport/MultiSnapshotHtmlRenderer.cs
+++ b/Core/Report/MultiSnapshotReport/MultiSnapshotHtmlRenderer.cs
@@ -5,26 +5,52 @@
namespace MemorySnapshotDataTools.Report.MultiSnapshotReport;
///
-/// Renders a to a self-contained HTML document with one session-grouped table.
+/// Renders a to a self-contained HTML document with one
+/// session-grouped table. The table is driven by a column-descriptor list () so
+/// columns — including the high-level Allocated Memory Distribution summary columns — can be toggled on
+/// and off, and each snapshot row can open its full single-snapshot report in an inline iframe drawer.
///
public static class MultiSnapshotHtmlRenderer
{
- private const int ColSnapshot = 0;
- private const int ColAbCount = 1;
- private const int ColAbAlloc = 2;
- private const int ColAbRes = 3;
- private const int ColSfCount = 4;
- private const int ColSfAlloc = 5;
- private const int ColSfRes = 6;
- private const int ColPmrAlloc = 7;
- private const int ColPmrRes = 8;
- private const int ColumnCount = 9;
+ // Allocated Memory Distribution category names, matching SummaryMetricsCalculator. Graphics and
+ // Untracked carry no resident value (ResidentAvailable=false) so their resident cells show N/A.
+ private const string CatNative = "Native";
+ private const string CatManaged = "Managed";
+ private const string CatExecutables = "Executables & Mapped";
+ private const string CatGraphics = "Graphics (Estimated)";
+ private const string CatUntracked = "Untracked";
+ private const string CatAndroidRuntime = "Android Runtime";
+
+ // data-group keys (used for column visibility toggling).
+ private const string GroupAssetBundle = "ab";
+ private const string GroupSerializedFile = "sf";
+ private const string GroupPmr = "pmr";
+ private const string GroupResidentSummary = "sumRes";
+ private const string GroupCommittedSummary = "sumCom";
+
+ /// One renderable value: a number (count or bytes) or null (rendered as N/A).
+ private readonly record struct CellValue(long? Number, bool IsCount);
+
+ /// Describes one data column: its group, headers, default visibility, and value selector.
+ private sealed record ColumnDef(
+ string Group,
+ string GroupHeader,
+ string SubHeader,
+ bool DefaultVisible,
+ Func Value);
+
+ /// Builds the full HTML report string from the model (no per-snapshot report links).
+ public static string Render(MultiSnapshotReportModel model) => Render(model, null);
///
- /// Builds the full HTML report string from the model.
+ /// Builds the full HTML report string. When maps a snapshot's
+ /// to a relative report href, that row becomes
+ /// clickable and opens the report in the inline drawer.
///
- public static string Render(MultiSnapshotReportModel model)
+ public static string Render(MultiSnapshotReportModel model, IReadOnlyDictionary? reportLinks)
{
+ var columns = BuildColumns(model);
+
var sb = new StringBuilder();
sb.Append("""
@@ -57,15 +83,23 @@ public static string Render(MultiSnapshotReportModel model)
sb.Append(" snapshots
\n");
if (model.Sessions.Count == 0)
+ {
sb.Append("No matching database files found.
");
+ }
else
- sb.Append(RenderUnifiedTable(model));
+ {
+ sb.Append(RenderToggleBar(columns));
+ sb.Append(RenderUnifiedTable(model, columns, reportLinks));
+ sb.Append(DrawerHtml);
+ }
sb.Append("""