Compare commits
12 Commits
ash/react-
...
pyroscope/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45e60f849b | ||
|
|
d43f4b576a | ||
|
|
f79f7ac33d | ||
|
|
f03ee8d19c | ||
|
|
eb37860388 | ||
|
|
da7b70336c | ||
|
|
17817bdda7 | ||
|
|
5bed426fd8 | ||
|
|
8bc405d5ed | ||
|
|
b8c9ee987e | ||
|
|
49e4d6760b | ||
|
|
665a54f02f |
@@ -58,6 +58,7 @@
|
|||||||
"d3": "^7.8.5",
|
"d3": "^7.8.5",
|
||||||
"lodash": "4.17.21",
|
"lodash": "4.17.21",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
|
"react-table": "^7.8.0",
|
||||||
"react-use": "17.6.0",
|
"react-use": "17.6.0",
|
||||||
"react-virtualized-auto-sizer": "1.0.26",
|
"react-virtualized-auto-sizer": "1.0.26",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
@@ -81,6 +82,7 @@
|
|||||||
"@types/lodash": "4.17.20",
|
"@types/lodash": "4.17.20",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "24.10.1",
|
||||||
"@types/react": "18.3.18",
|
"@types/react": "18.3.18",
|
||||||
|
"@types/react-table": "^7.7.20",
|
||||||
"@types/react-virtualized-auto-sizer": "1.0.8",
|
"@types/react-virtualized-auto-sizer": "1.0.8",
|
||||||
"@types/tinycolor2": "1.4.6",
|
"@types/tinycolor2": "1.4.6",
|
||||||
"babel-jest": "29.7.0",
|
"babel-jest": "29.7.0",
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { Meta, StoryObj } from '@storybook/react';
|
||||||
|
|
||||||
|
import { createDataFrame } from '@grafana/data';
|
||||||
|
|
||||||
|
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
|
||||||
|
import { data } from '../FlameGraph/testData/dataNestedSet';
|
||||||
|
import { ColorScheme } from '../types';
|
||||||
|
|
||||||
|
import FlameGraphCallTreeContainer from './FlameGraphCallTreeContainer';
|
||||||
|
|
||||||
|
const meta: Meta<typeof FlameGraphCallTreeContainer> = {
|
||||||
|
title: 'CallTree',
|
||||||
|
component: FlameGraphCallTreeContainer,
|
||||||
|
args: {
|
||||||
|
colorScheme: ColorScheme.PackageBased,
|
||||||
|
search: '',
|
||||||
|
},
|
||||||
|
decorators: [
|
||||||
|
(Story) => (
|
||||||
|
<div style={{ width: '100%', height: '1000px' }}>
|
||||||
|
<Story />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
|
||||||
|
export const Basic: StoryObj<typeof meta> = {
|
||||||
|
render: (args) => {
|
||||||
|
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FlameGraphCallTreeContainer
|
||||||
|
{...args}
|
||||||
|
data={dataContainer}
|
||||||
|
onSymbolClick={(symbol) => {
|
||||||
|
console.log('Symbol clicked:', symbol);
|
||||||
|
}}
|
||||||
|
onSandwich={(item) => {
|
||||||
|
console.log('Sandwich:', item);
|
||||||
|
}}
|
||||||
|
onSearch={(symbol) => {
|
||||||
|
console.log('Search:', symbol);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
580
packages/grafana-flamegraph/src/CallTree/utils.ts
Normal file
580
packages/grafana-flamegraph/src/CallTree/utils.ts
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
import { FlameGraphDataContainer, LevelItem } from '../FlameGraph/dataTransform';
|
||||||
|
|
||||||
|
export interface CallTreeNode {
|
||||||
|
id: string; // Path-based ID (e.g., "0.2.1")
|
||||||
|
label: string; // Function name
|
||||||
|
self: number; // Self value
|
||||||
|
total: number; // Total value
|
||||||
|
selfPercent: number; // Self as % of root
|
||||||
|
totalPercent: number; // Total as % of root
|
||||||
|
depth: number; // Indentation level
|
||||||
|
parentId?: string; // Parent node ID
|
||||||
|
hasChildren: boolean; // Has expandable children
|
||||||
|
childCount: number; // Number of direct children
|
||||||
|
subtreeSize: number; // Total number of nodes in subtree (excluding self)
|
||||||
|
levelItem: LevelItem; // Reference to original data
|
||||||
|
subRows?: CallTreeNode[]; // Child nodes for react-table useExpanded
|
||||||
|
isLastChild: boolean; // Whether this is the last child of its parent
|
||||||
|
|
||||||
|
// For diff profiles
|
||||||
|
selfRight?: number;
|
||||||
|
totalRight?: number;
|
||||||
|
selfPercentRight?: number;
|
||||||
|
totalPercentRight?: number;
|
||||||
|
diffPercent?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build hierarchical call tree node from the LevelItem structure.
|
||||||
|
* Each node gets a unique ID based on its path in the tree.
|
||||||
|
* Children are stored in the subRows property for react-table useExpanded.
|
||||||
|
*/
|
||||||
|
export function buildCallTreeNode(
|
||||||
|
data: FlameGraphDataContainer,
|
||||||
|
rootItem: LevelItem,
|
||||||
|
rootTotal: number,
|
||||||
|
parentId?: string,
|
||||||
|
parentDepth: number = -1,
|
||||||
|
childIndex: number = 0
|
||||||
|
): CallTreeNode {
|
||||||
|
const nodeId = parentId ? `${parentId}.${childIndex}` : `${childIndex}`;
|
||||||
|
const depth = parentDepth + 1;
|
||||||
|
|
||||||
|
// Get values for current item
|
||||||
|
const itemIndex = rootItem.itemIndexes[0];
|
||||||
|
const label = data.getLabel(itemIndex);
|
||||||
|
const self = data.getSelf(itemIndex);
|
||||||
|
const total = data.getValue(itemIndex);
|
||||||
|
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
|
||||||
|
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
|
||||||
|
|
||||||
|
// For diff profiles
|
||||||
|
let selfRight: number | undefined;
|
||||||
|
let totalRight: number | undefined;
|
||||||
|
let selfPercentRight: number | undefined;
|
||||||
|
let totalPercentRight: number | undefined;
|
||||||
|
let diffPercent: number | undefined;
|
||||||
|
|
||||||
|
if (data.isDiffFlamegraph()) {
|
||||||
|
selfRight = data.getSelfRight(itemIndex);
|
||||||
|
totalRight = data.getValueRight(itemIndex);
|
||||||
|
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
|
||||||
|
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
|
||||||
|
|
||||||
|
// Calculate diff percentage (change from baseline to comparison)
|
||||||
|
if (self > 0) {
|
||||||
|
diffPercent = ((selfRight - self) / self) * 100;
|
||||||
|
} else if (selfRight > 0) {
|
||||||
|
diffPercent = Infinity; // New in comparison
|
||||||
|
} else {
|
||||||
|
diffPercent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recursively build children
|
||||||
|
const subRows =
|
||||||
|
rootItem.children.length > 0
|
||||||
|
? rootItem.children.map((child, index) => {
|
||||||
|
const childNode = buildCallTreeNode(data, child, rootTotal, nodeId, depth, index);
|
||||||
|
// Mark if this is the last child
|
||||||
|
childNode.isLastChild = index === rootItem.children.length - 1;
|
||||||
|
return childNode;
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Calculate child count and subtree size
|
||||||
|
const childCount = rootItem.children.length;
|
||||||
|
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
|
||||||
|
|
||||||
|
const node: CallTreeNode = {
|
||||||
|
id: nodeId,
|
||||||
|
label,
|
||||||
|
self,
|
||||||
|
total,
|
||||||
|
selfPercent,
|
||||||
|
totalPercent,
|
||||||
|
depth,
|
||||||
|
parentId,
|
||||||
|
hasChildren: rootItem.children.length > 0,
|
||||||
|
childCount,
|
||||||
|
subtreeSize,
|
||||||
|
levelItem: rootItem,
|
||||||
|
subRows,
|
||||||
|
isLastChild: false, // Will be set by parent
|
||||||
|
selfRight,
|
||||||
|
totalRight,
|
||||||
|
selfPercentRight,
|
||||||
|
totalPercentRight,
|
||||||
|
diffPercent,
|
||||||
|
};
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build all call tree nodes from the root level items.
|
||||||
|
* Returns an array of root nodes, each with their children in subRows.
|
||||||
|
* This handles cases where there might be multiple root items.
|
||||||
|
*/
|
||||||
|
export function buildAllCallTreeNodes(data: FlameGraphDataContainer): CallTreeNode[] {
|
||||||
|
const levels = data.getLevels();
|
||||||
|
const rootTotal = levels.length > 0 ? levels[0][0].value : 0;
|
||||||
|
|
||||||
|
// Build hierarchical structure for each root item
|
||||||
|
const rootNodes = levels[0].map((rootItem, index) => buildCallTreeNode(data, rootItem, rootTotal, undefined, -1, index));
|
||||||
|
|
||||||
|
return rootNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build call tree nodes from an array of levels (from mergeParentSubtrees).
|
||||||
|
* This is used for the callers view where we get LevelItem[][] from getSandwichLevels.
|
||||||
|
* Unlike buildCallTreeNode which recursively processes children, this function
|
||||||
|
* processes pre-organized levels and builds the hierarchy from them.
|
||||||
|
*/
|
||||||
|
export function buildCallTreeFromLevels(
|
||||||
|
levels: LevelItem[][],
|
||||||
|
data: FlameGraphDataContainer,
|
||||||
|
rootTotal: number
|
||||||
|
): CallTreeNode[] {
|
||||||
|
if (levels.length === 0 || levels[0].length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map to track LevelItem -> CallTreeNode for building relationships
|
||||||
|
const levelItemToNode = new Map<LevelItem, CallTreeNode>();
|
||||||
|
|
||||||
|
// Process each level and build nodes
|
||||||
|
levels.forEach((level, levelIndex) => {
|
||||||
|
level.forEach((levelItem, itemIndex) => {
|
||||||
|
// Get values from data
|
||||||
|
const itemDataIndex = levelItem.itemIndexes[0];
|
||||||
|
const label = data.getLabel(itemDataIndex);
|
||||||
|
const self = data.getSelf(itemDataIndex);
|
||||||
|
const total = data.getValue(itemDataIndex);
|
||||||
|
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
|
||||||
|
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
|
||||||
|
|
||||||
|
// For diff profiles
|
||||||
|
let selfRight: number | undefined;
|
||||||
|
let totalRight: number | undefined;
|
||||||
|
let selfPercentRight: number | undefined;
|
||||||
|
let totalPercentRight: number | undefined;
|
||||||
|
let diffPercent: number | undefined;
|
||||||
|
|
||||||
|
if (data.isDiffFlamegraph()) {
|
||||||
|
selfRight = data.getSelfRight(itemDataIndex);
|
||||||
|
totalRight = data.getValueRight(itemDataIndex);
|
||||||
|
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
|
||||||
|
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
|
||||||
|
|
||||||
|
// Calculate diff percentage
|
||||||
|
if (self > 0) {
|
||||||
|
diffPercent = ((selfRight - self) / self) * 100;
|
||||||
|
} else if (selfRight > 0) {
|
||||||
|
diffPercent = Infinity;
|
||||||
|
} else {
|
||||||
|
diffPercent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine parent (if exists)
|
||||||
|
let parentId: string | undefined;
|
||||||
|
let depth = levelIndex;
|
||||||
|
|
||||||
|
if (levelItem.parents && levelItem.parents.length > 0) {
|
||||||
|
const parentNode = levelItemToNode.get(levelItem.parents[0]);
|
||||||
|
if (parentNode) {
|
||||||
|
parentId = parentNode.id;
|
||||||
|
depth = parentNode.depth + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate path-based ID
|
||||||
|
// For root nodes, use index at level 0
|
||||||
|
// For child nodes, append index to parent ID
|
||||||
|
let nodeId: string;
|
||||||
|
if (!parentId) {
|
||||||
|
nodeId = `${itemIndex}`;
|
||||||
|
} else {
|
||||||
|
// Find index among siblings
|
||||||
|
const parent = levelItemToNode.get(levelItem.parents![0]);
|
||||||
|
const siblingIndex = parent?.subRows?.length || 0;
|
||||||
|
nodeId = `${parentId}.${siblingIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the node (without children initially)
|
||||||
|
const node: CallTreeNode = {
|
||||||
|
id: nodeId,
|
||||||
|
label,
|
||||||
|
self,
|
||||||
|
total,
|
||||||
|
selfPercent,
|
||||||
|
totalPercent,
|
||||||
|
depth,
|
||||||
|
parentId,
|
||||||
|
hasChildren: levelItem.children.length > 0,
|
||||||
|
childCount: levelItem.children.length,
|
||||||
|
subtreeSize: 0, // Will be calculated later
|
||||||
|
levelItem,
|
||||||
|
subRows: undefined,
|
||||||
|
isLastChild: false,
|
||||||
|
selfRight,
|
||||||
|
totalRight,
|
||||||
|
selfPercentRight,
|
||||||
|
totalPercentRight,
|
||||||
|
diffPercent,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add to map
|
||||||
|
levelItemToNode.set(levelItem, node);
|
||||||
|
|
||||||
|
// Add as child to parent
|
||||||
|
if (levelItem.parents && levelItem.parents.length > 0) {
|
||||||
|
const parentNode = levelItemToNode.get(levelItem.parents[0]);
|
||||||
|
if (parentNode) {
|
||||||
|
if (!parentNode.subRows) {
|
||||||
|
parentNode.subRows = [];
|
||||||
|
}
|
||||||
|
parentNode.subRows.push(node);
|
||||||
|
// Mark if this is the last child
|
||||||
|
const isLastChild = parentNode.subRows.length === parentNode.childCount;
|
||||||
|
node.isLastChild = isLastChild;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Calculate subtreeSize for all nodes (bottom-up)
|
||||||
|
const calculateSubtreeSize = (node: CallTreeNode): number => {
|
||||||
|
if (!node.subRows || node.subRows.length === 0) {
|
||||||
|
node.subtreeSize = 0;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const size = node.subRows.reduce((sum, child) => {
|
||||||
|
return sum + calculateSubtreeSize(child) + 1;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
node.subtreeSize = size;
|
||||||
|
return size;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Collect root nodes (level 0)
|
||||||
|
const rootNodes: CallTreeNode[] = [];
|
||||||
|
levels[0].forEach((levelItem) => {
|
||||||
|
const node = levelItemToNode.get(levelItem);
|
||||||
|
if (node) {
|
||||||
|
calculateSubtreeSize(node);
|
||||||
|
rootNodes.push(node);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return rootNodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recursively collect expanded state for nodes up to a certain depth.
|
||||||
|
*/
|
||||||
|
function collectExpandedByDepth(
|
||||||
|
node: CallTreeNode,
|
||||||
|
levelsToExpand: number,
|
||||||
|
expanded: Record<string, boolean>
|
||||||
|
): void {
|
||||||
|
if (node.depth < levelsToExpand && node.hasChildren) {
|
||||||
|
expanded[node.id] = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.subRows) {
|
||||||
|
node.subRows.forEach((child) => collectExpandedByDepth(child, levelsToExpand, expanded));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get initial expanded state for the tree.
|
||||||
|
* Auto-expands first N levels.
|
||||||
|
*/
|
||||||
|
export function getInitialExpandedState(nodes: CallTreeNode[], levelsToExpand: number = 2): Record<string, boolean> {
|
||||||
|
const expanded: Record<string, boolean> = {};
|
||||||
|
|
||||||
|
nodes.forEach((node) => {
|
||||||
|
collectExpandedByDepth(node, levelsToExpand, expanded);
|
||||||
|
});
|
||||||
|
|
||||||
|
return expanded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restructure the callers tree to show a specific target node at the root.
|
||||||
|
* In the callers view, we want to show the target function with its callers as children.
|
||||||
|
* This function finds the target node and collects all paths that lead to it,
|
||||||
|
* then restructures them so the target is at the root.
|
||||||
|
*/
|
||||||
|
export function restructureCallersTree(
|
||||||
|
nodes: CallTreeNode[],
|
||||||
|
targetLabel: string
|
||||||
|
): { restructuredTree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
|
||||||
|
// First, find all paths from root to target node
|
||||||
|
const findPathsToTarget = (
|
||||||
|
nodes: CallTreeNode[],
|
||||||
|
targetLabel: string,
|
||||||
|
currentPath: CallTreeNode[] = []
|
||||||
|
): CallTreeNode[][] => {
|
||||||
|
const paths: CallTreeNode[][] = [];
|
||||||
|
|
||||||
|
for (const node of nodes) {
|
||||||
|
const newPath = [...currentPath, node];
|
||||||
|
|
||||||
|
if (node.label === targetLabel) {
|
||||||
|
// Found a path to the target
|
||||||
|
paths.push(newPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.subRows && node.subRows.length > 0) {
|
||||||
|
// Continue searching in children
|
||||||
|
const childPaths = findPathsToTarget(node.subRows, targetLabel, newPath);
|
||||||
|
paths.push(...childPaths);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
};
|
||||||
|
|
||||||
|
const paths = findPathsToTarget(nodes, targetLabel);
|
||||||
|
|
||||||
|
if (paths.length === 0) {
|
||||||
|
// Target not found, return original tree
|
||||||
|
return { restructuredTree: nodes, targetNode: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the target node from the first path (they should all have the same target node)
|
||||||
|
const targetNode = paths[0][paths[0].length - 1];
|
||||||
|
|
||||||
|
// Now restructure: create a new tree with target at root
|
||||||
|
// Each path to the target becomes a branch under the target
|
||||||
|
// For example, if we have: root -> A -> B -> target
|
||||||
|
// We want: target -> B -> A -> root (inverted)
|
||||||
|
|
||||||
|
const buildInvertedChildren = (paths: CallTreeNode[][]): CallTreeNode[] => {
|
||||||
|
// Group paths by their immediate caller (the node right before target)
|
||||||
|
const callerGroups = new Map<string, CallTreeNode[][]>();
|
||||||
|
|
||||||
|
for (const path of paths) {
|
||||||
|
if (path.length <= 1) {
|
||||||
|
// Path is just the target node itself, no callers
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The immediate caller is the node right before the target
|
||||||
|
const immediateCaller = path[path.length - 2];
|
||||||
|
const callerKey = immediateCaller.label;
|
||||||
|
|
||||||
|
if (!callerGroups.has(callerKey)) {
|
||||||
|
callerGroups.set(callerKey, []);
|
||||||
|
}
|
||||||
|
callerGroups.get(callerKey)!.push(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build nodes for each immediate caller
|
||||||
|
const callerNodes: CallTreeNode[] = [];
|
||||||
|
let callerIndex = 0;
|
||||||
|
|
||||||
|
for (const [, callerPaths] of callerGroups.entries()) {
|
||||||
|
// Get the immediate caller node from one of the paths
|
||||||
|
const immediateCallerNode = callerPaths[0][callerPaths[0].length - 2];
|
||||||
|
|
||||||
|
// For this caller, recursively build its callers (from the remaining path)
|
||||||
|
const remainingPaths = callerPaths.map((path) => path.slice(0, -1)); // Remove target from paths
|
||||||
|
const grandCallers = buildInvertedChildren(remainingPaths);
|
||||||
|
|
||||||
|
// Create a new node for this caller as a child of the target
|
||||||
|
const newCallerId = `0.${callerIndex}`;
|
||||||
|
const callerNode: CallTreeNode = {
|
||||||
|
...immediateCallerNode,
|
||||||
|
id: newCallerId,
|
||||||
|
depth: 1,
|
||||||
|
parentId: '0',
|
||||||
|
subRows: grandCallers.length > 0 ? grandCallers : undefined,
|
||||||
|
hasChildren: grandCallers.length > 0,
|
||||||
|
childCount: grandCallers.length,
|
||||||
|
isLastChild: callerIndex === callerGroups.size - 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update IDs of grandCallers
|
||||||
|
if (grandCallers.length > 0) {
|
||||||
|
grandCallers.forEach((grandCaller, idx) => {
|
||||||
|
updateNodeIds(grandCaller, newCallerId, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
callerNodes.push(callerNode);
|
||||||
|
callerIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return callerNodes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Helper to recursively update node IDs
|
||||||
|
const updateNodeIds = (node: CallTreeNode, parentId: string, index: number) => {
|
||||||
|
node.id = `${parentId}.${index}`;
|
||||||
|
node.parentId = parentId;
|
||||||
|
node.depth = parentId.split('.').length;
|
||||||
|
|
||||||
|
if (node.subRows) {
|
||||||
|
node.subRows.forEach((child, idx) => {
|
||||||
|
updateNodeIds(child, node.id, idx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build the inverted children for the target
|
||||||
|
const invertedChildren = buildInvertedChildren(paths);
|
||||||
|
|
||||||
|
// Create the restructured target node as root
|
||||||
|
const restructuredTarget: CallTreeNode = {
|
||||||
|
...targetNode,
|
||||||
|
id: '0',
|
||||||
|
depth: 0,
|
||||||
|
parentId: undefined,
|
||||||
|
subRows: invertedChildren.length > 0 ? invertedChildren : undefined,
|
||||||
|
hasChildren: invertedChildren.length > 0,
|
||||||
|
childCount: invertedChildren.length,
|
||||||
|
subtreeSize: invertedChildren.reduce((sum, child) => sum + child.subtreeSize + 1, 0),
|
||||||
|
isLastChild: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return { restructuredTree: [restructuredTarget], targetNode: restructuredTarget };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a callers tree directly from sandwich levels data.
|
||||||
|
* This creates an inverted tree where the target function is at the root
|
||||||
|
* and its callers are shown as children.
|
||||||
|
*/
|
||||||
|
export function buildCallersTreeFromLevels(
|
||||||
|
levels: LevelItem[][],
|
||||||
|
targetLabel: string,
|
||||||
|
data: FlameGraphDataContainer,
|
||||||
|
rootTotal: number
|
||||||
|
): { tree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
|
||||||
|
if (levels.length === 0) {
|
||||||
|
return { tree: [], targetNode: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the target node in the levels
|
||||||
|
let targetLevelIndex = -1;
|
||||||
|
let targetItem: LevelItem | undefined;
|
||||||
|
|
||||||
|
for (let i = 0; i < levels.length; i++) {
|
||||||
|
for (const item of levels[i]) {
|
||||||
|
const label = data.getLabel(item.itemIndexes[0]);
|
||||||
|
if (label === targetLabel) {
|
||||||
|
targetLevelIndex = i;
|
||||||
|
targetItem = item;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (targetItem) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetItem || targetLevelIndex === -1) {
|
||||||
|
// Target not found
|
||||||
|
return { tree: [], targetNode: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map from LevelItem to all items that reference it as a parent
|
||||||
|
const childrenMap = new Map<LevelItem, LevelItem[]>();
|
||||||
|
|
||||||
|
for (const level of levels) {
|
||||||
|
for (const item of level) {
|
||||||
|
if (item.parents) {
|
||||||
|
for (const parent of item.parents) {
|
||||||
|
if (!childrenMap.has(parent)) {
|
||||||
|
childrenMap.set(parent, []);
|
||||||
|
}
|
||||||
|
childrenMap.get(parent)!.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the inverted tree recursively
|
||||||
|
// For callers view: the target is root, and parents become children
|
||||||
|
const buildInvertedNode = (
|
||||||
|
item: LevelItem,
|
||||||
|
nodeId: string,
|
||||||
|
depth: number,
|
||||||
|
parentId: string | undefined
|
||||||
|
): CallTreeNode => {
|
||||||
|
const itemIdx = item.itemIndexes[0];
|
||||||
|
const label = data.getLabel(itemIdx);
|
||||||
|
const self = data.getSelf(itemIdx);
|
||||||
|
const total = data.getValue(itemIdx);
|
||||||
|
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
|
||||||
|
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
|
||||||
|
|
||||||
|
// For diff profiles
|
||||||
|
let selfRight: number | undefined;
|
||||||
|
let totalRight: number | undefined;
|
||||||
|
let selfPercentRight: number | undefined;
|
||||||
|
let totalPercentRight: number | undefined;
|
||||||
|
let diffPercent: number | undefined;
|
||||||
|
|
||||||
|
if (data.isDiffFlamegraph()) {
|
||||||
|
selfRight = data.getSelfRight(itemIdx);
|
||||||
|
totalRight = data.getValueRight(itemIdx);
|
||||||
|
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
|
||||||
|
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
|
||||||
|
|
||||||
|
if (self > 0) {
|
||||||
|
diffPercent = ((selfRight - self) / self) * 100;
|
||||||
|
} else if (selfRight > 0) {
|
||||||
|
diffPercent = Infinity;
|
||||||
|
} else {
|
||||||
|
diffPercent = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In the inverted tree, parents become children (callers)
|
||||||
|
const callers = item.parents || [];
|
||||||
|
const subRows =
|
||||||
|
callers.length > 0
|
||||||
|
? callers.map((caller, idx) => {
|
||||||
|
const callerId = `${nodeId}.${idx}`;
|
||||||
|
const callerNode = buildInvertedNode(caller, callerId, depth + 1, nodeId);
|
||||||
|
callerNode.isLastChild = idx === callers.length - 1;
|
||||||
|
return callerNode;
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const childCount = callers.length;
|
||||||
|
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: nodeId,
|
||||||
|
label,
|
||||||
|
self,
|
||||||
|
total,
|
||||||
|
selfPercent,
|
||||||
|
totalPercent,
|
||||||
|
depth,
|
||||||
|
parentId,
|
||||||
|
hasChildren: callers.length > 0,
|
||||||
|
childCount,
|
||||||
|
subtreeSize,
|
||||||
|
levelItem: item,
|
||||||
|
subRows,
|
||||||
|
isLastChild: false,
|
||||||
|
selfRight,
|
||||||
|
totalRight,
|
||||||
|
selfPercentRight,
|
||||||
|
totalPercentRight,
|
||||||
|
diffPercent,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build tree with target as root
|
||||||
|
const targetNode = buildInvertedNode(targetItem, '0', 0, undefined);
|
||||||
|
|
||||||
|
return { tree: [targetNode], targetNode };
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ const meta: Meta<typeof FlameGraph> = {
|
|||||||
rangeMax: 1,
|
rangeMax: 1,
|
||||||
textAlign: 'left',
|
textAlign: 'left',
|
||||||
colorScheme: ColorScheme.PackageBased,
|
colorScheme: ColorScheme.PackageBased,
|
||||||
selectedView: SelectedView.Both,
|
selectedView: SelectedView.Multi,
|
||||||
search: '',
|
search: '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -43,10 +43,13 @@ describe('FlameGraph', () => {
|
|||||||
setRangeMax={setRangeMax}
|
setRangeMax={setRangeMax}
|
||||||
onItemFocused={onItemFocused}
|
onItemFocused={onItemFocused}
|
||||||
textAlign={'left'}
|
textAlign={'left'}
|
||||||
|
onTextAlignChange={jest.fn()}
|
||||||
onSandwich={onSandwich}
|
onSandwich={onSandwich}
|
||||||
onFocusPillClick={onFocusPillClick}
|
onFocusPillClick={onFocusPillClick}
|
||||||
onSandwichPillClick={onSandwichPillClick}
|
onSandwichPillClick={onSandwichPillClick}
|
||||||
colorScheme={ColorScheme.ValueBased}
|
colorScheme={ColorScheme.ValueBased}
|
||||||
|
onColorSchemeChange={jest.fn()}
|
||||||
|
isDiffMode={false}
|
||||||
selectedView={SelectedView.FlameGraph}
|
selectedView={SelectedView.FlameGraph}
|
||||||
search={''}
|
search={''}
|
||||||
collapsedMap={container.getCollapsedMap()}
|
collapsedMap={container.getCollapsedMap()}
|
||||||
|
|||||||
@@ -19,8 +19,10 @@
|
|||||||
import { css, cx } from '@emotion/css';
|
import { css, cx } from '@emotion/css';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { Icon } from '@grafana/ui';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
|
import { Button, ButtonGroup, Dropdown, Icon, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
|
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './colors';
|
||||||
import { PIXELS_PER_LEVEL } from '../constants';
|
import { PIXELS_PER_LEVEL } from '../constants';
|
||||||
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
|
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
|
||||||
|
|
||||||
@@ -39,11 +41,14 @@ type Props = {
|
|||||||
onItemFocused: (data: ClickedItemData) => void;
|
onItemFocused: (data: ClickedItemData) => void;
|
||||||
focusedItemData?: ClickedItemData;
|
focusedItemData?: ClickedItemData;
|
||||||
textAlign: TextAlign;
|
textAlign: TextAlign;
|
||||||
|
onTextAlignChange: (align: TextAlign) => void;
|
||||||
sandwichItem?: string;
|
sandwichItem?: string;
|
||||||
onSandwich: (label: string) => void;
|
onSandwich: (label: string) => void;
|
||||||
onFocusPillClick: () => void;
|
onFocusPillClick: () => void;
|
||||||
onSandwichPillClick: () => void;
|
onSandwichPillClick: () => void;
|
||||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
colorScheme: ColorScheme | ColorSchemeDiff;
|
||||||
|
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
||||||
|
isDiffMode: boolean;
|
||||||
showFlameGraphOnly?: boolean;
|
showFlameGraphOnly?: boolean;
|
||||||
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
|
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
|
||||||
collapsing?: boolean;
|
collapsing?: boolean;
|
||||||
@@ -63,11 +68,14 @@ const FlameGraph = ({
|
|||||||
onItemFocused,
|
onItemFocused,
|
||||||
focusedItemData,
|
focusedItemData,
|
||||||
textAlign,
|
textAlign,
|
||||||
|
onTextAlignChange,
|
||||||
onSandwich,
|
onSandwich,
|
||||||
sandwichItem,
|
sandwichItem,
|
||||||
onFocusPillClick,
|
onFocusPillClick,
|
||||||
onSandwichPillClick,
|
onSandwichPillClick,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
|
onColorSchemeChange,
|
||||||
|
isDiffMode,
|
||||||
showFlameGraphOnly,
|
showFlameGraphOnly,
|
||||||
getExtraContextMenuButtons,
|
getExtraContextMenuButtons,
|
||||||
collapsing,
|
collapsing,
|
||||||
@@ -76,7 +84,7 @@ const FlameGraph = ({
|
|||||||
collapsedMap,
|
collapsedMap,
|
||||||
setCollapsedMap,
|
setCollapsedMap,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const styles = getStyles();
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const [levels, setLevels] = useState<LevelItem[][]>();
|
const [levels, setLevels] = useState<LevelItem[][]>();
|
||||||
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
|
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
|
||||||
@@ -175,28 +183,183 @@ const FlameGraph = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alignOptions: Array<SelectableValue<TextAlign>> = [
|
||||||
|
{ value: 'left', description: 'Align text left', icon: 'align-left' },
|
||||||
|
{ value: 'right', description: 'Align text right', icon: 'align-right' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.graph}>
|
<div className={styles.graph}>
|
||||||
<FlameGraphMetadata
|
<div className={styles.toolbar}>
|
||||||
data={data}
|
<FlameGraphMetadata
|
||||||
focusedItem={focusedItemData}
|
data={data}
|
||||||
sandwichedLabel={sandwichItem}
|
focusedItem={focusedItemData}
|
||||||
totalTicks={totalViewTicks}
|
sandwichedLabel={sandwichItem}
|
||||||
onFocusPillClick={onFocusPillClick}
|
totalTicks={totalViewTicks}
|
||||||
onSandwichPillClick={onSandwichPillClick}
|
onFocusPillClick={onFocusPillClick}
|
||||||
/>
|
onSandwichPillClick={onSandwichPillClick}
|
||||||
|
/>
|
||||||
|
<div className={styles.controls}>
|
||||||
|
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
|
||||||
|
<ButtonGroup className={styles.buttonSpacing}>
|
||||||
|
<Button
|
||||||
|
variant={'secondary'}
|
||||||
|
fill={'outline'}
|
||||||
|
size={'sm'}
|
||||||
|
tooltip={'Expand all groups'}
|
||||||
|
onClick={() => {
|
||||||
|
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
|
||||||
|
}}
|
||||||
|
aria-label={'Expand all groups'}
|
||||||
|
icon={'angle-double-down'}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant={'secondary'}
|
||||||
|
fill={'outline'}
|
||||||
|
size={'sm'}
|
||||||
|
tooltip={'Collapse all groups'}
|
||||||
|
onClick={() => {
|
||||||
|
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
|
||||||
|
}}
|
||||||
|
aria-label={'Collapse all groups'}
|
||||||
|
icon={'angle-double-up'}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
<RadioButtonGroup<TextAlign>
|
||||||
|
size="sm"
|
||||||
|
options={alignOptions}
|
||||||
|
value={textAlign}
|
||||||
|
onChange={onTextAlignChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{canvas}
|
{canvas}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const getStyles = () => ({
|
type ColorSchemeButtonProps = {
|
||||||
|
value: ColorScheme | ColorSchemeDiff;
|
||||||
|
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
||||||
|
isDiffMode: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ColorSchemeButton(props: ColorSchemeButtonProps) {
|
||||||
|
const styles = useStyles2(getStyles);
|
||||||
|
let menu = (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
|
||||||
|
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Show a bit different gradient as a way to indicate selected value
|
||||||
|
const colorDotStyle =
|
||||||
|
{
|
||||||
|
[ColorScheme.ValueBased]: styles.colorDotByValue,
|
||||||
|
[ColorScheme.PackageBased]: styles.colorDotByPackage,
|
||||||
|
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
|
||||||
|
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
|
||||||
|
}[props.value] || styles.colorDotByValue;
|
||||||
|
|
||||||
|
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
|
||||||
|
|
||||||
|
if (props.isDiffMode) {
|
||||||
|
menu = (
|
||||||
|
<Menu>
|
||||||
|
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
|
||||||
|
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
|
||||||
|
</Menu>
|
||||||
|
);
|
||||||
|
|
||||||
|
contents = (
|
||||||
|
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
|
||||||
|
<div>-100% (removed)</div>
|
||||||
|
<div>0%</div>
|
||||||
|
<div>+100% (added)</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown overlay={menu}>
|
||||||
|
<Button
|
||||||
|
variant={'secondary'}
|
||||||
|
fill={'outline'}
|
||||||
|
size={'sm'}
|
||||||
|
tooltip={'Change color scheme'}
|
||||||
|
onClick={() => {}}
|
||||||
|
className={styles.buttonSpacing}
|
||||||
|
aria-label={'Change color scheme'}
|
||||||
|
>
|
||||||
|
{contents}
|
||||||
|
</Button>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const getStyles = (theme: GrafanaTheme2) => ({
|
||||||
graph: css({
|
graph: css({
|
||||||
label: 'graph',
|
label: 'graph',
|
||||||
overflow: 'auto',
|
overflow: 'auto',
|
||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
flexBasis: '50%',
|
flexBasis: '50%',
|
||||||
}),
|
}),
|
||||||
|
toolbar: css({
|
||||||
|
label: 'toolbar',
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
controls: css({
|
||||||
|
label: 'controls',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
buttonSpacing: css({
|
||||||
|
label: 'buttonSpacing',
|
||||||
|
marginRight: theme.spacing(1),
|
||||||
|
}),
|
||||||
|
colorDot: css({
|
||||||
|
label: 'colorDot',
|
||||||
|
display: 'inline-block',
|
||||||
|
width: '10px',
|
||||||
|
height: '10px',
|
||||||
|
borderRadius: theme.shape.radius.circle,
|
||||||
|
}),
|
||||||
|
colorDotDiff: css({
|
||||||
|
label: 'colorDotDiff',
|
||||||
|
display: 'flex',
|
||||||
|
width: '200px',
|
||||||
|
height: '12px',
|
||||||
|
color: 'white',
|
||||||
|
fontSize: 9,
|
||||||
|
lineHeight: 1.3,
|
||||||
|
fontWeight: 300,
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
padding: '0 2px',
|
||||||
|
// We have a specific sizing for this so probably makes sense to use hardcoded value here
|
||||||
|
// eslint-disable-next-line @grafana/no-border-radius-literal
|
||||||
|
borderRadius: '2px',
|
||||||
|
}),
|
||||||
|
colorDotByValue: css({
|
||||||
|
label: 'colorDotByValue',
|
||||||
|
background: byValueGradient,
|
||||||
|
}),
|
||||||
|
colorDotByPackage: css({
|
||||||
|
label: 'colorDotByPackage',
|
||||||
|
background: byPackageGradient,
|
||||||
|
}),
|
||||||
|
colorDotDiffDefault: css({
|
||||||
|
label: 'colorDotDiffDefault',
|
||||||
|
background: diffDefaultGradient,
|
||||||
|
}),
|
||||||
|
colorDotDiffColorBlind: css({
|
||||||
|
label: 'colorDotDiffColorBlind',
|
||||||
|
background: diffColorBlindGradient,
|
||||||
|
}),
|
||||||
sandwichCanvasWrapper: css({
|
sandwichCanvasWrapper: css({
|
||||||
label: 'sandwichCanvasWrapper',
|
label: 'sandwichCanvasWrapper',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,19 +1,18 @@
|
|||||||
import { css } from '@emotion/css';
|
import { css } from '@emotion/css';
|
||||||
import uFuzzy from '@leeoniya/ufuzzy';
|
import uFuzzy from '@leeoniya/ufuzzy';
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMeasure } from 'react-use';
|
import { useMeasure } from 'react-use';
|
||||||
|
|
||||||
import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
|
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
|
||||||
import { ThemeContext } from '@grafana/ui';
|
import { ThemeContext } from '@grafana/ui';
|
||||||
|
|
||||||
import FlameGraph from './FlameGraph/FlameGraph';
|
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
|
||||||
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
|
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
|
||||||
import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform';
|
|
||||||
import FlameGraphHeader from './FlameGraphHeader';
|
import FlameGraphHeader from './FlameGraphHeader';
|
||||||
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
|
import FlameGraphPane from './FlameGraphPane';
|
||||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
|
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
|
||||||
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
|
import { PaneView, SelectedView, ViewMode } from './types';
|
||||||
import { getAssistantContextFromDataFrame } from './utils';
|
import { getAssistantContextFromDataFrame } from './utils';
|
||||||
|
|
||||||
const ufuzzy = new uFuzzy();
|
const ufuzzy = new uFuzzy();
|
||||||
@@ -104,17 +103,18 @@ const FlameGraphContainer = ({
|
|||||||
getExtraContextMenuButtons,
|
getExtraContextMenuButtons,
|
||||||
showAnalyzeWithAssistant = true,
|
showAnalyzeWithAssistant = true,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
|
// Shared state across all views
|
||||||
|
|
||||||
const [rangeMin, setRangeMin] = useState(0);
|
|
||||||
const [rangeMax, setRangeMax] = useState(1);
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedView, setSelectedView] = useState(SelectedView.Both);
|
const [selectedView, setSelectedView] = useState(SelectedView.Multi);
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Split);
|
||||||
|
const [leftPaneView, setLeftPaneView] = useState<PaneView>(PaneView.TopTable);
|
||||||
|
const [rightPaneView, setRightPaneView] = useState<PaneView>(PaneView.FlameGraph);
|
||||||
|
const [singleView, setSingleView] = useState<PaneView>(PaneView.FlameGraph);
|
||||||
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
||||||
const [textAlign, setTextAlign] = useState<TextAlign>('left');
|
// Used to trigger reset of pane-specific state (focus, sandwich) when parent reset button is clicked
|
||||||
// This is a label of the item because in sandwich view we group all items by label and present a merged graph
|
const [resetKey, setResetKey] = useState(0);
|
||||||
const [sandwichItem, setSandwichItem] = useState<string>();
|
// Track if we temporarily switched away from Both view due to narrow width
|
||||||
const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap());
|
const [viewBeforeNarrow, setViewBeforeNarrow] = useState<SelectedView | null>(null);
|
||||||
|
|
||||||
const theme = useMemo(() => getTheme(), [getTheme]);
|
const theme = useMemo(() => getTheme(), [getTheme]);
|
||||||
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
|
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
|
||||||
@@ -122,157 +122,220 @@ const FlameGraphContainer = ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
|
return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
|
||||||
setCollapsedMap(container.getCollapsedMap());
|
|
||||||
return container;
|
|
||||||
}, [data, theme, disableCollapsing]);
|
}, [data, theme, disableCollapsing]);
|
||||||
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
|
|
||||||
const styles = getStyles(theme);
|
const styles = getStyles(theme);
|
||||||
const matchedLabels = useLabelSearch(search, dataContainer);
|
const matchedLabels = useLabelSearch(search, dataContainer);
|
||||||
|
|
||||||
// If user resizes window with both as the selected view
|
// Handle responsive layout: switch away from Both view when narrow, restore when wide again
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (containerWidth === 0) {
|
||||||
containerWidth > 0 &&
|
|
||||||
containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH &&
|
|
||||||
selectedView === SelectedView.Both &&
|
|
||||||
!vertical
|
|
||||||
) {
|
|
||||||
setSelectedView(SelectedView.FlameGraph);
|
|
||||||
}
|
|
||||||
}, [selectedView, setSelectedView, containerWidth, vertical]);
|
|
||||||
|
|
||||||
const resetFocus = useCallback(() => {
|
|
||||||
setFocusedItemData(undefined);
|
|
||||||
setRangeMin(0);
|
|
||||||
setRangeMax(1);
|
|
||||||
}, [setFocusedItemData, setRangeMax, setRangeMin]);
|
|
||||||
|
|
||||||
const resetSandwich = useCallback(() => {
|
|
||||||
setSandwichItem(undefined);
|
|
||||||
}, [setSandwichItem]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!keepFocusOnDataChange) {
|
|
||||||
resetFocus();
|
|
||||||
resetSandwich();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dataContainer && focusedItemData) {
|
const isNarrow = containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && !vertical;
|
||||||
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
|
|
||||||
|
|
||||||
if (item) {
|
if (isNarrow && selectedView === SelectedView.Multi) {
|
||||||
setFocusedItemData({ ...focusedItemData, item });
|
// Going narrow: save current view and switch to FlameGraph
|
||||||
|
setViewBeforeNarrow(SelectedView.Multi);
|
||||||
const levels = dataContainer.getLevels();
|
setSelectedView(SelectedView.FlameGraph);
|
||||||
const totalViewTicks = levels.length ? levels[0][0].value : 0;
|
} else if (!isNarrow && viewBeforeNarrow !== null) {
|
||||||
setRangeMin(item.start / totalViewTicks);
|
// Going wide again: restore the previous view
|
||||||
setRangeMax((item.start + item.value) / totalViewTicks);
|
setSelectedView(viewBeforeNarrow);
|
||||||
} else {
|
setViewBeforeNarrow(null);
|
||||||
setFocusedItemData({
|
|
||||||
...focusedItemData,
|
|
||||||
item: {
|
|
||||||
start: 0,
|
|
||||||
value: 0,
|
|
||||||
itemIndexes: [],
|
|
||||||
children: [],
|
|
||||||
level: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
setRangeMin(0);
|
|
||||||
setRangeMax(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps
|
}, [containerWidth, vertical, selectedView, viewBeforeNarrow]);
|
||||||
|
|
||||||
const onSymbolClick = useCallback(
|
|
||||||
(symbol: string) => {
|
|
||||||
const anchored = `^${escapeStringForRegex(symbol)}$`;
|
|
||||||
|
|
||||||
if (search === anchored) {
|
|
||||||
setSearch('');
|
|
||||||
} else {
|
|
||||||
onTableSymbolClick?.(symbol);
|
|
||||||
setSearch(anchored);
|
|
||||||
resetFocus();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[setSearch, resetFocus, onTableSymbolClick, search]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!dataContainer) {
|
if (!dataContainer) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const flameGraph = (
|
|
||||||
<FlameGraph
|
|
||||||
data={dataContainer}
|
|
||||||
rangeMin={rangeMin}
|
|
||||||
rangeMax={rangeMax}
|
|
||||||
matchedLabels={matchedLabels}
|
|
||||||
setRangeMin={setRangeMin}
|
|
||||||
setRangeMax={setRangeMax}
|
|
||||||
onItemFocused={(data) => setFocusedItemData(data)}
|
|
||||||
focusedItemData={focusedItemData}
|
|
||||||
textAlign={textAlign}
|
|
||||||
sandwichItem={sandwichItem}
|
|
||||||
onSandwich={(label: string) => {
|
|
||||||
resetFocus();
|
|
||||||
setSandwichItem(label);
|
|
||||||
}}
|
|
||||||
onFocusPillClick={resetFocus}
|
|
||||||
onSandwichPillClick={resetSandwich}
|
|
||||||
colorScheme={colorScheme}
|
|
||||||
showFlameGraphOnly={showFlameGraphOnly}
|
|
||||||
collapsing={!disableCollapsing}
|
|
||||||
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
|
||||||
selectedView={selectedView}
|
|
||||||
search={search}
|
|
||||||
collapsedMap={collapsedMap}
|
|
||||||
setCollapsedMap={setCollapsedMap}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const table = (
|
|
||||||
<FlameGraphTopTableContainer
|
|
||||||
data={dataContainer}
|
|
||||||
onSymbolClick={onSymbolClick}
|
|
||||||
search={search}
|
|
||||||
matchedLabels={matchedLabels}
|
|
||||||
sandwichItem={sandwichItem}
|
|
||||||
onSandwich={setSandwichItem}
|
|
||||||
onSearch={(str) => {
|
|
||||||
if (!str) {
|
|
||||||
setSearch('');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSearch(`^${escapeStringForRegex(str)}$`);
|
|
||||||
}}
|
|
||||||
onTableSort={onTableSort}
|
|
||||||
colorScheme={colorScheme}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
let body;
|
let body;
|
||||||
if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) {
|
if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) {
|
||||||
body = flameGraph;
|
body = (
|
||||||
|
<FlameGraphPane
|
||||||
|
paneView={PaneView.FlameGraph}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
} else if (selectedView === SelectedView.TopTable) {
|
} else if (selectedView === SelectedView.TopTable) {
|
||||||
body = <div className={styles.tableContainer}>{table}</div>;
|
body = (
|
||||||
} else if (selectedView === SelectedView.Both) {
|
<FlameGraphPane
|
||||||
if (vertical) {
|
paneView={PaneView.TopTable}
|
||||||
body = (
|
dataContainer={dataContainer}
|
||||||
<div>
|
search={search}
|
||||||
<div className={styles.verticalGraphContainer}>{flameGraph}</div>
|
matchedLabels={matchedLabels}
|
||||||
<div className={styles.verticalTableContainer}>{table}</div>
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
</div>
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
);
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedView === SelectedView.CallTree) {
|
||||||
|
body = (
|
||||||
|
<FlameGraphPane
|
||||||
|
paneView={PaneView.CallTree}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (selectedView === SelectedView.Multi) {
|
||||||
|
// New view model: support split view with independent pane selections
|
||||||
|
if (viewMode === ViewMode.Split) {
|
||||||
|
if (vertical) {
|
||||||
|
body = (
|
||||||
|
<div>
|
||||||
|
<div className={styles.verticalPaneContainer}>
|
||||||
|
<FlameGraphPane
|
||||||
|
key="left-pane"
|
||||||
|
paneView={leftPaneView}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.verticalPaneContainer}>
|
||||||
|
<FlameGraphPane
|
||||||
|
key="right-pane"
|
||||||
|
paneView={rightPaneView}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
body = (
|
||||||
|
<div className={styles.horizontalContainer}>
|
||||||
|
<div className={styles.horizontalPaneContainer}>
|
||||||
|
<FlameGraphPane
|
||||||
|
key="left-pane"
|
||||||
|
paneView={leftPaneView}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className={styles.horizontalPaneContainer}>
|
||||||
|
<FlameGraphPane
|
||||||
|
key="right-pane"
|
||||||
|
paneView={rightPaneView}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// Single view mode
|
||||||
body = (
|
body = (
|
||||||
<div className={styles.horizontalContainer}>
|
<div className={styles.singlePaneContainer}>
|
||||||
<div className={styles.horizontalTableContainer}>{table}</div>
|
<FlameGraphPane
|
||||||
<div className={styles.horizontalGraphContainer}>{flameGraph}</div>
|
key={`single-${singleView}`}
|
||||||
|
paneView={singleView}
|
||||||
|
dataContainer={dataContainer}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
onTableSymbolClick={onTableSymbolClick}
|
||||||
|
onTextAlignSelected={onTextAlignSelected}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
disableCollapsing={disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
viewMode={viewMode}
|
||||||
|
theme={theme}
|
||||||
|
setSearch={setSearch}
|
||||||
|
resetKey={resetKey}
|
||||||
|
keepFocusOnDataChange={keepFocusOnDataChange}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -292,25 +355,24 @@ const FlameGraphContainer = ({
|
|||||||
setSelectedView(view);
|
setSelectedView(view);
|
||||||
onViewSelected?.(view);
|
onViewSelected?.(view);
|
||||||
}}
|
}}
|
||||||
|
viewMode={viewMode}
|
||||||
|
setViewMode={setViewMode}
|
||||||
|
leftPaneView={leftPaneView}
|
||||||
|
setLeftPaneView={setLeftPaneView}
|
||||||
|
rightPaneView={rightPaneView}
|
||||||
|
setRightPaneView={setRightPaneView}
|
||||||
|
singleView={singleView}
|
||||||
|
setSingleView={setSingleView}
|
||||||
containerWidth={containerWidth}
|
containerWidth={containerWidth}
|
||||||
onReset={() => {
|
onReset={() => {
|
||||||
resetFocus();
|
// Reset search and pane states when user clicks reset button
|
||||||
resetSandwich();
|
setSearch('');
|
||||||
|
setResetKey((k) => k + 1);
|
||||||
}}
|
}}
|
||||||
textAlign={textAlign}
|
showResetButton={Boolean(search)}
|
||||||
onTextAlignChange={(align) => {
|
|
||||||
setTextAlign(align);
|
|
||||||
onTextAlignSelected?.(align);
|
|
||||||
}}
|
|
||||||
showResetButton={Boolean(focusedItemData || sandwichItem)}
|
|
||||||
colorScheme={colorScheme}
|
|
||||||
onColorSchemeChange={setColorScheme}
|
|
||||||
stickyHeader={Boolean(stickyHeader)}
|
stickyHeader={Boolean(stickyHeader)}
|
||||||
extraHeaderElements={extraHeaderElements}
|
extraHeaderElements={extraHeaderElements}
|
||||||
vertical={vertical}
|
vertical={vertical}
|
||||||
isDiffMode={dataContainer.isDiffFlamegraph()}
|
|
||||||
setCollapsedMap={setCollapsedMap}
|
|
||||||
collapsedMap={collapsedMap}
|
|
||||||
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
|
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -321,18 +383,6 @@ const FlameGraphContainer = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
|
|
||||||
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
|
|
||||||
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
|
|
||||||
|
|
||||||
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
|
|
||||||
useEffect(() => {
|
|
||||||
setColorScheme(defaultColorScheme);
|
|
||||||
}, [defaultColorScheme]);
|
|
||||||
|
|
||||||
return [colorScheme, setColorScheme] as const;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later.
|
* Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later.
|
||||||
*/
|
*/
|
||||||
@@ -420,12 +470,6 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
flexGrow: 1,
|
flexGrow: 1,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
tableContainer: css({
|
|
||||||
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
|
|
||||||
// in explore we need a specific height.
|
|
||||||
height: 800,
|
|
||||||
}),
|
|
||||||
|
|
||||||
horizontalContainer: css({
|
horizontalContainer: css({
|
||||||
label: 'horizontalContainer',
|
label: 'horizontalContainer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -435,20 +479,20 @@ function getStyles(theme: GrafanaTheme2) {
|
|||||||
width: '100%',
|
width: '100%',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
horizontalGraphContainer: css({
|
horizontalPaneContainer: css({
|
||||||
flexBasis: '50%',
|
label: 'horizontalPaneContainer',
|
||||||
}),
|
|
||||||
|
|
||||||
horizontalTableContainer: css({
|
|
||||||
flexBasis: '50%',
|
flexBasis: '50%',
|
||||||
maxHeight: 800,
|
maxHeight: 800,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
verticalGraphContainer: css({
|
verticalPaneContainer: css({
|
||||||
|
label: 'verticalPaneContainer',
|
||||||
marginBottom: theme.spacing(1),
|
marginBottom: theme.spacing(1),
|
||||||
|
height: 800,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
verticalTableContainer: css({
|
singlePaneContainer: css({
|
||||||
|
label: 'singlePaneContainer',
|
||||||
height: 800,
|
height: 800,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import { render, screen } from '@testing-library/react';
|
|||||||
import userEvent from '@testing-library/user-event';
|
import userEvent from '@testing-library/user-event';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { CollapsedMap } from './FlameGraph/dataTransform';
|
|
||||||
import FlameGraphHeader from './FlameGraphHeader';
|
import FlameGraphHeader from './FlameGraphHeader';
|
||||||
import { ColorScheme, SelectedView } from './types';
|
import { PaneView, SelectedView, ViewMode } from './types';
|
||||||
|
|
||||||
jest.mock('@grafana/assistant', () => ({
|
jest.mock('@grafana/assistant', () => ({
|
||||||
useAssistant: jest.fn().mockReturnValue({
|
useAssistant: jest.fn().mockReturnValue({
|
||||||
@@ -20,26 +19,30 @@ describe('FlameGraphHeader', () => {
|
|||||||
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
|
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
|
||||||
const setSearch = jest.fn();
|
const setSearch = jest.fn();
|
||||||
const setSelectedView = jest.fn();
|
const setSelectedView = jest.fn();
|
||||||
|
const setViewMode = jest.fn();
|
||||||
|
const setLeftPaneView = jest.fn();
|
||||||
|
const setRightPaneView = jest.fn();
|
||||||
|
const setSingleView = jest.fn();
|
||||||
const onReset = jest.fn();
|
const onReset = jest.fn();
|
||||||
const onSchemeChange = jest.fn();
|
|
||||||
|
|
||||||
const renderResult = render(
|
const renderResult = render(
|
||||||
<FlameGraphHeader
|
<FlameGraphHeader
|
||||||
search={''}
|
search={''}
|
||||||
setSearch={setSearch}
|
setSearch={setSearch}
|
||||||
selectedView={SelectedView.Both}
|
selectedView={SelectedView.Multi}
|
||||||
setSelectedView={setSelectedView}
|
setSelectedView={setSelectedView}
|
||||||
|
viewMode={ViewMode.Split}
|
||||||
|
setViewMode={setViewMode}
|
||||||
|
leftPaneView={PaneView.TopTable}
|
||||||
|
setLeftPaneView={setLeftPaneView}
|
||||||
|
rightPaneView={PaneView.FlameGraph}
|
||||||
|
setRightPaneView={setRightPaneView}
|
||||||
|
singleView={PaneView.FlameGraph}
|
||||||
|
setSingleView={setSingleView}
|
||||||
containerWidth={1600}
|
containerWidth={1600}
|
||||||
onReset={onReset}
|
onReset={onReset}
|
||||||
onTextAlignChange={jest.fn()}
|
|
||||||
textAlign={'left'}
|
|
||||||
showResetButton={true}
|
showResetButton={true}
|
||||||
colorScheme={ColorScheme.ValueBased}
|
|
||||||
onColorSchemeChange={onSchemeChange}
|
|
||||||
stickyHeader={false}
|
stickyHeader={false}
|
||||||
isDiffMode={false}
|
|
||||||
setCollapsedMap={() => {}}
|
|
||||||
collapsedMap={new CollapsedMap()}
|
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -50,7 +53,6 @@ describe('FlameGraphHeader', () => {
|
|||||||
setSearch,
|
setSearch,
|
||||||
setSelectedView,
|
setSelectedView,
|
||||||
onReset,
|
onReset,
|
||||||
onSchemeChange,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -70,27 +72,4 @@ describe('FlameGraphHeader', () => {
|
|||||||
await userEvent.click(resetButton);
|
await userEvent.click(resetButton);
|
||||||
expect(handlers.onReset).toHaveBeenCalledTimes(1);
|
expect(handlers.onReset).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls on color scheme change when clicked', async () => {
|
|
||||||
const { handlers } = setup();
|
|
||||||
const changeButton = screen.getByLabelText(/Change color scheme/);
|
|
||||||
expect(changeButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(changeButton);
|
|
||||||
|
|
||||||
const byPackageButton = screen.getByText(/By package name/);
|
|
||||||
expect(byPackageButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(byPackageButton);
|
|
||||||
|
|
||||||
expect(handlers.onSchemeChange).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows diff color scheme switch when diff', async () => {
|
|
||||||
setup({ isDiffMode: true });
|
|
||||||
const changeButton = screen.getByLabelText(/Change color scheme/);
|
|
||||||
expect(changeButton).toBeInTheDocument();
|
|
||||||
await userEvent.click(changeButton);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Default/)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Color blind/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,30 +5,29 @@ import { useDebounce, usePrevious } from 'react-use';
|
|||||||
|
|
||||||
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
|
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
|
||||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||||
import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||||
|
|
||||||
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors';
|
|
||||||
import { CollapsedMap } from './FlameGraph/dataTransform';
|
|
||||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
|
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
|
||||||
import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
|
import { PaneView, SelectedView, ViewMode } from './types';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
search: string;
|
search: string;
|
||||||
setSearch: (search: string) => void;
|
setSearch: (search: string) => void;
|
||||||
selectedView: SelectedView;
|
selectedView: SelectedView;
|
||||||
setSelectedView: (view: SelectedView) => void;
|
setSelectedView: (view: SelectedView) => void;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
setViewMode: (mode: ViewMode) => void;
|
||||||
|
leftPaneView: PaneView;
|
||||||
|
setLeftPaneView: (view: PaneView) => void;
|
||||||
|
rightPaneView: PaneView;
|
||||||
|
setRightPaneView: (view: PaneView) => void;
|
||||||
|
singleView: PaneView;
|
||||||
|
setSingleView: (view: PaneView) => void;
|
||||||
containerWidth: number;
|
containerWidth: number;
|
||||||
onReset: () => void;
|
onReset: () => void;
|
||||||
textAlign: TextAlign;
|
|
||||||
onTextAlignChange: (align: TextAlign) => void;
|
|
||||||
showResetButton: boolean;
|
showResetButton: boolean;
|
||||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
|
||||||
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
|
||||||
stickyHeader: boolean;
|
stickyHeader: boolean;
|
||||||
vertical?: boolean;
|
vertical?: boolean;
|
||||||
isDiffMode: boolean;
|
|
||||||
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
|
|
||||||
collapsedMap: CollapsedMap;
|
|
||||||
|
|
||||||
extraHeaderElements?: React.ReactNode;
|
extraHeaderElements?: React.ReactNode;
|
||||||
|
|
||||||
@@ -40,19 +39,20 @@ const FlameGraphHeader = ({
|
|||||||
setSearch,
|
setSearch,
|
||||||
selectedView,
|
selectedView,
|
||||||
setSelectedView,
|
setSelectedView,
|
||||||
|
viewMode,
|
||||||
|
setViewMode,
|
||||||
|
leftPaneView,
|
||||||
|
setLeftPaneView,
|
||||||
|
rightPaneView,
|
||||||
|
setRightPaneView,
|
||||||
|
singleView,
|
||||||
|
setSingleView,
|
||||||
containerWidth,
|
containerWidth,
|
||||||
onReset,
|
onReset,
|
||||||
textAlign,
|
|
||||||
onTextAlignChange,
|
|
||||||
showResetButton,
|
showResetButton,
|
||||||
colorScheme,
|
|
||||||
onColorSchemeChange,
|
|
||||||
stickyHeader,
|
stickyHeader,
|
||||||
extraHeaderElements,
|
extraHeaderElements,
|
||||||
vertical,
|
vertical,
|
||||||
isDiffMode,
|
|
||||||
setCollapsedMap,
|
|
||||||
collapsedMap,
|
|
||||||
assistantContext,
|
assistantContext,
|
||||||
}: Props) => {
|
}: Props) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
@@ -87,6 +87,25 @@ const FlameGraphHeader = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{selectedView === SelectedView.Multi && viewMode === ViewMode.Split && (
|
||||||
|
<div className={styles.middleContainer}>
|
||||||
|
<RadioButtonGroup<PaneView>
|
||||||
|
size="sm"
|
||||||
|
options={paneViewOptions}
|
||||||
|
value={leftPaneView}
|
||||||
|
onChange={setLeftPaneView}
|
||||||
|
className={styles.buttonSpacing}
|
||||||
|
/>
|
||||||
|
<RadioButtonGroup<PaneView>
|
||||||
|
size="sm"
|
||||||
|
options={paneViewOptions}
|
||||||
|
value={rightPaneView}
|
||||||
|
onChange={setRightPaneView}
|
||||||
|
className={styles.buttonSpacing}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className={styles.rightContainer}>
|
<div className={styles.rightContainer}>
|
||||||
{!!assistantContext?.length && (
|
{!!assistantContext?.length && (
|
||||||
<div className={styles.buttonSpacing}>
|
<div className={styles.buttonSpacing}>
|
||||||
@@ -111,129 +130,63 @@ const FlameGraphHeader = ({
|
|||||||
aria-label={'Reset focus and sandwich state'}
|
aria-label={'Reset focus and sandwich state'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
|
{selectedView === SelectedView.Multi ? (
|
||||||
<ButtonGroup className={styles.buttonSpacing}>
|
<>
|
||||||
<Button
|
{viewMode === ViewMode.Single && (
|
||||||
variant={'secondary'}
|
<RadioButtonGroup<PaneView>
|
||||||
fill={'outline'}
|
size="sm"
|
||||||
size={'sm'}
|
options={paneViewOptions}
|
||||||
tooltip={'Expand all groups'}
|
value={singleView}
|
||||||
onClick={() => {
|
onChange={setSingleView}
|
||||||
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
|
className={styles.buttonSpacing}
|
||||||
}}
|
/>
|
||||||
aria-label={'Expand all groups'}
|
)}
|
||||||
icon={'angle-double-down'}
|
<RadioButtonGroup<ViewMode>
|
||||||
disabled={selectedView === SelectedView.TopTable}
|
size="sm"
|
||||||
|
options={viewModeOptions}
|
||||||
|
value={viewMode}
|
||||||
|
onChange={setViewMode}
|
||||||
|
className={styles.buttonSpacing}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<RadioButtonGroup<SelectedView>
|
||||||
|
size="sm"
|
||||||
|
options={getViewOptions(containerWidth, vertical)}
|
||||||
|
value={selectedView}
|
||||||
|
onChange={setSelectedView}
|
||||||
|
className={styles.buttonSpacing}
|
||||||
/>
|
/>
|
||||||
<Button
|
)}
|
||||||
variant={'secondary'}
|
|
||||||
fill={'outline'}
|
|
||||||
size={'sm'}
|
|
||||||
tooltip={'Collapse all groups'}
|
|
||||||
onClick={() => {
|
|
||||||
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
|
|
||||||
}}
|
|
||||||
aria-label={'Collapse all groups'}
|
|
||||||
icon={'angle-double-up'}
|
|
||||||
disabled={selectedView === SelectedView.TopTable}
|
|
||||||
/>
|
|
||||||
</ButtonGroup>
|
|
||||||
<RadioButtonGroup<TextAlign>
|
|
||||||
size="sm"
|
|
||||||
disabled={selectedView === SelectedView.TopTable}
|
|
||||||
options={alignOptions}
|
|
||||||
value={textAlign}
|
|
||||||
onChange={onTextAlignChange}
|
|
||||||
className={styles.buttonSpacing}
|
|
||||||
/>
|
|
||||||
<RadioButtonGroup<SelectedView>
|
|
||||||
size="sm"
|
|
||||||
options={getViewOptions(containerWidth, vertical)}
|
|
||||||
value={selectedView}
|
|
||||||
onChange={setSelectedView}
|
|
||||||
/>
|
|
||||||
{extraHeaderElements && <div className={styles.extraElements}>{extraHeaderElements}</div>}
|
{extraHeaderElements && <div className={styles.extraElements}>{extraHeaderElements}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
type ColorSchemeButtonProps = {
|
const viewModeOptions: Array<SelectableValue<ViewMode>> = [
|
||||||
value: ColorScheme | ColorSchemeDiff;
|
{ value: ViewMode.Single, label: 'Single', description: 'Single view' },
|
||||||
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
{ value: ViewMode.Split, label: 'Split', description: 'Split view' },
|
||||||
isDiffMode: boolean;
|
];
|
||||||
};
|
|
||||||
function ColorSchemeButton(props: ColorSchemeButtonProps) {
|
|
||||||
// TODO: probably create separate getStyles
|
|
||||||
const styles = useStyles2(getStyles);
|
|
||||||
let menu = (
|
|
||||||
<Menu>
|
|
||||||
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
|
|
||||||
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Show a bit different gradient as a way to indicate selected value
|
const paneViewOptions: Array<SelectableValue<PaneView>> = [
|
||||||
const colorDotStyle =
|
{ value: PaneView.TopTable, label: 'Top Table' },
|
||||||
{
|
{ value: PaneView.FlameGraph, label: 'Flame Graph' },
|
||||||
[ColorScheme.ValueBased]: styles.colorDotByValue,
|
{ value: PaneView.CallTree, label: 'Call Tree' },
|
||||||
[ColorScheme.PackageBased]: styles.colorDotByPackage,
|
|
||||||
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
|
|
||||||
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
|
|
||||||
}[props.value] || styles.colorDotByValue;
|
|
||||||
|
|
||||||
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
|
|
||||||
|
|
||||||
if (props.isDiffMode) {
|
|
||||||
menu = (
|
|
||||||
<Menu>
|
|
||||||
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
|
|
||||||
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
|
|
||||||
contents = (
|
|
||||||
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
|
|
||||||
<div>-100% (removed)</div>
|
|
||||||
<div>0%</div>
|
|
||||||
<div>+100% (added)</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown overlay={menu}>
|
|
||||||
<Button
|
|
||||||
variant={'secondary'}
|
|
||||||
fill={'outline'}
|
|
||||||
size={'sm'}
|
|
||||||
tooltip={'Change color scheme'}
|
|
||||||
onClick={() => {}}
|
|
||||||
className={styles.buttonSpacing}
|
|
||||||
aria-label={'Change color scheme'}
|
|
||||||
>
|
|
||||||
{contents}
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const alignOptions: Array<SelectableValue<TextAlign>> = [
|
|
||||||
{ value: 'left', description: 'Align text left', icon: 'align-left' },
|
|
||||||
{ value: 'right', description: 'Align text right', icon: 'align-right' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function getViewOptions(width: number, vertical?: boolean): Array<SelectableValue<SelectedView>> {
|
function getViewOptions(width: number, vertical?: boolean): Array<SelectableValue<SelectedView>> {
|
||||||
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
|
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
|
||||||
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
|
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
|
||||||
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
|
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
|
||||||
|
{ value: SelectedView.CallTree, label: 'Call Tree', description: 'Only show call tree' },
|
||||||
];
|
];
|
||||||
|
|
||||||
if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) {
|
if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) {
|
||||||
viewOptions.push({
|
viewOptions.push({
|
||||||
value: SelectedView.Both,
|
value: SelectedView.Multi,
|
||||||
label: 'Both',
|
label: 'Multi',
|
||||||
description: 'Show both the top table and flame graph',
|
description: 'Show split or single view with multiple visualizations',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -273,10 +226,12 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-start',
|
||||||
width: '100%',
|
width: '100%',
|
||||||
top: 0,
|
top: 0,
|
||||||
gap: theme.spacing(1),
|
gap: theme.spacing(1),
|
||||||
marginTop: theme.spacing(1),
|
marginTop: theme.spacing(1),
|
||||||
|
position: 'relative',
|
||||||
}),
|
}),
|
||||||
stickyHeader: css({
|
stickyHeader: css({
|
||||||
zIndex: theme.zIndex.navbarFixed,
|
zIndex: theme.zIndex.navbarFixed,
|
||||||
@@ -285,10 +240,20 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
}),
|
}),
|
||||||
inputContainer: css({
|
inputContainer: css({
|
||||||
label: 'inputContainer',
|
label: 'inputContainer',
|
||||||
flexGrow: 1,
|
flexGrow: 0,
|
||||||
minWidth: '150px',
|
minWidth: '150px',
|
||||||
maxWidth: '350px',
|
maxWidth: '350px',
|
||||||
}),
|
}),
|
||||||
|
middleContainer: css({
|
||||||
|
label: 'middleContainer',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: theme.spacing(1),
|
||||||
|
position: 'absolute',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
}),
|
||||||
rightContainer: css({
|
rightContainer: css({
|
||||||
label: 'rightContainer',
|
label: 'rightContainer',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
@@ -309,44 +274,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
|||||||
padding: '0 5px',
|
padding: '0 5px',
|
||||||
color: theme.colors.text.disabled,
|
color: theme.colors.text.disabled,
|
||||||
}),
|
}),
|
||||||
colorDot: css({
|
|
||||||
label: 'colorDot',
|
|
||||||
display: 'inline-block',
|
|
||||||
width: '10px',
|
|
||||||
height: '10px',
|
|
||||||
borderRadius: theme.shape.radius.circle,
|
|
||||||
}),
|
|
||||||
colorDotDiff: css({
|
|
||||||
label: 'colorDotDiff',
|
|
||||||
display: 'flex',
|
|
||||||
width: '200px',
|
|
||||||
height: '12px',
|
|
||||||
color: 'white',
|
|
||||||
fontSize: 9,
|
|
||||||
lineHeight: 1.3,
|
|
||||||
fontWeight: 300,
|
|
||||||
justifyContent: 'space-between',
|
|
||||||
padding: '0 2px',
|
|
||||||
// We have a specific sizing for this so probably makes sense to use hardcoded value here
|
|
||||||
// eslint-disable-next-line @grafana/no-border-radius-literal
|
|
||||||
borderRadius: '2px',
|
|
||||||
}),
|
|
||||||
colorDotByValue: css({
|
|
||||||
label: 'colorDotByValue',
|
|
||||||
background: byValueGradient,
|
|
||||||
}),
|
|
||||||
colorDotByPackage: css({
|
|
||||||
label: 'colorDotByPackage',
|
|
||||||
background: byPackageGradient,
|
|
||||||
}),
|
|
||||||
colorDotDiffDefault: css({
|
|
||||||
label: 'colorDotDiffDefault',
|
|
||||||
background: diffDefaultGradient,
|
|
||||||
}),
|
|
||||||
colorDotDiffColorBlind: css({
|
|
||||||
label: 'colorDotDiffColorBlind',
|
|
||||||
background: diffColorBlindGradient,
|
|
||||||
}),
|
|
||||||
extraElements: css({
|
extraElements: css({
|
||||||
label: 'extraElements',
|
label: 'extraElements',
|
||||||
marginLeft: theme.spacing(1),
|
marginLeft: theme.spacing(1),
|
||||||
|
|||||||
269
packages/grafana-flamegraph/src/FlameGraphPane.tsx
Normal file
269
packages/grafana-flamegraph/src/FlameGraphPane.tsx
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
|
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
|
||||||
|
|
||||||
|
import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer';
|
||||||
|
import FlameGraph from './FlameGraph/FlameGraph';
|
||||||
|
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
|
||||||
|
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
|
||||||
|
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
|
||||||
|
import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types';
|
||||||
|
|
||||||
|
export type FlameGraphPaneProps = {
|
||||||
|
paneView: PaneView;
|
||||||
|
dataContainer: FlameGraphDataContainer;
|
||||||
|
search: string;
|
||||||
|
matchedLabels: Set<string> | undefined;
|
||||||
|
onTableSymbolClick?: (symbol: string) => void;
|
||||||
|
onTextAlignSelected?: (align: string) => void;
|
||||||
|
onTableSort?: (sort: string) => void;
|
||||||
|
showFlameGraphOnly?: boolean;
|
||||||
|
disableCollapsing?: boolean;
|
||||||
|
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
|
||||||
|
selectedView: SelectedView;
|
||||||
|
viewMode: ViewMode;
|
||||||
|
theme: GrafanaTheme2;
|
||||||
|
setSearch: (search: string) => void;
|
||||||
|
/** When this key changes, the pane's internal state (focus, sandwich, etc.) will be reset */
|
||||||
|
resetKey?: number;
|
||||||
|
/** Whether to preserve focus when the data changes */
|
||||||
|
keepFocusOnDataChange?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FlameGraphPane = ({
|
||||||
|
paneView,
|
||||||
|
dataContainer,
|
||||||
|
search,
|
||||||
|
matchedLabels,
|
||||||
|
onTableSymbolClick,
|
||||||
|
onTextAlignSelected,
|
||||||
|
onTableSort,
|
||||||
|
showFlameGraphOnly,
|
||||||
|
disableCollapsing,
|
||||||
|
getExtraContextMenuButtons,
|
||||||
|
selectedView,
|
||||||
|
viewMode,
|
||||||
|
theme,
|
||||||
|
setSearch,
|
||||||
|
resetKey,
|
||||||
|
keepFocusOnDataChange,
|
||||||
|
}: FlameGraphPaneProps) => {
|
||||||
|
// Pane-specific state - each instance maintains its own
|
||||||
|
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
|
||||||
|
const [rangeMin, setRangeMin] = useState(0);
|
||||||
|
const [rangeMax, setRangeMax] = useState(1);
|
||||||
|
const [textAlign, setTextAlign] = useState<TextAlign>('left');
|
||||||
|
const [sandwichItem, setSandwichItem] = useState<string>();
|
||||||
|
// Initialize collapsedMap from dataContainer to ensure collapsed groups are shown correctly on first render
|
||||||
|
const [collapsedMap, setCollapsedMap] = useState(() => dataContainer.getCollapsedMap());
|
||||||
|
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
|
||||||
|
|
||||||
|
const styles = useMemo(() => getStyles(theme), [theme]);
|
||||||
|
|
||||||
|
// Re-initialize collapsed map when dataContainer changes (e.g., new data loaded)
|
||||||
|
// Using useLayoutEffect to ensure collapsed state is applied before browser paint
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
setCollapsedMap(dataContainer.getCollapsedMap());
|
||||||
|
}, [dataContainer]);
|
||||||
|
|
||||||
|
// Reset internal state when resetKey changes (triggered by parent's reset button)
|
||||||
|
useEffect(() => {
|
||||||
|
if (resetKey !== undefined && resetKey > 0) {
|
||||||
|
setFocusedItemData(undefined);
|
||||||
|
setRangeMin(0);
|
||||||
|
setRangeMax(1);
|
||||||
|
setSandwichItem(undefined);
|
||||||
|
}
|
||||||
|
}, [resetKey]);
|
||||||
|
|
||||||
|
// Handle focus preservation or reset when data changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!keepFocusOnDataChange) {
|
||||||
|
setFocusedItemData(undefined);
|
||||||
|
setRangeMin(0);
|
||||||
|
setRangeMax(1);
|
||||||
|
setSandwichItem(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dataContainer && focusedItemData) {
|
||||||
|
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
|
||||||
|
|
||||||
|
if (item) {
|
||||||
|
setFocusedItemData({ ...focusedItemData, item });
|
||||||
|
|
||||||
|
const levels = dataContainer.getLevels();
|
||||||
|
const totalViewTicks = levels.length ? levels[0][0].value : 0;
|
||||||
|
setRangeMin(item.start / totalViewTicks);
|
||||||
|
setRangeMax((item.start + item.value) / totalViewTicks);
|
||||||
|
} else {
|
||||||
|
setFocusedItemData({
|
||||||
|
...focusedItemData,
|
||||||
|
item: {
|
||||||
|
start: 0,
|
||||||
|
value: 0,
|
||||||
|
itemIndexes: [],
|
||||||
|
children: [],
|
||||||
|
level: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
setRangeMin(0);
|
||||||
|
setRangeMax(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [dataContainer, keepFocusOnDataChange]);
|
||||||
|
|
||||||
|
const resetFocus = useCallback(() => {
|
||||||
|
setFocusedItemData(undefined);
|
||||||
|
setRangeMin(0);
|
||||||
|
setRangeMax(1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetSandwich = useCallback(() => {
|
||||||
|
setSandwichItem(undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onSymbolClick = useCallback(
|
||||||
|
(symbol: string) => {
|
||||||
|
const anchored = `^${escapeStringForRegex(symbol)}$`;
|
||||||
|
if (search === anchored) {
|
||||||
|
setSearch('');
|
||||||
|
} else {
|
||||||
|
onTableSymbolClick?.(symbol);
|
||||||
|
setSearch(anchored);
|
||||||
|
resetFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[search, setSearch, resetFocus, onTableSymbolClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Separate callback for CallTree that doesn't trigger search
|
||||||
|
const onCallTreeSymbolClick = useCallback(
|
||||||
|
(symbol: string) => {
|
||||||
|
onTableSymbolClick?.(symbol);
|
||||||
|
},
|
||||||
|
[onTableSymbolClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Search callback for CallTree search button
|
||||||
|
const onCallTreeSearch = useCallback(
|
||||||
|
(symbol: string) => {
|
||||||
|
const anchored = `^${escapeStringForRegex(symbol)}$`;
|
||||||
|
if (search === anchored) {
|
||||||
|
setSearch('');
|
||||||
|
} else {
|
||||||
|
onTableSymbolClick?.(symbol);
|
||||||
|
setSearch(anchored);
|
||||||
|
resetFocus();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[search, setSearch, resetFocus, onTableSymbolClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
const isInSplitView = selectedView === SelectedView.Multi && viewMode === ViewMode.Split;
|
||||||
|
const isCallTreeInSplitView = isInSplitView && paneView === PaneView.CallTree;
|
||||||
|
|
||||||
|
switch (paneView) {
|
||||||
|
case PaneView.TopTable:
|
||||||
|
return (
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FlameGraphTopTableContainer
|
||||||
|
data={dataContainer}
|
||||||
|
onSymbolClick={onSymbolClick}
|
||||||
|
search={search}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
sandwichItem={sandwichItem}
|
||||||
|
onSandwich={setSandwichItem}
|
||||||
|
onSearch={(str) => {
|
||||||
|
if (!str) {
|
||||||
|
setSearch('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSearch(`^${escapeStringForRegex(str)}$`);
|
||||||
|
}}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
colorScheme={colorScheme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case PaneView.FlameGraph:
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<FlameGraph
|
||||||
|
data={dataContainer}
|
||||||
|
rangeMin={rangeMin}
|
||||||
|
rangeMax={rangeMax}
|
||||||
|
matchedLabels={matchedLabels}
|
||||||
|
setRangeMin={setRangeMin}
|
||||||
|
setRangeMax={setRangeMax}
|
||||||
|
onItemFocused={(data) => setFocusedItemData(data)}
|
||||||
|
focusedItemData={focusedItemData}
|
||||||
|
textAlign={textAlign}
|
||||||
|
onTextAlignChange={(align) => {
|
||||||
|
setTextAlign(align);
|
||||||
|
onTextAlignSelected?.(align);
|
||||||
|
}}
|
||||||
|
sandwichItem={sandwichItem}
|
||||||
|
onSandwich={(label: string) => {
|
||||||
|
resetFocus();
|
||||||
|
setSandwichItem(label);
|
||||||
|
}}
|
||||||
|
onFocusPillClick={resetFocus}
|
||||||
|
onSandwichPillClick={resetSandwich}
|
||||||
|
colorScheme={colorScheme}
|
||||||
|
onColorSchemeChange={setColorScheme}
|
||||||
|
isDiffMode={dataContainer.isDiffFlamegraph()}
|
||||||
|
showFlameGraphOnly={showFlameGraphOnly}
|
||||||
|
collapsing={!disableCollapsing}
|
||||||
|
getExtraContextMenuButtons={getExtraContextMenuButtons}
|
||||||
|
selectedView={selectedView}
|
||||||
|
search={search}
|
||||||
|
collapsedMap={collapsedMap}
|
||||||
|
setCollapsedMap={setCollapsedMap}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case PaneView.CallTree:
|
||||||
|
return (
|
||||||
|
<div className={styles.tableContainer}>
|
||||||
|
<FlameGraphCallTreeContainer
|
||||||
|
data={dataContainer}
|
||||||
|
onSymbolClick={onCallTreeSymbolClick}
|
||||||
|
sandwichItem={sandwichItem}
|
||||||
|
onSandwich={setSandwichItem}
|
||||||
|
onTableSort={onTableSort}
|
||||||
|
colorScheme={colorScheme}
|
||||||
|
search={search}
|
||||||
|
compact={isCallTreeInSplitView}
|
||||||
|
onSearch={onCallTreeSearch}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
|
||||||
|
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
|
||||||
|
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
|
||||||
|
|
||||||
|
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
|
||||||
|
useEffect(() => {
|
||||||
|
setColorScheme(defaultColorScheme);
|
||||||
|
}, [defaultColorScheme]);
|
||||||
|
|
||||||
|
return [colorScheme, setColorScheme] as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) {
|
||||||
|
return {
|
||||||
|
tableContainer: css({
|
||||||
|
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
|
||||||
|
// in explore we need a specific height.
|
||||||
|
height: 800,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default FlameGraphPane;
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
export { default as FlameGraph, type Props } from './FlameGraphContainer';
|
export { default as FlameGraph, type Props } from './FlameGraphContainer';
|
||||||
|
export { default as FlameGraphCallTreeContainer } from './CallTree/FlameGraphCallTreeContainer';
|
||||||
export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform';
|
export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform';
|
||||||
export { data } from './FlameGraph/testData/dataNestedSet';
|
export { data } from './FlameGraph/testData/dataNestedSet';
|
||||||
|
|||||||
@@ -20,7 +20,19 @@ export enum SampleUnit {
|
|||||||
export enum SelectedView {
|
export enum SelectedView {
|
||||||
TopTable = 'topTable',
|
TopTable = 'topTable',
|
||||||
FlameGraph = 'flameGraph',
|
FlameGraph = 'flameGraph',
|
||||||
Both = 'both',
|
Multi = 'multi',
|
||||||
|
CallTree = 'callTree',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum ViewMode {
|
||||||
|
Single = 'single',
|
||||||
|
Split = 'split',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum PaneView {
|
||||||
|
TopTable = 'topTable',
|
||||||
|
FlameGraph = 'flameGraph',
|
||||||
|
CallTree = 'callTree',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TableData {
|
export interface TableData {
|
||||||
|
|||||||
@@ -1,14 +1,40 @@
|
|||||||
import { Decorator } from '@storybook/react';
|
import { Decorator } from '@storybook/react';
|
||||||
|
import { useEffect } from 'react';
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
import { getThemeById, ThemeContext } from '@grafana/data';
|
import { createTheme, getThemeById, ThemeContext } from '@grafana/data';
|
||||||
import { GlobalStyles } from '@grafana/ui';
|
import { GlobalStyles, PortalContainer } from '@grafana/ui';
|
||||||
|
|
||||||
interface ThemeableStoryProps {
|
interface ThemeableStoryProps {
|
||||||
themeId: string;
|
themeId?: string;
|
||||||
}
|
}
|
||||||
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
|
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
|
||||||
const theme = getThemeById(themeId);
|
// Always ensure we have a valid theme
|
||||||
|
const theme = React.useMemo(() => {
|
||||||
|
const id = themeId || 'dark';
|
||||||
|
let resolvedTheme = getThemeById(id);
|
||||||
|
|
||||||
|
// If getThemeById returns undefined, create a default theme
|
||||||
|
if (!resolvedTheme) {
|
||||||
|
console.warn(`Theme '${id}' not found, using default theme`);
|
||||||
|
resolvedTheme = createTheme({ colors: { mode: id === 'light' ? 'light' : 'dark' } });
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('withTheme: resolved theme', { id, hasTheme: !!resolvedTheme, hasSpacing: !!resolvedTheme?.spacing });
|
||||||
|
return resolvedTheme;
|
||||||
|
}, [themeId]);
|
||||||
|
|
||||||
|
// Apply theme to document root for Portals
|
||||||
|
useEffect(() => {
|
||||||
|
if (!theme) return;
|
||||||
|
|
||||||
|
document.body.style.setProperty('--theme-background', theme.colors.background.primary);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
if (!theme) {
|
||||||
|
console.error('withTheme: No theme available!');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const css = `
|
const css = `
|
||||||
#storybook-root {
|
#storybook-root {
|
||||||
@@ -23,6 +49,7 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
|
|||||||
return (
|
return (
|
||||||
<ThemeContext.Provider value={theme}>
|
<ThemeContext.Provider value={theme}>
|
||||||
<GlobalStyles />
|
<GlobalStyles />
|
||||||
|
<PortalContainer />
|
||||||
|
|
||||||
<style>{css}</style>
|
<style>{css}</style>
|
||||||
{children}
|
{children}
|
||||||
@@ -33,4 +60,4 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
|
|||||||
export const withTheme =
|
export const withTheme =
|
||||||
(): Decorator =>
|
(): Decorator =>
|
||||||
// eslint-disable-next-line react/display-name
|
// eslint-disable-next-line react/display-name
|
||||||
(story, context) => <ThemeableStory themeId={context.globals.theme}>{story()}</ThemeableStory>;
|
(story, context) => <ThemeableStory themeId={context.globals?.theme}>{story()}</ThemeableStory>;
|
||||||
|
|||||||
@@ -3507,6 +3507,7 @@ __metadata:
|
|||||||
"@types/lodash": "npm:4.17.20"
|
"@types/lodash": "npm:4.17.20"
|
||||||
"@types/node": "npm:24.10.1"
|
"@types/node": "npm:24.10.1"
|
||||||
"@types/react": "npm:18.3.18"
|
"@types/react": "npm:18.3.18"
|
||||||
|
"@types/react-table": "npm:^7.7.20"
|
||||||
"@types/react-virtualized-auto-sizer": "npm:1.0.8"
|
"@types/react-virtualized-auto-sizer": "npm:1.0.8"
|
||||||
"@types/tinycolor2": "npm:1.4.6"
|
"@types/tinycolor2": "npm:1.4.6"
|
||||||
babel-jest: "npm:29.7.0"
|
babel-jest: "npm:29.7.0"
|
||||||
@@ -3517,6 +3518,7 @@ __metadata:
|
|||||||
jest-canvas-mock: "npm:2.5.2"
|
jest-canvas-mock: "npm:2.5.2"
|
||||||
lodash: "npm:4.17.21"
|
lodash: "npm:4.17.21"
|
||||||
react: "npm:18.3.1"
|
react: "npm:18.3.1"
|
||||||
|
react-table: "npm:^7.8.0"
|
||||||
react-use: "npm:17.6.0"
|
react-use: "npm:17.6.0"
|
||||||
react-virtualized-auto-sizer: "npm:1.0.26"
|
react-virtualized-auto-sizer: "npm:1.0.26"
|
||||||
rollup: "npm:^4.22.4"
|
rollup: "npm:^4.22.4"
|
||||||
@@ -11159,7 +11161,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"@types/react-table@npm:7.7.20":
|
"@types/react-table@npm:7.7.20, @types/react-table@npm:^7.7.20":
|
||||||
version: 7.7.20
|
version: 7.7.20
|
||||||
resolution: "@types/react-table@npm:7.7.20"
|
resolution: "@types/react-table@npm:7.7.20"
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -29371,7 +29373,7 @@ __metadata:
|
|||||||
languageName: node
|
languageName: node
|
||||||
linkType: hard
|
linkType: hard
|
||||||
|
|
||||||
"react-table@npm:7.8.0":
|
"react-table@npm:7.8.0, react-table@npm:^7.8.0":
|
||||||
version: 7.8.0
|
version: 7.8.0
|
||||||
resolution: "react-table@npm:7.8.0"
|
resolution: "react-table@npm:7.8.0"
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
|||||||
Reference in New Issue
Block a user