blob: 6c8f2f8d105144ae2e7cfcacbd2badd18d74642a [file] [log] [blame] [edit]
// Copyright (C) 2024 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 {assertExists, assertTrue} from '../base/logging';
import {Monitor} from '../base/monitor';
import {Button, ButtonBar} from './button';
import {Chip} from './chip';
import {Intent} from './common';
import {copyToClipboard} from '../base/clipboard';
import {CopyToClipboardButton} from './copy_to_clipboard_button';
import {EmptyState} from './empty_state';
import {Form, FormLabel} from './form';
import {Icon} from './icon';
import {MiddleEllipsis} from './middle_ellipsis';
import {Popup, PopupPosition} from './popup';
import {Select} from './select';
import {Spinner} from './spinner';
import {SegmentedButtons} from './segmented_buttons';
import {TagInput} from './tag_input';
import {TextInput} from './text_input';
import {Tooltip} from './tooltip';
import {z} from 'zod';
import {Rect2D, Size2D} from '../base/geom';
import {VirtualOverlayCanvas} from './virtual_overlay_canvas';
import {MenuItem, MenuItemAttrs, PopupMenu} from './menu';
import {Color, HSLColor} from '../base/color';
import {hash} from '../base/hash';
import {MithrilEvent} from '../base/mithril_utils';
import {Icons} from '../base/semantic_icons';
const LABEL_FONT_STYLE = '12px Roboto';
const NODE_HEIGHT = 20;
const MIN_PIXEL_DISPLAYED = 3;
const LABEL_PADDING_PX = 5;
const LABEL_MIN_WIDTH_FOR_TEXT_PX = 5;
const PADDING_NODE_COUNT = 8;
interface BaseSource {
readonly queryXStart: number;
readonly queryXEnd: number;
readonly type: 'ABOVE_ROOT' | 'BELOW_ROOT' | 'ROOT';
}
interface MergedSource extends BaseSource {
readonly kind: 'MERGED';
}
interface RootSource extends BaseSource {
readonly kind: 'ROOT';
}
interface NodeSource extends BaseSource {
readonly kind: 'NODE';
readonly queryIdx: number;
}
type Source = MergedSource | NodeSource | RootSource;
interface RenderNode {
readonly x: number;
readonly y: number;
readonly width: number;
readonly source: Source;
readonly state: 'NORMAL' | 'PARTIAL' | 'SELECTED';
}
interface ZoomRegion {
readonly queryXStart: number;
readonly queryXEnd: number;
readonly type: 'ABOVE_ROOT' | 'BELOW_ROOT' | 'ROOT';
}
export interface FlamegraphOptionalAction {
readonly name: string;
execute?: (kv: ReadonlyMap<string, string>) => void;
readonly subActions?: FlamegraphOptionalAction[];
}
export interface FlamegraphOptionalMarker {
readonly name: string;
isVisible: (properties: ReadonlyMap<string, string>) => boolean;
}
export type FlamegraphPropertyDefinition = {
displayName: string;
value: string;
isVisible: boolean;
isAggregatable: boolean;
};
export interface FlamegraphNode {
readonly id: number;
readonly parentId: number;
readonly depth: number;
readonly name: string;
readonly selfValue: number;
readonly cumulativeValue: number;
readonly parentCumulativeValue?: number;
readonly properties: ReadonlyMap<string, FlamegraphPropertyDefinition>;
readonly marker?: string;
readonly xStart: number;
readonly xEnd: number;
}
export interface FlamegraphQueryData {
readonly nodes: ReadonlyArray<FlamegraphNode>;
readonly unfilteredCumulativeValue: number;
readonly allRootsCumulativeValue: number;
readonly minDepth: number;
readonly maxDepth: number;
readonly nodeActions: ReadonlyArray<FlamegraphOptionalAction>;
readonly rootActions: ReadonlyArray<FlamegraphOptionalAction>;
}
const FLAMEGRAPH_FILTER_SCHEMA = z
.object({
kind: z
.union([
z.literal('SHOW_STACK').readonly(),
z.literal('HIDE_STACK').readonly(),
z.literal('SHOW_FROM_FRAME').readonly(),
z.literal('HIDE_FRAME').readonly(),
z.literal('OPTIONS').readonly(),
])
.readonly(),
filter: z.string().readonly(),
})
.readonly();
type FlamegraphFilter = z.infer<typeof FLAMEGRAPH_FILTER_SCHEMA>;
const FLAMEGRAPH_VIEW_SCHEMA = z
.discriminatedUnion('kind', [
z.object({kind: z.literal('TOP_DOWN').readonly()}),
z.object({kind: z.literal('BOTTOM_UP').readonly()}),
z.object({
kind: z.literal('PIVOT').readonly(),
pivot: z.string().readonly(),
}),
])
.readonly();
export type FlamegraphView = z.infer<typeof FLAMEGRAPH_VIEW_SCHEMA>;
export const FLAMEGRAPH_STATE_SCHEMA = z
.object({
selectedMetricName: z.string().readonly(),
filters: z.array(FLAMEGRAPH_FILTER_SCHEMA),
view: FLAMEGRAPH_VIEW_SCHEMA,
})
.readonly();
export type FlamegraphState = z.infer<typeof FLAMEGRAPH_STATE_SCHEMA>;
interface FlamegraphMetric {
readonly name: string;
readonly unit: string;
// Label for the name column in copy stack table and tooltip.
// Examples: "Symbol", "Slice", "Class". Defaults to "Name".
readonly nameColumnLabel?: string;
}
export interface FlamegraphAttrs {
readonly metrics: ReadonlyArray<FlamegraphMetric>;
readonly state: FlamegraphState;
readonly data: FlamegraphQueryData | undefined;
readonly onStateChange: (filters: FlamegraphState) => void;
}
type FilterType =
| 'SHOW_STACK'
| 'HIDE_STACK'
| 'SHOW_FROM_FRAME'
| 'HIDE_FRAME'
| 'PIVOT';
interface FilterTypeOption {
readonly value: FilterType;
readonly label: string;
readonly shortLabel: string;
readonly description: string;
}
const FILTER_TYPES: ReadonlyArray<FilterTypeOption> = [
{
value: 'SHOW_STACK',
label: 'Show Stack',
shortLabel: 'SS',
description:
'Keep only samples whose stack contains a matching frame. ' +
'Non-matching samples are removed entirely.',
},
{
value: 'HIDE_STACK',
label: 'Hide Stack',
shortLabel: 'HS',
description:
'Remove samples whose stack contains a matching frame. ' +
'Also called "Drop function" in other profilers.',
},
{
value: 'SHOW_FROM_FRAME',
label: 'Show From Frame',
shortLabel: 'SFF',
description:
'Keep only matching frames and their descendants, removing ancestors. ' +
'Also called "Focus on subtree" in other profilers.',
},
{
value: 'HIDE_FRAME',
label: 'Hide Frame',
shortLabel: 'HF',
description:
'Remove matching frames from all stacks, collapsing children into parent. ' +
'Also called "Merge function" in other profilers.',
},
{
value: 'PIVOT',
label: 'Pivot',
shortLabel: 'P',
description:
'Re-root the flamegraph at matching frames. ' +
'Shows callers above and callees below the pivot point.',
},
];
interface FilterBuilderAttrs {
onAdd: (filters: Array<{type: FilterType; value: string}>) => void;
hasPivot?: boolean;
}
class FilterBuilder implements m.ClassComponent<FilterBuilderAttrs> {
private type: FilterType = 'SHOW_STACK';
private filter = '';
view({attrs}: m.CVnode<FilterBuilderAttrs>) {
const {onAdd, hasPivot} = attrs;
const opt = FILTER_TYPES.find((o) => o.value === this.type);
return m(
Form,
{
submitLabel: 'Add',
cancelLabel: 'Cancel',
onSubmit: () => {
if (!this.filter.trim()) return;
onAdd([{type: this.type, value: this.filter.trim()}]);
this.filter = '';
},
validation: () => this.filter.trim() !== '',
},
m(FormLabel, 'Type'),
m(
Select,
{
oninput: (e: Event) => {
this.type = (e.target as HTMLSelectElement).value as FilterType;
},
},
FILTER_TYPES.map((o) => m('option', {value: o.value}, o.label)),
),
opt && m('.pf-filter-builder__desc', opt.description),
m(FormLabel, 'Filter'),
m(TextInput, {
autofocus: true,
placeholder: 'e.g. main, alloc.*',
value: this.filter,
onInput: (v) => {
this.filter = v;
},
}),
hasPivot &&
this.type === 'PIVOT' &&
m('.pf-filter-builder__warn', 'Replaces current pivot'),
m('.pf-filter-builder__separator'),
m(
'.pf-filter-builder__tip',
m(Icon, {icon: 'lightbulb_outline'}),
' You can also type directly in the filter bar ',
m(
Tooltip,
{trigger: m(Icon, {icon: 'help_outline'})},
m(
'.pf-filter-builder__help',
m('.pf-filter-builder__help-title', 'Filter bar syntax:'),
FILTER_TYPES.map((o) =>
m(
'.pf-filter-builder__help-row',
m('strong', `${o.shortLabel}:`),
` ${o.label}`,
),
),
m('.pf-filter-builder__help-row', 'Example: SS: main HF: alloc.*'),
),
),
),
);
}
}
/*
* Widget for visualizing "tree-like" data structures using an interactive
* flamegraph visualization.
*
* To use this widget, provide an array of "metrics", which correspond to
* different properties of the tree to switch between (e.g. object size
* and object count) and the data which should be displayed.
*
* Note that it's valid to pass "undefined" as the data: this will cause a
* loading container to be shown.
*
* Example:
*
* ```
* const metrics = [...];
* let state = ...;
* let data = ...;
*
* m(Flamegraph, {
* metrics,
* state,
* data,
* onStateChange: (newState) => {
* state = newState,
* data = undefined;
* fetchData();
* },
* });
* ```
*/
export class Flamegraph implements m.ClassComponent<FlamegraphAttrs> {
private attrs: FlamegraphAttrs;
private showFilterBuilder: boolean = false;
private quickAddValue: string = '';
private dataChangeMonitor = new Monitor([() => this.attrs.data]);
private zoomRegion?: ZoomRegion;
private renderNodesMonitor = new Monitor([
() => this.attrs.data,
() => this.canvasWidth,
() => this.zoomRegion,
]);
private renderNodes?: ReadonlyArray<RenderNode>;
private tooltipPos?: {
x: number;
y: number;
source: Source;
state: 'HOVER' | 'CLICK' | 'DECLICK';
};
private lastClickedNode?: RenderNode;
// Track hovered node by index to avoid redraws when mouse moves within same node.
// We also keep hoveredX/Y so we can re-find the node after render nodes change.
private hoveredNodeIdx?: number;
private hoveredX?: number;
private hoveredY?: number;
private canvasWidth = 0;
private labelCharWidth = 0;
private viewportRect?: Rect2D;
private lastPopupVisible = false;
constructor({attrs}: m.Vnode<FlamegraphAttrs, {}>) {
this.attrs = attrs;
}
view({attrs}: m.Vnode<FlamegraphAttrs, this>): void | m.Children {
this.attrs = attrs;
if (this.dataChangeMonitor.ifStateChanged()) {
this.zoomRegion = undefined;
this.lastClickedNode = undefined;
this.tooltipPos = undefined;
}
if (attrs.data === undefined) {
return m(
'.pf-flamegraph',
this.renderFilterBar(attrs),
m(
'.loading-container',
m(
EmptyState,
{
icon: 'bar_chart',
title: 'Computing graph ...',
className: 'flamegraph-loading',
},
m(Spinner, {easing: true}),
),
),
);
}
const {minDepth, maxDepth} = attrs.data;
const canvasHeight =
Math.max(maxDepth - minDepth + PADDING_NODE_COUNT, PADDING_NODE_COUNT) *
NODE_HEIGHT;
const hoveredNode =
this.hoveredNodeIdx !== undefined
? this.renderNodes?.[this.hoveredNodeIdx]
: undefined;
return m(
'.pf-flamegraph',
this.renderFilterBar(attrs),
m(
VirtualOverlayCanvas,
{
className: 'pf-virtual-canvas',
overflowX: 'hidden',
overflowY: 'auto',
onscroll: (e: MithrilEvent<Event>) => {
// Only redraw if popup visibility would change
if (!this.tooltipPos) {
e.redraw = false;
return;
}
const target = e.target as HTMLElement;
const scrollTop = target.scrollTop;
const clientHeight = target.clientHeight;
const tooltipY = this.tooltipPos.y;
const nowVisible =
tooltipY >= scrollTop && tooltipY <= scrollTop + clientHeight;
if (nowVisible === this.lastPopupVisible) {
e.redraw = false;
}
},
onCanvasRedraw: ({
ctx,
virtualCanvasSize,
canvasRect,
viewportRect,
}) => {
this.drawCanvas(ctx, virtualCanvasSize, canvasRect, viewportRect);
},
},
m(
'div',
{
style: {
height: `${canvasHeight}px`,
cursor: hoveredNode === undefined ? 'default' : 'pointer',
},
onmousemove: (e: MithrilEvent<MouseEvent>) => {
const {offsetX, offsetY} = e;
this.hoveredX = offsetX;
this.hoveredY = offsetY;
const nodeIdx = this.renderNodes?.findIndex((n) =>
isIntersecting(offsetX, offsetY, n),
);
const newHoveredIdx =
nodeIdx !== undefined && nodeIdx !== -1 ? nodeIdx : undefined;
if (newHoveredIdx === this.hoveredNodeIdx) {
e.redraw = false;
return;
}
this.hoveredNodeIdx = newHoveredIdx;
if (this.tooltipPos?.state === 'CLICK') {
return;
}
if (newHoveredIdx === undefined) {
this.tooltipPos = undefined;
return;
}
const renderNode = this.renderNodes![newHoveredIdx];
this.tooltipPos = {
x: offsetX,
y: renderNode.y,
source: renderNode.source,
state: 'HOVER',
};
},
onmouseout: () => {
this.hoveredNodeIdx = undefined;
this.hoveredX = undefined;
this.hoveredY = undefined;
if (
this.tooltipPos?.state === 'HOVER' ||
this.tooltipPos?.state === 'DECLICK'
) {
this.tooltipPos = undefined;
}
},
onclick: ({offsetX, offsetY}: MouseEvent) => {
const renderNode = this.renderNodes?.find((n) =>
isIntersecting(offsetX, offsetY, n),
);
this.lastClickedNode = renderNode;
if (renderNode === undefined) {
this.tooltipPos = undefined;
} else if (
isIntersecting(
this.tooltipPos?.x,
this.tooltipPos?.y,
renderNode,
)
) {
this.tooltipPos!.state =
this.tooltipPos?.state === 'CLICK' ? 'DECLICK' : 'CLICK';
} else {
this.tooltipPos = {
x: offsetX,
y: renderNode.y,
source: renderNode.source,
state: 'CLICK',
};
}
},
ondblclick: ({offsetX, offsetY}: MouseEvent) => {
const renderNode = this.renderNodes?.find((n) =>
isIntersecting(offsetX, offsetY, n),
);
if (renderNode?.source.kind === 'MERGED') {
return;
}
this.zoomRegion = renderNode?.source;
},
},
(() => {
const popupVisible =
this.isPopupAnchorVisible() &&
(this.tooltipPos?.state === 'HOVER' ||
this.tooltipPos?.state === 'CLICK');
this.lastPopupVisible = popupVisible;
return m(
Popup,
{
trigger: m('.popup-anchor', {
style: {
left: this.tooltipPos?.x + 'px',
top: this.tooltipPos?.y + 'px',
},
}),
fitContent: true,
position: PopupPosition.Right,
isOpen: popupVisible,
className: 'pf-flamegraph-tooltip-popup',
offset: NODE_HEIGHT,
},
this.renderTooltip(),
);
})(),
),
),
);
}
static createDefaultState(
metrics: ReadonlyArray<FlamegraphMetric>,
): FlamegraphState {
return {
selectedMetricName: metrics[0].name,
filters: [],
view: {kind: 'TOP_DOWN'},
};
}
/**
* Updates a FlamegraphState with new metrics, preserving filters where possible.
*
* If the current state has no metric selected (empty string), this will
* initialize it with the first metric. Otherwise, it preserves the selected
* metric if it still exists in the new metrics array, or falls back to the
* first metric if it doesn't.
*/
static updateState(
state: FlamegraphState | undefined,
metrics: ReadonlyArray<FlamegraphMetric>,
): FlamegraphState {
if (state === undefined) {
return Flamegraph.createDefaultState(metrics);
}
const metricStillExists = metrics.some(
(m) => m.name === state.selectedMetricName,
);
return {
filters: state.filters,
view: state.view,
selectedMetricName: metricStillExists
? state.selectedMetricName
: metrics[0].name,
};
}
private drawCanvas(
ctx: CanvasRenderingContext2D,
size: Size2D,
canvasRect: Rect2D,
viewportRect: Rect2D,
) {
this.viewportRect = viewportRect;
this.canvasWidth = size.width;
if (this.renderNodesMonitor.ifStateChanged()) {
if (this.attrs.data === undefined) {
this.renderNodes = undefined;
this.lastClickedNode = undefined;
} else {
this.renderNodes = computeRenderNodes(
this.attrs.data,
this.zoomRegion ?? {
queryXStart: 0,
queryXEnd: this.attrs.data.allRootsCumulativeValue,
type: 'ROOT',
},
size.width,
);
this.lastClickedNode = this.renderNodes?.find((n) =>
isIntersecting(this.lastClickedNode?.x, this.lastClickedNode?.y, n),
);
}
this.tooltipPos = undefined;
// Re-find hovered node using stored coordinates
const nodeIdx = this.renderNodes?.findIndex((n) =>
isIntersecting(this.hoveredX, this.hoveredY, n),
);
this.hoveredNodeIdx =
nodeIdx !== undefined && nodeIdx !== -1 ? nodeIdx : undefined;
}
if (this.attrs.data === undefined || this.renderNodes === undefined) {
return;
}
const yStart = canvasRect.top;
const yEnd = canvasRect.bottom;
const {allRootsCumulativeValue, unfilteredCumulativeValue, nodes} =
this.attrs.data;
const unit = assertExists(this.selectedMetric).unit;
ctx.font = LABEL_FONT_STYLE;
ctx.textBaseline = 'middle';
ctx.strokeStyle = 'white';
ctx.lineWidth = 0.5;
if (this.labelCharWidth === 0) {
this.labelCharWidth = ctx.measureText('_').width;
}
for (let i = 0; i < this.renderNodes.length; i++) {
const node = this.renderNodes[i];
const {x, y, width: width, source, state} = node;
if (y + NODE_HEIGHT <= yStart || y >= yEnd) {
continue;
}
const hover = isIntersecting(this.hoveredX, this.hoveredY, node);
let name: string;
let colorScheme;
if (source.kind === 'ROOT') {
const val = displaySize(allRootsCumulativeValue, unit);
const percent = displayPercentage(
allRootsCumulativeValue,
unfilteredCumulativeValue,
);
name = `root: ${val} (${percent})`;
colorScheme = getFlamegraphColorScheme('root', state === 'PARTIAL');
} else if (source.kind === 'MERGED') {
name = '(merged)';
colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
} else {
name = nodes[source.queryIdx].name;
colorScheme = getFlamegraphColorScheme(name, state === 'PARTIAL');
}
const bgColor = hover ? colorScheme.variant : colorScheme.base;
const textColor = hover ? colorScheme.textVariant : colorScheme.textBase;
ctx.fillStyle = bgColor.cssString;
ctx.fillRect(x, y, width - 1, NODE_HEIGHT - 1);
// Render marker
const MARKER_SIZE = 3;
const MARKER_LEFT_MARGIN = 2;
const MIN_WIDTH_FOR_MARKER = 15; // Don't show marker on very small nodes
const hasMarker =
source.kind === 'NODE' &&
nodes[source.queryIdx].marker !== undefined &&
width >= MIN_WIDTH_FOR_MARKER;
if (hasMarker) {
ctx.fillStyle = textColor.cssString;
const markerX = x + MARKER_LEFT_MARGIN;
const markerY = y + 2; // Position at top of node with small margin
ctx.fillRect(markerX, markerY, MARKER_SIZE, MARKER_SIZE);
}
// Text positioning - no need to reserve space since marker is in top corner
const widthNoPadding = width - LABEL_PADDING_PX * 2;
if (widthNoPadding >= LABEL_MIN_WIDTH_FOR_TEXT_PX) {
ctx.fillStyle = textColor.cssString;
ctx.fillText(
name.substring(0, widthNoPadding / this.labelCharWidth),
x + LABEL_PADDING_PX,
y + (NODE_HEIGHT - 1) / 2,
widthNoPadding,
);
}
if (this.lastClickedNode?.x === x && this.lastClickedNode?.y === y) {
ctx.strokeStyle = 'blue';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x + width, y);
ctx.lineTo(x + width, y + NODE_HEIGHT - 1);
ctx.lineTo(x, y + NODE_HEIGHT - 1);
ctx.lineTo(x, y);
ctx.stroke();
ctx.strokeStyle = 'white';
ctx.lineWidth = 0.5;
}
}
}
private isPopupAnchorVisible(): boolean {
if (!this.tooltipPos || !this.viewportRect) {
return false;
}
const {y} = this.tooltipPos;
return y >= this.viewportRect.top && y <= this.viewportRect.bottom;
}
private renderFilterBar(attrs: FlamegraphAttrs) {
const tags = toTags(this.attrs.state);
const hasPivot = this.attrs.state.view.kind === 'PIVOT';
const hasFilters = tags.length > 0;
const removeTag = (i: number) => {
if (i === this.attrs.state.filters.length) {
this.attrs.onStateChange({
...this.attrs.state,
view: {kind: 'TOP_DOWN'},
});
} else {
const filters = this.attrs.state.filters.filter((_, j) => j !== i);
this.attrs.onStateChange({...this.attrs.state, filters});
}
};
const addFilterFn = (filters: Array<{type: FilterType; value: string}>) => {
let newState = this.attrs.state;
for (const {type, value} of filters) {
if (type === 'PIVOT') {
newState = {...newState, view: {kind: 'PIVOT', pivot: value}};
} else {
newState = addFilter(newState, {kind: type, filter: value});
}
}
this.attrs.onStateChange(newState);
};
return m(
'.filter-bar',
m(
Select,
{
value: attrs.state.selectedMetricName,
onchange: (e: Event) => {
const el = e.target as HTMLSelectElement;
attrs.onStateChange({
...this.attrs.state,
selectedMetricName: el.value,
});
},
},
attrs.metrics.map((x) => {
return m('option', {value: x.name}, x.name);
}),
),
m('.pf-flamegraph-filter-bar-separator'),
m('span.pf-flamegraph-filter-label', 'Filters:'),
// Tag input: chips + text input combined
m(TagInput, {
tags,
value: this.quickAddValue,
onChange: (text) => {
this.quickAddValue = text;
},
onTagAdd: (text) => {
const filters = splitFilters(text).map((part) => parseFilter(part));
if (filters.length > 0) {
addFilterFn(filters);
this.quickAddValue = '';
}
},
onTagRemove: removeTag,
placeholder: hasFilters ? '' : 'e.g. SS: main HF: alloc.*',
renderTag: (text, onRemove) =>
m(Chip, {
ondblclick: () => {
this.quickAddValue = text;
onRemove();
},
label: m(MiddleEllipsis, {text}),
removable: true,
compact: true,
intent: Intent.Primary,
onRemove,
}),
}),
// [+] button opens guided form dialog
m(
Popup,
{
trigger: m(Button, {
icon: Icons.Add,
compact: true,
active: this.showFilterBuilder,
onclick: () => {
this.showFilterBuilder = !this.showFilterBuilder;
},
}),
isOpen: this.showFilterBuilder,
onChange: (shouldOpen: boolean) => {
this.showFilterBuilder = shouldOpen;
},
position: PopupPosition.RightStart,
closeOnOutsideClick: true,
closeOnEscape: true,
className: 'pf-filter-builder',
},
m(FilterBuilder, {onAdd: addFilterFn, hasPivot}),
),
m(CopyToClipboardButton(), {
textToCopy: () => tags.join(' '),
compact: true,
disabled: !hasFilters,
}),
m(Button, {
icon: 'delete',
compact: true,
disabled: !hasFilters,
onclick: () => {
attrs.onStateChange({
...this.attrs.state,
filters: [],
view:
this.attrs.state.view.kind === 'PIVOT'
? {kind: 'TOP_DOWN'}
: this.attrs.state.view,
});
},
}),
m('.pf-flamegraph-filter-bar-separator'),
m(SegmentedButtons, {
options: [{label: 'Top Down'}, {label: 'Bottom Up'}],
selectedOption: this.attrs.state.view.kind === 'TOP_DOWN' ? 0 : 1,
onOptionSelected: (num) => {
this.attrs.onStateChange({
...this.attrs.state,
view: {kind: num === 0 ? 'TOP_DOWN' : 'BOTTOM_UP'},
});
},
disabled: this.attrs.state.view.kind === 'PIVOT',
}),
);
}
private renderTooltip() {
if (this.tooltipPos === undefined) {
return undefined;
}
const {source} = this.tooltipPos;
if (source.kind === 'MERGED') {
return m(
'div',
m('.tooltip-bold-text', '(merged)'),
m('.tooltip-text', 'Nodes too small to show, please use filters'),
);
}
const {
nodes,
allRootsCumulativeValue,
unfilteredCumulativeValue,
nodeActions,
rootActions,
} = assertExists(this.attrs.data);
const {unit, nameColumnLabel} = assertExists(this.selectedMetric);
if (source.kind === 'ROOT') {
const val = displaySize(allRootsCumulativeValue, unit);
const percent = displayPercentage(
allRootsCumulativeValue,
unfilteredCumulativeValue,
);
return m(
'div',
m('.tooltip-bold-text', 'root'),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', 'Cumulative:'),
m('.tooltip-text', `${val}, ${percent}`),
this.renderActionsMenu(rootActions, new Map()),
),
);
}
const {queryIdx} = source;
const {
name,
cumulativeValue,
selfValue,
parentCumulativeValue,
properties,
marker,
} = nodes[queryIdx];
const filterButtonClick = (state: FlamegraphState) => {
this.attrs.onStateChange(state);
this.tooltipPos = undefined;
};
const percent = displayPercentage(
cumulativeValue,
unfilteredCumulativeValue,
);
const selfPercent = displayPercentage(selfValue, unfilteredCumulativeValue);
let percentText = `all: ${percent}`;
let selfPercentText = `all: ${selfPercent}`;
if (parentCumulativeValue !== undefined) {
const parentPercent = displayPercentage(
cumulativeValue,
parentCumulativeValue,
);
percentText += `, parent: ${parentPercent}`;
const parentSelfPercent = displayPercentage(
selfValue,
parentCumulativeValue,
);
selfPercentText += `, parent: ${parentSelfPercent}`;
}
const nameLabel = nameColumnLabel ?? 'Name';
return m(
'div',
// Show marker at the top of the tooltip
marker &&
m('.tooltip-text-line', m('.tooltip-marker-text', `â–  ${marker}`)),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', `${nameLabel}:`),
m('.tooltip-text', name),
),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', 'Cumulative:'),
m(
'.tooltip-text',
`${displaySize(cumulativeValue, unit)} (${percentText})`,
),
),
m(
'.tooltip-text-line',
m('.tooltip-bold-text', 'Self:'),
m(
'.tooltip-text',
`${displaySize(selfValue, unit)} (${selfPercentText})`,
),
),
Array.from(properties, ([_, value]) => {
if (value.isVisible) {
return m(
'.tooltip-text-line',
m('.tooltip-bold-text', value.displayName + ':'),
m('.tooltip-text', value.value),
);
}
return null;
}),
m(
ButtonBar,
{},
m(Button, {
label: 'Zoom',
onclick: () => {
this.zoomRegion = source;
},
}),
m(Button, {
label: 'Show Stack',
onclick: () => {
filterButtonClick(
addFilter(this.attrs.state, {
kind: 'SHOW_STACK',
filter: `^${name}$`,
}),
);
},
}),
m(Button, {
label: 'Hide Stack',
onclick: () => {
filterButtonClick(
addFilter(this.attrs.state, {
kind: 'HIDE_STACK',
filter: `^${name}$`,
}),
);
},
}),
m(Button, {
label: 'Hide Frame',
onclick: () => {
filterButtonClick(
addFilter(this.attrs.state, {
kind: 'HIDE_FRAME',
filter: `^${name}$`,
}),
);
},
}),
m(Button, {
label: 'Show From Frame',
onclick: () => {
filterButtonClick(
addFilter(this.attrs.state, {
kind: 'SHOW_FROM_FRAME',
filter: `^${name}$`,
}),
);
},
}),
m(Button, {
label: 'Pivot',
onclick: () => {
filterButtonClick({
...this.attrs.state,
view: {kind: 'PIVOT', pivot: `^${name}$`},
});
},
}),
this.renderActionsMenu(nodeActions, properties, nodes[queryIdx]),
),
);
}
private get selectedMetric() {
return this.attrs.metrics.find(
(x) => x.name === this.attrs.state.selectedMetricName,
);
}
private renderActionsMenu(
actions: ReadonlyArray<FlamegraphOptionalAction>,
properties: ReadonlyMap<string, FlamegraphPropertyDefinition>,
node?: FlamegraphNode,
) {
if (actions.length === 0 && node === undefined) {
return null;
}
return m(
PopupMenu,
{
trigger: m(Button, {
icon: 'menu',
compact: true,
}),
position: PopupPosition.Bottom,
},
node !== undefined &&
m(MenuItem, {
label: 'Copy Stack',
icon: Icons.Copy,
onclick: () => {
copyToClipboard(this.buildStackString(node, false));
},
}),
node !== undefined &&
m(MenuItem, {
label: 'Copy Stack With Details',
icon: Icons.Copy,
onclick: () => {
copyToClipboard(this.buildStackString(node, true));
},
}),
actions.map((action) => this.renderMenuItem(action, properties)),
);
}
private renderMenuItem(
action: FlamegraphOptionalAction,
properties: ReadonlyMap<string, FlamegraphPropertyDefinition>,
): m.Vnode<MenuItemAttrs> {
if (action.subActions !== undefined && action.subActions.length > 0) {
return this.renderParentMenuItem(action, action.subActions, properties);
} else if (action.execute) {
return this.renderExecutableMenuItem(action, properties);
} else {
return this.renderDisabledMenuItem(action);
}
}
private renderParentMenuItem(
action: FlamegraphOptionalAction,
subActions: FlamegraphOptionalAction[],
properties: ReadonlyMap<string, FlamegraphPropertyDefinition>,
): m.Vnode<MenuItemAttrs> {
return m(
MenuItem,
{
label: action.name,
// No onclick handler for parent menu items
},
// Directly render sub-actions as children of the MenuItem
subActions.map((subAction) => this.renderMenuItem(subAction, properties)),
);
}
private renderExecutableMenuItem(
action: FlamegraphOptionalAction,
properties: ReadonlyMap<string, FlamegraphPropertyDefinition>,
): m.Vnode<MenuItemAttrs> {
return m(MenuItem, {
label: action.name,
onclick: () => {
const reducedProperties = this.createReducedProperties(properties);
action.execute!(reducedProperties);
this.tooltipPos = undefined; // Close tooltip after action
},
});
}
private renderDisabledMenuItem(
action: FlamegraphOptionalAction,
): m.Vnode<MenuItemAttrs> {
return m(MenuItem, {
label: action.name,
disabled: true,
});
}
private buildStackString(node: FlamegraphNode, withDetails: boolean): string {
const {nodes, unfilteredCumulativeValue} = assertExists(this.attrs.data);
const metric = assertExists(this.selectedMetric);
const view = this.attrs.state.view;
// Walk via parentId for all modes. Reverse for TOP_DOWN and PIVOT below.
const stack: FlamegraphNode[] = [];
let currentId = node.id;
while (currentId !== -1) {
const current = assertExists(nodes.find((n) => n.id === currentId));
stack.push(current);
currentId = current.parentId;
}
const shouldReverse =
view.kind === 'TOP_DOWN' || (view.kind === 'PIVOT' && node.depth > 0);
if (shouldReverse) {
stack.reverse();
}
if (!withDetails) {
return stack.map((n) => n.name).join('\n');
}
// Collect all unique property keys, separated by aggregatable status
const unaggKeys: string[] = [];
const aggKeys: string[] = [];
for (const entry of stack) {
for (const [key, prop] of entry.properties) {
if (prop.isAggregatable) {
if (!aggKeys.includes(key)) {
aggKeys.push(key);
}
} else {
if (!unaggKeys.includes(key)) {
unaggKeys.push(key);
}
}
}
}
// Helper to get display name for a property key
const getDisplayName = (key: string): string => {
for (const entry of stack) {
const prop = entry.properties.get(key);
if (prop !== undefined) {
return prop.displayName;
}
}
return key;
};
// Build header: Name | Non-agg props | Cumulative | Self | Agg props
const nameLabel = metric.nameColumnLabel ?? 'Name';
const unitDisplay = getUnitDisplayName(metric.unit);
const headers = [nameLabel];
for (const key of unaggKeys) {
headers.push(getDisplayName(key));
}
headers.push(
`Cumulative ${metric.name} (${unitDisplay})`,
`Self ${metric.name} (${unitDisplay})`,
);
for (const key of aggKeys) {
headers.push(getDisplayName(key));
}
// Format as markdown table
const lines: string[] = [];
lines.push('| ' + headers.join(' | ') + ' |');
lines.push('|' + headers.map(() => '------').join('|') + '|');
for (const entry of stack) {
const cumulative = displaySize(entry.cumulativeValue, metric.unit);
const cumulativePercent = displayPercentage(
entry.cumulativeValue,
unfilteredCumulativeValue,
);
const self = displaySize(entry.selfValue, metric.unit);
const selfPercent = displayPercentage(
entry.selfValue,
unfilteredCumulativeValue,
);
const cols = [entry.name];
for (const key of unaggKeys) {
cols.push(entry.properties.get(key)?.value ?? '');
}
cols.push(
`${cumulative} (${cumulativePercent})`,
`${self} (${selfPercent})`,
);
for (const key of aggKeys) {
cols.push(entry.properties.get(key)?.value ?? '');
}
lines.push('| ' + cols.join(' | ') + ' |');
}
return lines.join('\n');
}
private createReducedProperties(
properties: ReadonlyMap<string, FlamegraphPropertyDefinition>,
): ReadonlyMap<string, string> {
return new Map([...properties].map(([key, {value}]) => [key, value]));
}
}
function computeRenderNodes(
{nodes, allRootsCumulativeValue, minDepth}: FlamegraphQueryData,
zoomRegion: ZoomRegion,
canvasWidth: number,
): ReadonlyArray<RenderNode> {
const renderNodes: RenderNode[] = [];
const mergedKeyToX = new Map<string, number>();
const keyToChildMergedIdx = new Map<string, number>();
renderNodes.push({
x: 0,
y: -minDepth * NODE_HEIGHT,
width: canvasWidth,
source: {
kind: 'ROOT',
queryXStart: 0,
queryXEnd: allRootsCumulativeValue,
type: 'ROOT',
},
state:
zoomRegion.queryXStart === 0 &&
zoomRegion.queryXEnd === allRootsCumulativeValue
? 'NORMAL'
: 'PARTIAL',
});
const zoomQueryWidth = zoomRegion.queryXEnd - zoomRegion.queryXStart;
for (let i = 0; i < nodes.length; i++) {
const {id, parentId, depth, xStart: qXStart, xEnd: qXEnd} = nodes[i];
assertTrue(depth !== 0);
const depthMatchingZoom = isDepthMatchingZoom(depth, zoomRegion);
if (
depthMatchingZoom &&
(qXEnd <= zoomRegion.queryXStart || qXStart >= zoomRegion.queryXEnd)
) {
continue;
}
const queryXPerPx = depthMatchingZoom
? zoomQueryWidth / canvasWidth
: allRootsCumulativeValue / canvasWidth;
const relativeXStart = depthMatchingZoom
? qXStart - zoomRegion.queryXStart
: qXStart;
const relativeXEnd = depthMatchingZoom
? qXEnd - zoomRegion.queryXStart
: qXEnd;
const relativeWidth = relativeXEnd - relativeXStart;
const x = Math.max(0, relativeXStart) / queryXPerPx;
const y = NODE_HEIGHT * (depth - minDepth);
const width = depthMatchingZoom
? Math.min(relativeWidth, zoomQueryWidth) / queryXPerPx
: relativeWidth / queryXPerPx;
const state = computeState(qXStart, qXEnd, zoomRegion, depthMatchingZoom);
if (width < MIN_PIXEL_DISPLAYED) {
// Check if parent was merged - if so, use x-position-based key so that
// children of different parents that were merged together also merge.
// This enables recursive merging: if parents A and B merged into the
// same visual node, their children should also merge together.
const parentMergedX = mergedKeyToX.get(`${parentId}_${depth}`);
const parentChildMergeKey =
parentMergedX !== undefined
? `x_${Math.round(parentMergedX)}_${depth}`
: `p_${parentId}_${depth}`;
const mergedXKey = `${id}_${depth > 0 ? depth + 1 : depth - 1}`;
const childMergedIdx = keyToChildMergedIdx.get(parentChildMergeKey);
if (childMergedIdx !== undefined) {
const r = renderNodes[childMergedIdx];
const mergedWidth = isDepthMatchingZoom(depth, zoomRegion)
? Math.min(qXEnd - r.source.queryXStart, zoomQueryWidth) / queryXPerPx
: (qXEnd - r.source.queryXStart) / queryXPerPx;
renderNodes[childMergedIdx] = {
...r,
width: Math.max(mergedWidth, MIN_PIXEL_DISPLAYED),
source: {
...(r.source as MergedSource),
queryXEnd: qXEnd,
},
};
mergedKeyToX.set(mergedXKey, r.x);
continue;
}
const mergedX = parentMergedX ?? x;
renderNodes.push({
x: mergedX,
y,
width: Math.max(width, MIN_PIXEL_DISPLAYED),
source: {
kind: 'MERGED',
queryXStart: qXStart,
queryXEnd: qXEnd,
type: depth > 0 ? 'BELOW_ROOT' : 'ABOVE_ROOT',
},
state,
});
keyToChildMergedIdx.set(parentChildMergeKey, renderNodes.length - 1);
mergedKeyToX.set(mergedXKey, mergedX);
continue;
}
renderNodes.push({
x,
y,
width,
source: {
kind: 'NODE',
queryXStart: qXStart,
queryXEnd: qXEnd,
queryIdx: i,
type: depth > 0 ? 'BELOW_ROOT' : 'ABOVE_ROOT',
},
state,
});
}
return renderNodes;
}
function isDepthMatchingZoom(depth: number, zoomRegion: ZoomRegion): boolean {
assertTrue(
depth !== 0,
'Handling zooming root not possible in this function',
);
return (
(depth > 0 && zoomRegion.type === 'BELOW_ROOT') ||
(depth < 0 && zoomRegion.type === 'ABOVE_ROOT')
);
}
function computeState(
qXStart: number,
qXEnd: number,
zoomRegion: ZoomRegion,
isDepthMatchingZoom: boolean,
) {
if (!isDepthMatchingZoom) {
return 'NORMAL';
}
if (qXStart === zoomRegion.queryXStart && qXEnd === zoomRegion.queryXEnd) {
return 'SELECTED';
}
if (qXStart < zoomRegion.queryXStart || qXEnd > zoomRegion.queryXEnd) {
return 'PARTIAL';
}
return 'NORMAL';
}
function isIntersecting(
needleX: number | undefined,
needleY: number | undefined,
{x, y, width}: RenderNode,
) {
if (needleX === undefined || needleY === undefined) {
return false;
}
return (
needleX >= x &&
needleX < x + width &&
needleY >= y &&
needleY < y + NODE_HEIGHT
);
}
function displaySize(totalSize: number, unit: string): string {
if (unit === '' || unit === 'count') return totalSize.toLocaleString();
if (totalSize === 0) return `0 ${unit}`;
let step: number;
let units: string[];
switch (unit) {
case 'B':
step = 1024;
units = ['B', 'KiB', 'MiB', 'GiB'];
break;
case 'ns':
step = 1000;
units = ['ns', 'us', 'ms', 's'];
break;
default:
step = 1000;
units = [unit, `K${unit}`, `M${unit}`, `G${unit}`];
break;
}
const unitsIndex = Math.min(
Math.trunc(Math.log(totalSize) / Math.log(step)),
units.length - 1,
);
const pow = Math.pow(step, unitsIndex);
const result = totalSize / pow;
const resultString =
totalSize % pow === 0 ? result.toString() : result.toFixed(2);
return `${resultString} ${units[unitsIndex]}`;
}
function displayPercentage(size: number, totalSize: number): string {
if (totalSize === 0) {
return `[NULL]%`;
}
return `${((size / totalSize) * 100.0).toFixed(2)}%`;
}
function getUnitDisplayName(unit: string | undefined): string {
if (unit === undefined || unit === '' || unit === 'count') {
return 'count';
}
return unit;
}
function toTags(state: FlamegraphState): ReadonlyArray<string> {
const toString = (x: FlamegraphFilter) => {
switch (x.kind) {
case 'HIDE_FRAME':
return 'Hide Frame: ' + x.filter;
case 'HIDE_STACK':
return 'Hide Stack: ' + x.filter;
case 'SHOW_FROM_FRAME':
return 'Show From Frame: ' + x.filter;
case 'SHOW_STACK':
return 'Show Stack: ' + x.filter;
case 'OPTIONS':
return 'Options';
}
};
const filters = state.filters.map((x) => toString(x));
return filters.concat(
state.view.kind === 'PIVOT' ? ['Pivot: ' + state.view.pivot] : [],
);
}
function addFilter(
state: FlamegraphState,
filter: FlamegraphFilter,
): FlamegraphState {
return {
...state,
filters: state.filters.concat([filter]),
};
}
// Split text into individual filters by finding filter type prefixes
// e.g. 'Show Stack: main Hide Frame: alloc' -> ['Show Stack: main', 'Hide Frame: alloc']
// e.g. 'SS: foo HF: bar' -> ['SS: foo', 'HF: bar']
function splitFilters(text: string): string[] {
const lowerText = text.toLowerCase();
// Find all positions where a filter prefix starts (case insensitive)
const splitPositions: number[] = [];
for (const type of FILTER_TYPES) {
for (const prefix of [type.shortLabel, type.label]) {
const searchStr = prefix.toLowerCase() + ':';
let pos = 0;
while ((pos = lowerText.indexOf(searchStr, pos)) !== -1) {
// Only split if at start or preceded by whitespace
if (pos === 0 || /\s/.test(text[pos - 1])) {
splitPositions.push(pos);
}
pos += searchStr.length;
}
}
}
// Sort and deduplicate positions
splitPositions.sort((a, b) => a - b);
// If no prefixes found, return the whole text as one filter
if (splitPositions.length === 0) {
return text.trim() ? [text.trim()] : [];
}
// Split text at those positions
const result: string[] = [];
for (let i = 0; i < splitPositions.length; i++) {
const start = splitPositions[i];
const end = splitPositions[i + 1] ?? text.length;
const part = text.substring(start, end).trim();
if (part) {
result.push(part);
}
}
return result;
}
// Parse a filter string into type and value
// e.g. 'SS: main' -> {type: 'SHOW_STACK', value: 'main'}
// e.g. 'Show Stack: main' -> {type: 'SHOW_STACK', value: 'main'}
function parseFilter(
text: string,
defaultType: FilterType = 'SHOW_STACK',
): {type: FilterType; value: string} {
const i = text.indexOf(':');
if (i === -1) return {type: defaultType, value: text};
const prefix = text.substring(0, i).trim().toLowerCase();
const value = text.substring(i + 1).trim();
const match = FILTER_TYPES.find(
(o) =>
o.shortLabel.toLowerCase() === prefix || o.label.toLowerCase() === prefix,
);
return match ? {type: match.value, value} : {type: defaultType, value: text};
}
// Unfortunately, widgets *cannot* depend on components so we cannot use the
// colorizer code. Since we need very little of that code anyway, just inline
// what we need here.
const PERCEIVED_BRIGHTNESS_LIMIT = 180;
const WHITE_COLOR = new HSLColor([0, 0, 100]);
const BLACK_COLOR = new HSLColor([0, 0, 0]);
// Lightness 85 ensures even darken(10) stays above brightness threshold for black text
const GRAY_VARIANT_COLOR = new HSLColor([0, 0, 85]);
interface ColorScheme {
readonly base: Color;
readonly variant: Color;
readonly textBase: Color;
readonly textVariant: Color;
}
function makeColorScheme(base: Color, variant: Color): ColorScheme {
// Use the same text color for both base and variant to prevent text color
// switching on hover. The text color is determined by the base color only.
const textColor =
base.perceivedBrightness >= PERCEIVED_BRIGHTNESS_LIMIT
? BLACK_COLOR
: WHITE_COLOR;
return {
base,
variant,
textBase: textColor,
textVariant: textColor,
};
}
// Pre-computed color schemes for special cases
const GREYED_COLOR_SCHEME = makeColorScheme(
GRAY_VARIANT_COLOR,
GRAY_VARIANT_COLOR.darken(5),
);
const ROOT_COLOR_SCHEME = makeColorScheme(
GRAY_VARIANT_COLOR.darken(10),
GRAY_VARIANT_COLOR.darken(15),
);
// Cache for computed color schemes by name
const colorSchemeCache = new Map<string, ColorScheme>();
function getFlamegraphColorScheme(name: string, greyed: boolean): ColorScheme {
if (greyed) {
return GREYED_COLOR_SCHEME;
}
if (name === 'unknown' || name === 'root') {
return ROOT_COLOR_SCHEME;
}
// Check cache first
let scheme = colorSchemeCache.get(name);
if (scheme !== undefined) {
return scheme;
}
// Hash the name to get a predictable hue, then create color with fixed
// saturation and lightness values to match what pprof web UI does.
const hue = hash(name, 360);
const base = new HSLColor({h: hue, s: 46, l: 80});
scheme = makeColorScheme(base, base.darken(15).saturate(15));
colorSchemeCache.set(name, scheme);
return scheme;
}