mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Flamegraph: Add collapsing for similar items in the stack (#77461)
This commit is contained in:
parent
6a5de14ed1
commit
494a07b522
@ -163,6 +163,7 @@ Experimental features might be changed or removed without prior notice.
|
||||
| `extractFieldsNameDeduplication` | Make sure extracted field names are unique in the dataframe |
|
||||
| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles |
|
||||
| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards |
|
||||
| `flameGraphItemCollapsing` | Allow collapsing of flame graph items |
|
||||
|
||||
## Development feature toggles
|
||||
|
||||
|
@ -160,4 +160,5 @@ export interface FeatureToggles {
|
||||
pdfTables?: boolean;
|
||||
ssoSettingsApi?: boolean;
|
||||
logsInfiniteScrolling?: boolean;
|
||||
flameGraphItemCollapsing?: boolean;
|
||||
}
|
||||
|
@ -25,7 +25,7 @@ jest.mock('react-use', () => {
|
||||
describe('FlameGraph', () => {
|
||||
function setup() {
|
||||
const flameGraphData = createDataFrame(data);
|
||||
const container = new FlameGraphDataContainer(flameGraphData);
|
||||
const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true });
|
||||
|
||||
const setRangeMin = jest.fn();
|
||||
const setRangeMax = jest.fn();
|
||||
|
@ -17,7 +17,7 @@
|
||||
// TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
|
||||
// THIS SOFTWARE.
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import { Icon } from '@grafana/ui';
|
||||
|
||||
@ -26,7 +26,7 @@ import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../typ
|
||||
|
||||
import FlameGraphCanvas from './FlameGraphCanvas';
|
||||
import FlameGraphMetadata from './FlameGraphMetadata';
|
||||
import { FlameGraphDataContainer } from './dataTransform';
|
||||
import { CollapsedMap, FlameGraphDataContainer } from './dataTransform';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
@ -44,6 +44,7 @@ type Props = {
|
||||
onFocusPillClick: () => void;
|
||||
onSandwichPillClick: () => void;
|
||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
||||
collapsing?: boolean;
|
||||
};
|
||||
|
||||
const FlameGraph = ({
|
||||
@ -61,9 +62,17 @@ const FlameGraph = ({
|
||||
onFocusPillClick,
|
||||
onSandwichPillClick,
|
||||
colorScheme,
|
||||
collapsing,
|
||||
}: Props) => {
|
||||
const styles = getStyles();
|
||||
|
||||
const [collapsedMap, setCollapsedMap] = useState<CollapsedMap>(data ? data.getCollapsedMap() : new Map());
|
||||
useEffect(() => {
|
||||
if (data) {
|
||||
setCollapsedMap(data.getCollapsedMap());
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
const [levels, levelsCallers, totalProfileTicks, totalProfileTicksRight, totalViewTicks] = useMemo(() => {
|
||||
let levels = data.getLevels();
|
||||
let totalProfileTicks = levels.length ? levels[0][0].value : 0;
|
||||
@ -96,6 +105,9 @@ const FlameGraph = ({
|
||||
totalProfileTicks,
|
||||
totalProfileTicksRight,
|
||||
totalViewTicks,
|
||||
collapsedMap,
|
||||
setCollapsedMap,
|
||||
collapsing,
|
||||
};
|
||||
const canvas = levelsCallers ? (
|
||||
<>
|
||||
@ -109,6 +121,8 @@ const FlameGraph = ({
|
||||
root={levelsCallers[levelsCallers.length - 1][0]}
|
||||
depth={levelsCallers.length}
|
||||
direction={'parents'}
|
||||
// We do not support collapsing in sandwich view for now.
|
||||
collapsing={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -117,7 +131,13 @@ const FlameGraph = ({
|
||||
<Icon className={styles.sandwichMarkerIcon} name={'arrow-up'} />
|
||||
Callees
|
||||
</div>
|
||||
<FlameGraphCanvas {...commonCanvasProps} root={levels[0][0]} depth={levels.length} direction={'children'} />
|
||||
<FlameGraphCanvas
|
||||
{...commonCanvasProps}
|
||||
root={levels[0][0]}
|
||||
depth={levels.length}
|
||||
direction={'children'}
|
||||
collapsing={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
|
@ -0,0 +1,40 @@
|
||||
import { convertPixelCoordinatesToBarCoordinates } from './FlameGraphCanvas';
|
||||
import { textToDataContainer } from './testHelpers';
|
||||
|
||||
describe('convertPixelCoordinatesToBarCoordinates', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0///////////]
|
||||
[1][3//][4///]
|
||||
[2] [5///]
|
||||
[6]
|
||||
`)!;
|
||||
const root = container.getLevels()[0][0];
|
||||
const testPosFn = (pos: { x: number; y: number }) => {
|
||||
return convertPixelCoordinatesToBarCoordinates(
|
||||
pos,
|
||||
root,
|
||||
'children',
|
||||
container.getLevels().length,
|
||||
1,
|
||||
14,
|
||||
0,
|
||||
container.getCollapsedMap()
|
||||
)!;
|
||||
};
|
||||
|
||||
it('returns correct item', () => {
|
||||
expect(testPosFn({ x: 4, y: 23 })!.itemIndexes[0]).toEqual(3);
|
||||
});
|
||||
|
||||
it('returns no item when pointing to collapsed item', () => {
|
||||
expect(testPosFn({ x: 1, y: 45 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns item when pointing to first collapsed item', () => {
|
||||
expect(testPosFn({ x: 1, y: 23 })!.itemIndexes[0]).toEqual(1);
|
||||
});
|
||||
|
||||
it('returns correct shifted item because of collapsing', () => {
|
||||
expect(testPosFn({ x: 9, y: 45 })!.itemIndexes[0]).toEqual(6);
|
||||
});
|
||||
});
|
@ -7,7 +7,7 @@ import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../typ
|
||||
|
||||
import FlameGraphContextMenu from './FlameGraphContextMenu';
|
||||
import FlameGraphTooltip from './FlameGraphTooltip';
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { getBarX, useFlameRender } from './rendering';
|
||||
|
||||
type Props = {
|
||||
@ -32,6 +32,10 @@ type Props = {
|
||||
totalProfileTicks: number;
|
||||
totalProfileTicksRight?: number;
|
||||
totalViewTicks: number;
|
||||
|
||||
collapsedMap: CollapsedMap;
|
||||
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
|
||||
collapsing?: boolean;
|
||||
};
|
||||
|
||||
const FlameGraphCanvas = ({
|
||||
@ -52,6 +56,9 @@ const FlameGraphCanvas = ({
|
||||
root,
|
||||
direction,
|
||||
depth,
|
||||
collapsedMap,
|
||||
setCollapsedMap,
|
||||
collapsing,
|
||||
}: Props) => {
|
||||
const styles = getStyles();
|
||||
|
||||
@ -78,6 +85,7 @@ const FlameGraphCanvas = ({
|
||||
totalColorTicks: data.isDiffFlamegraph() ? totalProfileTicks : totalViewTicks,
|
||||
totalTicksRight: totalProfileTicksRight,
|
||||
wrapperWidth,
|
||||
collapsedMap,
|
||||
});
|
||||
|
||||
const onGraphClick = useCallback(
|
||||
@ -91,7 +99,8 @@ const FlameGraphCanvas = ({
|
||||
depth,
|
||||
pixelsPerTick,
|
||||
totalViewTicks,
|
||||
rangeMin
|
||||
rangeMin,
|
||||
collapsedMap
|
||||
);
|
||||
|
||||
// if clicking on a block in the canvas
|
||||
@ -107,7 +116,7 @@ const FlameGraphCanvas = ({
|
||||
setClickedItemData(undefined);
|
||||
}
|
||||
},
|
||||
[data, rangeMin, rangeMax, totalViewTicks, root, direction, depth]
|
||||
[data, rangeMin, rangeMax, totalViewTicks, root, direction, depth, collapsedMap]
|
||||
);
|
||||
|
||||
const [mousePosition, setMousePosition] = useState<{ x: number; y: number }>();
|
||||
@ -124,7 +133,8 @@ const FlameGraphCanvas = ({
|
||||
depth,
|
||||
pixelsPerTick,
|
||||
totalViewTicks,
|
||||
rangeMin
|
||||
rangeMin,
|
||||
collapsedMap
|
||||
);
|
||||
|
||||
if (item) {
|
||||
@ -133,7 +143,7 @@ const FlameGraphCanvas = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[rangeMin, rangeMax, totalViewTicks, clickedItemData, setMousePosition, root, direction, depth]
|
||||
[rangeMin, rangeMax, totalViewTicks, clickedItemData, setMousePosition, root, direction, depth, collapsedMap]
|
||||
);
|
||||
|
||||
const onGraphMouseLeave = useCallback(() => {
|
||||
@ -165,10 +175,18 @@ const FlameGraphCanvas = ({
|
||||
onMouseLeave={onGraphMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
<FlameGraphTooltip position={mousePosition} item={tooltipItem} data={data} totalTicks={totalViewTicks} />
|
||||
<FlameGraphTooltip
|
||||
position={mousePosition}
|
||||
item={tooltipItem}
|
||||
data={data}
|
||||
totalTicks={totalViewTicks}
|
||||
collapseConfig={tooltipItem ? collapsedMap.get(tooltipItem) : undefined}
|
||||
/>
|
||||
{clickedItemData && (
|
||||
<FlameGraphContextMenu
|
||||
itemData={clickedItemData}
|
||||
collapsing={collapsing}
|
||||
collapseConfig={collapsedMap.get(clickedItemData.item)}
|
||||
onMenuItemClick={() => {
|
||||
setClickedItemData(undefined);
|
||||
}}
|
||||
@ -180,12 +198,47 @@ const FlameGraphCanvas = ({
|
||||
onSandwich={() => {
|
||||
onSandwich(data.getLabel(clickedItemData.item.itemIndexes[0]));
|
||||
}}
|
||||
onExpandGroup={() => {
|
||||
setCollapsedMap(setCollapsedStatus(collapsedMap, clickedItemData.item, false));
|
||||
}}
|
||||
onCollapseGroup={() => {
|
||||
setCollapsedMap(setCollapsedStatus(collapsedMap, clickedItemData.item, true));
|
||||
}}
|
||||
onExpandAllGroups={() => {
|
||||
setCollapsedMap(setAllCollapsedStatus(collapsedMap, false));
|
||||
}}
|
||||
onCollapseAllGroups={() => {
|
||||
setCollapsedMap(setAllCollapsedStatus(collapsedMap, true));
|
||||
}}
|
||||
allGroupsCollapsed={Array.from(collapsedMap.values()).every((i) => i.collapsed)}
|
||||
allGroupsExpanded={Array.from(collapsedMap.values()).every((i) => !i.collapsed)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
function setCollapsedStatus(collapsedMap: CollapsedMap, item: LevelItem, collapsed: boolean) {
|
||||
const newMap = new Map(collapsedMap);
|
||||
const collapsedConfig = collapsedMap.get(item)!;
|
||||
const newConfig = { ...collapsedConfig, collapsed };
|
||||
for (const item of collapsedConfig.items) {
|
||||
newMap.set(item, newConfig);
|
||||
}
|
||||
return newMap;
|
||||
}
|
||||
|
||||
function setAllCollapsedStatus(collapsedMap: CollapsedMap, collapsed: boolean) {
|
||||
const newMap = new Map(collapsedMap);
|
||||
for (const item of collapsedMap.keys()) {
|
||||
const collapsedConfig = collapsedMap.get(item)!;
|
||||
const newConfig = { ...collapsedConfig, collapsed };
|
||||
newMap.set(item, newConfig);
|
||||
}
|
||||
|
||||
return newMap;
|
||||
}
|
||||
|
||||
const getStyles = () => ({
|
||||
graph: css({
|
||||
label: 'graph',
|
||||
@ -217,7 +270,7 @@ const getStyles = () => ({
|
||||
}),
|
||||
});
|
||||
|
||||
const convertPixelCoordinatesToBarCoordinates = (
|
||||
export const convertPixelCoordinatesToBarCoordinates = (
|
||||
// position relative to the start of the graph
|
||||
pos: { x: number; y: number },
|
||||
root: LevelItem,
|
||||
@ -225,7 +278,8 @@ const convertPixelCoordinatesToBarCoordinates = (
|
||||
depth: number,
|
||||
pixelsPerTick: number,
|
||||
totalTicks: number,
|
||||
rangeMin: number
|
||||
rangeMin: number,
|
||||
collapsedMap: Map<LevelItem, CollapseConfig>
|
||||
): LevelItem | undefined => {
|
||||
let next: LevelItem | undefined = root;
|
||||
let currentLevel = direction === 'children' ? 0 : depth - 1;
|
||||
@ -247,7 +301,13 @@ const convertPixelCoordinatesToBarCoordinates = (
|
||||
const xEnd = getBarX(child.start + child.value, totalTicks, rangeMin, pixelsPerTick);
|
||||
if (xStart <= pos.x && pos.x < xEnd) {
|
||||
next = child;
|
||||
currentLevel = currentLevel + (direction === 'children' ? 1 : -1);
|
||||
// Check if item is a collapsed item. if so also check if the item is the first collapsed item in the chain,
|
||||
// which we render, or a child which we don't render. If it's a child in the chain then don't increase the
|
||||
// level end effectively skip it.
|
||||
const collapsedConfig = collapsedMap.get(child);
|
||||
if (!collapsedConfig || !collapsedConfig.collapsed || collapsedConfig.items[0] === child) {
|
||||
currentLevel = currentLevel + (direction === 'children' ? 1 : -1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -1,17 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
import { MenuItem, ContextMenu } from '@grafana/ui';
|
||||
import { MenuItem, MenuGroup, ContextMenu } from '@grafana/ui';
|
||||
|
||||
import { ClickedItemData } from '../types';
|
||||
|
||||
import { CollapseConfig } from './dataTransform';
|
||||
|
||||
type Props = {
|
||||
itemData: ClickedItemData;
|
||||
onMenuItemClick: () => void;
|
||||
onItemFocus: () => void;
|
||||
onSandwich: () => void;
|
||||
onExpandGroup: () => void;
|
||||
onCollapseGroup: () => void;
|
||||
onExpandAllGroups: () => void;
|
||||
onCollapseAllGroups: () => void;
|
||||
collapseConfig?: CollapseConfig;
|
||||
collapsing?: boolean;
|
||||
allGroupsCollapsed?: boolean;
|
||||
allGroupsExpanded?: boolean;
|
||||
};
|
||||
|
||||
const FlameGraphContextMenu = ({ itemData, onMenuItemClick, onItemFocus, onSandwich }: Props) => {
|
||||
const FlameGraphContextMenu = ({
|
||||
itemData,
|
||||
onMenuItemClick,
|
||||
onItemFocus,
|
||||
onSandwich,
|
||||
collapseConfig,
|
||||
onExpandGroup,
|
||||
onCollapseGroup,
|
||||
onExpandAllGroups,
|
||||
onCollapseAllGroups,
|
||||
collapsing,
|
||||
allGroupsExpanded,
|
||||
allGroupsCollapsed,
|
||||
}: Props) => {
|
||||
function renderItems() {
|
||||
return (
|
||||
<>
|
||||
@ -40,6 +63,52 @@ const FlameGraphContextMenu = ({ itemData, onMenuItemClick, onItemFocus, onSandw
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
|
||||
{collapsing && (
|
||||
<MenuGroup label={'Grouping'}>
|
||||
{collapseConfig ? (
|
||||
collapseConfig.collapsed ? (
|
||||
<MenuItem
|
||||
label="Expand group"
|
||||
icon={'angle-double-down'}
|
||||
onClick={() => {
|
||||
onExpandGroup();
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<MenuItem
|
||||
label="Collapse group"
|
||||
icon={'angle-double-up'}
|
||||
onClick={() => {
|
||||
onCollapseGroup();
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{!allGroupsExpanded && (
|
||||
<MenuItem
|
||||
label="Expand all groups"
|
||||
icon={'angle-double-down'}
|
||||
onClick={() => {
|
||||
onExpandAllGroups();
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!allGroupsCollapsed && (
|
||||
<MenuItem
|
||||
label="Collapse all groups"
|
||||
icon={'angle-double-up'}
|
||||
onClick={() => {
|
||||
onCollapseAllGroups();
|
||||
onMenuItemClick();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</MenuGroup>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@ -12,7 +12,7 @@ function setupData(unit?: string) {
|
||||
{ name: 'label', values: ['total'] },
|
||||
],
|
||||
});
|
||||
return new FlameGraphDataContainer(flameGraphData);
|
||||
return new FlameGraphDataContainer(flameGraphData, { collapsing: true });
|
||||
}
|
||||
|
||||
function setupDiffData() {
|
||||
@ -26,7 +26,7 @@ function setupDiffData() {
|
||||
{ name: 'label', values: ['total', 'func1'] },
|
||||
],
|
||||
});
|
||||
return new FlameGraphDataContainer(flameGraphData);
|
||||
return new FlameGraphDataContainer(flameGraphData, { collapsing: true });
|
||||
}
|
||||
|
||||
describe('FlameGraphTooltip', () => {
|
||||
|
@ -4,16 +4,17 @@ import React from 'react';
|
||||
import { DisplayValue, getValueFormat, GrafanaTheme2 } from '@grafana/data';
|
||||
import { InteractiveTable, Portal, useStyles2, VizTooltipContainer } from '@grafana/ui';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { CollapseConfig, FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
|
||||
type Props = {
|
||||
data: FlameGraphDataContainer;
|
||||
totalTicks: number;
|
||||
position?: { x: number; y: number };
|
||||
item?: LevelItem;
|
||||
collapseConfig?: CollapseConfig;
|
||||
};
|
||||
|
||||
const FlameGraphTooltip = ({ data, item, totalTicks, position }: Props) => {
|
||||
const FlameGraphTooltip = ({ data, item, totalTicks, position, collapseConfig }: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!(item && position)) {
|
||||
@ -56,7 +57,17 @@ const FlameGraphTooltip = ({ data, item, totalTicks, position }: Props) => {
|
||||
<Portal>
|
||||
<VizTooltipContainer className={styles.tooltipContainer} position={position} offset={{ x: 15, y: 0 }}>
|
||||
<div className={styles.tooltipContent}>
|
||||
<p className={styles.tooltipName}>{data.getLabel(item.itemIndexes[0])}</p>
|
||||
<p className={styles.tooltipName}>
|
||||
{data.getLabel(item.itemIndexes[0])}
|
||||
{collapseConfig && collapseConfig.collapsed ? (
|
||||
<span>
|
||||
<br />
|
||||
and {collapseConfig.items.length} similar items
|
||||
</span>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</p>
|
||||
{content}
|
||||
</div>
|
||||
</VizTooltipContainer>
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,6 +1,7 @@
|
||||
import { createDataFrame, FieldType } from '@grafana/data';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem, nestedSetToLevels } from './dataTransform';
|
||||
import { textToDataContainer } from './testHelpers';
|
||||
|
||||
describe('nestedSetToLevels', () => {
|
||||
it('converts nested set data frame to levels', () => {
|
||||
@ -17,7 +18,7 @@ describe('nestedSetToLevels', () => {
|
||||
{ name: 'self', values: [0, 0, 0, 0, 0, 0, 0, 0, 0] },
|
||||
],
|
||||
});
|
||||
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
|
||||
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame, { collapsing: true }));
|
||||
|
||||
const n9: LevelItem = { itemIndexes: [8], start: 5, children: [], value: 1, level: 4 };
|
||||
const n8: LevelItem = { itemIndexes: [7], start: 5, children: [n9], value: 2, level: 3 };
|
||||
@ -54,7 +55,7 @@ describe('nestedSetToLevels', () => {
|
||||
{ name: 'self', values: [10, 5, 3, 1] },
|
||||
],
|
||||
});
|
||||
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame));
|
||||
const [levels] = nestedSetToLevels(new FlameGraphDataContainer(frame, { collapsing: true }));
|
||||
|
||||
const n4: LevelItem = { itemIndexes: [3], start: 8, children: [], value: 1, level: 1 };
|
||||
const n3: LevelItem = { itemIndexes: [2], start: 5, children: [], value: 3, level: 1 };
|
||||
@ -69,3 +70,34 @@ describe('nestedSetToLevels', () => {
|
||||
expect(levels[1]).toEqual([n2, n3, n4]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlameGraphDataContainer', () => {
|
||||
it('creates correct collapse map', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0//////////////]
|
||||
[1][3//][6///]
|
||||
[2][4] [7///]
|
||||
[5] [8///]
|
||||
[9///]
|
||||
`)!;
|
||||
|
||||
const collapsedMap = container.getCollapsedMap();
|
||||
expect(Array.from(collapsedMap.keys()).map((item) => item.itemIndexes[0])).toEqual([1, 2, 4, 5, 6, 7, 8, 9]);
|
||||
|
||||
expect(Array.from(collapsedMap.values())[0]).toMatchObject({
|
||||
collapsed: true,
|
||||
items: [{ itemIndexes: [1] }, { itemIndexes: [2] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates empty collapse map if no items are similar', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0//////////////]
|
||||
[1][3//][6///]
|
||||
[9/]
|
||||
`)!;
|
||||
|
||||
const collapsedMap = container.getCollapsedMap();
|
||||
expect(Array.from(collapsedMap.keys()).length).toEqual(0);
|
||||
});
|
||||
});
|
||||
|
@ -40,12 +40,18 @@ export type CollapsedMap = Map<LevelItem, CollapseConfig>;
|
||||
* Convert data frame with nested set format into array of level. This is mainly done for compatibility with current
|
||||
* rendering code.
|
||||
*/
|
||||
export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelItem[][], Record<string, LevelItem[]>] {
|
||||
export function nestedSetToLevels(
|
||||
container: FlameGraphDataContainer,
|
||||
options?: {
|
||||
collapsing: boolean;
|
||||
}
|
||||
): [LevelItem[][], Record<string, LevelItem[]>, CollapsedMap] {
|
||||
const levels: LevelItem[][] = [];
|
||||
let offset = 0;
|
||||
|
||||
let parent: LevelItem | undefined = undefined;
|
||||
const uniqueLabels: Record<string, LevelItem[]> = {};
|
||||
const collapsedMap: CollapsedMap = new Map();
|
||||
|
||||
for (let i = 0; i < container.data.length; i++) {
|
||||
const currentLevel = container.getLevel(i);
|
||||
@ -76,6 +82,22 @@ export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelIte
|
||||
level: currentLevel,
|
||||
};
|
||||
|
||||
if (options?.collapsing) {
|
||||
// We collapse similar items here, where it seems like parent and child are the same thing and so the distinction
|
||||
// isn't that important. We create a map of items that should be collapsed together.
|
||||
if (parent && newItem.value === parent.value) {
|
||||
if (collapsedMap.has(parent)) {
|
||||
const config = collapsedMap.get(parent)!;
|
||||
collapsedMap.set(newItem, config);
|
||||
config.items.push(newItem);
|
||||
} else {
|
||||
const config = { items: [parent, newItem], collapsed: true };
|
||||
collapsedMap.set(parent, config);
|
||||
collapsedMap.set(newItem, config);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (uniqueLabels[container.getLabel(i)]) {
|
||||
uniqueLabels[container.getLabel(i)].push(newItem);
|
||||
} else {
|
||||
@ -90,7 +112,7 @@ export function nestedSetToLevels(container: FlameGraphDataContainer): [LevelIte
|
||||
levels[currentLevel].push(newItem);
|
||||
}
|
||||
|
||||
return [levels, uniqueLabels];
|
||||
return [levels, uniqueLabels, collapsedMap];
|
||||
}
|
||||
|
||||
export function getMessageCheckFieldsResult(wrongFields: CheckFieldsResult) {
|
||||
@ -146,6 +168,8 @@ export function checkFields(data: DataFrame): CheckFieldsResult | undefined {
|
||||
|
||||
export class FlameGraphDataContainer {
|
||||
data: DataFrame;
|
||||
options: { collapsing: boolean };
|
||||
|
||||
labelField: Field;
|
||||
levelField: Field;
|
||||
valueField: Field;
|
||||
@ -161,9 +185,11 @@ export class FlameGraphDataContainer {
|
||||
|
||||
private levels: LevelItem[][] | undefined;
|
||||
private uniqueLabelsMap: Record<string, LevelItem[]> | undefined;
|
||||
private collapsedMap: Map<LevelItem, CollapseConfig> | undefined;
|
||||
|
||||
constructor(data: DataFrame, theme: GrafanaTheme2 = createTheme()) {
|
||||
constructor(data: DataFrame, options: { collapsing: boolean }, theme: GrafanaTheme2 = createTheme()) {
|
||||
this.data = data;
|
||||
this.options = options;
|
||||
|
||||
const wrongFields = checkFields(data);
|
||||
if (wrongFields) {
|
||||
@ -275,11 +301,17 @@ export class FlameGraphDataContainer {
|
||||
return this.uniqueLabelsMap![label];
|
||||
}
|
||||
|
||||
getCollapsedMap() {
|
||||
this.initLevels();
|
||||
return this.collapsedMap!;
|
||||
}
|
||||
|
||||
private initLevels() {
|
||||
if (!this.levels) {
|
||||
const [levels, uniqueLabelsMap] = nestedSetToLevels(this);
|
||||
const [levels, uniqueLabelsMap, collapsedMap] = nestedSetToLevels(this, this.options);
|
||||
this.levels = levels;
|
||||
this.uniqueLabelsMap = uniqueLabelsMap;
|
||||
this.collapsedMap = collapsedMap;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -2,6 +2,7 @@ import { createDataFrame, FieldType } from '@grafana/data';
|
||||
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { walkTree } from './rendering';
|
||||
import { textToDataContainer } from './testHelpers';
|
||||
|
||||
function makeDataFrame(fields: Record<string, Array<number | string>>) {
|
||||
return createDataFrame({
|
||||
@ -20,22 +21,36 @@ type RenderData = {
|
||||
width: number;
|
||||
height: number;
|
||||
label: string;
|
||||
collapsed: boolean;
|
||||
muted: boolean;
|
||||
};
|
||||
|
||||
describe('walkTree', () => {
|
||||
it('correctly compute sizes for a single item', () => {
|
||||
const root: LevelItem = { start: 0, itemIndexes: [0], children: [], value: 100, level: 0 };
|
||||
const container = new FlameGraphDataContainer(makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }));
|
||||
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
|
||||
expect(item).toEqual(root);
|
||||
expect(x).toEqual(0);
|
||||
expect(y).toEqual(0);
|
||||
expect(width).toEqual(99); // -1 for border
|
||||
expect(height).toEqual(22);
|
||||
expect(label).toEqual('1');
|
||||
expect(collapsed).toEqual(false);
|
||||
});
|
||||
const container = new FlameGraphDataContainer(
|
||||
makeDataFrame({ value: [100], level: [1], label: ['1'], self: [0] }),
|
||||
{ collapsing: true }
|
||||
);
|
||||
|
||||
walkTree(
|
||||
root,
|
||||
'children',
|
||||
container,
|
||||
100,
|
||||
0,
|
||||
1,
|
||||
100,
|
||||
container.getCollapsedMap(),
|
||||
(item, x, y, width, height, label, collapsed) => {
|
||||
expect(item).toEqual(root);
|
||||
expect(x).toEqual(0);
|
||||
expect(y).toEqual(0);
|
||||
expect(width).toEqual(99); // -1 for border
|
||||
expect(height).toEqual(22);
|
||||
expect(label).toEqual('1');
|
||||
expect(collapsed).toEqual(false);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('should render a multiple items', () => {
|
||||
@ -50,20 +65,31 @@ describe('walkTree', () => {
|
||||
],
|
||||
};
|
||||
const container = new FlameGraphDataContainer(
|
||||
makeDataFrame({ value: [100, 50, 50], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 50, 50] })
|
||||
makeDataFrame({ value: [100, 50, 50], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 50, 50] }),
|
||||
{ collapsing: true }
|
||||
);
|
||||
const renderData: RenderData[] = [];
|
||||
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
|
||||
renderData.push({ item, x, y, width, height, label, collapsed });
|
||||
});
|
||||
walkTree(
|
||||
root,
|
||||
'children',
|
||||
container,
|
||||
100,
|
||||
0,
|
||||
1,
|
||||
100,
|
||||
container.getCollapsedMap(),
|
||||
(item, x, y, width, height, label, muted) => {
|
||||
renderData.push({ item, x, y, width, height, label, muted });
|
||||
}
|
||||
);
|
||||
expect(renderData).toEqual([
|
||||
{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' },
|
||||
{ item: root.children[0], width: 49, height: 22, x: 0, y: 22, collapsed: false, label: '2' },
|
||||
{ item: root.children[1], width: 49, height: 22, x: 50, y: 22, collapsed: false, label: '3' },
|
||||
{ item: root, width: 99, height: 22, x: 0, y: 0, muted: false, label: '1' },
|
||||
{ item: root.children[0], width: 49, height: 22, x: 0, y: 22, muted: false, label: '2' },
|
||||
{ item: root.children[1], width: 49, height: 22, x: 50, y: 22, muted: false, label: '3' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render a collapsed items', () => {
|
||||
it('should render a muted items', () => {
|
||||
const root: LevelItem = {
|
||||
start: 0,
|
||||
itemIndexes: [0],
|
||||
@ -75,16 +101,27 @@ describe('walkTree', () => {
|
||||
],
|
||||
};
|
||||
const container = new FlameGraphDataContainer(
|
||||
makeDataFrame({ value: [100, 1, 1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 1, 1] })
|
||||
makeDataFrame({ value: [100, 1, 1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 1, 1] }),
|
||||
{ collapsing: true }
|
||||
);
|
||||
const renderData: RenderData[] = [];
|
||||
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
|
||||
renderData.push({ item, x, y, width, height, label, collapsed });
|
||||
});
|
||||
walkTree(
|
||||
root,
|
||||
'children',
|
||||
container,
|
||||
100,
|
||||
0,
|
||||
1,
|
||||
100,
|
||||
container.getCollapsedMap(),
|
||||
(item, x, y, width, height, label, muted) => {
|
||||
renderData.push({ item, x, y, width, height, label, muted });
|
||||
}
|
||||
);
|
||||
expect(renderData).toEqual([
|
||||
{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' },
|
||||
{ item: root.children[0], width: 1, height: 22, x: 0, y: 22, collapsed: true, label: '2' },
|
||||
{ item: root.children[1], width: 1, height: 22, x: 1, y: 22, collapsed: true, label: '3' },
|
||||
{ item: root, width: 99, height: 22, x: 0, y: 0, muted: false, label: '1' },
|
||||
{ item: root.children[0], width: 1, height: 22, x: 0, y: 22, muted: true, label: '2' },
|
||||
{ item: root.children[1], width: 1, height: 22, x: 1, y: 22, muted: true, label: '3' },
|
||||
]);
|
||||
});
|
||||
|
||||
@ -100,12 +137,54 @@ describe('walkTree', () => {
|
||||
],
|
||||
};
|
||||
const container = new FlameGraphDataContainer(
|
||||
makeDataFrame({ value: [100, 0.1, 0.1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 0.1, 0.1] })
|
||||
makeDataFrame({ value: [100, 0.1, 0.1], level: [0, 1, 1], label: ['1', '2', '3'], self: [0, 0.1, 0.1] }),
|
||||
{ collapsing: true }
|
||||
);
|
||||
const renderData: RenderData[] = [];
|
||||
walkTree(root, 'children', container, 100, 0, 1, 100, (item, x, y, width, height, label, collapsed) => {
|
||||
renderData.push({ item, x, y, width, height, label, collapsed });
|
||||
});
|
||||
expect(renderData).toEqual([{ item: root, width: 99, height: 22, x: 0, y: 0, collapsed: false, label: '1' }]);
|
||||
walkTree(
|
||||
root,
|
||||
'children',
|
||||
container,
|
||||
100,
|
||||
0,
|
||||
1,
|
||||
100,
|
||||
container.getCollapsedMap(),
|
||||
(item, x, y, width, height, label, muted) => {
|
||||
renderData.push({ item, x, y, width, height, label, muted });
|
||||
}
|
||||
);
|
||||
expect(renderData).toEqual([{ item: root, width: 99, height: 22, x: 0, y: 0, muted: false, label: '1' }]);
|
||||
});
|
||||
|
||||
it('should correctly skip a collapsed items', () => {
|
||||
const container = textToDataContainer(`
|
||||
[0///////////]
|
||||
[1][3//][4///]
|
||||
[2] [5///]
|
||||
`)!;
|
||||
|
||||
const root = container.getLevels()[0][0];
|
||||
|
||||
const renderData: RenderData[] = [];
|
||||
walkTree(
|
||||
root,
|
||||
'children',
|
||||
container,
|
||||
14,
|
||||
0,
|
||||
1,
|
||||
14,
|
||||
container.getCollapsedMap(),
|
||||
(item, x, y, width, height, label, muted) => {
|
||||
renderData.push({ item, x, y, width, height, label, muted });
|
||||
}
|
||||
);
|
||||
expect(renderData).toEqual([
|
||||
{ item: root, width: 13, height: 22, x: 0, y: 0, muted: false, label: '0' },
|
||||
{ item: root.children[0], width: 3, height: 22, x: 0, y: 22, muted: true, label: '1' },
|
||||
{ item: root.children[1], width: 5, height: 22, x: 3, y: 22, muted: true, label: '3' },
|
||||
{ item: root.children[2], width: 6, height: 22, x: 8, y: 22, muted: true, label: '4' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
@ -8,7 +8,7 @@ import { useTheme2 } from '@grafana/ui';
|
||||
import {
|
||||
BAR_BORDER_WIDTH,
|
||||
BAR_TEXT_PADDING_LEFT,
|
||||
COLLAPSE_THRESHOLD,
|
||||
MUTE_THRESHOLD,
|
||||
HIDE_THRESHOLD,
|
||||
LABEL_THRESHOLD,
|
||||
PIXELS_PER_LEVEL,
|
||||
@ -16,7 +16,7 @@ import {
|
||||
import { ClickedItemData, ColorScheme, ColorSchemeDiff, TextAlign } from '../types';
|
||||
|
||||
import { getBarColorByDiff, getBarColorByPackage, getBarColorByValue } from './colors';
|
||||
import { FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
import { CollapseConfig, CollapsedMap, FlameGraphDataContainer, LevelItem } from './dataTransform';
|
||||
|
||||
const ufuzzy = new uFuzzy();
|
||||
|
||||
@ -46,6 +46,7 @@ type RenderOptions = {
|
||||
totalTicksRight: number | undefined;
|
||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
||||
focusedItemData?: ClickedItemData;
|
||||
collapsedMap: CollapsedMap;
|
||||
};
|
||||
|
||||
export function useFlameRender(options: RenderOptions) {
|
||||
@ -65,6 +66,7 @@ export function useFlameRender(options: RenderOptions) {
|
||||
totalTicksRight,
|
||||
colorScheme,
|
||||
focusedItemData,
|
||||
collapsedMap,
|
||||
} = options;
|
||||
const foundLabels = useFoundLabels(search, data);
|
||||
const ctx = useSetupCanvas(canvasRef, wrapperWidth, depth);
|
||||
@ -83,7 +85,7 @@ export function useFlameRender(options: RenderOptions) {
|
||||
foundLabels,
|
||||
focusedItemData ? focusedItemData.item.level : 0
|
||||
);
|
||||
const renderFunc = useRenderFunc(ctx, data, getBarColor, textAlign);
|
||||
const renderFunc = useRenderFunc(ctx, data, getBarColor, textAlign, collapsedMap);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ctx) {
|
||||
@ -91,8 +93,8 @@ export function useFlameRender(options: RenderOptions) {
|
||||
}
|
||||
|
||||
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
|
||||
walkTree(root, direction, data, totalViewTicks, rangeMin, rangeMax, wrapperWidth, renderFunc);
|
||||
}, [ctx, data, root, wrapperWidth, rangeMin, rangeMax, totalViewTicks, direction, renderFunc]);
|
||||
walkTree(root, direction, data, totalViewTicks, rangeMin, rangeMax, wrapperWidth, collapsedMap, renderFunc);
|
||||
}, [ctx, data, root, wrapperWidth, rangeMin, rangeMax, totalViewTicks, direction, renderFunc, collapsedMap]);
|
||||
}
|
||||
|
||||
type RenderFunc = (
|
||||
@ -102,39 +104,112 @@ type RenderFunc = (
|
||||
width: number,
|
||||
height: number,
|
||||
label: string,
|
||||
// Collapsed means the width is too small to show the label, and we group collapsed siblings together.
|
||||
collapsed: boolean
|
||||
// muted means the width is too small, and we just show gray rectangle.
|
||||
muted: boolean
|
||||
) => void;
|
||||
|
||||
function useRenderFunc(
|
||||
ctx: CanvasRenderingContext2D | undefined,
|
||||
data: FlameGraphDataContainer,
|
||||
getBarColor: (item: LevelItem, label: string, collapsed: boolean) => string,
|
||||
textAlign: TextAlign
|
||||
getBarColor: (item: LevelItem, label: string, muted: boolean) => string,
|
||||
textAlign: TextAlign,
|
||||
collapsedMap: CollapsedMap
|
||||
): RenderFunc {
|
||||
return useMemo(() => {
|
||||
if (!ctx) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
return (item, x, y, width, height, label, collapsed) => {
|
||||
return (item, x, y, width, height, label, muted) => {
|
||||
ctx.beginPath();
|
||||
ctx.rect(x + (collapsed ? 0 : BAR_BORDER_WIDTH), y, width, height);
|
||||
ctx.fillStyle = getBarColor(item, label, collapsed);
|
||||
ctx.rect(x + (muted ? 0 : BAR_BORDER_WIDTH), y, width, height);
|
||||
ctx.fillStyle = getBarColor(item, label, muted);
|
||||
|
||||
if (collapsed) {
|
||||
// Only fill the collapsed rects
|
||||
const collapsedItemConfig = collapsedMap.get(item);
|
||||
if (collapsedItemConfig && collapsedItemConfig.collapsed) {
|
||||
const numberOfCollapsedItems = collapsedItemConfig.items.length;
|
||||
label = `(${numberOfCollapsedItems}) ` + label;
|
||||
}
|
||||
|
||||
if (muted) {
|
||||
// Only fill the muted rects
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.stroke();
|
||||
ctx.fill();
|
||||
|
||||
if (width >= LABEL_THRESHOLD) {
|
||||
renderLabel(ctx, data, label, item, width, x, y, textAlign);
|
||||
if (collapsedItemConfig) {
|
||||
if (width >= LABEL_THRESHOLD) {
|
||||
renderLabel(
|
||||
ctx,
|
||||
data,
|
||||
label,
|
||||
item,
|
||||
width,
|
||||
textAlign === 'left' ? x + groupStripMarginLeft + 4 : x,
|
||||
y,
|
||||
textAlign
|
||||
);
|
||||
|
||||
renderGroupingStrip(ctx, x, y, height, item, collapsedItemConfig);
|
||||
}
|
||||
} else {
|
||||
if (width >= LABEL_THRESHOLD) {
|
||||
renderLabel(ctx, data, label, item, width, x, y, textAlign);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}, [ctx, getBarColor, textAlign, data]);
|
||||
}, [ctx, getBarColor, textAlign, data, collapsedMap]);
|
||||
}
|
||||
|
||||
const groupStripMarginLeft = 8;
|
||||
|
||||
/**
|
||||
* Render small strip on the left side of the bar to indicate that this item is part of a group that can be collapsed.
|
||||
* @param ctx
|
||||
* @param x
|
||||
* @param y
|
||||
* @param height
|
||||
* @param item
|
||||
* @param collapsedItemConfig
|
||||
*/
|
||||
function renderGroupingStrip(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
x: number,
|
||||
y: number,
|
||||
height: number,
|
||||
item: LevelItem,
|
||||
collapsedItemConfig: CollapseConfig
|
||||
) {
|
||||
const groupStripX = x + groupStripMarginLeft;
|
||||
const groupStripWidth = 6;
|
||||
|
||||
// This is to mask the label in case we align it right to left.
|
||||
ctx.beginPath();
|
||||
ctx.rect(x, y, groupStripX - x + groupStripWidth + 6, height);
|
||||
ctx.fill();
|
||||
|
||||
// For item in a group that can be collapsed, we draw a small strip to mark them. On the items that are at the
|
||||
// start or and end of a group we draw just half the strip so 2 groups next to each other are separated
|
||||
// visually.
|
||||
ctx.beginPath();
|
||||
if (collapsedItemConfig.collapsed) {
|
||||
ctx.rect(groupStripX, y + height / 4, groupStripWidth, height / 2);
|
||||
} else {
|
||||
if (collapsedItemConfig.items[0] === item) {
|
||||
// Top item
|
||||
ctx.rect(groupStripX, y + height / 2, groupStripWidth, height / 2);
|
||||
} else if (collapsedItemConfig.items[collapsedItemConfig.items.length - 1] === item) {
|
||||
// Bottom item
|
||||
ctx.rect(groupStripX, y, groupStripWidth, height / 2);
|
||||
} else {
|
||||
ctx.rect(groupStripX, y, groupStripWidth, height);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -151,19 +226,22 @@ export function walkTree(
|
||||
rangeMin: number,
|
||||
rangeMax: number,
|
||||
wrapperWidth: number,
|
||||
collapsedMap: CollapsedMap,
|
||||
renderFunc: RenderFunc
|
||||
) {
|
||||
const stack: LevelItem[] = [];
|
||||
stack.push(root);
|
||||
// The levelOffset here is to keep track if items that we don't render because they are collapsed into single row.
|
||||
// That means we have to render next items with an offset of some rows up in the stack.
|
||||
const stack: Array<{ item: LevelItem; levelOffset: number }> = [];
|
||||
stack.push({ item: root, levelOffset: 0 });
|
||||
|
||||
const pixelsPerTick = (wrapperWidth * window.devicePixelRatio) / totalViewTicks / (rangeMax - rangeMin);
|
||||
let collapsedItemRendered: LevelItem | undefined = undefined;
|
||||
|
||||
while (stack.length > 0) {
|
||||
const item = stack.shift()!;
|
||||
const { item, levelOffset } = stack.shift()!;
|
||||
let curBarTicks = item.value;
|
||||
// Multiple collapsed items are shown as a single gray bar
|
||||
const collapsed = curBarTicks * pixelsPerTick <= COLLAPSE_THRESHOLD;
|
||||
const width = curBarTicks * pixelsPerTick - (collapsed ? 0 : BAR_BORDER_WIDTH * 2);
|
||||
const muted = curBarTicks * pixelsPerTick <= MUTE_THRESHOLD;
|
||||
const width = curBarTicks * pixelsPerTick - (muted ? 0 : BAR_BORDER_WIDTH * 2);
|
||||
const height = PIXELS_PER_LEVEL;
|
||||
|
||||
if (width < HIDE_THRESHOLD) {
|
||||
@ -171,16 +249,39 @@ export function walkTree(
|
||||
continue;
|
||||
}
|
||||
|
||||
const barX = getBarX(item.start, totalViewTicks, rangeMin, pixelsPerTick);
|
||||
const barY = item.level * PIXELS_PER_LEVEL;
|
||||
let offsetModifier = 0;
|
||||
let skipRender = false;
|
||||
const collapsedItemConfig = collapsedMap.get(item);
|
||||
const isCollapsedItem = collapsedItemConfig && collapsedItemConfig.collapsed;
|
||||
|
||||
let label = data.getLabel(item.itemIndexes[0]);
|
||||
if (isCollapsedItem) {
|
||||
if (collapsedItemRendered === collapsedItemConfig.items[0]) {
|
||||
offsetModifier = direction === 'children' ? -1 : +1;
|
||||
skipRender = true;
|
||||
} else {
|
||||
// This is a case where we have another collapsed group right after different collapsed group, so we need to
|
||||
// reset.
|
||||
collapsedItemRendered = undefined;
|
||||
}
|
||||
} else {
|
||||
collapsedItemRendered = undefined;
|
||||
}
|
||||
|
||||
renderFunc(item, barX, barY, width, height, label, collapsed);
|
||||
if (!skipRender) {
|
||||
const barX = getBarX(item.start, totalViewTicks, rangeMin, pixelsPerTick);
|
||||
const barY = (item.level + levelOffset) * PIXELS_PER_LEVEL;
|
||||
|
||||
let label = data.getLabel(item.itemIndexes[0]);
|
||||
if (isCollapsedItem) {
|
||||
collapsedItemRendered = item;
|
||||
}
|
||||
|
||||
renderFunc(item, barX, barY, width, height, label, muted);
|
||||
}
|
||||
|
||||
const nextList = direction === 'children' ? item.children : item.parents;
|
||||
if (nextList) {
|
||||
stack.unshift(...nextList);
|
||||
stack.unshift(...nextList.map((c) => ({ item: c, levelOffset: levelOffset + offsetModifier })));
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -225,9 +326,9 @@ function useColorFunction(
|
||||
? barMutedColor.darken(10).toHexString()
|
||||
: barMutedColor.lighten(10).toHexString();
|
||||
|
||||
return function getColor(item: LevelItem, label: string, collapsed: boolean) {
|
||||
return function getColor(item: LevelItem, label: string, muted: boolean) {
|
||||
// If collapsed and no search we can quickly return the muted color
|
||||
if (collapsed && !foundNames) {
|
||||
if (muted && !foundNames) {
|
||||
// Collapsed are always grayed
|
||||
return barMutedColorHex;
|
||||
}
|
||||
@ -312,7 +413,7 @@ function renderLabel(
|
||||
}
|
||||
}
|
||||
|
||||
ctx.fillText(fullLabel, labelX, y + PIXELS_PER_LEVEL / 2);
|
||||
ctx.fillText(fullLabel, labelX, y + PIXELS_PER_LEVEL / 2 + 2);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
@ -80,7 +80,7 @@ export function textToDataContainer(text: string) {
|
||||
const df = arrayToDataFrame(dfSorted);
|
||||
const labelField = df.fields.find((f) => f.name === 'label')!;
|
||||
labelField.type = FieldType.string;
|
||||
return new FlameGraphDataContainer(df);
|
||||
return new FlameGraphDataContainer(df, { collapsing: true });
|
||||
}
|
||||
|
||||
export function trimLevelsString(s: string) {
|
||||
|
@ -53,6 +53,11 @@ export type Props = {
|
||||
* If true the flamegraph will be rendered on top of the table.
|
||||
*/
|
||||
vertical?: boolean;
|
||||
|
||||
/**
|
||||
* Disable behaviour where similar items in the same stack will be collapsed into single item.
|
||||
*/
|
||||
disableCollapsing?: boolean;
|
||||
};
|
||||
|
||||
const FlameGraphContainer = ({
|
||||
@ -65,6 +70,7 @@ const FlameGraphContainer = ({
|
||||
stickyHeader,
|
||||
extraHeaderElements,
|
||||
vertical,
|
||||
disableCollapsing,
|
||||
}: Props) => {
|
||||
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
|
||||
|
||||
@ -83,8 +89,8 @@ const FlameGraphContainer = ({
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
return new FlameGraphDataContainer(data, theme);
|
||||
}, [data, theme]);
|
||||
return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
|
||||
}, [data, theme, disableCollapsing]);
|
||||
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
|
||||
const styles = getStyles(theme, vertical);
|
||||
|
||||
@ -198,6 +204,7 @@ const FlameGraphContainer = ({
|
||||
onFocusPillClick={resetFocus}
|
||||
onSandwichPillClick={resetSandwich}
|
||||
colorScheme={colorScheme}
|
||||
collapsing={!disableCollapsing}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
@ -12,7 +12,7 @@ import FlameGraphTopTableContainer from './FlameGraphTopTableContainer';
|
||||
describe('FlameGraphTopTableContainer', () => {
|
||||
const setup = () => {
|
||||
const flameGraphData = createDataFrame(data);
|
||||
const container = new FlameGraphDataContainer(flameGraphData);
|
||||
const container = new FlameGraphDataContainer(flameGraphData, { collapsing: true });
|
||||
const onSearch = jest.fn();
|
||||
const onSandwich = jest.fn();
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
export const PIXELS_PER_LEVEL = 22 * window.devicePixelRatio;
|
||||
export const COLLAPSE_THRESHOLD = 10 * window.devicePixelRatio;
|
||||
export const MUTE_THRESHOLD = 10 * window.devicePixelRatio;
|
||||
export const HIDE_THRESHOLD = 0.5 * window.devicePixelRatio;
|
||||
export const LABEL_THRESHOLD = 20 * window.devicePixelRatio;
|
||||
export const BAR_BORDER_WIDTH = 0.5 * window.devicePixelRatio;
|
||||
|
@ -1032,6 +1032,13 @@ var (
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityLogsSquad,
|
||||
},
|
||||
{
|
||||
Name: "flameGraphItemCollapsing",
|
||||
Description: "Allow collapsing of flame graph items",
|
||||
Stage: FeatureStageExperimental,
|
||||
FrontendOnly: true,
|
||||
Owner: grafanaObservabilityTracesAndProfilingSquad,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
@ -141,3 +141,4 @@ panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,false,tru
|
||||
pdfTables,privatePreview,@grafana/sharing-squad,false,false,false,false
|
||||
ssoSettingsApi,experimental,@grafana/identity-access-team,true,false,false,false
|
||||
logsInfiniteScrolling,experimental,@grafana/observability-logs,false,false,false,true
|
||||
flameGraphItemCollapsing,experimental,@grafana/observability-traces-and-profiling,false,false,false,true
|
||||
|
|
@ -574,4 +574,8 @@ const (
|
||||
// FlagLogsInfiniteScrolling
|
||||
// Enables infinite scrolling for the Logs panel in Explore and Dashboards
|
||||
FlagLogsInfiniteScrolling = "logsInfiniteScrolling"
|
||||
|
||||
// FlagFlameGraphItemCollapsing
|
||||
// Allow collapsing of flame graph items
|
||||
FlagFlameGraphItemCollapsing = "flameGraphItemCollapsing"
|
||||
)
|
||||
|
@ -31,6 +31,7 @@ export const FlameGraphExploreContainer = (props: Props) => {
|
||||
onViewSelected={(view: string) => interaction('view_selected', { view })}
|
||||
onTextAlignSelected={(align: string) => interaction('text_align_selected', { align })}
|
||||
onTableSort={(sort: string) => interaction('table_sort_selected', { sort })}
|
||||
disableCollapsing={!config.featureToggles.flameGraphItemCollapsing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@ -29,6 +29,7 @@ export const FlameGraphPanel = (props: PanelProps) => {
|
||||
onViewSelected={(view: string) => interaction('view_selected', { view })}
|
||||
onTextAlignSelected={(align: string) => interaction('text_align_selected', { align })}
|
||||
onTableSort={(sort: string) => interaction('table_sort_selected', { sort })}
|
||||
disableCollapsing={!config.featureToggles.flameGraphItemCollapsing}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
Loading…
Reference in New Issue
Block a user