| // 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}); |
| } |
| } |