Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2cf5bd0d5d |
@@ -103,11 +103,10 @@ To configure basic settings for the data source, complete the following steps:
|
||||
|
||||
1. Set the data source's basic configuration options:
|
||||
|
||||
| Name | Description |
|
||||
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
|
||||
| **Default** | Sets whether the data source is pre-selected for new panels. |
|
||||
| **Universe Domain** | The universe domain to connect to. For more information, refer to [Documentation on universe domains](https://docs.cloud.google.com/python/docs/reference/monitoring/latest/google.cloud.monitoring_v3.services.service_monitoring_service.ServiceMonitoringServiceAsyncClient#google_cloud_monitoring_v3_services_service_monitoring_service_ServiceMonitoringServiceAsyncClient_universe_domain). Defaults to `googleapis.com`. |
|
||||
| Name | Description |
|
||||
| ----------- | ------------------------------------------------------------------------ |
|
||||
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
|
||||
| **Default** | Sets whether the data source is pre-selected for new panels. |
|
||||
|
||||
### Provision the data source
|
||||
|
||||
@@ -130,7 +129,6 @@ datasources:
|
||||
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
|
||||
authenticationType: jwt
|
||||
defaultProject: my-project-name
|
||||
universeDomain: googleapis.com
|
||||
secureJsonData:
|
||||
privateKey: |
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
@@ -154,7 +152,6 @@ datasources:
|
||||
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
|
||||
authenticationType: jwt
|
||||
defaultProject: my-project-name
|
||||
universeDomain: googleapis.com
|
||||
privateKeyPath: /etc/secrets/gce.pem
|
||||
```
|
||||
|
||||
@@ -169,7 +166,6 @@ datasources:
|
||||
access: proxy
|
||||
jsonData:
|
||||
authenticationType: gce
|
||||
universeDomain: googleapis.com
|
||||
```
|
||||
|
||||
## Import pre-configured dashboards
|
||||
|
||||
@@ -87,7 +87,6 @@ With a Grafana Enterprise license, you also get access to premium data sources,
|
||||
- [CockroachDB](/grafana/plugins/grafana-cockroachdb-datasource)
|
||||
- [Databricks](/grafana/plugins/grafana-databricks-datasource)
|
||||
- [DataDog](/grafana/plugins/grafana-datadog-datasource)
|
||||
- [IBM Db2](/grafana/plugins/grafana-ibmdb2-datasource)
|
||||
- [Drone](/grafana/plugins/grafana-drone-datasource)
|
||||
- [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/)
|
||||
- [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource)
|
||||
|
||||
@@ -58,7 +58,6 @@
|
||||
"d3": "^7.8.5",
|
||||
"lodash": "4.17.21",
|
||||
"react": "18.3.1",
|
||||
"react-table": "^7.8.0",
|
||||
"react-use": "17.6.0",
|
||||
"react-virtualized-auto-sizer": "1.0.26",
|
||||
"tinycolor2": "1.6.0",
|
||||
@@ -82,7 +81,6 @@
|
||||
"@types/lodash": "4.17.20",
|
||||
"@types/node": "24.10.1",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-table": "^7.7.20",
|
||||
"@types/react-virtualized-auto-sizer": "1.0.8",
|
||||
"@types/tinycolor2": "1.4.6",
|
||||
"babel-jest": "29.7.0",
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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
@@ -1,580 +0,0 @@
|
||||
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,
|
||||
textAlign: 'left',
|
||||
colorScheme: ColorScheme.PackageBased,
|
||||
selectedView: SelectedView.Multi,
|
||||
selectedView: SelectedView.Both,
|
||||
search: '',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -43,13 +43,10 @@ describe('FlameGraph', () => {
|
||||
setRangeMax={setRangeMax}
|
||||
onItemFocused={onItemFocused}
|
||||
textAlign={'left'}
|
||||
onTextAlignChange={jest.fn()}
|
||||
onSandwich={onSandwich}
|
||||
onFocusPillClick={onFocusPillClick}
|
||||
onSandwichPillClick={onSandwichPillClick}
|
||||
colorScheme={ColorScheme.ValueBased}
|
||||
onColorSchemeChange={jest.fn()}
|
||||
isDiffMode={false}
|
||||
selectedView={SelectedView.FlameGraph}
|
||||
search={''}
|
||||
collapsedMap={container.getCollapsedMap()}
|
||||
|
||||
@@ -19,10 +19,8 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Button, ButtonGroup, Dropdown, Icon, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { Icon } from '@grafana/ui';
|
||||
|
||||
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './colors';
|
||||
import { PIXELS_PER_LEVEL } from '../constants';
|
||||
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
|
||||
|
||||
@@ -41,14 +39,11 @@ type Props = {
|
||||
onItemFocused: (data: ClickedItemData) => void;
|
||||
focusedItemData?: ClickedItemData;
|
||||
textAlign: TextAlign;
|
||||
onTextAlignChange: (align: TextAlign) => void;
|
||||
sandwichItem?: string;
|
||||
onSandwich: (label: string) => void;
|
||||
onFocusPillClick: () => void;
|
||||
onSandwichPillClick: () => void;
|
||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
||||
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
||||
isDiffMode: boolean;
|
||||
showFlameGraphOnly?: boolean;
|
||||
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
|
||||
collapsing?: boolean;
|
||||
@@ -68,14 +63,11 @@ const FlameGraph = ({
|
||||
onItemFocused,
|
||||
focusedItemData,
|
||||
textAlign,
|
||||
onTextAlignChange,
|
||||
onSandwich,
|
||||
sandwichItem,
|
||||
onFocusPillClick,
|
||||
onSandwichPillClick,
|
||||
colorScheme,
|
||||
onColorSchemeChange,
|
||||
isDiffMode,
|
||||
showFlameGraphOnly,
|
||||
getExtraContextMenuButtons,
|
||||
collapsing,
|
||||
@@ -84,7 +76,7 @@ const FlameGraph = ({
|
||||
collapsedMap,
|
||||
setCollapsedMap,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
const styles = getStyles();
|
||||
|
||||
const [levels, setLevels] = useState<LevelItem[][]>();
|
||||
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
|
||||
@@ -183,183 +175,28 @@ 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 (
|
||||
<div className={styles.graph}>
|
||||
<div className={styles.toolbar}>
|
||||
<FlameGraphMetadata
|
||||
data={data}
|
||||
focusedItem={focusedItemData}
|
||||
sandwichedLabel={sandwichItem}
|
||||
totalTicks={totalViewTicks}
|
||||
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>
|
||||
<FlameGraphMetadata
|
||||
data={data}
|
||||
focusedItem={focusedItemData}
|
||||
sandwichedLabel={sandwichItem}
|
||||
totalTicks={totalViewTicks}
|
||||
onFocusPillClick={onFocusPillClick}
|
||||
onSandwichPillClick={onSandwichPillClick}
|
||||
/>
|
||||
{canvas}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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) => ({
|
||||
const getStyles = () => ({
|
||||
graph: css({
|
||||
label: 'graph',
|
||||
overflow: 'auto',
|
||||
flexGrow: 1,
|
||||
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({
|
||||
label: 'sandwichCanvasWrapper',
|
||||
display: 'flex',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,18 +1,19 @@
|
||||
import { css } from '@emotion/css';
|
||||
import uFuzzy from '@leeoniya/ufuzzy';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import * as React from 'react';
|
||||
import { useMeasure } from 'react-use';
|
||||
|
||||
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
|
||||
import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
|
||||
import { ThemeContext } from '@grafana/ui';
|
||||
|
||||
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
|
||||
import FlameGraph from './FlameGraph/FlameGraph';
|
||||
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
|
||||
import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform';
|
||||
import FlameGraphHeader from './FlameGraphHeader';
|
||||
import FlameGraphPane from './FlameGraphPane';
|
||||
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
|
||||
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
|
||||
import { PaneView, SelectedView, ViewMode } from './types';
|
||||
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
|
||||
import { getAssistantContextFromDataFrame } from './utils';
|
||||
|
||||
const ufuzzy = new uFuzzy();
|
||||
@@ -103,18 +104,17 @@ const FlameGraphContainer = ({
|
||||
getExtraContextMenuButtons,
|
||||
showAnalyzeWithAssistant = true,
|
||||
}: Props) => {
|
||||
// Shared state across all views
|
||||
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
|
||||
|
||||
const [rangeMin, setRangeMin] = useState(0);
|
||||
const [rangeMax, setRangeMax] = useState(1);
|
||||
const [search, setSearch] = useState('');
|
||||
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 [selectedView, setSelectedView] = useState(SelectedView.Both);
|
||||
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
|
||||
// Used to trigger reset of pane-specific state (focus, sandwich) when parent reset button is clicked
|
||||
const [resetKey, setResetKey] = useState(0);
|
||||
// Track if we temporarily switched away from Both view due to narrow width
|
||||
const [viewBeforeNarrow, setViewBeforeNarrow] = useState<SelectedView | null>(null);
|
||||
const [textAlign, setTextAlign] = useState<TextAlign>('left');
|
||||
// This is a label of the item because in sandwich view we group all items by label and present a merged graph
|
||||
const [sandwichItem, setSandwichItem] = useState<string>();
|
||||
const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap());
|
||||
|
||||
const theme = useMemo(() => getTheme(), [getTheme]);
|
||||
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
|
||||
@@ -122,220 +122,157 @@ const FlameGraphContainer = ({
|
||||
return;
|
||||
}
|
||||
|
||||
return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
|
||||
const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
|
||||
setCollapsedMap(container.getCollapsedMap());
|
||||
return container;
|
||||
}, [data, theme, disableCollapsing]);
|
||||
|
||||
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
|
||||
const styles = getStyles(theme);
|
||||
const matchedLabels = useLabelSearch(search, dataContainer);
|
||||
|
||||
// Handle responsive layout: switch away from Both view when narrow, restore when wide again
|
||||
// If user resizes window with both as the selected view
|
||||
useEffect(() => {
|
||||
if (containerWidth === 0) {
|
||||
if (
|
||||
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;
|
||||
}
|
||||
|
||||
const isNarrow = containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && !vertical;
|
||||
if (dataContainer && focusedItemData) {
|
||||
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
|
||||
|
||||
if (isNarrow && selectedView === SelectedView.Multi) {
|
||||
// Going narrow: save current view and switch to FlameGraph
|
||||
setViewBeforeNarrow(SelectedView.Multi);
|
||||
setSelectedView(SelectedView.FlameGraph);
|
||||
} else if (!isNarrow && viewBeforeNarrow !== null) {
|
||||
// Going wide again: restore the previous view
|
||||
setSelectedView(viewBeforeNarrow);
|
||||
setViewBeforeNarrow(null);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}, [containerWidth, vertical, selectedView, viewBeforeNarrow]);
|
||||
}, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
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) {
|
||||
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;
|
||||
if (showFlameGraphOnly || selectedView === SelectedView.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}
|
||||
/>
|
||||
);
|
||||
body = flameGraph;
|
||||
} else if (selectedView === SelectedView.TopTable) {
|
||||
body = (
|
||||
<FlameGraphPane
|
||||
paneView={PaneView.TopTable}
|
||||
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.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 {
|
||||
// Single view mode
|
||||
body = <div className={styles.tableContainer}>{table}</div>;
|
||||
} else if (selectedView === SelectedView.Both) {
|
||||
if (vertical) {
|
||||
body = (
|
||||
<div className={styles.singlePaneContainer}>
|
||||
<FlameGraphPane
|
||||
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 className={styles.verticalGraphContainer}>{flameGraph}</div>
|
||||
<div className={styles.verticalTableContainer}>{table}</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
body = (
|
||||
<div className={styles.horizontalContainer}>
|
||||
<div className={styles.horizontalTableContainer}>{table}</div>
|
||||
<div className={styles.horizontalGraphContainer}>{flameGraph}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -355,24 +292,25 @@ const FlameGraphContainer = ({
|
||||
setSelectedView(view);
|
||||
onViewSelected?.(view);
|
||||
}}
|
||||
viewMode={viewMode}
|
||||
setViewMode={setViewMode}
|
||||
leftPaneView={leftPaneView}
|
||||
setLeftPaneView={setLeftPaneView}
|
||||
rightPaneView={rightPaneView}
|
||||
setRightPaneView={setRightPaneView}
|
||||
singleView={singleView}
|
||||
setSingleView={setSingleView}
|
||||
containerWidth={containerWidth}
|
||||
onReset={() => {
|
||||
// Reset search and pane states when user clicks reset button
|
||||
setSearch('');
|
||||
setResetKey((k) => k + 1);
|
||||
resetFocus();
|
||||
resetSandwich();
|
||||
}}
|
||||
showResetButton={Boolean(search)}
|
||||
textAlign={textAlign}
|
||||
onTextAlignChange={(align) => {
|
||||
setTextAlign(align);
|
||||
onTextAlignSelected?.(align);
|
||||
}}
|
||||
showResetButton={Boolean(focusedItemData || sandwichItem)}
|
||||
colorScheme={colorScheme}
|
||||
onColorSchemeChange={setColorScheme}
|
||||
stickyHeader={Boolean(stickyHeader)}
|
||||
extraHeaderElements={extraHeaderElements}
|
||||
vertical={vertical}
|
||||
isDiffMode={dataContainer.isDiffFlamegraph()}
|
||||
setCollapsedMap={setCollapsedMap}
|
||||
collapsedMap={collapsedMap}
|
||||
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
|
||||
/>
|
||||
)}
|
||||
@@ -383,6 +321,18 @@ 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.
|
||||
*/
|
||||
@@ -470,6 +420,12 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
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({
|
||||
label: 'horizontalContainer',
|
||||
display: 'flex',
|
||||
@@ -479,20 +435,20 @@ function getStyles(theme: GrafanaTheme2) {
|
||||
width: '100%',
|
||||
}),
|
||||
|
||||
horizontalPaneContainer: css({
|
||||
label: 'horizontalPaneContainer',
|
||||
horizontalGraphContainer: css({
|
||||
flexBasis: '50%',
|
||||
}),
|
||||
|
||||
horizontalTableContainer: css({
|
||||
flexBasis: '50%',
|
||||
maxHeight: 800,
|
||||
}),
|
||||
|
||||
verticalPaneContainer: css({
|
||||
label: 'verticalPaneContainer',
|
||||
verticalGraphContainer: css({
|
||||
marginBottom: theme.spacing(1),
|
||||
height: 800,
|
||||
}),
|
||||
|
||||
singlePaneContainer: css({
|
||||
label: 'singlePaneContainer',
|
||||
verticalTableContainer: css({
|
||||
height: 800,
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -3,8 +3,9 @@ import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import * as React from 'react';
|
||||
|
||||
import { CollapsedMap } from './FlameGraph/dataTransform';
|
||||
import FlameGraphHeader from './FlameGraphHeader';
|
||||
import { PaneView, SelectedView, ViewMode } from './types';
|
||||
import { ColorScheme, SelectedView } from './types';
|
||||
|
||||
jest.mock('@grafana/assistant', () => ({
|
||||
useAssistant: jest.fn().mockReturnValue({
|
||||
@@ -19,30 +20,26 @@ describe('FlameGraphHeader', () => {
|
||||
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
|
||||
const setSearch = 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 onSchemeChange = jest.fn();
|
||||
|
||||
const renderResult = render(
|
||||
<FlameGraphHeader
|
||||
search={''}
|
||||
setSearch={setSearch}
|
||||
selectedView={SelectedView.Multi}
|
||||
selectedView={SelectedView.Both}
|
||||
setSelectedView={setSelectedView}
|
||||
viewMode={ViewMode.Split}
|
||||
setViewMode={setViewMode}
|
||||
leftPaneView={PaneView.TopTable}
|
||||
setLeftPaneView={setLeftPaneView}
|
||||
rightPaneView={PaneView.FlameGraph}
|
||||
setRightPaneView={setRightPaneView}
|
||||
singleView={PaneView.FlameGraph}
|
||||
setSingleView={setSingleView}
|
||||
containerWidth={1600}
|
||||
onReset={onReset}
|
||||
onTextAlignChange={jest.fn()}
|
||||
textAlign={'left'}
|
||||
showResetButton={true}
|
||||
colorScheme={ColorScheme.ValueBased}
|
||||
onColorSchemeChange={onSchemeChange}
|
||||
stickyHeader={false}
|
||||
isDiffMode={false}
|
||||
setCollapsedMap={() => {}}
|
||||
collapsedMap={new CollapsedMap()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -53,6 +50,7 @@ describe('FlameGraphHeader', () => {
|
||||
setSearch,
|
||||
setSelectedView,
|
||||
onReset,
|
||||
onSchemeChange,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -72,4 +70,27 @@ describe('FlameGraphHeader', () => {
|
||||
await userEvent.click(resetButton);
|
||||
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,29 +5,30 @@ import { useDebounce, usePrevious } from 'react-use';
|
||||
|
||||
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
|
||||
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
|
||||
import { Button, ButtonGroup, Dropdown, Input, Menu, 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 { PaneView, SelectedView, ViewMode } from './types';
|
||||
import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
|
||||
|
||||
type Props = {
|
||||
search: string;
|
||||
setSearch: (search: string) => void;
|
||||
selectedView: SelectedView;
|
||||
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;
|
||||
onReset: () => void;
|
||||
textAlign: TextAlign;
|
||||
onTextAlignChange: (align: TextAlign) => void;
|
||||
showResetButton: boolean;
|
||||
colorScheme: ColorScheme | ColorSchemeDiff;
|
||||
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
||||
stickyHeader: boolean;
|
||||
vertical?: boolean;
|
||||
isDiffMode: boolean;
|
||||
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
|
||||
collapsedMap: CollapsedMap;
|
||||
|
||||
extraHeaderElements?: React.ReactNode;
|
||||
|
||||
@@ -39,20 +40,19 @@ const FlameGraphHeader = ({
|
||||
setSearch,
|
||||
selectedView,
|
||||
setSelectedView,
|
||||
viewMode,
|
||||
setViewMode,
|
||||
leftPaneView,
|
||||
setLeftPaneView,
|
||||
rightPaneView,
|
||||
setRightPaneView,
|
||||
singleView,
|
||||
setSingleView,
|
||||
containerWidth,
|
||||
onReset,
|
||||
textAlign,
|
||||
onTextAlignChange,
|
||||
showResetButton,
|
||||
colorScheme,
|
||||
onColorSchemeChange,
|
||||
stickyHeader,
|
||||
extraHeaderElements,
|
||||
vertical,
|
||||
isDiffMode,
|
||||
setCollapsedMap,
|
||||
collapsedMap,
|
||||
assistantContext,
|
||||
}: Props) => {
|
||||
const styles = useStyles2(getStyles);
|
||||
@@ -87,25 +87,6 @@ const FlameGraphHeader = ({
|
||||
/>
|
||||
</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}>
|
||||
{!!assistantContext?.length && (
|
||||
<div className={styles.buttonSpacing}>
|
||||
@@ -130,63 +111,129 @@ const FlameGraphHeader = ({
|
||||
aria-label={'Reset focus and sandwich state'}
|
||||
/>
|
||||
)}
|
||||
{selectedView === SelectedView.Multi ? (
|
||||
<>
|
||||
{viewMode === ViewMode.Single && (
|
||||
<RadioButtonGroup<PaneView>
|
||||
size="sm"
|
||||
options={paneViewOptions}
|
||||
value={singleView}
|
||||
onChange={setSingleView}
|
||||
className={styles.buttonSpacing}
|
||||
/>
|
||||
)}
|
||||
<RadioButtonGroup<ViewMode>
|
||||
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}
|
||||
<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'}
|
||||
disabled={selectedView === SelectedView.TopTable}
|
||||
/>
|
||||
)}
|
||||
<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>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const viewModeOptions: Array<SelectableValue<ViewMode>> = [
|
||||
{ value: ViewMode.Single, label: 'Single', description: 'Single view' },
|
||||
{ value: ViewMode.Split, label: 'Split', description: 'Split view' },
|
||||
];
|
||||
type ColorSchemeButtonProps = {
|
||||
value: ColorScheme | ColorSchemeDiff;
|
||||
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
|
||||
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>
|
||||
);
|
||||
|
||||
const paneViewOptions: Array<SelectableValue<PaneView>> = [
|
||||
{ value: PaneView.TopTable, label: 'Top Table' },
|
||||
{ value: PaneView.FlameGraph, label: 'Flame Graph' },
|
||||
{ value: PaneView.CallTree, label: 'Call Tree' },
|
||||
// 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 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>> {
|
||||
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
|
||||
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
|
||||
{ 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) {
|
||||
viewOptions.push({
|
||||
value: SelectedView.Multi,
|
||||
label: 'Multi',
|
||||
description: 'Show split or single view with multiple visualizations',
|
||||
value: SelectedView.Both,
|
||||
label: 'Both',
|
||||
description: 'Show both the top table and flame graph',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,12 +273,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
width: '100%',
|
||||
top: 0,
|
||||
gap: theme.spacing(1),
|
||||
marginTop: theme.spacing(1),
|
||||
position: 'relative',
|
||||
}),
|
||||
stickyHeader: css({
|
||||
zIndex: theme.zIndex.navbarFixed,
|
||||
@@ -240,20 +285,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
}),
|
||||
inputContainer: css({
|
||||
label: 'inputContainer',
|
||||
flexGrow: 0,
|
||||
flexGrow: 1,
|
||||
minWidth: '150px',
|
||||
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({
|
||||
label: 'rightContainer',
|
||||
display: 'flex',
|
||||
@@ -274,6 +309,44 @@ const getStyles = (theme: GrafanaTheme2) => ({
|
||||
padding: '0 5px',
|
||||
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({
|
||||
label: 'extraElements',
|
||||
marginLeft: theme.spacing(1),
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
export { default as FlameGraph, type Props } from './FlameGraphContainer';
|
||||
export { default as FlameGraphCallTreeContainer } from './CallTree/FlameGraphCallTreeContainer';
|
||||
export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform';
|
||||
export { data } from './FlameGraph/testData/dataNestedSet';
|
||||
|
||||
@@ -20,19 +20,7 @@ export enum SampleUnit {
|
||||
export enum SelectedView {
|
||||
TopTable = 'topTable',
|
||||
FlameGraph = 'flameGraph',
|
||||
Multi = 'multi',
|
||||
CallTree = 'callTree',
|
||||
}
|
||||
|
||||
export enum ViewMode {
|
||||
Single = 'single',
|
||||
Split = 'split',
|
||||
}
|
||||
|
||||
export enum PaneView {
|
||||
TopTable = 'topTable',
|
||||
FlameGraph = 'flameGraph',
|
||||
CallTree = 'callTree',
|
||||
Both = 'both',
|
||||
}
|
||||
|
||||
export interface TableData {
|
||||
|
||||
@@ -1,40 +1,14 @@
|
||||
import { Decorator } from '@storybook/react';
|
||||
import { useEffect } from 'react';
|
||||
import * as React from 'react';
|
||||
|
||||
import { createTheme, getThemeById, ThemeContext } from '@grafana/data';
|
||||
import { GlobalStyles, PortalContainer } from '@grafana/ui';
|
||||
import { getThemeById, ThemeContext } from '@grafana/data';
|
||||
import { GlobalStyles } from '@grafana/ui';
|
||||
|
||||
interface ThemeableStoryProps {
|
||||
themeId?: string;
|
||||
themeId: string;
|
||||
}
|
||||
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
|
||||
// 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 theme = getThemeById(themeId);
|
||||
|
||||
const css = `
|
||||
#storybook-root {
|
||||
@@ -49,7 +23,6 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
|
||||
return (
|
||||
<ThemeContext.Provider value={theme}>
|
||||
<GlobalStyles />
|
||||
<PortalContainer />
|
||||
|
||||
<style>{css}</style>
|
||||
{children}
|
||||
@@ -60,4 +33,4 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
|
||||
export const withTheme =
|
||||
(): Decorator =>
|
||||
// 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>;
|
||||
|
||||
@@ -92,7 +92,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
|
||||
}, nil
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/v3/projects/%s/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
|
||||
url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
|
||||
request, err := http.NewRequest(http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -139,7 +139,6 @@ type datasourceInfo struct {
|
||||
defaultProject string
|
||||
clientEmail string
|
||||
tokenUri string
|
||||
universeDomain string
|
||||
services map[string]datasourceService
|
||||
privateKey string
|
||||
usingImpersonation bool
|
||||
@@ -151,7 +150,6 @@ type datasourceJSONData struct {
|
||||
DefaultProject string `json:"defaultProject"`
|
||||
ClientEmail string `json:"clientEmail"`
|
||||
TokenURI string `json:"tokenUri"`
|
||||
UniverseDomain string `json:"universeDomain"`
|
||||
UsingImpersonation bool `json:"usingImpersonation"`
|
||||
ServiceAccountToImpersonate string `json:"serviceAccountToImpersonate"`
|
||||
}
|
||||
@@ -181,7 +179,6 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
|
||||
defaultProject: jsonData.DefaultProject,
|
||||
clientEmail: jsonData.ClientEmail,
|
||||
tokenUri: jsonData.TokenURI,
|
||||
universeDomain: jsonData.UniverseDomain,
|
||||
usingImpersonation: jsonData.UsingImpersonation,
|
||||
serviceAccountToImpersonate: jsonData.ServiceAccountToImpersonate,
|
||||
services: map[string]datasourceService{},
|
||||
@@ -197,13 +194,13 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for name := range routes {
|
||||
for name, info := range routes {
|
||||
client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dsInfo.services[name] = datasourceService{
|
||||
url: buildURL(name, dsInfo.universeDomain),
|
||||
url: info.url,
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ type routeInfo struct {
|
||||
var routes = map[string]routeInfo{
|
||||
cloudMonitor: {
|
||||
method: "GET",
|
||||
url: "https://monitoring.",
|
||||
url: "https://monitoring.googleapis.com",
|
||||
scopes: []string{cloudMonitorScope},
|
||||
},
|
||||
resourceManager: {
|
||||
method: "GET",
|
||||
url: "https://cloudresourcemanager.",
|
||||
url: "https://cloudresourcemanager.googleapis.com",
|
||||
scopes: []string{resourceManagerScope},
|
||||
},
|
||||
}
|
||||
@@ -68,13 +68,6 @@ func getMiddleware(model *datasourceInfo, routePath string) (httpclient.Middlewa
|
||||
return tokenprovider.AuthMiddleware(provider), nil
|
||||
}
|
||||
|
||||
func buildURL(route string, universeDomain string) string {
|
||||
if universeDomain == "" {
|
||||
universeDomain = "googleapis.com"
|
||||
}
|
||||
return routes[route].url + universeDomain
|
||||
}
|
||||
|
||||
func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider *httpclient.Provider, route string) (*http.Client, error) {
|
||||
m, err := getMiddleware(model, route)
|
||||
if err != nil {
|
||||
|
||||
@@ -111,7 +111,7 @@ func Test_setRequestVariables(t *testing.T) {
|
||||
im: &fakeInstance{
|
||||
services: map[string]datasourceService{
|
||||
cloudMonitor: {
|
||||
url: buildURL(cloudMonitor, "googleapis.com"),
|
||||
url: routes[cloudMonitor].url,
|
||||
client: &http.Client{},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('buildCategories', () => {
|
||||
it('should add enterprise phantom plugins', () => {
|
||||
const enterprisePluginsCategory = categories[3];
|
||||
expect(enterprisePluginsCategory.title).toBe('Enterprise plugins');
|
||||
expect(enterprisePluginsCategory.plugins.length).toBe(32);
|
||||
expect(enterprisePluginsCategory.plugins.length).toBe(31);
|
||||
expect(enterprisePluginsCategory.plugins[0].name).toBe('Adobe Analytics');
|
||||
expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Zendesk');
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import catchpointSvg from 'img/plugins/catchpoint.svg';
|
||||
import cloudflareJpg from 'img/plugins/cloudflare.jpg';
|
||||
import cockroachdbJpg from 'img/plugins/cockroachdb.jpg';
|
||||
import datadogPng from 'img/plugins/datadog.png';
|
||||
import db2Svg from 'img/plugins/db2.svg';
|
||||
import droneSvg from 'img/plugins/drone.svg';
|
||||
import dynatracePng from 'img/plugins/dynatrace.png';
|
||||
import gitlabSvg from 'img/plugins/gitlab.svg';
|
||||
@@ -419,12 +418,6 @@ function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] {
|
||||
name: 'SolarWinds',
|
||||
imgUrl: solarWindsSvg,
|
||||
}),
|
||||
getPhantomPlugin({
|
||||
id: 'grafana-ibmdb2-datasource',
|
||||
description: t('datasources.get-enterprise-phantom-plugins.description.ibmdb2-datasource', 'IBM Db2 data source'),
|
||||
name: 'IBM Db2',
|
||||
imgUrl: db2Svg,
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useAssistant, createAssistantContextItem, OpenAssistantButton } from "@grafana/assistant";
|
||||
import { CoreApp, DataSourceApi, DataSourceInstanceSettings } from "@grafana/data";
|
||||
import { t } from "@grafana/i18n";
|
||||
import { DataQuery, DataSourceJsonData } from "@grafana/schema";
|
||||
import { queryIsEmpty } from "app/core/utils/query";
|
||||
|
||||
|
||||
interface QueryActionAssistantButtonProps<TQuery extends DataQuery = DataQuery> {
|
||||
query: TQuery;
|
||||
queries: TQuery[];
|
||||
dataSourceInstance: DataSourceInstanceSettings;
|
||||
app?: CoreApp;
|
||||
datasource: DataSourceApi<TQuery, DataSourceJsonData, {}> | null;
|
||||
}
|
||||
|
||||
export function QueryActionAssistantButton<TQuery extends DataQuery = DataQuery>({
|
||||
query,
|
||||
queries,
|
||||
dataSourceInstance,
|
||||
app,
|
||||
datasource,
|
||||
}: QueryActionAssistantButtonProps<TQuery>) {
|
||||
const { isAvailable } = useAssistant();
|
||||
if (!isAvailable) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show for Explore and Dashboard apps
|
||||
if (app !== CoreApp.Explore && app !== CoreApp.Dashboard) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only show for loki and prometheus datasources
|
||||
const pluginId = dataSourceInstance.type;
|
||||
if (pluginId !== 'loki' && pluginId !== 'prometheus') {
|
||||
return null;
|
||||
}
|
||||
const origin = `grafana/query-editor/${pluginId}/${app ?? CoreApp.Unknown}`;
|
||||
|
||||
// Check if current query has content
|
||||
const hasCurrentQuery = !queryIsEmpty(query);
|
||||
const otherQueries = queries.filter((q) => q.refId !== query.refId && !queryIsEmpty(q));
|
||||
|
||||
// Build context items
|
||||
const context = [
|
||||
createAssistantContextItem('datasource', {
|
||||
datasourceUid: dataSourceInstance.uid,
|
||||
}),
|
||||
];
|
||||
|
||||
// Add current query if it has content
|
||||
if (hasCurrentQuery) {
|
||||
context.push(
|
||||
createAssistantContextItem('structured', {
|
||||
title: t('query-operation.header.current-query', 'Current query'),
|
||||
data: query,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Add other queries if they exist
|
||||
if (otherQueries.length > 0) {
|
||||
context.push(
|
||||
createAssistantContextItem('structured', {
|
||||
title: t('query-operation.header.other-queries', 'Other queries'),
|
||||
data: {
|
||||
queries: otherQueries,
|
||||
},
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Get query display text to determine if we're creating or updating
|
||||
const queryDisplayText = hasCurrentQuery && datasource?.getQueryDisplayText
|
||||
? datasource.getQueryDisplayText(query)
|
||||
: null;
|
||||
|
||||
// Determine if we're creating or updating based on queryDisplayText
|
||||
const isUpdating = !!queryDisplayText;
|
||||
const actionText = isUpdating
|
||||
? t('query-operation.header.assistant-prompt-update', 'Help me update the current query to answer my questions and provide the insights I need.')
|
||||
: t('query-operation.header.assistant-prompt-create', 'Help me create a new query to answer my questions and provide the insights I need.');
|
||||
|
||||
// Format app name nicely
|
||||
const appName = app === CoreApp.Explore
|
||||
? t('query-operation.header.app-explore', 'Explore')
|
||||
: app === CoreApp.Dashboard
|
||||
? t('query-operation.header.app-dashboard', 'Dashboard')
|
||||
: '';
|
||||
|
||||
// Build the prompt with proper formatting
|
||||
const codeBlockLines: string[] = [];
|
||||
|
||||
if (queryDisplayText) {
|
||||
codeBlockLines.push(
|
||||
t('query-operation.header.current-query-label', 'Current query:') + ` ${queryDisplayText}`
|
||||
);
|
||||
}
|
||||
|
||||
codeBlockLines.push(
|
||||
t('query-operation.header.selected-datasource-label', 'Selected data source:') + ` ${dataSourceInstance.name}`
|
||||
);
|
||||
|
||||
if (appName) {
|
||||
codeBlockLines.push(
|
||||
t('query-operation.header.app-label', 'App:') + ` ${appName}`
|
||||
);
|
||||
}
|
||||
|
||||
// Add actionable sentence to motivate users
|
||||
const actionableSentence = isUpdating
|
||||
? t('query-operation.header.assistant-actionable-update', 'Please describe what you want to change or improve in this query.')
|
||||
: t('query-operation.header.assistant-actionable-create', 'Please describe what you want to query and what insights you\'re looking for.');
|
||||
|
||||
// Build final prompt with code block
|
||||
const prompt = [
|
||||
actionText,
|
||||
'```',
|
||||
...codeBlockLines,
|
||||
'```',
|
||||
actionableSentence,
|
||||
].join('\n');
|
||||
|
||||
return (
|
||||
<OpenAssistantButton
|
||||
origin={origin}
|
||||
prompt={prompt}
|
||||
context={context}
|
||||
autoSend={false}
|
||||
title={t('query-operation.header.query-with-assistant', 'Query with Assistant')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
import { useQueryLibraryContext } from '../../explore/QueryLibrary/QueryLibraryContext';
|
||||
import { ExpressionDatasourceUID } from '../../expressions/types';
|
||||
|
||||
import { QueryActionAssistantButton } from './QueryActionAssistantButton';
|
||||
import { QueryActionComponent, RowActionComponents } from './QueryActionComponent';
|
||||
import { QueryEditorRowHeader } from './QueryEditorRowHeader';
|
||||
import { QueryErrorAlert } from './QueryErrorAlert';
|
||||
@@ -399,7 +400,7 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
|
||||
};
|
||||
|
||||
renderActions = (props: QueryOperationRowRenderProps) => {
|
||||
const { query, hideHideQueryButton: hideHideQueryButton = false, queryLibraryRef, app } = this.props;
|
||||
const { query, queries, hideHideQueryButton: hideHideQueryButton = false, queryLibraryRef, app } = this.props;
|
||||
const { datasource, showingHelp } = this.state;
|
||||
const isHidden = !!query.hide;
|
||||
|
||||
@@ -410,6 +411,13 @@ export class QueryEditorRow<TQuery extends DataQuery> extends PureComponent<Prop
|
||||
|
||||
return (
|
||||
<>
|
||||
<QueryActionAssistantButton
|
||||
query={query}
|
||||
queries={queries}
|
||||
dataSourceInstance={this.props.dataSource}
|
||||
datasource={datasource}
|
||||
app={app}
|
||||
/>
|
||||
{!isEditingQueryLibrary && !isUnifiedAlerting && !isExpressionQuery && (
|
||||
<SavedQueryButtons
|
||||
query={{
|
||||
|
||||
+6
-25
@@ -1,10 +1,10 @@
|
||||
import { memo } from 'react';
|
||||
|
||||
import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data';
|
||||
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
|
||||
import { ConnectionConfig } from '@grafana/google-sdk';
|
||||
import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui';
|
||||
import { config, reportInteraction } from '@grafana/runtime';
|
||||
import { Divider, Field, Input, SecureSocksProxySettings, Stack } from '@grafana/ui';
|
||||
import { reportInteraction, config } from '@grafana/runtime';
|
||||
import { Divider, SecureSocksProxySettings } from '@grafana/ui';
|
||||
|
||||
import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../types/types';
|
||||
|
||||
@@ -36,33 +36,14 @@ export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => {
|
||||
<Divider />
|
||||
<ConfigSection
|
||||
title="Additional settings"
|
||||
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy and Universe Domain."
|
||||
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy."
|
||||
isCollapsible
|
||||
isInitiallyOpen={
|
||||
options.jsonData.enableSecureSocksProxy !== undefined || options.jsonData.universeDomain !== undefined
|
||||
}
|
||||
isInitiallyOpen={options.jsonData.enableSecureSocksProxy !== undefined}
|
||||
>
|
||||
<Stack direction={'column'}>
|
||||
<Field noMargin label="Universe Domain">
|
||||
<Input
|
||||
width={50}
|
||||
value={options.jsonData.universeDomain}
|
||||
onChange={(event) =>
|
||||
updateDatasourcePluginJsonDataOption(
|
||||
{ options, onOptionsChange },
|
||||
'universeDomain',
|
||||
event.currentTarget.value
|
||||
)
|
||||
}
|
||||
placeholder="googleapis.com"
|
||||
></Input>
|
||||
</Field>
|
||||
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
|
||||
</Stack>
|
||||
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
|
||||
</ConfigSection>
|
||||
</>
|
||||
)}
|
||||
<Divider />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -38,7 +38,6 @@ export interface Aggregation {
|
||||
export interface CloudMonitoringOptions extends DataSourceOptions {
|
||||
gceDefaultProject?: string;
|
||||
enableSecureSocksProxy?: boolean;
|
||||
universeDomain?: string;
|
||||
}
|
||||
|
||||
export interface CloudMonitoringSecureJsonData extends DataSourceSecureJsonData {}
|
||||
|
||||
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 38 KiB |
@@ -6983,7 +6983,6 @@
|
||||
"drone-datasource": "Drone datasource",
|
||||
"git-lab-integration-and-datasource": "GitLab integration and datasource",
|
||||
"honeycomb-integration-and-datasource": "Honeycomb integration and datasource",
|
||||
"ibmdb2-datasource": "IBM Db2 data source",
|
||||
"jira-integration-and-datasource": "Jira integration and datasource",
|
||||
"logic-monitor-devices-datasource": "LogicMonitor Devices datasource",
|
||||
"mongo-db-integration-and-data-source": "MongoDB integration and data source",
|
||||
|
||||
@@ -3507,7 +3507,6 @@ __metadata:
|
||||
"@types/lodash": "npm:4.17.20"
|
||||
"@types/node": "npm:24.10.1"
|
||||
"@types/react": "npm:18.3.18"
|
||||
"@types/react-table": "npm:^7.7.20"
|
||||
"@types/react-virtualized-auto-sizer": "npm:1.0.8"
|
||||
"@types/tinycolor2": "npm:1.4.6"
|
||||
babel-jest: "npm:29.7.0"
|
||||
@@ -3518,7 +3517,6 @@ __metadata:
|
||||
jest-canvas-mock: "npm:2.5.2"
|
||||
lodash: "npm:4.17.21"
|
||||
react: "npm:18.3.1"
|
||||
react-table: "npm:^7.8.0"
|
||||
react-use: "npm:17.6.0"
|
||||
react-virtualized-auto-sizer: "npm:1.0.26"
|
||||
rollup: "npm:^4.22.4"
|
||||
@@ -11161,7 +11159,7 @@ __metadata:
|
||||
languageName: node
|
||||
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
|
||||
resolution: "@types/react-table@npm:7.7.20"
|
||||
dependencies:
|
||||
@@ -29373,7 +29371,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"react-table@npm:7.8.0, react-table@npm:^7.8.0":
|
||||
"react-table@npm:7.8.0":
|
||||
version: 7.8.0
|
||||
resolution: "react-table@npm:7.8.0"
|
||||
peerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user