Skip to content

Commit 55b3bbc

Browse files
committed
Live memory page
1 parent e5fc89c commit 55b3bbc

17 files changed

Lines changed: 3416 additions & 23 deletions

File tree

ui/src/components/widgets/charts/chart_option_builder.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,8 @@ export function buildTooltipOption(
127127
extra?: Record<string, unknown>,
128128
): Record<string, unknown> {
129129
return {
130+
showDelay: 0,
131+
transitionDuration: 0,
130132
...extra,
131133
};
132134
}
@@ -155,7 +157,7 @@ export function buildBrushOption(config: BrushConfig): Record<string, unknown> {
155157
* Theme colors are applied by EChartView.
156158
*/
157159
export function buildLegendOption(
158-
position: 'top' | 'right' = 'top',
160+
position: 'top' | 'right' | 'bottom' = 'top',
159161
): Record<string, unknown> {
160162
if (position === 'right') {
161163
return {
@@ -175,6 +177,14 @@ export function buildLegendOption(
175177
pageButtonPosition: 'end',
176178
};
177179
}
180+
if (position === 'bottom') {
181+
return {
182+
show: true,
183+
type: 'scroll',
184+
bottom: 0,
185+
textStyle: {fontSize: 10},
186+
};
187+
}
178188
return {
179189
show: true,
180190
top: 0,

ui/src/components/widgets/charts/line_chart.ts

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,17 @@ export interface LineChartAttrs {
166166
* Note: When stacked, all series must be aligned to the same X values.
167167
*/
168168
readonly stacked?: boolean;
169+
170+
/**
171+
* Position of the legend. Defaults to 'bottom' when multiple series,
172+
* 'top' otherwise.
173+
*/
174+
readonly legendPosition?: 'top' | 'right' | 'bottom';
175+
176+
/**
177+
* Callback when a series is clicked. Called with the series name.
178+
*/
179+
readonly onSeriesClick?: (seriesName: string) => void;
169180
}
170181

171182
export class LineChart implements m.ClassComponent<LineChartAttrs> {
@@ -208,12 +219,17 @@ function buildLineOption(
208219
lineWidth = 2,
209220
gridLines,
210221
stacked = false,
222+
legendPosition,
211223
} = attrs;
212224
const fmtX = formatXValue ?? formatNumber;
213225
const fmtY = formatYValue ?? formatNumber;
214226

215227
const displayLegend = showLegend ?? data.series.length > 1;
228+
const legendPos =
229+
legendPosition ?? (data.series.length > 1 ? 'bottom' : 'top');
216230

231+
// When stacking, reverse the series order so that the first series is drawn
232+
// on top. This aligns the series order with the legend and tooltip.
217233
const series = data.series.map((s, i) => {
218234
const base: Record<string, unknown> = {
219235
type: 'line',
@@ -227,9 +243,13 @@ function buildLineOption(
227243
showSymbol: showPoints,
228244
symbolSize: 6,
229245
triggerLineEvent: true,
230-
emphasis: {focus: 'series', itemStyle: {borderWidth: 2}},
246+
emphasis: stacked
247+
? {focus: 'series', blurScope: 'global' as const}
248+
: {focus: 'series', itemStyle: {borderWidth: 2}},
231249
stack: stacked ? 'total' : undefined,
232-
areaStyle: stacked ? {} : undefined,
250+
areaStyle: stacked ? {opacity: 0.8} : undefined,
251+
// invisible wider hitbox
252+
silent: false,
233253
};
234254

235255
// Render selection highlight on the first series only.
@@ -243,8 +263,16 @@ function buildLineOption(
243263

244264
const option = buildChartOption({
245265
grid: {
246-
top: displayLegend ? 30 : 10,
247-
bottom: xAxisLabel ? 40 : 25,
266+
top: legendPos === 'top' && displayLegend ? 30 : 10,
267+
right: legendPos === 'right' && displayLegend ? 150 : undefined,
268+
bottom:
269+
legendPos === 'bottom' && displayLegend
270+
? xAxisLabel
271+
? 70
272+
: 55
273+
: xAxisLabel
274+
? 40
275+
: 25,
248276
},
249277
xAxis: {
250278
// Nasty ECharts quirk: when stacking, the xAxis must be type 'category'
@@ -282,16 +310,31 @@ function buildLineOption(
282310
) => {
283311
if (!Array.isArray(params) || params.length === 0) return '';
284312
const xVal = params[0].data?.[0];
285-
const header = xVal !== undefined ? `X: ${fmtX(xVal)}` : '';
286-
const lines = params.map(
313+
const header = xVal !== undefined ? fmtX(xVal) : '';
314+
const ordered = stacked ? [...params].reverse() : params;
315+
const lines = ordered.map(
287316
(p) =>
288317
`${p.marker ?? ''} ${p.seriesName ?? ''}: ${fmtY(p.data?.[1] ?? 0)}`,
289318
);
290319
return [header, ...lines].join('<br>');
291320
},
292321
},
293322
brush: attrs.onBrush ? {xAxisIndex: 0, brushType: 'lineX'} : undefined,
294-
legend: displayLegend ? buildLegendOption() : {show: false},
323+
legend: displayLegend
324+
? {
325+
...buildLegendOption(legendPos),
326+
...(stacked
327+
? {data: [...data.series].reverse().map((s) => s.name)}
328+
: {}),
329+
formatter: (name: string) => {
330+
const s = data.series.find((sr) => sr.name === name);
331+
if (s !== undefined && s.points.length > 0) {
332+
return `${name} ${fmtY(s.points[s.points.length - 1].y)}`;
333+
}
334+
return name;
335+
},
336+
}
337+
: {show: false},
295338
});
296339

297340
(option as Record<string, unknown>).series = series;
@@ -302,18 +345,16 @@ function buildLineEventHandlers(
302345
attrs: LineChartAttrs,
303346
data: LineChartData | undefined,
304347
): ReadonlyArray<EChartEventHandler> {
348+
const handlers: EChartEventHandler[] = [];
349+
305350
if (
306-
!attrs.onBrush ||
307-
data === undefined ||
308-
data.series.length === 0 ||
309-
data.series.every((s) => s.points.length === 0)
351+
attrs.onBrush &&
352+
data !== undefined &&
353+
data.series.length > 0 &&
354+
data.series.some((s) => s.points.length > 0)
310355
) {
311-
return [];
312-
}
313-
const onBrush = attrs.onBrush;
314-
315-
return [
316-
{
356+
const onBrush = attrs.onBrush;
357+
handlers.push({
317358
eventName: 'brushEnd',
318359
handler: (params) => {
319360
const range = extractBrushRange(params);
@@ -322,6 +363,21 @@ function buildLineEventHandlers(
322363
onBrush({start, end});
323364
}
324365
},
325-
},
326-
];
366+
});
367+
}
368+
369+
if (attrs.onSeriesClick) {
370+
const onSeriesClick = attrs.onSeriesClick;
371+
handlers.push({
372+
eventName: 'click',
373+
handler: (params) => {
374+
const p = params as {seriesName?: string};
375+
if (p.seriesName !== undefined) {
376+
onSeriesClick(p.seriesName);
377+
}
378+
},
379+
});
380+
}
381+
382+
return handlers;
327383
}

ui/src/components/widgets/charts/sankey.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@
1515
import m from 'mithril';
1616
import type {EChartsCoreOption} from 'echarts/core';
1717
import {formatNumber} from './chart_utils';
18-
import {EChartView, EChartEventHandler, EChartClickParams} from './echart_view';
18+
import {
19+
type ThemeColors,
20+
EChartView,
21+
EChartEventHandler,
22+
EChartClickParams,
23+
} from './echart_view';
1924
import {buildTooltipOption} from './chart_option_builder';
2025

2126
export interface SankeyNode {
@@ -63,6 +68,7 @@ export class Sankey implements m.ClassComponent<SankeyChartAttrs> {
6368
className,
6469
empty: isEmpty,
6570
eventHandlers: buildSankeyEventHandlers(attrs, data),
71+
resolveOption: applySankeyTheme,
6672
});
6773
}
6874
}
@@ -131,6 +137,7 @@ function buildSankeyOption(
131137
emphasis: {
132138
focus: 'adjacency',
133139
},
140+
draggable: false,
134141
nodeWidth: 24,
135142
nodeGap: 16,
136143
layoutIterations: 0,
@@ -143,6 +150,28 @@ function buildSankeyOption(
143150
};
144151
}
145152

153+
interface SankeySeries {
154+
type?: string;
155+
label?: {color?: unknown};
156+
}
157+
158+
function applySankeyTheme(
159+
option: EChartsCoreOption,
160+
colors: ThemeColors,
161+
): EChartsCoreOption {
162+
const series = (option as {series?: unknown[]}).series;
163+
if (!Array.isArray(series) || series.length === 0) return option;
164+
165+
const sankey = series[0] as SankeySeries;
166+
if (sankey.type !== 'sankey') return option;
167+
168+
if (sankey.label) {
169+
sankey.label.color = colors.textColor;
170+
}
171+
172+
return option;
173+
}
174+
146175
function buildSankeyEventHandlers(
147176
attrs: SankeyChartAttrs,
148177
data: SankeyData | undefined,

ui/src/components/widgets/datagrid/datagrid.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2300,7 +2300,7 @@ export class DataGrid implements m.ClassComponent<DataGridAttrs> {
23002300
}
23012301
}
23022302

2303-
export function renderCell(value: SqlValue, columnName: string) {
2303+
export function renderCell(value: SqlValue, columnName?: string) {
23042304
if (value === undefined) {
23052305
return '';
23062306
} else if (value instanceof Uint8Array) {
@@ -2310,7 +2310,7 @@ export function renderCell(value: SqlValue, columnName: string) {
23102310
icon: Icons.Download,
23112311
onclick: () =>
23122312
download({
2313-
fileName: `${columnName}.blob`,
2313+
fileName: `${columnName ?? 'untitled'}.bin`,
23142314
content: value,
23152315
}),
23162316
},

ui/src/core/default_plugins.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ export const defaultPlugins = [
6666
'dev.perfetto.InstrumentsSamplesProfile',
6767
'dev.perfetto.KernelTrackEvent',
6868
'dev.perfetto.LinuxPerf',
69+
'dev.perfetto.LiveMemory',
6970
'dev.perfetto.MetricsPage',
7071
'dev.perfetto.Notes',
7172
'dev.perfetto.PowerRails',
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Copyright (C) 2026 The Android Open Source Project
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import m from 'mithril';
16+
import {App} from '../../public/app';
17+
import {PerfettoPlugin} from '../../public/plugin';
18+
import {LiveMemoryPage} from './live_memory_page';
19+
import RecordPageV2 from '../dev.perfetto.RecordTraceV2';
20+
21+
export default class implements PerfettoPlugin {
22+
static readonly id = 'dev.perfetto.LiveMemory';
23+
24+
// The live memory page depends on record page imports only, not at runtime.
25+
static readonly dependencies = [RecordPageV2];
26+
27+
static onActivate(app: App) {
28+
app.sidebar.addMenuItem({
29+
section: 'trace_files',
30+
text: 'Memory Monitor',
31+
href: '#!/memory_monitor',
32+
icon: 'memory',
33+
sortOrder: 2.5,
34+
});
35+
app.pages.registerPage({
36+
route: '/memory_monitor',
37+
render: () => m(LiveMemoryPage, {app}),
38+
});
39+
}
40+
}

0 commit comments

Comments
 (0)