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(""" @@ -74,38 +108,135 @@ public static string Render(MultiSnapshotReportModel model) return sb.ToString(); } - private static string RenderUnifiedTable(MultiSnapshotReportModel model) + /// Builds the ordered column descriptors; the Android Runtime columns appear only when present. + private static List BuildColumns(MultiSnapshotReportModel model) + { + var hasAndroidRuntime = model.Sessions + .SelectMany(s => s.Snapshots) + .Any(snap => snap.Summary?.AllocatedMemoryDistribution + .Any(c => string.Equals(c.Name, CatAndroidRuntime, StringComparison.Ordinal)) == true); + + var cols = new List + { + new(GroupAssetBundle, "Asset Bundle", "Count", true, r => Count(GetTypeMetrics(r, "AssetBundle").Count)), + new(GroupAssetBundle, "Asset Bundle", "Allocated", true, r => Bytes(GetTypeMetrics(r, "AssetBundle").AllocatedBytes)), + new(GroupAssetBundle, "Asset Bundle", "Resident", true, r => Bytes(GetTypeMetrics(r, "AssetBundle").ResidentBytes)), + new(GroupSerializedFile, "Serialized File", "Count", true, r => Count(GetTypeMetrics(r, "SerializedFile").Count)), + new(GroupSerializedFile, "Serialized File", "Allocated", true, r => Bytes(GetTypeMetrics(r, "SerializedFile").AllocatedBytes)), + new(GroupSerializedFile, "Serialized File", "Resident", true, r => Bytes(GetTypeMetrics(r, "SerializedFile").ResidentBytes)), + new(GroupPmr, "PMR", "Allocated", true, r => Bytes(AggregateRemapper(r).AllocatedBytes)), + new(GroupPmr, "PMR", "Resident", true, r => Bytes(AggregateRemapper(r).ResidentBytes)), + + new(GroupResidentSummary, "Resident Memory", "Total", true, r => Bytes(TotalResident(r))), + new(GroupResidentSummary, "Resident Memory", "Native", true, r => Bytes(ResidentOf(r, CatNative))), + new(GroupResidentSummary, "Resident Memory", "Managed", true, r => Bytes(ResidentOf(r, CatManaged))), + new(GroupResidentSummary, "Resident Memory", "Exec & Mapped", true, r => Bytes(ResidentOf(r, CatExecutables))), + new(GroupResidentSummary, "Resident Memory", "Graphics", true, r => Bytes(ResidentOf(r, CatGraphics))), + new(GroupResidentSummary, "Resident Memory", "Untracked", true, r => Bytes(ResidentOf(r, CatUntracked))), + }; + if (hasAndroidRuntime) + cols.Add(new(GroupResidentSummary, "Resident Memory", "Android RT", true, r => Bytes(ResidentOf(r, CatAndroidRuntime)))); + + cols.AddRange(new ColumnDef[] + { + new(GroupCommittedSummary, "Committed Memory", "Total", false, r => Bytes(TotalCommitted(r))), + new(GroupCommittedSummary, "Committed Memory", "Native", false, r => Bytes(CommittedOf(r, CatNative))), + new(GroupCommittedSummary, "Committed Memory", "Managed", false, r => Bytes(CommittedOf(r, CatManaged))), + new(GroupCommittedSummary, "Committed Memory", "Exec & Mapped", false, r => Bytes(CommittedOf(r, CatExecutables))), + new(GroupCommittedSummary, "Committed Memory", "Graphics", false, r => Bytes(CommittedOf(r, CatGraphics))), + new(GroupCommittedSummary, "Committed Memory", "Untracked", false, r => Bytes(CommittedOf(r, CatUntracked))), + }); + if (hasAndroidRuntime) + cols.Add(new(GroupCommittedSummary, "Committed Memory", "Android RT", false, r => Bytes(CommittedOf(r, CatAndroidRuntime)))); + + return cols; + } + + private static string RenderToggleBar(List columns) { var sb = new StringBuilder(); - sb.Append(""" -
- - - - - - - - - - - - - - - - - - - - """); + sb.Append("
Columns:"); + var seen = new HashSet(StringComparer.Ordinal); + foreach (var col in columns) + { + if (!seen.Add(col.Group)) + continue; + sb.Append(""); + } + sb.Append("
\n"); + return sb.ToString(); + } + + private static string RenderUnifiedTable( + MultiSnapshotReportModel model, + List columns, + IReadOnlyDictionary? reportLinks) + { + var initialVisibleLeaves = columns.Count(c => c.DefaultVisible) + 1; // +1 for the snapshot column + + var sb = new StringBuilder(); + sb.Append("
SnapshotAsset BundleSerialized FilePMR
CountAllocatedResidentCountAllocatedResidentAllocatedResident
\n\n\n"); + sb.Append("\n"); + + // Group-header row: coalesce consecutive same-group columns; colspan counts visible columns only. + var i = 0; + while (i < columns.Count) + { + var group = columns[i].Group; + var header = columns[i].GroupHeader; + var visible = 0; + var j = i; + while (j < columns.Count && columns[j].Group == group) + { + if (columns[j].DefaultVisible) + visible++; + j++; + } + + sb.Append("\n"); + i = j; + } + sb.Append("\n\n"); + + // Sub-header row: one sortable cell per column. + for (var c = 0; c < columns.Count; c++) + { + var col = columns[c]; + sb.Append("\n"); + } + sb.Append("\n\n\n"); foreach (var session in model.Sessions) { sb.Append(""); - AppendSnapshotCell(sb, snap); - AppendCountCell(sb, ColAbCount, ab.Count); - AppendBytesCell(sb, ColAbAlloc, ab.AllocatedBytes); - AppendBytesCell(sb, ColAbRes, ab.ResidentBytes); - AppendCountCell(sb, ColSfCount, sf.Count); - AppendBytesCell(sb, ColSfAlloc, sf.AllocatedBytes); - AppendBytesCell(sb, ColSfRes, sf.ResidentBytes); - AppendBytesCell(sb, ColPmrAlloc, pmr.AllocatedBytes); - AppendBytesCell(sb, ColPmrRes, pmr.ResidentBytes); + var hasReport = reportLinks != null + && reportLinks.TryGetValue(snap.DatabasePath, out var href) + && !string.IsNullOrEmpty(href); + + sb.Append("'); + + AppendSnapshotCell(sb, snap, hasReport); + for (var c = 0; c < columns.Count; c++) + AppendDataCell(sb, c + 1, columns[c], columns[c].Value(snap)); sb.Append("\n"); } } - sb.Append("
Snapshot"); + sb.Append(Escape(header)); + sb.Append("
"); + sb.Append(Escape(col.SubHeader)); + sb.Append("
"); sb.Append(PlatformIconHtml.Render(session.PlatformKind)); sb.Append(' '); @@ -119,35 +250,40 @@ private static string RenderUnifiedTable(MultiSnapshotReportModel model) foreach (var snap in session.Snapshots) { - var ab = GetTypeMetrics(snap, "AssetBundle"); - var sf = GetTypeMetrics(snap, "SerializedFile"); - var pmr = AggregateRemapper(snap); - - sb.Append("
"); + sb.Append("\n"); return sb.ToString(); } - private static void AppendSnapshotCell(StringBuilder sb, SnapshotMetricsRow snap) + private static void AppendSnapshotCell(StringBuilder sb, SnapshotMetricsRow snap, bool hasReport) { - sb.Append(""); + if (hasReport) + sb.Append(""); sb.Append(PlatformIconHtml.Render(snap.PlatformKind, snap.Platform)); sb.Append(""); } - private static void AppendCountCell(StringBuilder sb, int col, int count) + private static void AppendDataCell(StringBuilder sb, int colId, ColumnDef col, CellValue value) { - sb.Append(""); - sb.Append(count.ToString("N0", CultureInfo.InvariantCulture)); - sb.Append(""); - } + sb.Append(""); - sb.Append(ReportHtmlHelper.FmtBytesHtml(bytes)); - sb.Append(""); - } - - private static void AppendBytesCell(StringBuilder sb, int col, long? bytes) - { - sb.Append(""); - sb.Append(ReportHtmlHelper.FmtBytesHtml(bytes.Value)); + sb.Append(n.ToString("N0", CultureInfo.InvariantCulture)); + } + else if (value.Number.HasValue) + { + sb.Append(" data-sort=\""); + sb.Append(value.Number.Value.ToString(CultureInfo.InvariantCulture)); + sb.Append("\">"); + sb.Append(ReportHtmlHelper.FmtBytesHtml(value.Number.Value)); } else { @@ -210,6 +338,38 @@ private static void AppendBytesCell(StringBuilder sb, int col, long? bytes) sb.Append(""); } + private static CellValue Count(int count) => new(count, true); + + private static CellValue Bytes(long bytes) => new(bytes, false); + + private static CellValue Bytes(long? bytes) => new(bytes, false); + + private static long? TotalResident(SnapshotMetricsRow snap) => + snap.Summary == null ? null : ClampToLong(snap.Summary.TotalResidentBytes); + + private static long? TotalCommitted(SnapshotMetricsRow snap) => + snap.Summary == null ? null : ClampToLong(snap.Summary.TotalAllocatedBytes); + + private static long? ResidentOf(SnapshotMetricsRow snap, string categoryName) + { + var category = FindCategory(snap, categoryName); + if (category == null || !category.ResidentAvailable) + return null; + return ClampToLong(category.ResidentBytes); + } + + private static long? CommittedOf(SnapshotMetricsRow snap, string categoryName) + { + var category = FindCategory(snap, categoryName); + return category == null ? null : ClampToLong(category.CommittedBytes); + } + + private static SummaryCategory? FindCategory(SnapshotMetricsRow snap, string categoryName) => + snap.Summary?.AllocatedMemoryDistribution + .FirstOrDefault(c => string.Equals(c.Name, categoryName, StringComparison.Ordinal)); + + private static long ClampToLong(ulong value) => value > long.MaxValue ? long.MaxValue : (long)value; + private static NativeTypeSnapshotMetrics GetTypeMetrics(SnapshotMetricsRow snap, string typeName) => snap.NativeTypes.TryGetValue(typeName, out var m) ? m @@ -245,6 +405,21 @@ private static string Escape(string? value) => private static string EscapeAttr(string value) => System.Net.WebUtility.HtmlEncode(value); + private const string DrawerHtml = """ + + + """; + private const string SortableScript = """ document.querySelectorAll('table.multi-snapshot th.sub[data-col]').forEach(function(th) { th.style.cursor = 'pointer'; @@ -283,12 +458,74 @@ private static string EscapeAttr(string value) => }); """; + private const string InteractiveScript = """ + function recomputeColspans() { + var table = document.querySelector('table.multi-snapshot'); + if (!table) return; + table.querySelectorAll('thead th.group-hdr').forEach(function(gh) { + var g = gh.getAttribute('data-group'); + var n = table.querySelectorAll('th.sub[data-group="' + g + '"]:not(.col-hidden)').length; + if (n > 0) { gh.colSpan = n; gh.classList.remove('col-hidden'); } + else { gh.classList.add('col-hidden'); } + }); + var leaves = table.querySelectorAll('th.sub:not(.col-hidden)').length + 1; + table.querySelectorAll('tr.session-header > td').forEach(function(td) { td.colSpan = leaves; }); + } + function setGroupVisible(group, visible) { + document.querySelectorAll('td[data-group="' + group + '"], th.sub[data-group="' + group + '"]').forEach(function(el) { + el.classList.toggle('col-hidden', !visible); + }); + recomputeColspans(); + } + (function() { + document.querySelectorAll('.col-toggles input[type=checkbox]').forEach(function(cb) { + cb.addEventListener('change', function() { setGroupVisible(cb.getAttribute('data-group'), cb.checked); }); + }); + recomputeColspans(); + + var drawer = document.getElementById('report-drawer'); + if (!drawer) return; + var iframe = drawer.querySelector('.report-drawer-iframe'); + var popout = drawer.querySelector('.report-popout'); + var titleEl = drawer.querySelector('.report-drawer-title'); + function openReport(href, name) { + popout.setAttribute('href', href); + titleEl.textContent = name || 'Snapshot report'; + if (iframe.getAttribute('src') !== href) iframe.setAttribute('src', href); + drawer.classList.add('open'); + drawer.setAttribute('aria-hidden', 'false'); + } + function closeReport() { + drawer.classList.remove('open'); + drawer.setAttribute('aria-hidden', 'true'); + } + document.querySelectorAll('table.multi-snapshot tbody').forEach(function(tb) { + tb.addEventListener('click', function(e) { + var tr = e.target.closest('tr.snapshot-row'); + if (!tr) return; + var href = tr.getAttribute('data-report'); + if (!href) return; + var nameEl = tr.querySelector('.snapshot-filename'); + openReport(href, nameEl ? nameEl.textContent : ''); + }); + }); + drawer.querySelector('.report-drawer-close').addEventListener('click', closeReport); + drawer.querySelector('.report-drawer-backdrop').addEventListener('click', closeReport); + document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeReport(); }); + })(); + """; + private static readonly string Css = """ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 13px; background: #f0f2f5; color: #1a1a2e; padding: 24px; line-height: 1.5; } main { max-width: 100%; margin: 0 auto; } h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; } - .subtitle { font-size: 12px; color: #666; margin-bottom: 24px; font-family: "SF Mono", Consolas, monospace; word-break: break-all; } + .subtitle { font-size: 12px; color: #666; margin-bottom: 16px; font-family: "SF Mono", Consolas, monospace; word-break: break-all; } + .col-toggles { display: flex; flex-wrap: wrap; align-items: center; gap: 14px; background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,.08); padding: 10px 14px; margin-bottom: 16px; } + .col-toggles .toggle-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; color: #888; } + .col-toggles label { display: inline-flex; align-items: center; gap: 5px; font-size: 12px; color: #333; cursor: pointer; user-select: none; } + .col-toggles input { cursor: pointer; } + .col-hidden { display: none !important; } .table-wrap { background: #fff; border-radius: 8px; box-shadow: 0 1px 4px rgba(0,0,0,.08); overflow-x: auto; overflow-y: visible; } table.multi-snapshot { width: 100%; border-collapse: separate; border-spacing: 0; table-layout: auto; --header-row1-h: 37px; } thead th { @@ -314,6 +551,8 @@ thead th { .session-count { font-weight: 400; color: #666; } tbody tr.snapshot-row:nth-child(even) { background: #f8f9fb; } tbody tr.snapshot-row:hover { background: #eef2ff; } + tbody tr.snapshot-row.has-report { cursor: pointer; } + tbody tr.snapshot-row.has-report:hover { background: #e3ecff; } td { padding: 6px 10px; border-bottom: 1px solid #f0f2f5; vertical-align: top; } .platform-icon { display: inline-flex; align-items: center; margin-right: 6px; vertical-align: middle; color: #475569; } .platform-icon.ios { color: #1a1a2e; } @@ -321,9 +560,23 @@ thead th { td.snapshot-name { font-size: 11px; white-space: nowrap; min-width: 280px; } .snapshot-label { display: inline-flex; align-items: center; gap: 4px; } .snapshot-filename { font-family: "SF Mono", Consolas, monospace; } + .report-chevron { color: #1a73e8; font-weight: 700; } td.num { font-variant-numeric: tabular-nums; font-family: "SF Mono", Consolas, monospace; font-size: 12px; white-space: nowrap; } .bytes { border-bottom: 1px dotted #94a3b8; cursor: help; } em.na { color: #999; font-style: italic; } .empty { color: #999; font-style: italic; padding: 24px; } + .report-drawer { position: fixed; inset: 0; z-index: 200; visibility: hidden; pointer-events: none; } + .report-drawer.open { visibility: visible; pointer-events: auto; } + .report-drawer-backdrop { position: absolute; inset: 0; background: rgba(20,20,40,.35); opacity: 0; transition: opacity .2s ease; } + .report-drawer.open .report-drawer-backdrop { opacity: 1; } + .report-drawer-panel { position: absolute; top: 0; right: 0; height: 100%; width: min(960px, 88vw); background: #fff; box-shadow: -4px 0 24px rgba(0,0,0,.18); display: flex; flex-direction: column; transform: translateX(100%); transition: transform .22s ease; } + .report-drawer.open .report-drawer-panel { transform: translateX(0); } + .report-drawer-header { display: flex; align-items: center; gap: 14px; padding: 12px 16px; border-bottom: 1px solid #e8eaed; background: #1a1a2e; color: #fff; } + .report-drawer-title { font-size: 13px; font-weight: 600; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: "SF Mono", Consolas, monospace; } + .report-popout { font-size: 12px; color: #9ec1ff; text-decoration: none; white-space: nowrap; } + .report-popout:hover { color: #fff; text-decoration: underline; } + .report-drawer-close { background: transparent; border: none; color: #fff; font-size: 22px; line-height: 1; cursor: pointer; padding: 0 4px; } + .report-drawer-close:hover { color: #ff9b9b; } + .report-drawer-iframe { flex: 1; width: 100%; border: none; background: #f0f2f5; } """; } diff --git a/Core/Report/MultiSnapshotReport/MultiSnapshotReportBuilder.cs b/Core/Report/MultiSnapshotReport/MultiSnapshotReportBuilder.cs index bff1aa5..4fe5bf1 100644 --- a/Core/Report/MultiSnapshotReport/MultiSnapshotReportBuilder.cs +++ b/Core/Report/MultiSnapshotReport/MultiSnapshotReportBuilder.cs @@ -77,7 +77,8 @@ private static SnapshotMetricsRow QueryDuckDb(string dbPath) var snapshotMeta = QuerySnapshotMetadata(connection, isDuckDb: true); var nativeTypes = QueryNativeTypes(connection, isDuckDb: true); var remapperRoots = QueryRemapperRoots(connection, isDuckDb: true); - return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection)); + var (summary, summaryAvailable) = SummaryMetricsDbReader.Read(connection); + return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection), summary, summaryAvailable); } private static SnapshotMetricsRow QuerySqlite(string dbPath) @@ -89,7 +90,8 @@ private static SnapshotMetricsRow QuerySqlite(string dbPath) var snapshotMeta = QuerySnapshotMetadata(connection, isDuckDb: false); var nativeTypes = QueryNativeTypes(connection, isDuckDb: false); var remapperRoots = QueryRemapperRoots(connection, isDuckDb: false); - return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection)); + var (summary, summaryAvailable) = SummaryMetricsDbReader.Read(connection); + return BuildRow(dbPath, nativeTypes, remapperRoots, snapshotMeta, DatabaseSchemaInfo.ReadVersion(connection), summary, summaryAvailable); } private static Dictionary QueryNativeTypes(object connection, bool isDuckDb) @@ -263,7 +265,9 @@ private static SnapshotMetricsRow BuildRow( Dictionary nativeTypes, List remapperRoots, DbSnapshotMetadata dbMeta, - (int Major, int Minor) schemaVersion) + (int Major, int Minor) schemaVersion, + SummaryMetrics summary, + bool summaryAvailable) { var fileName = Path.GetFileNameWithoutExtension(dbPath); var meta = EnrichMetadata(dbPath, fileName, dbMeta); @@ -284,6 +288,8 @@ private static SnapshotMetricsRow BuildRow( SortTimestamp = meta.SortTimestamp, NativeTypes = nativeTypes, RemapperRoots = remapperRoots, + Summary = summaryAvailable ? summary : null, + SummaryAvailable = summaryAvailable, SchemaVersion = DatabaseSchemaInfo.DescribeVersion(schemaVersion.Major, schemaVersion.Minor), SchemaUpToDate = DatabaseSchemaInfo.Evaluate(schemaVersion.Major, schemaVersion.Minor) == SchemaAction.None, }; diff --git a/Core/Report/MultiSnapshotReport/MultiSnapshotReportModel.cs b/Core/Report/MultiSnapshotReport/MultiSnapshotReportModel.cs index d456a8d..24069f1 100644 --- a/Core/Report/MultiSnapshotReport/MultiSnapshotReportModel.cs +++ b/Core/Report/MultiSnapshotReport/MultiSnapshotReportModel.cs @@ -86,6 +86,15 @@ public sealed record SnapshotMetricsRow /// Remapper / PMR root metrics (may be empty). public IReadOnlyList RemapperRoots { get; init; } = []; + + /// + /// MemoryProfiler "Summary" page metrics (Allocated Memory Distribution totals/breakdown) read from + /// the database's summary_metrics table, or null when that table is absent (older export). + /// + public SummaryMetrics? Summary { get; init; } + + /// True when the database had a summary_metrics table to read from. + public bool SummaryAvailable { get; init; } } /// diff --git a/Core/Report/MultiSnapshotReport/MultiSnapshotReportRunner.cs b/Core/Report/MultiSnapshotReport/MultiSnapshotReportRunner.cs index 42125a4..98f1df6 100644 --- a/Core/Report/MultiSnapshotReport/MultiSnapshotReportRunner.cs +++ b/Core/Report/MultiSnapshotReport/MultiSnapshotReportRunner.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using MemorySnapshotDataTools.Report.Queries; namespace MemorySnapshotDataTools.Report.MultiSnapshotReport; @@ -18,6 +19,12 @@ public sealed class MultiSnapshotReportRunOptions /// HTML document title. public string ReportTitle { get; set; } = "Multi-Snapshot Memory Report"; + + /// + /// When true (default), generate a full single-snapshot report for each database so rows can be + /// previewed in the inline drawer. Set false (--no-reports) for the faster table-only output. + /// + public bool GenerateReports { get; set; } = true; } /// @@ -42,30 +49,28 @@ public static int Run(MultiSnapshotReportRunOptions options, IProgressReporter p var model = MultiSnapshotReportBuilder.Build(options.Directory, options.NameFilter, options.ReportTitle); swQuery.Stop(); + var (outPath, reportsDir, reportsFolderName, openBrowser) = ResolveOutputLayout(options); + + // Generate one full single-snapshot report per database so rows are clickable in the drawer. + var generatedAtUtc = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture) + " UTC"; + var swReports = Stopwatch.StartNew(); + var reportLinks = options.GenerateReports + ? GenerateIndividualReports(model, reportsDir!, reportsFolderName!, generatedAtUtc, progress) + : new Dictionary(); + swReports.Stop(); + var swRender = Stopwatch.StartNew(); - var html = MultiSnapshotHtmlRenderer.Render(model); + var html = MultiSnapshotHtmlRenderer.Render(model, reportLinks); swRender.Stop(); - var outPath = options.ReportOutputPath; - var openBrowser = string.IsNullOrEmpty(outPath); - if (string.IsNullOrEmpty(outPath)) - { - outPath = Path.Combine(Path.GetTempPath(), "multi_memsnapshot_" + Guid.NewGuid().ToString("N")[..8] + ".html"); - } - else - { - var dir = Path.GetDirectoryName(outPath); - if (!string.IsNullOrEmpty(dir)) - Directory.CreateDirectory(dir); - } - var swWrite = Stopwatch.StartNew(); File.WriteAllText(outPath, html, System.Text.Encoding.UTF8); swWrite.Stop(); + var totalSnapshots = model.Sessions.Sum(s => s.Snapshots.Count); progress.Report($"Report written → {outPath}", force: true); progress.Report( - $"Timings: query_ms={swQuery.ElapsedMilliseconds}, render_ms={swRender.ElapsedMilliseconds}, write_ms={swWrite.ElapsedMilliseconds}", + $"Timings: query_ms={swQuery.ElapsedMilliseconds}, reports_ms={swReports.ElapsedMilliseconds} (reports={reportLinks.Count}/{totalSnapshots}), render_ms={swRender.ElapsedMilliseconds}, write_ms={swWrite.ElapsedMilliseconds}", force: true); if (openBrowser) @@ -82,4 +87,105 @@ public static int Run(MultiSnapshotReportRunOptions options, IProgressReporter p return 0; } + + /// + /// Resolves where the multi-report HTML and the per-snapshot reports are written. With --out, + /// reports go in a <basename>_reports folder beside it; otherwise a dedicated temp folder + /// holds index.html plus a reports subfolder (so the drawer's relative iframe links + /// resolve under one root). When reports are disabled and no --out is given, the original loose + /// temp file is used. + /// + private static (string OutPath, string? ReportsDir, string? ReportsFolderName, bool OpenBrowser) ResolveOutputLayout( + MultiSnapshotReportRunOptions options) + { + if (!string.IsNullOrEmpty(options.ReportOutputPath)) + { + var outPath = options.ReportOutputPath; + var outDir = Path.GetDirectoryName(outPath); + if (!string.IsNullOrEmpty(outDir)) + Directory.CreateDirectory(outDir); + + if (!options.GenerateReports) + return (outPath, null, null, false); + + var folderName = Path.GetFileNameWithoutExtension(outPath) + "_reports"; + var reportsDir = Path.Combine(outDir ?? string.Empty, folderName); + return (outPath, reportsDir, folderName, false); + } + + if (!options.GenerateReports) + { + // Original behavior: a single loose temp file opened in the browser. + var loose = Path.Combine(Path.GetTempPath(), "multi_memsnapshot_" + Guid.NewGuid().ToString("N")[..8] + ".html"); + return (loose, null, null, true); + } + + var rootDir = Path.Combine(Path.GetTempPath(), "multi_memsnapshot_" + Guid.NewGuid().ToString("N")[..8]); + Directory.CreateDirectory(rootDir); + return (Path.Combine(rootDir, "index.html"), Path.Combine(rootDir, "reports"), "reports", true); + } + + /// + /// Builds and writes a full single-snapshot report for each database, returning a map from each + /// snapshot's database path to the relative href of its report (forward slashes). A database whose + /// report fails to build is skipped (and simply stays non-clickable), mirroring the per-DB skip in + /// . + /// + private static Dictionary GenerateIndividualReports( + MultiSnapshotReportModel model, + string reportsDir, + string reportsFolderName, + string generatedAtUtc, + IProgressReporter progress) + { + Directory.CreateDirectory(reportsDir); + + var links = new Dictionary(StringComparer.Ordinal); + var usedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + var snapshots = model.Sessions.SelectMany(s => s.Snapshots).ToList(); + var done = 0; + + foreach (var snap in snapshots) + { + done++; + var safeName = UniqueFileName(snap.SnapshotName, usedNames); + var reportPath = Path.Combine(reportsDir, safeName + ".html"); + try + { + using var backend = ReportQueryFactory.Create(snap.DatabasePath); + var reportModel = ReportBuilder.Build(backend, $"{snap.SnapshotName} — Report", snap.DatabasePath, generatedAtUtc); + File.WriteAllText(reportPath, ReportRenderer.Render(reportModel), System.Text.Encoding.UTF8); + links[snap.DatabasePath] = reportsFolderName + "/" + safeName + ".html"; + progress.Report($"Report {done}/{snapshots.Count}: {snap.SnapshotName}"); + } + catch (Exception ex) + { + usedNames.Remove(safeName); + progress.Report($"Skipping report for {snap.SnapshotName}: {ex.Message}", force: true); + } + } + + return links; + } + + /// + /// Produces a filesystem- and URL-safe report filename from a snapshot name (conservative ASCII set), + /// disambiguating collisions across the batch with a numeric suffix. + /// + private static string UniqueFileName(string snapshotName, HashSet usedNames) + { + var chars = snapshotName.Select(ch => + char.IsLetterOrDigit(ch) || ch is '-' or '_' or '.' ? ch : '_').ToArray(); + var baseName = new string(chars).Trim('.', '_'); + if (string.IsNullOrEmpty(baseName)) + baseName = "snapshot"; + if (baseName.Length > 120) + baseName = baseName[..120]; + + var candidate = baseName; + var suffix = 2; + while (!usedNames.Add(candidate)) + candidate = $"{baseName}_{suffix++}"; + return candidate; + } } diff --git a/Core/Report/ReportRenderer.cs b/Core/Report/ReportRenderer.cs index 072e11c..717d3c4 100644 --- a/Core/Report/ReportRenderer.cs +++ b/Core/Report/ReportRenderer.cs @@ -80,6 +80,12 @@ internal static class ReportRenderer .kv-label { font-size: 10px; color: #888; text-transform: uppercase; letter-spacing: .05em; } .kv-value { font-size: 15px; font-weight: 600; color: #1a1a2e; margin-top: 2px; } .kv-value.mono { font-family: "SF Mono", "Fira Code", Consolas, monospace; font-size: 11px; font-weight: 400; color: #444; word-break: break-all; white-space: normal; } + /* When the viewport is too narrow for the fixed nav to sit in the right gutter (e.g. inside the + multi-report iframe drawer), drop it into normal flow at the top so it never overlaps content. */ + @media (max-width: 1360px) { + nav { position: static; width: auto; max-width: 100%; max-height: 220px; margin: 0 0 20px; } + main { max-width: 100%; } + } """; /// Builds the full HTML document from the report model (nav, title, groups, sections). diff --git a/Core/Report/SummaryMetricsDbReader.cs b/Core/Report/SummaryMetricsDbReader.cs new file mode 100644 index 0000000..fa26279 --- /dev/null +++ b/Core/Report/SummaryMetricsDbReader.cs @@ -0,0 +1,72 @@ +using System.Data.Common; +using MemorySnapshotDataTools.Validation; + +namespace MemorySnapshotDataTools.Report; + +/// +/// Reads the summary_metrics table from an exported DuckDB or SQLite database into a +/// . Shared by the summary command and the multi-snapshot report so +/// the group-mapping and DuckDB/SQLite normalization live in one place. The query is the constant +/// (no parameters, no identifier interpolation), +/// and a missing table yields Available = false rather than throwing — see docs/sql-safety.md. +/// +internal static class SummaryMetricsDbReader +{ + /// + /// Reads summary_metrics from an already-open (ideally read-only) connection. + /// + /// The populated metrics and whether the table was present. + public static (SummaryMetrics Metrics, bool Available) Read(DbConnection connection) + { + var metrics = new SummaryMetrics(); + try + { + using var cmd = connection.CreateCommand(); + cmd.CommandText = GoldenValidationQueries.SummaryMetricsSql; + using var reader = cmd.ExecuteReader(); + + var any = false; + while (reader.Read()) + { + any = true; + var group = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); + var category = reader.IsDBNull(1) ? string.Empty : reader.GetString(1); + var committed = ToULong(DbScalarReader.GetInt64(reader, 2)); + var resident = ToULong(DbScalarReader.GetInt64(reader, 3)); + var residentAvailable = DbScalarReader.GetInt64(reader, 4) != 0; + + if (group == SummaryMetricsTable.GroupTotals && category == SummaryMetricsTable.CategoryTotal) + { + metrics.TotalAllocatedBytes = committed; + metrics.TotalResidentBytes = resident; + } + else if (group == SummaryMetricsTable.GroupAllocatedMemoryDistribution) + { + metrics.AllocatedMemoryDistribution.Add(MakeCategory(category, committed, resident, residentAvailable)); + } + else if (group == SummaryMetricsTable.GroupManagedHeapUtilization) + { + metrics.ManagedHeapUtilization.Add(MakeCategory(category, committed, resident, residentAvailable)); + } + } + + return (metrics, any); + } + catch (DbException) + { + // summary_metrics table absent (database exported by an older tool version). + return (metrics, false); + } + } + + private static SummaryCategory MakeCategory(string name, ulong committed, ulong resident, bool residentAvailable) => + new() + { + Name = name, + CommittedBytes = committed, + ResidentBytes = resident, + ResidentAvailable = residentAvailable, + }; + + private static ulong ToULong(long value) => value < 0 ? 0UL : (ulong)value; +} diff --git a/Core/Report/SummaryReportRunner.cs b/Core/Report/SummaryReportRunner.cs index 6c743c8..704b7de 100644 --- a/Core/Report/SummaryReportRunner.cs +++ b/Core/Report/SummaryReportRunner.cs @@ -149,7 +149,7 @@ private static SummaryReport FromDatabase(string databasePath, string extension) connection.Open(); var info = ReadSnapshotInfo(connection); - var (metrics, available) = ReadSummaryMetrics(connection); + var (metrics, available) = SummaryMetricsDbReader.Read(connection); var categories = ReadUnityObjectCategories(connection); var (major, minor) = DatabaseSchemaInfo.ReadVersion(connection); @@ -204,49 +204,6 @@ uint Num(string name) => return info; } - private static (SummaryMetrics Metrics, bool Available) ReadSummaryMetrics(DbConnection connection) - { - var metrics = new SummaryMetrics(); - try - { - using var cmd = connection.CreateCommand(); - cmd.CommandText = GoldenValidationQueries.SummaryMetricsSql; - using var reader = cmd.ExecuteReader(); - - var any = false; - while (reader.Read()) - { - any = true; - var group = reader.IsDBNull(0) ? string.Empty : reader.GetString(0); - var category = reader.IsDBNull(1) ? string.Empty : reader.GetString(1); - var committed = ToULong(DbScalarReader.GetInt64(reader, 2)); - var resident = ToULong(DbScalarReader.GetInt64(reader, 3)); - var residentAvailable = DbScalarReader.GetInt64(reader, 4) != 0; - - if (group == SummaryMetricsTable.GroupTotals && category == SummaryMetricsTable.CategoryTotal) - { - metrics.TotalAllocatedBytes = committed; - metrics.TotalResidentBytes = resident; - } - else if (group == SummaryMetricsTable.GroupAllocatedMemoryDistribution) - { - metrics.AllocatedMemoryDistribution.Add(MakeCategory(category, committed, resident, residentAvailable)); - } - else if (group == SummaryMetricsTable.GroupManagedHeapUtilization) - { - metrics.ManagedHeapUtilization.Add(MakeCategory(category, committed, resident, residentAvailable)); - } - } - - return (metrics, any); - } - catch (DbException) - { - // summary_metrics table absent (database exported by an older tool version). - return (metrics, false); - } - } - private const string UnityObjectCategoriesSql = """ SELECT COALESCE(native_type_name, '(unknown)') AS type_name, COUNT(*) AS obj_count, @@ -283,15 +240,6 @@ private static List ReadUnityObjectCategories(DbConnection return categories; } - private static SummaryCategory MakeCategory(string name, ulong committed, ulong resident, bool residentAvailable) => - new() - { - Name = name, - CommittedBytes = committed, - ResidentBytes = resident, - ResidentAvailable = residentAvailable, - }; - private static ulong ToULong(long value) => value < 0 ? 0UL : (ulong)value; /// Forwards every message as forced, so summary always shows decode progress.