Compare commits

..

9 Commits

Author SHA1 Message Date
grafakus 7f584d143f shrug... 2026-01-14 15:15:51 +01:00
grafakus e38240369b Update cue files++ 2026-01-14 14:58:36 +01:00
grafakus 26ac64cf1a Merge branch 'main' into grafakus/query-variable-support-multiprops 2026-01-14 14:20:50 +01:00
grafakus d8777012cb Fix lint issues 2026-01-14 14:19:18 +01:00
grafakus dfa07d8e10 Fix openapi tests 2026-01-14 14:17:43 +01:00
grafakus dc63eb3314 Regen 2026-01-14 14:03:13 +01:00
grafakus 1e3c6fed55 Merge branch 'main' into grafakus/query-variable-support-multiprops
# Conflicts:
#	apps/dashboard/pkg/apis/dashboard_manifest.go
2026-01-14 13:50:54 +01:00
grafakus d02201f564 Update schemas 2026-01-14 13:41:21 +01:00
grafakus 2a9377ac02 QueryVariable: Support preview and autocomplete of multi-props 2026-01-14 13:14:32 +01:00
40 changed files with 2952 additions and 74117 deletions
@@ -800,6 +800,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -804,6 +804,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -241,6 +241,8 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable
@@ -241,6 +241,8 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable
@@ -804,6 +804,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -1426,6 +1426,8 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.
@@ -5133,6 +5133,22 @@ func schema_pkg_apis_dashboard_v2alpha1_DashboardVariableOption(ref common.Refer
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2alpha1.DashboardStringOrArrayOfString"),
},
},
"properties": {
SchemaProps: spec.SchemaProps{
Description: "Additional properties for multi-props variables",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"text", "value"},
},
@@ -808,6 +808,8 @@ VariableOption: {
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
@@ -1429,6 +1429,8 @@ type DashboardVariableOption struct {
Text DashboardStringOrArrayOfString `json:"text"`
// Value of the option
Value DashboardStringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewDashboardVariableOption creates a new DashboardVariableOption object.
@@ -5196,6 +5196,22 @@ func schema_pkg_apis_dashboard_v2beta1_DashboardVariableOption(ref common.Refere
Ref: ref("github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v2beta1.DashboardStringOrArrayOfString"),
},
},
"properties": {
SchemaProps: spec.SchemaProps{
Description: "Additional properties for multi-props variables",
Type: []string{"object"},
AdditionalProperties: &spec.SchemaOrBool{
Allows: true,
Schema: &spec.Schema{
SchemaProps: spec.SchemaProps{
Default: "",
Type: []string{"string"},
Format: "",
},
},
},
},
},
},
Required: []string{"text", "value"},
},
File diff suppressed because one or more lines are too long
+2
View File
@@ -237,6 +237,8 @@ lineage: schemas: [{
text: string | [...string]
// Value of the option
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
} @cuetsy(kind="interface")
// Options to config when to refresh a variable
@@ -91,6 +91,8 @@ export interface VariableOption {
text: string | string[];
value: string | string[];
isNone?: boolean;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
properties?: Record<string, any>;
}
export interface IntervalVariableModel extends VariableWithOptions {
@@ -118,6 +120,7 @@ export interface QueryVariableModel extends VariableWithMultiSupport {
definition: string;
sort: VariableSort;
queryValue?: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
query: any;
regex: string;
regexApplyTo?: VariableRegexApplyTo;
@@ -193,6 +196,7 @@ export interface BaseVariableModel {
skipUrlSync: boolean;
index: number;
state: LoadingState;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
error: any | null;
description: string | null;
usedInRepeat?: boolean;
-2
View File
@@ -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
View File
@@ -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';
+1 -13
View File
@@ -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>;
@@ -231,6 +231,10 @@ export const defaultVariableModel: Partial<VariableModel> = {
* Option to be selected in a variable.
*/
export interface VariableOption {
/**
* Additional properties for multi-props variables
*/
properties?: Record<string, string>;
/**
* Whether the option is selected or not
*/
@@ -715,7 +715,9 @@ VariableOption: {
// Text to be displayed for the option
text: string | [...string]
// Value of the option
value: string | [...string]
value: string | [...string]
// Additional properties for multi-props variables
properties?: {[string]: string}
}
// Query variable specification
+2
View File
@@ -903,6 +903,8 @@ type VariableOption struct {
Text StringOrArrayOfString `json:"text"`
// Value of the option
Value StringOrArrayOfString `json:"value"`
// Additional properties for multi-props variables
Properties map[string]string `json:"properties,omitempty"`
}
// NewVariableOption creates a new VariableOption object.
@@ -3912,6 +3912,13 @@
"value"
],
"properties": {
"properties": {
"description": "Additional properties for multi-props variables",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"selected": {
"description": "Whether the option is selected or not",
"type": "boolean"
@@ -3939,6 +3939,13 @@
"value"
],
"properties": {
"properties": {
"description": "Additional properties for multi-props variables",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"selected": {
"description": "Whether the option is selected or not",
"type": "boolean"
@@ -284,6 +284,7 @@ function variableValueOptionsToVariableOptions(varState: MultiValueVariable['sta
value: String(o.value),
text: o.label,
selected: Array.isArray(varState.value) ? varState.value.includes(o.value) : varState.value === o.value,
...(o.properties && { properties: o.properties }),
}));
}
@@ -69,7 +69,6 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
const isHasVariableOptions = hasVariableOptions(variable);
const optionsForSelect = isHasVariableOptions ? variable.getOptionsForSelect(false) : [];
const hasMultiProps = 'valuesFormat' in variable.state && variable.state.valuesFormat === 'json';
const onDeleteVariable = (hideModal: () => void) => () => {
reportInteraction('Delete variable');
@@ -125,7 +124,7 @@ export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete
{EditorToRender && <EditorToRender variable={variable} onRunQuery={onRunQuery} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} />}
<div className={styles.buttonContainer}>
<Stack gap={2}>
@@ -10,13 +10,16 @@ import { Button, InlineFieldRow, InlineLabel, InteractiveTable, Text, useStyles2
export interface Props {
options: VariableValueOption[];
hasMultiProps?: boolean;
}
export const VariableValuesPreview = ({ options, hasMultiProps }: Props) => {
const hasMultiProps = (options: Props['options']) => {
return Object.keys(options[1]?.properties ?? options[0]?.properties ?? {}).length > 0;
};
export const VariableValuesPreview = ({ options }: Props) => {
const styles = useStyles2(getStyles);
const hasOptions = options.length > 0;
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasMultiProps;
const displayMultiPropsPreview = config.featureToggles.multiPropsVariables && hasOptions && hasMultiProps(options);
return (
<div className={styles.previewContainer} style={{ gap: '8px' }}>
@@ -43,7 +46,8 @@ function VariableValuesWithPropsPreview({ options }: { options: VariableValueOpt
return {
data,
columns: Object.keys(data[0] ?? {}).map((id) => ({
// the option at index 0 can be "All" so we try to grab the column names from the 2nd option
columns: Object.keys(data[1] ?? data[0] ?? {}).map((id) => ({
id,
// see https://github.com/TanStack/table/issues/1671
header: unsanitizeKey(id),
@@ -62,7 +66,6 @@ function VariableValuesWithPropsPreview({ options }: { options: VariableValueOpt
/>
);
}
const sanitizeKey = (key: string) => key.replace(/\./g, '__dot__');
const unsanitizeKey = (key: string) => key.replace(/__dot__/g, '.');
@@ -69,7 +69,7 @@ function ModalEditorMultiProps(props: ModalEditorProps) {
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={options} hasMultiProps={valuesFormat === 'json'} />
<VariableValuesPreview options={options} />
</div>
</Stack>
<Modal.ButtonRow>
@@ -81,16 +81,17 @@ const buildLabelPath = (label: string) => {
};
const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
if (!('valuesFormat' in variable) || variable.valuesFormat !== 'json') {
if (!('options' in variable) || !variable.options[0].properties) {
return [];
}
function collectFieldPaths(option: Record<string, string>, currentPath: string) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function collectFieldPaths(properties: Record<string, any>, currentPath: string) {
let paths: string[] = [];
for (const field in option) {
if (option.hasOwnProperty(field)) {
for (const field in properties) {
if (properties.hasOwnProperty(field)) {
const newPath = `${currentPath}.${field}`;
const value = option[field];
const value = properties[field];
if (typeof value === 'object' && value !== null) {
paths = [...paths, ...collectFieldPaths(value, newPath)];
}
@@ -100,11 +101,7 @@ const getVariableValueProperties = (variable: TypedVariableModel): string[] => {
return paths;
}
try {
return collectFieldPaths(JSON.parse(variable.query)[0], variable.name);
} catch {
return [];
}
return collectFieldPaths(variable.options[0].properties, variable.name);
};
export const getPanelLinksVariableSuggestions = (): VariableSuggestion[] => [
@@ -503,13 +503,16 @@ describe('linkSrv', () => {
});
describe('getPanelLinksVariableSuggestions', () => {
it('then it should return template variables, json properties and built-ins', () => {
it('then it should return template variables, options properties and built-ins', () => {
const templateSrvWithJsonValues = initTemplateSrv('key', [
{
type: 'custom',
name: 'customServers',
valuesFormat: 'json',
query: '[{"name":"web","ip":"192.168.0.100"},{"name":"ads","ip":"192.168.0.142"}]',
options: [
{ text: 'web', value: 'web', properties: { name: 'web', ip: '192.168.0.100' } },
{ text: 'ads', value: 'ads', properties: { name: 'ads', ip: '192.168.0.142' } },
],
},
]);
setTemplateSrv(templateSrvWithJsonValues);
+2 -4
View File
@@ -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: