blob: 59cc8a01869dd60d5811a870ea4cc464c8d42063 [file] [edit]
// 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 m from 'mithril';
import {GateDetector} from '../../../base/mithril_utils';
import type {
LineChartData,
LineChartSeries,
} from '../../../components/widgets/charts/line_chart';
import type {App} from '../../../public/app';
import {Button, ButtonGroup, ButtonVariant} from '../../../widgets/button';
import {Chip} from '../../../widgets/chip';
import {Intent} from '../../../widgets/common';
import {MenuDivider, MenuItem, PopupMenu} from '../../../widgets/menu';
import {PopupPosition} from '../../../widgets/popup';
import {RadioGroup} from '../../../widgets/radio_group';
import {Page} from '../components/page';
import type {
LiveSession,
ProfileView,
SnapshotData,
} from '../sessions/live_session';
import './dashboard.scss';
import {ProfilePage} from './profile_page';
import {renderPageCacheTab} from './tabs/page_cache';
import {renderPressureSwapTab} from './tabs/pressure_swap';
import {ProcessesTab} from './tabs/processes';
import {renderSystemTab} from './tabs/system';
type Tab = 'processes' | 'system' | 'file_cache' | 'pressure_swap';
function buildProcessMemoryBreakdown(
data: SnapshotData,
pid: number,
t0: number,
): LineChartData | undefined {
// SnapshotData keys process counters by upid; resolve from pid.
let upid: number | undefined;
for (const info of data.processInfo.values()) {
if (info.pid === pid) {
upid = info.upid;
break;
}
}
if (upid === undefined) return undefined;
const pidCounters = data.processCountersByUpid.get(upid);
if (pidCounters === undefined) return undefined;
const SERIES_NAMES = ['RSS Anon + Swap', 'File', 'DMA-BUF'] as const;
const counterMapping: Record<string, string> = {
'mem.rss.anon': 'RSS Anon + Swap',
'mem.swap': 'RSS Anon + Swap',
'mem.rss.file': 'File',
'mem.dmabuf_rss': 'DMA-BUF',
};
const tsSet = new Set<number>();
const bySeriesTs = new Map<number, Map<string, number>>();
for (const [counterName, samples] of pidCounters) {
const seriesName = counterMapping[counterName];
if (seriesName === undefined) continue;
for (const {ts, value} of samples) {
tsSet.add(ts);
let seriesMap = bySeriesTs.get(ts);
if (seriesMap === undefined) {
seriesMap = new Map();
bySeriesTs.set(ts, seriesMap);
}
seriesMap.set(
seriesName,
(seriesMap.get(seriesName) ?? 0) + Math.round(value / 1024),
);
}
}
const timestamps = [...tsSet].sort((a, b) => a - b);
if (timestamps.length < 2) return undefined;
const colors: Record<string, string> = {
'RSS Anon + Swap': 'var(--pf-color-warning)',
'File': 'var(--pf-color-success)',
'DMA-BUF': 'var(--pf-color-primary)',
};
const series: LineChartSeries[] = [];
for (const name of SERIES_NAMES) {
const points = timestamps.map((ts) => ({
x: (ts - t0) / 1e9,
y: bySeriesTs.get(ts)?.get(name) ?? 0,
}));
if (points.some((p) => p.y > 0)) {
series.push({name, points, color: colors[name]});
}
}
if (series.length === 0) return undefined;
return {series};
}
interface DashboardAttrs {
readonly app: App;
readonly session: LiveSession;
readonly onStopped: () => void;
}
export class Dashboard implements m.ClassComponent<DashboardAttrs> {
private activeTab: Tab = 'processes';
private profileBaseline?: {anonSwap: number; file: number; dmabuf: number};
view({attrs}: m.CVnode<DashboardAttrs>) {
const {session} = attrs;
return m(
GateDetector,
{
onVisibilityChanged: (visible) =>
visible ? session.resume() : session.pause(),
},
m(
Page,
// Title bar with status and actions (always shown).
this.renderTitleBar(attrs),
// Profile page or dashboard content.
session.profile !== undefined
? this.renderProfilePage(attrs, session.profile)
: this.renderDashboard(attrs),
),
);
}
private renderTitleBar(attrs: DashboardAttrs): m.Children {
return m(
'.pf-memscope-title-bar',
// Left: title + session pill (device + snapshot counter + pause/play).
m(
'.pf-memscope-title-bar__left',
m('h1', 'Memscope'),
attrs.session.profile === undefined && this.renderTabStrip(),
),
this.renderSessionPill(attrs),
);
}
private renderTabStrip(): m.Children {
return m(
RadioGroup,
{
intent: Intent.Primary,
selectedValue: this.activeTab,
onValueChange: (value) => {
this.activeTab = value as Tab;
},
},
m(RadioGroup.Button, {value: 'processes', icon: 'apps'}, 'Processes'),
m(RadioGroup.Button, {value: 'system', icon: 'monitoring'}, 'System'),
m(
RadioGroup.Button,
{value: 'file_cache', icon: 'file_copy'},
'Page Cache',
),
// Right: action buttons.
m(
RadioGroup.Button,
{value: 'pressure_swap', icon: 'speed'},
'Pressure, Faults & Swap',
),
);
}
private renderSessionPill(attrs: DashboardAttrs): m.Children {
const {session} = attrs;
return m(
'.pf-memscope-session-pill',
// Device identity sits to the left of the button group.
m(
'.pf-memscope-session-pill__device',
m('.pf-memscope-status-bar__dot', {
class: session.isPaused ? 'pf-memscope-status-bar__dot--paused' : '',
}),
m('span', session.deviceName),
session.data?.isUserDebug &&
m(Chip, {label: 'userdebug', intent: Intent.Warning}),
),
m(
ButtonGroup,
// Pause / Resume.
m(Button, {
variant: ButtonVariant.Filled,
label: session.isPaused ? 'Resume' : 'Pause',
icon: session.isPaused ? 'play_arrow' : 'pause',
onclick: () => {
session.togglePause();
m.redraw();
},
}),
// Overflow menu for session actions.
m(
PopupMenu,
{
trigger: m(Button, {
variant: ButtonVariant.Filled,
icon: 'more_vert',
}),
position: PopupPosition.BottomEnd,
},
m(MenuItem, {
label: 'Stop & Open Trace',
icon: 'open_in_new',
disabled: session.lastTraceBuffer === undefined,
onclick: () => this.stopAndOpenTrace(attrs),
}),
m(MenuItem, {
label: 'Disconnect',
icon: 'usb_off',
onclick: () => attrs.onStopped(),
}),
m(MenuDivider),
m(
MenuItem,
{
label: 'Snapshot stats',
icon: 'photo_camera',
},
this.renderSnapshotStats(session),
),
),
),
);
}
private renderDashboard(attrs: DashboardAttrs): m.Children {
const {session} = attrs;
return [
this.activeTab === 'processes' && m(ProcessesTab, {session}),
this.activeTab === 'system' && renderSystemTab(session),
this.activeTab === 'file_cache' && renderPageCacheTab(session),
this.activeTab === 'pressure_swap' && renderPressureSwapTab(session),
!session.data && m('.pf-memscope-placeholder', 'Waiting for data\u2026'),
];
}
private renderSnapshotStats(session: LiveSession): m.Children {
if (session.lastSnapshotMs <= 0) {
return m(
'.pf-memscope-snapshot-info',
m('.pf-memscope-snapshot-info__empty', 'Waiting for snapshot…'),
);
}
return m(
'.pf-memscope-snapshot-info',
m('.pf-memscope-snapshot-info__heading', 'Snapshot'),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Count'),
m('span', `#${session.snapshotCount}`),
),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Size'),
m('span', `${session.lastSnapshotSizeKb.toFixed(0)}kB`),
),
session.lastBufferUsagePct !== undefined &&
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Buffer usage'),
m('span', `${session.lastBufferUsagePct.toFixed(1)}%`),
),
session.data !== undefined && [
m('.pf-memscope-snapshot-info__heading', 'Counter range'),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'First sample'),
m('span', `${session.data.xMin.toFixed(1)}s`),
),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Last sample'),
m('span', `${session.data.xMax.toFixed(1)}s`),
),
],
m('.pf-memscope-snapshot-info__heading', 'Timings'),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Clone'),
m('span', `${session.lastCloneMs}ms`),
),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Parse'),
m('span', `${session.lastParseMs}ms`),
),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Query'),
m('span', `${session.lastQueryMs}ms`),
),
m(
'.pf-memscope-snapshot-info__row',
m('span', 'Extract'),
m('span', `${session.lastExtractMs}ms`),
),
m(
'.pf-memscope-snapshot-info__row.pf-memscope-snapshot-info__sum',
m('span', 'Total'),
m('span', `${session.lastSnapshotMs}ms`),
),
session.snapshotOverrun &&
m(
'.pf-memscope-snapshot-info__overrun',
m('span.material-icons', 'warning'),
'Exceeds interval — increase snapshot rate',
),
);
}
private renderProfilePage(
attrs: DashboardAttrs,
profile: ProfileView,
): m.Children {
const {session} = attrs;
const data = session.data;
const t0 = data?.ts0 ?? 0;
const chartData = data
? buildProcessMemoryBreakdown(data, profile.pid, t0)
: undefined;
// Capture baseline from first chart data.
if (this.profileBaseline === undefined && chartData !== undefined) {
const first = (name: string): number => {
const s = chartData.series.find(
(sr: LineChartSeries) => sr.name === name,
);
return s !== undefined && s.points.length > 0 ? s.points[0].y : 0;
};
this.profileBaseline = {
anonSwap: first('RSS Anon + Swap'),
file: first('File'),
dmabuf: first('DMA-BUF'),
};
}
return m(ProfilePage, {
state: profile.state as 'recording' | 'stopping' | 'finished',
bufferUsagePct: profile.bufferUsagePct,
processName: profile.processName,
pid: profile.pid,
startMs: profile.startMs,
chartData,
baseline: this.profileBaseline,
onStop: () => {
session.stopAndOpenProfile().then(() => {
this.profileBaseline = undefined;
m.redraw();
});
},
onStopAndDownload: () => {
session.stopAndDownloadProfile().then(() => {
this.profileBaseline = undefined;
m.redraw();
});
},
onCancel: () => {
session.cancelProfile().then(() => {
this.profileBaseline = undefined;
m.redraw();
});
},
});
}
private async stopAndOpenTrace(attrs: DashboardAttrs) {
const buffer = attrs.session.lastTraceBuffer;
if (buffer === undefined) return;
const fileName = `live-memory-${Date.now()}.perfetto-trace`;
await attrs.session.dispose();
attrs.app.openTraceFromBuffer({buffer, title: fileName, fileName});
}
}