ui: Improve URL state management in memory overview page (#7353)
Add a few improvements to the URL state management in the memory
overview page.
- The active tab ('summary' or 'smaps', defined by MemoryOverviewTab) is
now encoded in the route (e.g. `#!/memoryoverview/<upid>/smaps`) and
controlled via callbacks rather than local state.
- Auto-redirect to the 'best' process using `location.replace()` when a
upid is omitted from the URL rather than just showing it - e.g.
`#!/memoryoverview/<bestUpid>`. This keeps the URL in sync with what the
user is seeing.
- Small refactor:
- Refactor process stats logic out into a separate file.
- Rename landing_page.ts/scss to memory_overview_page.ts/scss.diff --git a/ui/src/plugins/dev.perfetto.Memscope/index.ts b/ui/src/plugins/dev.perfetto.Memscope/index.ts
index 432e3f0..b20412f 100644
--- a/ui/src/plugins/dev.perfetto.Memscope/index.ts
+++ b/ui/src/plugins/dev.perfetto.Memscope/index.ts
@@ -23,8 +23,11 @@
import {ConnectionPage} from './views/connection';
import {Dashboard} from './views/dashboard';
import {LiveSession} from './sessions/live_session';
-import {MemoryOverviewPage} from './views/landing_page/landing_page';
+import {MemoryOverviewPage} from './views/landing_page/memory_overview_page';
import {NUM} from '../../trace_processor/query_result';
+import {EmptyState} from '../../widgets/empty_state';
+import type {MemoryOverviewTab} from './views/landing_page/proc_mem_overview';
+import {getBestProcess} from './views/landing_page/proc_mem_stats';
export default class MemscopePlugin implements PerfettoPlugin {
static readonly id = 'dev.perfetto.Memscope';
@@ -98,21 +101,42 @@
const hideDefaultChangedHint = MemscopePlugin.hideDefaultChangedHintSetting;
const availability = await this.getMemoryOverviewAvailability(trace);
const autoNavigated = openByDefault.get() && availability.hasSmapsSnapshots;
+ const bestUpid = await getBestProcess(trace.engine);
trace.pages.registerPage({
route: pageRoot,
- render: (subpage) =>
- m(MemoryOverviewPage, {
- trace,
+ render: (subpage) => {
+ const {parsed, redirect} = resolveMemoryOverviewRoute(
subpage,
+ bestUpid,
+ pageRoot,
+ );
+ if (redirect !== undefined) {
+ return redirect;
+ }
+
+ return m(MemoryOverviewPage, {
+ trace,
+ upid: parsed.upid,
+ tab: parsed.tab,
autoNavigated,
hdeAvailable: availability.hasHeapDumps,
openByDefault,
hideDefaultChangedHint,
- onSubpageChange: (subpage) => {
- trace.navigate(`#!${pageRoot}/${subpage}`);
+ onUpidChange: (newUpid) => {
+ trace.navigate(
+ `#!${pageRoot}/${formatMemoryOverviewSubpage(newUpid, parsed.tab)}`,
+ );
},
- }),
+ onTabChange: (tab) => {
+ if (parsed.upid !== undefined) {
+ trace.navigate(
+ `#!${pageRoot}/${formatMemoryOverviewSubpage(parsed.upid, tab)}`,
+ );
+ }
+ },
+ });
+ },
});
if (availability.hasSmapsSnapshots || availability.hasHeapDumps) {
@@ -151,3 +175,67 @@
};
}
}
+
+interface MemoryOverviewSubpage {
+ readonly upid?: number;
+ readonly tab: MemoryOverviewTab;
+}
+
+function parseMemoryOverviewSubpage(subpage?: string): MemoryOverviewSubpage {
+ if (!subpage) {
+ return {tab: 'summary'};
+ }
+ const parts = subpage.split('/').filter((x) => x !== '');
+ if (parts.length === 0) {
+ return {tab: 'summary'};
+ }
+ const upid = parseInt(parts[0], 10);
+ const tab = parts[1] === 'smaps' ? 'smaps' : 'summary';
+ return {
+ upid: Number.isNaN(upid) ? Number.NaN : upid,
+ tab,
+ };
+}
+
+function formatMemoryOverviewSubpage(
+ upid: number,
+ tab?: MemoryOverviewTab,
+): string {
+ if (tab === 'summary') {
+ return `${upid}`;
+ }
+
+ if (tab !== undefined) {
+ return `${upid}/${tab}`;
+ }
+ return `${upid}`;
+}
+
+function resolveMemoryOverviewRoute(
+ subpage: string | undefined,
+ bestUpid: number | undefined,
+ pageRoot: string,
+): {parsed: MemoryOverviewSubpage; redirect?: m.Children} {
+ const parsed = parseMemoryOverviewSubpage(subpage);
+
+ if (parsed.upid === undefined) {
+ if (bestUpid !== undefined) {
+ location.replace(
+ `#!${pageRoot}/${formatMemoryOverviewSubpage(bestUpid, parsed.tab)}`,
+ );
+ return {
+ parsed,
+ redirect: m(EmptyState, {
+ icon: 'hourglass',
+ title: 'Loading process...',
+ }),
+ };
+ }
+ return {
+ parsed,
+ redirect: m(EmptyState, 'No processes with memory in this trace'),
+ };
+ }
+
+ return {parsed};
+}
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.scss b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.scss
similarity index 100%
rename from ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.scss
rename to ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.scss
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.ts b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.ts
similarity index 64%
rename from ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.ts
rename to ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.ts
index c662fc1..1b381f8 100644
--- a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/landing_page.ts
+++ b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/memory_overview_page.ts
@@ -17,12 +17,6 @@
import {AsyncMemo} from '../../../../base/async_memo';
import type {Setting} from '../../../../public/settings';
import type {Trace} from '../../../../public/trace';
-import type {Engine} from '../../../../trace_processor/engine';
-import {
- materializeRows,
- NUM,
- STR,
-} from '../../../../trace_processor/query_result';
import {Button, ButtonGroup, ButtonVariant} from '../../../../widgets/button';
import {Intent} from '../../../../widgets/common';
import {EmptyState} from '../../../../widgets/empty_state';
@@ -32,44 +26,40 @@
import {Callout} from '../../components/callout';
import {Page} from '../../components/page';
import {PreviewBanner} from '../../components/preview_banner';
-import './landing_page.scss';
-import {ProcessMemDetails} from './proc_mem_overview';
-
-// Per-process memory-capture counts, used to populate and score the process
-// picker on the overview page.
-interface ProcMemStat {
- readonly upid: number;
- readonly pid: number;
- readonly procName: string;
- readonly heapDumps: number;
- readonly smapsSnapshots: number;
- readonly nativeDumps: number;
-}
+import {MemoryOverviewTab, ProcessMemDetails} from './proc_mem_overview';
+import {
+ loadProcessMemoryStats,
+ type ProcMemStat,
+ type ProcWithMem,
+} from './proc_mem_stats';
+import './memory_overview_page.scss';
export interface MemoryOverviewPageAttrs {
readonly trace: Trace;
- readonly subpage: string | undefined;
+ readonly upid?: number;
+ readonly tab: MemoryOverviewTab;
readonly autoNavigated: boolean;
readonly hdeAvailable: boolean;
readonly openByDefault: Setting<boolean>;
readonly hideDefaultChangedHint: Setting<boolean>;
- readonly onSubpageChange: (subpage: string) => void;
+ readonly onUpidChange: (upid: number) => void;
+ readonly onTabChange: (tab: MemoryOverviewTab) => void;
}
-type ProcWithMem = readonly ProcMemStat[];
-
export class MemoryOverviewPage implements m.Component<MemoryOverviewPageAttrs> {
private readonly slot = new AsyncMemo<ProcWithMem>();
view({attrs}: m.Vnode<MemoryOverviewPageAttrs>) {
const {
trace,
- subpage,
+ upid,
+ tab,
autoNavigated,
hdeAvailable,
openByDefault,
hideDefaultChangedHint,
- onSubpageChange,
+ onUpidChange,
+ onTabChange,
} = attrs;
return m(
@@ -88,7 +78,7 @@
hideDefaultChangedHint,
),
m(PreviewBanner, {app: trace}),
- this.renderPageContent(trace, subpage, onSubpageChange),
+ this.renderPageContent(trace, upid, tab, onUpidChange, onTabChange),
);
}
@@ -164,8 +154,10 @@
private renderPageContent(
trace: Trace,
- subpage: string | undefined,
- onSubpageChange: (subpage: string) => void,
+ upid: number | undefined,
+ tab: MemoryOverviewTab,
+ onUpidChange: (upid: number) => void,
+ onTabChange: (tab: MemoryOverviewTab) => void,
) {
const procsWithMemResult = this.slot.use({
key: '',
@@ -177,16 +169,11 @@
return m(EmptyState, {icon: 'hourglass', title: 'Loading processes...'});
}
- const bestProc = pickBestProc(procs);
- if (!bestProc) {
+ if (procs.length === 0) {
return m(EmptyState, 'No processes with memory in this trace');
}
- // Use the upid in the url bar otherwise pick the 'best' proc - the one most
- // likely to be what the user was tracing.
- const selectedUpid = subpage
- ? parseUpidFromSubpage(subpage)
- : bestProc.upid;
+ const selectedUpid = upid;
return [
m('.pf-memscope-process-select', [
@@ -197,7 +184,7 @@
value: selectedUpid?.toString(),
onchange: (e: Event) => {
assertIsInstance(e.target, HTMLSelectElement);
- onSubpageChange(e.target.value);
+ onUpidChange(Number(e.target.value));
},
},
procs.map((p) =>
@@ -205,69 +192,13 @@
),
),
]),
- Number.isNaN(selectedUpid)
- ? m('', `Unable to parse upid from url '${subpage}'`)
- : m(ProcessMemDetails, {trace, upid: selectedUpid}),
+ selectedUpid === undefined || Number.isNaN(selectedUpid)
+ ? m('', 'Unable to parse upid from url')
+ : m(ProcessMemDetails, {trace, upid: selectedUpid, tab, onTabChange}),
];
}
}
-// Returns a list processes that have memory dumps/smaps/profiles in the trace.
-async function loadProcessMemoryStats(engine: Engine): Promise<ProcWithMem> {
- const result = await engine.query(`
- SELECT
- p.upid,
- p.pid,
- COALESCE(p.cmdline, p.name, '<unknown>') AS procName,
- (
- SELECT count(*)
- FROM heap_graph g
- WHERE g.upid = p.upid
- ) AS heapDumps,
- (
- SELECT count(DISTINCT ts)
- FROM profiler_smaps s
- WHERE s.upid = p.upid
- ) AS smapsSnapshots,
- (
- SELECT count(DISTINCT ts)
- FROM heap_profile_allocation a
- WHERE a.upid = p.upid
- ) AS nativeDumps
- FROM process p
- WHERE heapDumps > 0 OR smapsSnapshots > 0 OR nativeDumps > 0
- ORDER BY p.upid;
- `);
- return materializeRows(result, {
- upid: NUM,
- pid: NUM,
- procName: STR,
- heapDumps: NUM,
- smapsSnapshots: NUM,
- nativeDumps: NUM,
- });
-}
-
-// Scores a process to determine how relevant it is for the landing page.
-// Higher score = more relevant. We weight by data type and count to pick
-// the process with the richest memory analysis data.
-function scoreProc(p: ProcMemStat): number {
- // Heap dumps are the richest data source, followed by smaps, then profiles.
- return p.heapDumps * 3 + p.smapsSnapshots * 2 + p.nativeDumps * 1;
-}
-
-function pickBestProc(procs: ProcWithMem) {
- if (procs.length === 0) return undefined;
- return procs.reduce((best, p) => (scoreProc(p) > scoreProc(best) ? p : best));
-}
-
-// The subpage might look like '/123' or even '/123/foo'
-function parseUpidFromSubpage(subpage: string): number {
- const parts = subpage.split('/').filter((x) => x !== '');
- if (parts.length === 0) return Number.NaN;
- return parseInt(parts[0]);
-}
-
function procOptionLabel(p: ProcMemStat): string {
const parts: string[] = [];
if (p.heapDumps > 0) parts.push(`${p.heapDumps} java_hprof`);
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts
index 57a8224..5ca7699 100644
--- a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts
+++ b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_overview.ts
@@ -39,7 +39,6 @@
import {MemoryMap} from './summary/memory_map';
import {NativeSection} from './summary/native_section';
import {TraceOverview} from './summary/trace_overview';
-import './landing_page.scss';
// Sample count and observed time span of one capture source (smaps / heapprofd)
// for a process. spanS is undefined when there are fewer than two samples.
@@ -109,9 +108,13 @@
return Array.from(byTs.values());
}
+export type MemoryOverviewTab = 'summary' | 'smaps';
+
export interface ProcessMemDetailsAttrs {
readonly trace: Trace;
readonly upid: number;
+ readonly tab: MemoryOverviewTab;
+ readonly onTabChange: (tab: MemoryOverviewTab) => void;
}
export class ProcessMemDetails implements m.ClassComponent<ProcessMemDetailsAttrs> {
@@ -121,7 +124,6 @@
// Whole-trace per-snapshot smaps breakdown for the growth bar (keyed by
// upid; independent of the page's snapshot selection).
private readonly growthSlot = new AsyncMemo<GrowthSnapshot[]>();
- private activeTab: 'summary' | 'smaps' = 'summary';
// The page-wide snapshot selection, driven by the composition timeline and
// shared with the other summary sections as they're added.
private selection?: MemSelection;
@@ -132,7 +134,7 @@
}
view({attrs}: m.Vnode<ProcessMemDetailsAttrs>) {
- const {trace, upid} = attrs;
+ const {trace, upid, tab, onTabChange} = attrs;
let capture: CaptureInfo | undefined;
let error: string | undefined;
try {
@@ -145,7 +147,7 @@
}
const smapsMissing = capture !== undefined && capture.smaps.samples === 0;
- const activeTab = smapsMissing ? 'summary' : this.activeTab;
+ const activeTab = smapsMissing ? 'summary' : tab;
// Both tab bodies stay mounted and are toggled with a Gate (display:none
// when hidden) rather than conditionally rendered, so switching tabs doesn't
@@ -153,7 +155,8 @@
return [
error !== undefined && m('p.pf-error', `Error: ${error}`),
error === undefined && this.renderCaptureStrip(trace, capture),
- error === undefined && this.renderTabs(activeTab, smapsMissing),
+ error === undefined &&
+ this.renderTabs(activeTab, smapsMissing, onTabChange),
error === undefined &&
m(
Gate,
@@ -252,11 +255,12 @@
}
private renderTabs(
- activeTab: 'summary' | 'smaps',
+ activeTab: MemoryOverviewTab,
smapsMissing: boolean,
+ onTabChange: (tab: MemoryOverviewTab) => void,
): m.Children {
const tabs: {
- key: 'summary' | 'smaps';
+ key: MemoryOverviewTab;
label: string;
icon: string;
disabled?: boolean;
@@ -283,7 +287,7 @@
title: t.disabled
? 'No smaps data available for this process'
: undefined,
- onclick: () => (this.activeTab = t.key),
+ onclick: () => onTabChange(t.key),
},
[m(Icon, {icon: t.icon}), t.label],
),
diff --git a/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_stats.ts b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_stats.ts
new file mode 100644
index 0000000..e7c30ce
--- /dev/null
+++ b/ui/src/plugins/dev.perfetto.Memscope/views/landing_page/proc_mem_stats.ts
@@ -0,0 +1,91 @@
+// Copyright (C) 2026 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 type {Engine} from '../../../../trace_processor/engine';
+import {
+ materializeRows,
+ NUM,
+ STR,
+} from '../../../../trace_processor/query_result';
+
+// Per-process memory-capture counts, used to populate and score the process
+// picker on the overview page.
+export interface ProcMemStat {
+ readonly upid: number;
+ readonly pid: number;
+ readonly procName: string;
+ readonly heapDumps: number;
+ readonly smapsSnapshots: number;
+ readonly nativeDumps: number;
+}
+
+export type ProcWithMem = readonly ProcMemStat[];
+
+// Returns a list processes that have memory dumps/smaps/profiles in the trace.
+export async function loadProcessMemoryStats(
+ engine: Engine,
+): Promise<ProcWithMem> {
+ const result = await engine.query(`
+ SELECT
+ p.upid,
+ p.pid,
+ COALESCE(p.cmdline, p.name, '<unknown>') AS procName,
+ (
+ SELECT count(*)
+ FROM heap_graph g
+ WHERE g.upid = p.upid
+ ) AS heapDumps,
+ (
+ SELECT count(DISTINCT ts)
+ FROM profiler_smaps s
+ WHERE s.upid = p.upid
+ ) AS smapsSnapshots,
+ (
+ SELECT count(DISTINCT ts)
+ FROM heap_profile_allocation a
+ WHERE a.upid = p.upid
+ ) AS nativeDumps
+ FROM process p
+ WHERE heapDumps > 0 OR smapsSnapshots > 0 OR nativeDumps > 0
+ ORDER BY p.upid;
+ `);
+ return materializeRows(result, {
+ upid: NUM,
+ pid: NUM,
+ procName: STR,
+ heapDumps: NUM,
+ smapsSnapshots: NUM,
+ nativeDumps: NUM,
+ });
+}
+
+// Scores a process to determine how relevant it is for the landing page.
+// Higher score = more relevant. We weight by data type and count to pick
+// the process with the richest memory analysis data.
+export function scoreProc(p: ProcMemStat): number {
+ // Heap dumps are the richest data source, followed by smaps, then profiles.
+ return p.heapDumps * 3 + p.smapsSnapshots * 2 + p.nativeDumps * 1;
+}
+
+export function pickBestProc(procs: ProcWithMem): ProcMemStat | undefined {
+ if (procs.length === 0) return undefined;
+ return procs.reduce((best, p) => (scoreProc(p) > scoreProc(best) ? p : best));
+}
+
+export async function getBestProcess(
+ engine: Engine,
+): Promise<number | undefined> {
+ const procs = await loadProcessMemoryStats(engine);
+ return pickBestProc(procs)?.upid;
+}