Compare commits

...

16 Commits

Author SHA1 Message Date
Aleksandar Petrov 45e60f849b Improve naming consistency 2026-01-14 16:45:24 -04:00
Aleksandar Petrov d43f4b576a Fix issue with collapsed nodes found with E2E test 2026-01-14 16:44:12 -04:00
Aleksandar Petrov f79f7ac33d Move extra header elements to the right 2026-01-14 09:31:41 -04:00
Aleksandar Petrov f03ee8d19c Improve UI state handling in split view 2026-01-14 09:31:41 -04:00
Aleksandar Petrov eb37860388 Improve layout 2026-01-14 09:31:41 -04:00
Aleksandar Petrov da7b70336c Improve click handling in split view 2026-01-14 09:31:40 -04:00
Aleksandar Petrov 17817bdda7 Add call tree to flame graph container 2026-01-14 09:31:40 -04:00
Aleksandar Petrov 5bed426fd8 Add search support 2026-01-14 09:31:40 -04:00
Aleksandar Petrov 8bc405d5ed Add support to show callers in call tree 2026-01-14 09:31:40 -04:00
Aleksandar Petrov b8c9ee987e Simplify things a bit 2026-01-14 09:31:39 -04:00
Aleksandar Petrov 49e4d6760b Add action column, improve UX 2026-01-14 09:31:39 -04:00
Aleksandar Petrov 665a54f02f First iteration of call tree profile visualization 2026-01-14 09:31:39 -04:00
Fabrizio ba6a783997 Add Db2 plugin (#116190)
* Add new Db2 plugin

* Fix ID

* Fix ID

* Run `i18n-extract`

* Linting

* Fix ID

* Linting

* Rename plugin

* Fix i18n entry

* Run `yarn i18n-extract`
2026-01-14 14:20:24 +01:00
Andreas Christou f704b8aa79 Cloud Monitoring: Add support for Google Cloud universe_domain (#115931)
* feat(cloud-monitoring): add support for Google Cloud universe_domain (#110083)

This change introduces support for Google Cloud's `universe_domain`, enabling connections to sovereign cloud environments with custom API endpoints.

- Adds an optional "Universe Domain" field in the Google Cloud Monitoring data source configuration (frontend and backend).
- Allows specifying a custom API domain (e.g., `s3nsapis.fr`) for use in sovereign environments.
- Defaults to `googleapis.com` to ensure backward compatibility for existing configurations.

Signed-off-by: Andreas Christou <andreas.christou@grafana.com>

* Minor docs update

* Doc updates

* Update editor

* Update docs/sources/datasources/google-cloud-monitoring/_index.md

Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>

* Lint

* Review

---------

Signed-off-by: Andreas Christou <andreas.christou@grafana.com>
Co-authored-by: Larissa Wandzura <126723338+lwandz13@users.noreply.github.com>
2026-01-14 13:16:09 +00:00
Andreas Christou c1a46fdcb5 Elasticsearch: Decoupling from core (#115900)
* Complete decoupling of backend

- Replace usage of featuremgmt
- Copy simplejson
- Add standalone logic

* Complete frontend decoupling

- Fix imports
- Copy store and reducer logic

* Add required files for full decoupling

* Regen cue

* Prettier

* Remove unneeded script

* Jest fix

* Add jest config

* Lint

* Lit

* Prune suppresions
2026-01-14 12:54:21 +00:00
Cauê Marcondes 7143324229 Elasticsearch: Add support for serverless connections (#114855)
* serverless connecction

* Adding api key

* fix

* addressing pr comments

* fixing tests

* refactoring

* changing to value semantic

* addressing pr comments

* minor changes

---------

Co-authored-by: Lucas Francisco Lopez <lucas.lopez@elastic.co>
2026-01-14 12:51:42 +00:00
99 changed files with 76850 additions and 2998 deletions
+2
View File
@@ -121,6 +121,8 @@ linters:
- '**/pkg/tsdb/zipkin/**/*'
- '**/pkg/tsdb/jaeger/*'
- '**/pkg/tsdb/jaeger/**/*'
- '**/pkg/tsdb/elasticsearch/*'
- '**/pkg/tsdb/elasticsearch/**/*'
deny:
- pkg: github.com/grafana/grafana/pkg/api
desc: Core plugins are not allowed to depend on Grafana core packages
@@ -103,10 +103,11 @@ 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. |
| 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`. |
### Provision the data source
@@ -129,6 +130,7 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
secureJsonData:
privateKey: |
-----BEGIN PRIVATE KEY-----
@@ -152,6 +154,7 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
privateKeyPath: /etc/secrets/gce.pem
```
@@ -166,6 +169,7 @@ datasources:
access: proxy
jsonData:
authenticationType: gce
universeDomain: googleapis.com
```
## Import pre-configured dashboards
@@ -87,6 +87,7 @@ 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)
-25
View File
@@ -3743,46 +3743,21 @@
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/DateHistogramSettingsEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/SettingsEditor/TermsSettingsEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/aggregations.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/MetricEditor.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/SettingsEditor/SettingField.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 2
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/aggregations.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/plugins/datasource/elasticsearch/configuration/DataLinks.tsx": {
"no-restricted-syntax": {
"count": 1
+1
View File
@@ -82,6 +82,7 @@ module.exports = {
// Decoupled plugins run their own tests so ignoring them here.
'<rootDir>/public/app/plugins/datasource/azuremonitor',
'<rootDir>/public/app/plugins/datasource/cloud-monitoring',
'<rootDir>/public/app/plugins/datasource/elasticsearch',
'<rootDir>/public/app/plugins/datasource/grafana-postgresql-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-pyroscope-datasource',
'<rootDir>/public/app/plugins/datasource/grafana-testdata-datasource',
+2
View File
@@ -58,6 +58,7 @@
"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",
@@ -81,6 +82,7 @@
"@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",
@@ -0,0 +1,49 @@
import { Meta, StoryObj } from '@storybook/react';
import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import { ColorScheme } from '../types';
import FlameGraphCallTreeContainer from './FlameGraphCallTreeContainer';
const meta: Meta<typeof FlameGraphCallTreeContainer> = {
title: 'CallTree',
component: FlameGraphCallTreeContainer,
args: {
colorScheme: ColorScheme.PackageBased,
search: '',
},
decorators: [
(Story) => (
<div style={{ width: '100%', height: '1000px' }}>
<Story />
</div>
),
],
};
export default meta;
export const Basic: StoryObj<typeof meta> = {
render: (args) => {
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true });
return (
<FlameGraphCallTreeContainer
{...args}
data={dataContainer}
onSymbolClick={(symbol) => {
console.log('Symbol clicked:', symbol);
}}
onSandwich={(item) => {
console.log('Sandwich:', item);
}}
onSearch={(symbol) => {
console.log('Search:', symbol);
}}
/>
);
},
};
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,580 @@
import { FlameGraphDataContainer, LevelItem } from '../FlameGraph/dataTransform';
export interface CallTreeNode {
id: string; // Path-based ID (e.g., "0.2.1")
label: string; // Function name
self: number; // Self value
total: number; // Total value
selfPercent: number; // Self as % of root
totalPercent: number; // Total as % of root
depth: number; // Indentation level
parentId?: string; // Parent node ID
hasChildren: boolean; // Has expandable children
childCount: number; // Number of direct children
subtreeSize: number; // Total number of nodes in subtree (excluding self)
levelItem: LevelItem; // Reference to original data
subRows?: CallTreeNode[]; // Child nodes for react-table useExpanded
isLastChild: boolean; // Whether this is the last child of its parent
// For diff profiles
selfRight?: number;
totalRight?: number;
selfPercentRight?: number;
totalPercentRight?: number;
diffPercent?: number;
}
/**
* Build hierarchical call tree node from the LevelItem structure.
* Each node gets a unique ID based on its path in the tree.
* Children are stored in the subRows property for react-table useExpanded.
*/
export function buildCallTreeNode(
data: FlameGraphDataContainer,
rootItem: LevelItem,
rootTotal: number,
parentId?: string,
parentDepth: number = -1,
childIndex: number = 0
): CallTreeNode {
const nodeId = parentId ? `${parentId}.${childIndex}` : `${childIndex}`;
const depth = parentDepth + 1;
// Get values for current item
const itemIndex = rootItem.itemIndexes[0];
const label = data.getLabel(itemIndex);
const self = data.getSelf(itemIndex);
const total = data.getValue(itemIndex);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemIndex);
totalRight = data.getValueRight(itemIndex);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
// Calculate diff percentage (change from baseline to comparison)
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity; // New in comparison
} else {
diffPercent = 0;
}
}
// Recursively build children
const subRows =
rootItem.children.length > 0
? rootItem.children.map((child, index) => {
const childNode = buildCallTreeNode(data, child, rootTotal, nodeId, depth, index);
// Mark if this is the last child
childNode.isLastChild = index === rootItem.children.length - 1;
return childNode;
})
: undefined;
// Calculate child count and subtree size
const childCount = rootItem.children.length;
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
const node: CallTreeNode = {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: rootItem.children.length > 0,
childCount,
subtreeSize,
levelItem: rootItem,
subRows,
isLastChild: false, // Will be set by parent
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
return node;
}
/**
* Build all call tree nodes from the root level items.
* Returns an array of root nodes, each with their children in subRows.
* This handles cases where there might be multiple root items.
*/
export function buildAllCallTreeNodes(data: FlameGraphDataContainer): CallTreeNode[] {
const levels = data.getLevels();
const rootTotal = levels.length > 0 ? levels[0][0].value : 0;
// Build hierarchical structure for each root item
const rootNodes = levels[0].map((rootItem, index) => buildCallTreeNode(data, rootItem, rootTotal, undefined, -1, index));
return rootNodes;
}
/**
* Build call tree nodes from an array of levels (from mergeParentSubtrees).
* This is used for the callers view where we get LevelItem[][] from getSandwichLevels.
* Unlike buildCallTreeNode which recursively processes children, this function
* processes pre-organized levels and builds the hierarchy from them.
*/
export function buildCallTreeFromLevels(
levels: LevelItem[][],
data: FlameGraphDataContainer,
rootTotal: number
): CallTreeNode[] {
if (levels.length === 0 || levels[0].length === 0) {
return [];
}
// Map to track LevelItem -> CallTreeNode for building relationships
const levelItemToNode = new Map<LevelItem, CallTreeNode>();
// Process each level and build nodes
levels.forEach((level, levelIndex) => {
level.forEach((levelItem, itemIndex) => {
// Get values from data
const itemDataIndex = levelItem.itemIndexes[0];
const label = data.getLabel(itemDataIndex);
const self = data.getSelf(itemDataIndex);
const total = data.getValue(itemDataIndex);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemDataIndex);
totalRight = data.getValueRight(itemDataIndex);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
// Calculate diff percentage
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity;
} else {
diffPercent = 0;
}
}
// Determine parent (if exists)
let parentId: string | undefined;
let depth = levelIndex;
if (levelItem.parents && levelItem.parents.length > 0) {
const parentNode = levelItemToNode.get(levelItem.parents[0]);
if (parentNode) {
parentId = parentNode.id;
depth = parentNode.depth + 1;
}
}
// Generate path-based ID
// For root nodes, use index at level 0
// For child nodes, append index to parent ID
let nodeId: string;
if (!parentId) {
nodeId = `${itemIndex}`;
} else {
// Find index among siblings
const parent = levelItemToNode.get(levelItem.parents![0]);
const siblingIndex = parent?.subRows?.length || 0;
nodeId = `${parentId}.${siblingIndex}`;
}
// Create the node (without children initially)
const node: CallTreeNode = {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: levelItem.children.length > 0,
childCount: levelItem.children.length,
subtreeSize: 0, // Will be calculated later
levelItem,
subRows: undefined,
isLastChild: false,
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
// Add to map
levelItemToNode.set(levelItem, node);
// Add as child to parent
if (levelItem.parents && levelItem.parents.length > 0) {
const parentNode = levelItemToNode.get(levelItem.parents[0]);
if (parentNode) {
if (!parentNode.subRows) {
parentNode.subRows = [];
}
parentNode.subRows.push(node);
// Mark if this is the last child
const isLastChild = parentNode.subRows.length === parentNode.childCount;
node.isLastChild = isLastChild;
}
}
});
});
// Calculate subtreeSize for all nodes (bottom-up)
const calculateSubtreeSize = (node: CallTreeNode): number => {
if (!node.subRows || node.subRows.length === 0) {
node.subtreeSize = 0;
return 0;
}
const size = node.subRows.reduce((sum, child) => {
return sum + calculateSubtreeSize(child) + 1;
}, 0);
node.subtreeSize = size;
return size;
};
// Collect root nodes (level 0)
const rootNodes: CallTreeNode[] = [];
levels[0].forEach((levelItem) => {
const node = levelItemToNode.get(levelItem);
if (node) {
calculateSubtreeSize(node);
rootNodes.push(node);
}
});
return rootNodes;
}
/**
* Recursively collect expanded state for nodes up to a certain depth.
*/
function collectExpandedByDepth(
node: CallTreeNode,
levelsToExpand: number,
expanded: Record<string, boolean>
): void {
if (node.depth < levelsToExpand && node.hasChildren) {
expanded[node.id] = true;
}
if (node.subRows) {
node.subRows.forEach((child) => collectExpandedByDepth(child, levelsToExpand, expanded));
}
}
/**
* Get initial expanded state for the tree.
* Auto-expands first N levels.
*/
export function getInitialExpandedState(nodes: CallTreeNode[], levelsToExpand: number = 2): Record<string, boolean> {
const expanded: Record<string, boolean> = {};
nodes.forEach((node) => {
collectExpandedByDepth(node, levelsToExpand, expanded);
});
return expanded;
}
/**
* Restructure the callers tree to show a specific target node at the root.
* In the callers view, we want to show the target function with its callers as children.
* This function finds the target node and collects all paths that lead to it,
* then restructures them so the target is at the root.
*/
export function restructureCallersTree(
nodes: CallTreeNode[],
targetLabel: string
): { restructuredTree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
// First, find all paths from root to target node
const findPathsToTarget = (
nodes: CallTreeNode[],
targetLabel: string,
currentPath: CallTreeNode[] = []
): CallTreeNode[][] => {
const paths: CallTreeNode[][] = [];
for (const node of nodes) {
const newPath = [...currentPath, node];
if (node.label === targetLabel) {
// Found a path to the target
paths.push(newPath);
}
if (node.subRows && node.subRows.length > 0) {
// Continue searching in children
const childPaths = findPathsToTarget(node.subRows, targetLabel, newPath);
paths.push(...childPaths);
}
}
return paths;
};
const paths = findPathsToTarget(nodes, targetLabel);
if (paths.length === 0) {
// Target not found, return original tree
return { restructuredTree: nodes, targetNode: undefined };
}
// Get the target node from the first path (they should all have the same target node)
const targetNode = paths[0][paths[0].length - 1];
// Now restructure: create a new tree with target at root
// Each path to the target becomes a branch under the target
// For example, if we have: root -> A -> B -> target
// We want: target -> B -> A -> root (inverted)
const buildInvertedChildren = (paths: CallTreeNode[][]): CallTreeNode[] => {
// Group paths by their immediate caller (the node right before target)
const callerGroups = new Map<string, CallTreeNode[][]>();
for (const path of paths) {
if (path.length <= 1) {
// Path is just the target node itself, no callers
continue;
}
// The immediate caller is the node right before the target
const immediateCaller = path[path.length - 2];
const callerKey = immediateCaller.label;
if (!callerGroups.has(callerKey)) {
callerGroups.set(callerKey, []);
}
callerGroups.get(callerKey)!.push(path);
}
// Build nodes for each immediate caller
const callerNodes: CallTreeNode[] = [];
let callerIndex = 0;
for (const [, callerPaths] of callerGroups.entries()) {
// Get the immediate caller node from one of the paths
const immediateCallerNode = callerPaths[0][callerPaths[0].length - 2];
// For this caller, recursively build its callers (from the remaining path)
const remainingPaths = callerPaths.map((path) => path.slice(0, -1)); // Remove target from paths
const grandCallers = buildInvertedChildren(remainingPaths);
// Create a new node for this caller as a child of the target
const newCallerId = `0.${callerIndex}`;
const callerNode: CallTreeNode = {
...immediateCallerNode,
id: newCallerId,
depth: 1,
parentId: '0',
subRows: grandCallers.length > 0 ? grandCallers : undefined,
hasChildren: grandCallers.length > 0,
childCount: grandCallers.length,
isLastChild: callerIndex === callerGroups.size - 1,
};
// Update IDs of grandCallers
if (grandCallers.length > 0) {
grandCallers.forEach((grandCaller, idx) => {
updateNodeIds(grandCaller, newCallerId, idx);
});
}
callerNodes.push(callerNode);
callerIndex++;
}
return callerNodes;
};
// Helper to recursively update node IDs
const updateNodeIds = (node: CallTreeNode, parentId: string, index: number) => {
node.id = `${parentId}.${index}`;
node.parentId = parentId;
node.depth = parentId.split('.').length;
if (node.subRows) {
node.subRows.forEach((child, idx) => {
updateNodeIds(child, node.id, idx);
});
}
};
// Build the inverted children for the target
const invertedChildren = buildInvertedChildren(paths);
// Create the restructured target node as root
const restructuredTarget: CallTreeNode = {
...targetNode,
id: '0',
depth: 0,
parentId: undefined,
subRows: invertedChildren.length > 0 ? invertedChildren : undefined,
hasChildren: invertedChildren.length > 0,
childCount: invertedChildren.length,
subtreeSize: invertedChildren.reduce((sum, child) => sum + child.subtreeSize + 1, 0),
isLastChild: false,
};
return { restructuredTree: [restructuredTarget], targetNode: restructuredTarget };
}
/**
* Build a callers tree directly from sandwich levels data.
* This creates an inverted tree where the target function is at the root
* and its callers are shown as children.
*/
export function buildCallersTreeFromLevels(
levels: LevelItem[][],
targetLabel: string,
data: FlameGraphDataContainer,
rootTotal: number
): { tree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
if (levels.length === 0) {
return { tree: [], targetNode: undefined };
}
// Find the target node in the levels
let targetLevelIndex = -1;
let targetItem: LevelItem | undefined;
for (let i = 0; i < levels.length; i++) {
for (const item of levels[i]) {
const label = data.getLabel(item.itemIndexes[0]);
if (label === targetLabel) {
targetLevelIndex = i;
targetItem = item;
break;
}
}
if (targetItem) break;
}
if (!targetItem || targetLevelIndex === -1) {
// Target not found
return { tree: [], targetNode: undefined };
}
// Create a map from LevelItem to all items that reference it as a parent
const childrenMap = new Map<LevelItem, LevelItem[]>();
for (const level of levels) {
for (const item of level) {
if (item.parents) {
for (const parent of item.parents) {
if (!childrenMap.has(parent)) {
childrenMap.set(parent, []);
}
childrenMap.get(parent)!.push(item);
}
}
}
}
// Build the inverted tree recursively
// For callers view: the target is root, and parents become children
const buildInvertedNode = (
item: LevelItem,
nodeId: string,
depth: number,
parentId: string | undefined
): CallTreeNode => {
const itemIdx = item.itemIndexes[0];
const label = data.getLabel(itemIdx);
const self = data.getSelf(itemIdx);
const total = data.getValue(itemIdx);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemIdx);
totalRight = data.getValueRight(itemIdx);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity;
} else {
diffPercent = 0;
}
}
// In the inverted tree, parents become children (callers)
const callers = item.parents || [];
const subRows =
callers.length > 0
? callers.map((caller, idx) => {
const callerId = `${nodeId}.${idx}`;
const callerNode = buildInvertedNode(caller, callerId, depth + 1, nodeId);
callerNode.isLastChild = idx === callers.length - 1;
return callerNode;
})
: undefined;
const childCount = callers.length;
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
return {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: callers.length > 0,
childCount,
subtreeSize,
levelItem: item,
subRows,
isLastChild: false,
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
};
// Build tree with target as root
const targetNode = buildInvertedNode(targetItem, '0', 0, undefined);
return { tree: [targetNode], targetNode };
}
@@ -16,7 +16,7 @@ const meta: Meta<typeof FlameGraph> = {
rangeMax: 1,
textAlign: 'left',
colorScheme: ColorScheme.PackageBased,
selectedView: SelectedView.Both,
selectedView: SelectedView.Multi,
search: '',
},
};
@@ -43,10 +43,13 @@ 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,8 +19,10 @@
import { css, cx } from '@emotion/css';
import { useEffect, useState } from 'react';
import { Icon } from '@grafana/ui';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Icon, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './colors';
import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
@@ -39,11 +41,14 @@ 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;
@@ -63,11 +68,14 @@ const FlameGraph = ({
onItemFocused,
focusedItemData,
textAlign,
onTextAlignChange,
onSandwich,
sandwichItem,
onFocusPillClick,
onSandwichPillClick,
colorScheme,
onColorSchemeChange,
isDiffMode,
showFlameGraphOnly,
getExtraContextMenuButtons,
collapsing,
@@ -76,7 +84,7 @@ const FlameGraph = ({
collapsedMap,
setCollapsedMap,
}: Props) => {
const styles = getStyles();
const styles = useStyles2(getStyles);
const [levels, setLevels] = useState<LevelItem[][]>();
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
@@ -175,28 +183,183 @@ const FlameGraph = ({
);
}
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
];
return (
<div className={styles.graph}>
<FlameGraphMetadata
data={data}
focusedItem={focusedItemData}
sandwichedLabel={sandwichItem}
totalTicks={totalViewTicks}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
<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>
{canvas}
</div>
);
};
const getStyles = () => ({
type ColorSchemeButtonProps = {
value: ColorScheme | ColorSchemeDiff;
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
// Show a bit different gradient as a way to indicate selected value
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
graph: css({
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,19 +1,18 @@
import { css } from '@emotion/css';
import uFuzzy from '@leeoniya/ufuzzy';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import * as React from 'react';
import { useMeasure } from 'react-use';
import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { ThemeContext } from '@grafana/ui';
import FlameGraph from './FlameGraph/FlameGraph';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import FlameGraphPane from './FlameGraphPane';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
import { PaneView, SelectedView, ViewMode } from './types';
import { getAssistantContextFromDataFrame } from './utils';
const ufuzzy = new uFuzzy();
@@ -104,17 +103,18 @@ const FlameGraphContainer = ({
getExtraContextMenuButtons,
showAnalyzeWithAssistant = true,
}: Props) => {
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
// Shared state across all views
const [search, setSearch] = useState('');
const [selectedView, setSelectedView] = useState(SelectedView.Both);
const [selectedView, setSelectedView] = useState(SelectedView.Multi);
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Split);
const [leftPaneView, setLeftPaneView] = useState<PaneView>(PaneView.TopTable);
const [rightPaneView, setRightPaneView] = useState<PaneView>(PaneView.FlameGraph);
const [singleView, setSingleView] = useState<PaneView>(PaneView.FlameGraph);
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
const [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());
// 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 theme = useMemo(() => getTheme(), [getTheme]);
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
@@ -122,157 +122,220 @@ const FlameGraphContainer = ({
return;
}
const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
setCollapsedMap(container.getCollapsedMap());
return container;
return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
}, [data, theme, disableCollapsing]);
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = getStyles(theme);
const matchedLabels = useLabelSearch(search, dataContainer);
// If user resizes window with both as the selected view
// Handle responsive layout: switch away from Both view when narrow, restore when wide again
useEffect(() => {
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();
if (containerWidth === 0) {
return;
}
if (dataContainer && focusedItemData) {
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
const isNarrow = containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && !vertical;
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);
}
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);
}
}, [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]
);
}, [containerWidth, vertical, selectedView, viewBeforeNarrow]);
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 = flameGraph;
body = (
<FlameGraphPane
paneView={PaneView.FlameGraph}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
} else if (selectedView === SelectedView.TopTable) {
body = <div className={styles.tableContainer}>{table}</div>;
} else if (selectedView === SelectedView.Both) {
if (vertical) {
body = (
<div>
<div className={styles.verticalGraphContainer}>{flameGraph}</div>
<div className={styles.verticalTableContainer}>{table}</div>
</div>
);
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.horizontalContainer}>
<div className={styles.horizontalTableContainer}>{table}</div>
<div className={styles.horizontalGraphContainer}>{flameGraph}</div>
<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>
);
}
@@ -292,25 +355,24 @@ 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={() => {
resetFocus();
resetSandwich();
// Reset search and pane states when user clicks reset button
setSearch('');
setResetKey((k) => k + 1);
}}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
showResetButton={Boolean(focusedItemData || sandwichItem)}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
showResetButton={Boolean(search)}
stickyHeader={Boolean(stickyHeader)}
extraHeaderElements={extraHeaderElements}
vertical={vertical}
isDiffMode={dataContainer.isDiffFlamegraph()}
setCollapsedMap={setCollapsedMap}
collapsedMap={collapsedMap}
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
/>
)}
@@ -321,18 +383,6 @@ const FlameGraphContainer = ({
);
};
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
useEffect(() => {
setColorScheme(defaultColorScheme);
}, [defaultColorScheme]);
return [colorScheme, setColorScheme] as const;
}
/**
* Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later.
*/
@@ -420,12 +470,6 @@ 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',
@@ -435,20 +479,20 @@ function getStyles(theme: GrafanaTheme2) {
width: '100%',
}),
horizontalGraphContainer: css({
flexBasis: '50%',
}),
horizontalTableContainer: css({
horizontalPaneContainer: css({
label: 'horizontalPaneContainer',
flexBasis: '50%',
maxHeight: 800,
}),
verticalGraphContainer: css({
verticalPaneContainer: css({
label: 'verticalPaneContainer',
marginBottom: theme.spacing(1),
height: 800,
}),
verticalTableContainer: css({
singlePaneContainer: css({
label: 'singlePaneContainer',
height: 800,
}),
};
@@ -3,9 +3,8 @@ 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 { ColorScheme, SelectedView } from './types';
import { PaneView, SelectedView, ViewMode } from './types';
jest.mock('@grafana/assistant', () => ({
useAssistant: jest.fn().mockReturnValue({
@@ -20,26 +19,30 @@ 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.Both}
selectedView={SelectedView.Multi}
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}
/>
);
@@ -50,7 +53,6 @@ describe('FlameGraphHeader', () => {
setSearch,
setSelectedView,
onReset,
onSchemeChange,
},
};
}
@@ -70,27 +72,4 @@ 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,30 +5,29 @@ import { useDebounce, usePrevious } from 'react-use';
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors';
import { CollapsedMap } from './FlameGraph/dataTransform';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
import { PaneView, SelectedView, ViewMode } 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;
@@ -40,19 +39,20 @@ 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,6 +87,25 @@ 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}>
@@ -111,129 +130,63 @@ const FlameGraphHeader = ({
aria-label={'Reset focus and sandwich state'}
/>
)}
<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}
{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}
/>
<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>
);
};
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 viewModeOptions: Array<SelectableValue<ViewMode>> = [
{ value: ViewMode.Single, label: 'Single', description: 'Single view' },
{ value: ViewMode.Split, label: 'Split', description: 'Split view' },
];
// 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' },
const paneViewOptions: Array<SelectableValue<PaneView>> = [
{ value: PaneView.TopTable, label: 'Top Table' },
{ value: PaneView.FlameGraph, label: 'Flame Graph' },
{ value: PaneView.CallTree, label: 'Call Tree' },
];
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.Both,
label: 'Both',
description: 'Show both the top table and flame graph',
value: SelectedView.Multi,
label: 'Multi',
description: 'Show split or single view with multiple visualizations',
});
}
@@ -273,10 +226,12 @@ 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,
@@ -285,10 +240,20 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
inputContainer: css({
label: 'inputContainer',
flexGrow: 1,
flexGrow: 0,
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',
@@ -309,44 +274,6 @@ 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),
@@ -0,0 +1,269 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer';
import FlameGraph from './FlameGraph/FlameGraph';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types';
export type FlameGraphPaneProps = {
paneView: PaneView;
dataContainer: FlameGraphDataContainer;
search: string;
matchedLabels: Set<string> | undefined;
onTableSymbolClick?: (symbol: string) => void;
onTextAlignSelected?: (align: string) => void;
onTableSort?: (sort: string) => void;
showFlameGraphOnly?: boolean;
disableCollapsing?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
selectedView: SelectedView;
viewMode: ViewMode;
theme: GrafanaTheme2;
setSearch: (search: string) => void;
/** When this key changes, the pane's internal state (focus, sandwich, etc.) will be reset */
resetKey?: number;
/** Whether to preserve focus when the data changes */
keepFocusOnDataChange?: boolean;
};
const FlameGraphPane = ({
paneView,
dataContainer,
search,
matchedLabels,
onTableSymbolClick,
onTextAlignSelected,
onTableSort,
showFlameGraphOnly,
disableCollapsing,
getExtraContextMenuButtons,
selectedView,
viewMode,
theme,
setSearch,
resetKey,
keepFocusOnDataChange,
}: FlameGraphPaneProps) => {
// Pane-specific state - each instance maintains its own
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [textAlign, setTextAlign] = useState<TextAlign>('left');
const [sandwichItem, setSandwichItem] = useState<string>();
// Initialize collapsedMap from dataContainer to ensure collapsed groups are shown correctly on first render
const [collapsedMap, setCollapsedMap] = useState(() => dataContainer.getCollapsedMap());
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = useMemo(() => getStyles(theme), [theme]);
// Re-initialize collapsed map when dataContainer changes (e.g., new data loaded)
// Using useLayoutEffect to ensure collapsed state is applied before browser paint
useLayoutEffect(() => {
setCollapsedMap(dataContainer.getCollapsedMap());
}, [dataContainer]);
// Reset internal state when resetKey changes (triggered by parent's reset button)
useEffect(() => {
if (resetKey !== undefined && resetKey > 0) {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
setSandwichItem(undefined);
}
}, [resetKey]);
// Handle focus preservation or reset when data changes
useEffect(() => {
if (!keepFocusOnDataChange) {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
setSandwichItem(undefined);
return;
}
if (dataContainer && focusedItemData) {
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
if (item) {
setFocusedItemData({ ...focusedItemData, item });
const levels = dataContainer.getLevels();
const totalViewTicks = levels.length ? levels[0][0].value : 0;
setRangeMin(item.start / totalViewTicks);
setRangeMax((item.start + item.value) / totalViewTicks);
} else {
setFocusedItemData({
...focusedItemData,
item: {
start: 0,
value: 0,
itemIndexes: [],
children: [],
level: 0,
},
});
setRangeMin(0);
setRangeMax(1);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataContainer, keepFocusOnDataChange]);
const resetFocus = useCallback(() => {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}, []);
const resetSandwich = useCallback(() => {
setSandwichItem(undefined);
}, []);
const onSymbolClick = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[search, setSearch, resetFocus, onTableSymbolClick]
);
// Separate callback for CallTree that doesn't trigger search
const onCallTreeSymbolClick = useCallback(
(symbol: string) => {
onTableSymbolClick?.(symbol);
},
[onTableSymbolClick]
);
// Search callback for CallTree search button
const onCallTreeSearch = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[search, setSearch, resetFocus, onTableSymbolClick]
);
const isInSplitView = selectedView === SelectedView.Multi && viewMode === ViewMode.Split;
const isCallTreeInSplitView = isInSplitView && paneView === PaneView.CallTree;
switch (paneView) {
case PaneView.TopTable:
return (
<div className={styles.tableContainer}>
<FlameGraphTopTableContainer
data={dataContainer}
onSymbolClick={onSymbolClick}
search={search}
matchedLabels={matchedLabels}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onSearch={(str) => {
if (!str) {
setSearch('');
return;
}
setSearch(`^${escapeStringForRegex(str)}$`);
}}
onTableSort={onTableSort}
colorScheme={colorScheme}
/>
</div>
);
case PaneView.FlameGraph:
default:
return (
<FlameGraph
data={dataContainer}
rangeMin={rangeMin}
rangeMax={rangeMax}
matchedLabels={matchedLabels}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
setSandwichItem(label);
}}
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
isDiffMode={dataContainer.isDiffFlamegraph()}
showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
collapsedMap={collapsedMap}
setCollapsedMap={setCollapsedMap}
/>
);
case PaneView.CallTree:
return (
<div className={styles.tableContainer}>
<FlameGraphCallTreeContainer
data={dataContainer}
onSymbolClick={onCallTreeSymbolClick}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onTableSort={onTableSort}
colorScheme={colorScheme}
search={search}
compact={isCallTreeInSplitView}
onSearch={onCallTreeSearch}
/>
</div>
);
}
};
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
useEffect(() => {
setColorScheme(defaultColorScheme);
}, [defaultColorScheme]);
return [colorScheme, setColorScheme] as const;
}
function getStyles(theme: GrafanaTheme2) {
return {
tableContainer: css({
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
// in explore we need a specific height.
height: 800,
}),
};
}
export default FlameGraphPane;
+1
View File
@@ -1,3 +1,4 @@
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';
+13 -1
View File
@@ -20,7 +20,19 @@ export enum SampleUnit {
export enum SelectedView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
Both = 'both',
Multi = 'multi',
CallTree = 'callTree',
}
export enum ViewMode {
Single = 'single',
Split = 'split',
}
export enum PaneView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
CallTree = 'callTree',
}
export interface TableData {
@@ -1,14 +1,40 @@
import { Decorator } from '@storybook/react';
import { useEffect } from 'react';
import * as React from 'react';
import { getThemeById, ThemeContext } from '@grafana/data';
import { GlobalStyles } from '@grafana/ui';
import { createTheme, getThemeById, ThemeContext } from '@grafana/data';
import { GlobalStyles, PortalContainer } from '@grafana/ui';
interface ThemeableStoryProps {
themeId: string;
themeId?: string;
}
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
const theme = getThemeById(themeId);
// Always ensure we have a valid theme
const theme = React.useMemo(() => {
const id = themeId || 'dark';
let resolvedTheme = getThemeById(id);
// If getThemeById returns undefined, create a default theme
if (!resolvedTheme) {
console.warn(`Theme '${id}' not found, using default theme`);
resolvedTheme = createTheme({ colors: { mode: id === 'light' ? 'light' : 'dark' } });
}
console.log('withTheme: resolved theme', { id, hasTheme: !!resolvedTheme, hasSpacing: !!resolvedTheme?.spacing });
return resolvedTheme;
}, [themeId]);
// Apply theme to document root for Portals
useEffect(() => {
if (!theme) return;
document.body.style.setProperty('--theme-background', theme.colors.background.primary);
}, [theme]);
if (!theme) {
console.error('withTheme: No theme available!');
return null;
}
const css = `
#storybook-root {
@@ -23,6 +49,7 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
return (
<ThemeContext.Provider value={theme}>
<GlobalStyles />
<PortalContainer />
<style>{css}</style>
{children}
@@ -33,4 +60,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>;
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
export const pluginVersion = "12.4.0-pre";
export const pluginVersion = "%VERSION%";
export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested);
@@ -24,6 +24,24 @@ func TestMain(m *testing.M) {
testsuite.Run(m)
}
// mockElasticsearchHandler returns a handler that mocks Elasticsearch endpoints.
// It responds to GET / with cluster info (required for datasource initialization)
// and returns 401 Unauthorized for all other requests.
func mockElasticsearchHandler(onRequest func(r *http.Request)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/":
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"version":{"build_flavor":"default","number":"8.0.0"}}`))
default:
if onRequest != nil {
onRequest(r)
}
w.WriteHeader(http.StatusUnauthorized)
}
}
}
func TestIntegrationElasticsearch(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
@@ -35,9 +53,8 @@ func TestIntegrationElasticsearch(t *testing.T) {
ctx := context.Background()
var outgoingRequest *http.Request
outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
outgoingServer := httptest.NewServer(mockElasticsearchHandler(func(r *http.Request) {
outgoingRequest = r
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(outgoingServer.Close)
@@ -639,7 +639,7 @@
]
},
"dependencies": {
"grafanaDependency": "",
"grafanaDependency": "\u003e=11.6.0",
"grafanaVersion": "*",
"plugins": [],
"extensions": {
+6 -3
View File
@@ -92,7 +92,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}, nil
}
url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
url := fmt.Sprintf("%s/v3/projects/%s/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -139,6 +139,7 @@ type datasourceInfo struct {
defaultProject string
clientEmail string
tokenUri string
universeDomain string
services map[string]datasourceService
privateKey string
usingImpersonation bool
@@ -150,6 +151,7 @@ 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"`
}
@@ -179,6 +181,7 @@ 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{},
@@ -194,13 +197,13 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
return nil, err
}
for name, info := range routes {
for name := range routes {
client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name)
if err != nil {
return nil, err
}
dsInfo.services[name] = datasourceService{
url: info.url,
url: buildURL(name, dsInfo.universeDomain),
client: client,
}
}
+9 -2
View File
@@ -23,12 +23,12 @@ type routeInfo struct {
var routes = map[string]routeInfo{
cloudMonitor: {
method: "GET",
url: "https://monitoring.googleapis.com",
url: "https://monitoring.",
scopes: []string{cloudMonitorScope},
},
resourceManager: {
method: "GET",
url: "https://cloudresourcemanager.googleapis.com",
url: "https://cloudresourcemanager.",
scopes: []string{resourceManagerScope},
},
}
@@ -68,6 +68,13 @@ 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: routes[cloudMonitor].url,
url: buildURL(cloudMonitor, "googleapis.com"),
client: &http.Client{},
},
},
@@ -3,8 +3,8 @@ package elasticsearch
import (
"regexp"
"github.com/grafana/grafana/pkg/components/simplejson"
es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// addDateHistogramAgg adds a date histogram aggregation to the aggregation builder
+7 -3
View File
@@ -16,7 +16,6 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana-plugin-sdk-go/backend/tracing"
"github.com/grafana/grafana/pkg/services/featuremgmt"
)
// Used in logging to mark a stage
@@ -35,6 +34,7 @@ type DatasourceInfo struct {
Interval string
MaxConcurrentShardRequests int64
IncludeFrozen bool
ClusterInfo ClusterInfo
}
type ConfiguredFields struct {
@@ -159,7 +159,7 @@ func (c *baseClientImpl) ExecuteMultisearch(r *MultiSearchRequest) (*MultiSearch
resSpan.End()
}()
improvedParsingEnabled := isFeatureEnabled(c.ctx, featuremgmt.FlagElasticsearchImprovedParsing)
improvedParsingEnabled := isFeatureEnabled(c.ctx, "elasticsearchImprovedParsing")
msr, err := c.parser.parseMultiSearchResponse(res.Body, improvedParsingEnabled)
if err != nil {
return nil, err
@@ -197,7 +197,11 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque
func (c *baseClientImpl) getMultiSearchQueryParameters() string {
var qs []string
qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests))
// if the build flavor is not serverless, we can use the max concurrent shard requests
// this is because serverless clusters do not support max concurrent shard requests
if !c.ds.ClusterInfo.IsServerless() && c.ds.MaxConcurrentShardRequests > 0 {
qs = append(qs, fmt.Sprintf("max_concurrent_shard_requests=%d", c.ds.MaxConcurrentShardRequests))
}
if c.ds.IncludeFrozen {
qs = append(qs, "ignore_throttled=false")
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
func TestClient_ExecuteMultisearch(t *testing.T) {
@@ -0,0 +1,51 @@
package es
import (
"encoding/json"
"fmt"
"net/http"
)
type VersionInfo struct {
BuildFlavor string `json:"build_flavor"`
}
// ClusterInfo represents Elasticsearch cluster information returned from the root endpoint.
// It is used to determine cluster capabilities and configuration like whether the cluster is serverless.
type ClusterInfo struct {
Version VersionInfo `json:"version"`
}
const (
BuildFlavorServerless = "serverless"
)
// GetClusterInfo fetches cluster information from the Elasticsearch root endpoint.
// It returns the cluster build flavor which is used to determine if the cluster is serverless.
func GetClusterInfo(httpCli *http.Client, url string) (clusterInfo ClusterInfo, err error) {
resp, err := httpCli.Get(url)
if err != nil {
return ClusterInfo{}, fmt.Errorf("error getting ES cluster info: %w", err)
}
if resp.StatusCode != http.StatusOK {
return ClusterInfo{}, fmt.Errorf("unexpected status code %d getting ES cluster info", resp.StatusCode)
}
defer func() {
if closeErr := resp.Body.Close(); closeErr != nil && err == nil {
err = fmt.Errorf("error closing response body: %w", closeErr)
}
}()
err = json.NewDecoder(resp.Body).Decode(&clusterInfo)
if err != nil {
return ClusterInfo{}, fmt.Errorf("error decoding ES cluster info: %w", err)
}
return clusterInfo, nil
}
func (ci ClusterInfo) IsServerless() bool {
return ci.Version.BuildFlavor == BuildFlavorServerless
}
@@ -0,0 +1,188 @@
package es
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGetClusterInfo(t *testing.T) {
t.Run("Should successfully get cluster info", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
_, err := rw.Write([]byte(`{
"name": "test-cluster",
"cluster_name": "elasticsearch",
"cluster_uuid": "abc123",
"version": {
"number": "8.0.0",
"build_flavor": "default",
"build_type": "tar",
"build_hash": "abc123",
"build_date": "2023-01-01T00:00:00.000Z",
"build_snapshot": false,
"lucene_version": "9.0.0"
}
}`))
require.NoError(t, err)
}))
t.Cleanup(func() {
ts.Close()
})
clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL)
require.NoError(t, err)
require.NotNil(t, clusterInfo)
assert.Equal(t, "default", clusterInfo.Version.BuildFlavor)
})
t.Run("Should successfully get serverless cluster info", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
_, err := rw.Write([]byte(`{
"name": "serverless-cluster",
"cluster_name": "elasticsearch",
"cluster_uuid": "def456",
"version": {
"number": "8.11.0",
"build_flavor": "serverless",
"build_type": "docker",
"build_hash": "def456",
"build_date": "2023-11-01T00:00:00.000Z",
"build_snapshot": false,
"lucene_version": "9.8.0"
}
}`))
require.NoError(t, err)
}))
t.Cleanup(func() {
ts.Close()
})
clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL)
require.NoError(t, err)
require.NotNil(t, clusterInfo)
assert.Equal(t, "serverless", clusterInfo.Version.BuildFlavor)
assert.True(t, clusterInfo.IsServerless())
})
t.Run("Should return error when HTTP request fails", func(t *testing.T) {
clusterInfo, err := GetClusterInfo(http.DefaultClient, "http://invalid-url-that-does-not-exist.local:9999")
require.Error(t, err)
require.Equal(t, ClusterInfo{}, clusterInfo)
assert.Contains(t, err.Error(), "error getting ES cluster info")
})
t.Run("Should return error when response body is invalid JSON", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
_, err := rw.Write([]byte(`{"invalid json`))
require.NoError(t, err)
}))
t.Cleanup(func() {
ts.Close()
})
clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL)
require.Error(t, err)
require.Equal(t, ClusterInfo{}, clusterInfo)
assert.Contains(t, err.Error(), "error decoding ES cluster info")
})
t.Run("Should handle empty version object", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.Header().Set("Content-Type", "application/json")
_, err := rw.Write([]byte(`{
"name": "test-cluster",
"version": {}
}`))
require.NoError(t, err)
}))
t.Cleanup(func() {
ts.Close()
})
clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL)
require.NoError(t, err)
require.Equal(t, ClusterInfo{}, clusterInfo)
assert.Equal(t, "", clusterInfo.Version.BuildFlavor)
assert.False(t, clusterInfo.IsServerless())
})
t.Run("Should handle HTTP error status codes", func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
rw.WriteHeader(http.StatusUnauthorized)
_, err := rw.Write([]byte(`{"error": "Unauthorized"}`))
require.NoError(t, err)
}))
t.Cleanup(func() {
ts.Close()
})
clusterInfo, err := GetClusterInfo(ts.Client(), ts.URL)
require.Error(t, err)
require.Equal(t, ClusterInfo{}, clusterInfo)
assert.Contains(t, err.Error(), "unexpected status code 401 getting ES cluster info")
})
}
func TestClusterInfo_IsServerless(t *testing.T) {
t.Run("Should return true when build_flavor is serverless", func(t *testing.T) {
clusterInfo := ClusterInfo{
Version: VersionInfo{
BuildFlavor: BuildFlavorServerless,
},
}
assert.True(t, clusterInfo.IsServerless())
})
t.Run("Should return false when build_flavor is default", func(t *testing.T) {
clusterInfo := ClusterInfo{
Version: VersionInfo{
BuildFlavor: "default",
},
}
assert.False(t, clusterInfo.IsServerless())
})
t.Run("Should return false when build_flavor is empty", func(t *testing.T) {
clusterInfo := ClusterInfo{
Version: VersionInfo{
BuildFlavor: "",
},
}
assert.False(t, clusterInfo.IsServerless())
})
t.Run("Should return false when build_flavor is unknown value", func(t *testing.T) {
clusterInfo := ClusterInfo{
Version: VersionInfo{
BuildFlavor: "unknown",
},
}
assert.False(t, clusterInfo.IsServerless())
})
t.Run("should return false when cluster info is empty", func(t *testing.T) {
clusterInfo := ClusterInfo{}
assert.False(t, clusterInfo.IsServerless())
})
}
@@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
func TestSearchRequest(t *testing.T) {
@@ -6,8 +6,8 @@ import (
"strconv"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson"
es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// processQuery processes a single query and adds it to the multi-search request builder
@@ -3,7 +3,7 @@ package elasticsearch
import (
"strconv"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// setFloatPath converts a string value at the specified path to float64
+14
View File
@@ -88,6 +88,14 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins
httpCliOpts.SigV4.Service = "es"
}
apiKeyAuth, ok := jsonData["apiKeyAuth"].(bool)
if ok && apiKeyAuth {
apiKey := settings.DecryptedSecureJSONData["apiKey"]
if apiKey != "" {
httpCliOpts.Header.Add("Authorization", "ApiKey "+apiKey)
}
}
httpCli, err := httpClientProvider.New(httpCliOpts)
if err != nil {
return nil, err
@@ -151,6 +159,11 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins
includeFrozen = false
}
clusterInfo, err := es.GetClusterInfo(httpCli, settings.URL)
if err != nil {
return nil, err
}
configuredFields := es.ConfiguredFields{
TimeField: timeField,
LogLevelField: logLevelField,
@@ -166,6 +179,7 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins
ConfiguredFields: configuredFields,
Interval: interval,
IncludeFrozen: includeFrozen,
ClusterInfo: clusterInfo,
}
return model, nil
}
@@ -3,6 +3,8 @@ package elasticsearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@@ -18,8 +20,26 @@ type datasourceInfo struct {
Interval string `json:"interval"`
}
// mockElasticsearchServer creates a test HTTP server that mocks Elasticsearch cluster info endpoint
func mockElasticsearchServer() *httptest.Server {
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// Return a mock Elasticsearch cluster info response
_ = json.NewEncoder(w).Encode(map[string]interface{}{
"version": map[string]interface{}{
"build_flavor": "serverless",
"number": "8.0.0",
},
})
}))
}
func TestNewInstanceSettings(t *testing.T) {
t.Run("fields exist", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 5,
@@ -28,6 +48,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -37,6 +58,9 @@ func TestNewInstanceSettings(t *testing.T) {
t.Run("timeField", func(t *testing.T) {
t.Run("is nil", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
MaxConcurrentShardRequests: 5,
Interval: "Daily",
@@ -46,6 +70,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -54,6 +79,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("is empty", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
MaxConcurrentShardRequests: 5,
Interval: "Daily",
@@ -64,6 +92,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -74,6 +103,9 @@ func TestNewInstanceSettings(t *testing.T) {
t.Run("maxConcurrentShardRequests", func(t *testing.T) {
t.Run("no maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
}
@@ -81,6 +113,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -90,6 +123,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("string maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: "10",
@@ -98,6 +134,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -107,6 +144,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("number maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 10,
@@ -115,6 +155,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -124,6 +165,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("zero maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 0,
@@ -132,6 +176,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -141,6 +186,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("negative maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: -10,
@@ -149,6 +197,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -158,6 +207,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("float maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 10.5,
@@ -166,6 +218,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -175,6 +228,9 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("invalid maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: "invalid",
@@ -183,6 +239,7 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
+8 -1
View File
@@ -28,7 +28,6 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
Message: "Health check failed: Failed to get data source info",
}, nil
}
healthStatusUrl, err := url.Parse(ds.URL)
if err != nil {
logger.Error("Failed to parse data source URL", "error", err)
@@ -38,6 +37,14 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}, nil
}
// If the cluster is serverless, return a healthy result
if ds.ClusterInfo.IsServerless() {
return &backend.CheckHealthResult{
Status: backend.HealthStatusOk,
Message: "Elasticsearch Serverless data source is healthy.",
}, nil
}
// check that ES is healthy
healthStatusUrl.Path = path.Join(healthStatusUrl.Path, "_cluster/health")
healthStatusUrl.RawQuery = "wait_for_status=yellow"
@@ -9,7 +9,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/data"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// metricsResponseProcessor handles processing of metrics query responses
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"time"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// Query represents the time series query model of the datasource
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, error) {
@@ -5,7 +5,7 @@ import (
"fmt"
"strconv"
"github.com/grafana/grafana/pkg/components/simplejson"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// AggregationParser parses raw Elasticsearch DSL aggregations
+1 -1
View File
@@ -15,9 +15,9 @@ import (
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/grafana/grafana/pkg/components/simplejson"
es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/instrumentation"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
const (
+1 -1
View File
@@ -7,8 +7,8 @@ import (
"strings"
"time"
"github.com/grafana/grafana/pkg/components/simplejson"
es "github.com/grafana/grafana/pkg/tsdb/elasticsearch/client"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
)
// flatten flattens multi-level objects to single level objects. It uses dot notation to join keys.
@@ -0,0 +1,582 @@
// Package simplejson provides a wrapper for arbitrary JSON objects that adds methods to access properties.
// Use of this package in place of types and the standard library's encoding/json package is strongly discouraged.
//
// Don't lint for stale code, since it's a copied library and we might as well keep the whole thing.
// nolint:unused
package simplejson
import (
"bytes"
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"log"
)
// returns the current implementation version
func Version() string {
return "0.5.0"
}
type Json struct {
data any
}
func (j *Json) FromDB(data []byte) error {
j.data = make(map[string]any)
dec := json.NewDecoder(bytes.NewBuffer(data))
dec.UseNumber()
return dec.Decode(&j.data)
}
func (j *Json) ToDB() ([]byte, error) {
if j == nil || j.data == nil {
return nil, nil
}
return j.Encode()
}
func (j *Json) Scan(val any) error {
switch v := val.(type) {
case []byte:
if len(v) == 0 {
return nil
}
return json.Unmarshal(v, &j)
case string:
if len(v) == 0 {
return nil
}
return json.Unmarshal([]byte(v), &j)
default:
return fmt.Errorf("unsupported type: %T", v)
}
}
func (j *Json) Value() (driver.Value, error) {
return j.ToDB()
}
// DeepCopyInto creates a copy by serializing JSON
func (j *Json) DeepCopyInto(out *Json) {
b, err := j.Encode()
if err == nil {
_ = out.UnmarshalJSON(b)
}
}
// DeepCopy will make a deep copy of the JSON object
func (j *Json) DeepCopy() *Json {
if j == nil {
return nil
}
out := new(Json)
j.DeepCopyInto(out)
return out
}
// NewJson returns a pointer to a new `Json` object
// after unmarshaling `body` bytes
func NewJson(body []byte) (*Json, error) {
j := new(Json)
err := j.UnmarshalJSON(body)
if err != nil {
return nil, err
}
return j, nil
}
// MustJson returns a pointer to a new `Json` object, panicking if `body` cannot be parsed.
func MustJson(body []byte) *Json {
j, err := NewJson(body)
if err != nil {
panic(fmt.Sprintf("could not unmarshal JSON: %q", err))
}
return j
}
// New returns a pointer to a new, empty `Json` object
func New() *Json {
return &Json{
data: make(map[string]any),
}
}
// NewFromAny returns a pointer to a new `Json` object with provided data.
func NewFromAny(data any) *Json {
return &Json{data: data}
}
// Interface returns the underlying data
func (j *Json) Interface() any {
return j.data
}
// Encode returns its marshaled data as `[]byte`
func (j *Json) Encode() ([]byte, error) {
return j.MarshalJSON()
}
// EncodePretty returns its marshaled data as `[]byte` with indentation
func (j *Json) EncodePretty() ([]byte, error) {
return json.MarshalIndent(&j.data, "", " ")
}
// Implements the json.Marshaler interface.
func (j *Json) MarshalJSON() ([]byte, error) {
return json.Marshal(&j.data)
}
// Set modifies `Json` map by `key` and `value`
// Useful for changing single key/value in a `Json` object easily.
func (j *Json) Set(key string, val any) {
m, err := j.Map()
if err != nil {
return
}
m[key] = val
}
// SetPath modifies `Json`, recursively checking/creating map keys for the supplied path,
// and then finally writing in the value
func (j *Json) SetPath(branch []string, val any) {
if len(branch) == 0 {
j.data = val
return
}
// in order to insert our branch, we need map[string]any
if _, ok := (j.data).(map[string]any); !ok {
// have to replace with something suitable
j.data = make(map[string]any)
}
curr := j.data.(map[string]any)
for i := 0; i < len(branch)-1; i++ {
b := branch[i]
// key exists?
if _, ok := curr[b]; !ok {
n := make(map[string]any)
curr[b] = n
curr = n
continue
}
// make sure the value is the right sort of thing
if _, ok := curr[b].(map[string]any); !ok {
// have to replace with something suitable
n := make(map[string]any)
curr[b] = n
}
curr = curr[b].(map[string]any)
}
// add remaining k/v
curr[branch[len(branch)-1]] = val
}
// Del modifies `Json` map by deleting `key` if it is present.
func (j *Json) Del(key string) {
m, err := j.Map()
if err != nil {
return
}
delete(m, key)
}
// Get returns a pointer to a new `Json` object
// for `key` in its `map` representation
//
// useful for chaining operations (to traverse a nested JSON):
//
// js.Get("top_level").Get("dict").Get("value").Int()
func (j *Json) Get(key string) *Json {
m, err := j.Map()
if err == nil {
if val, ok := m[key]; ok {
return &Json{val}
}
}
return &Json{nil}
}
// GetPath searches for the item as specified by the branch
// without the need to deep dive using Get()'s.
//
// js.GetPath("top_level", "dict")
func (j *Json) GetPath(branch ...string) *Json {
jin := j
for _, p := range branch {
jin = jin.Get(p)
}
return jin
}
// GetIndex returns a pointer to a new `Json` object
// for `index` in its `array` representation
//
// this is the analog to Get when accessing elements of
// a json array instead of a json object:
//
// js.Get("top_level").Get("array").GetIndex(1).Get("key").Int()
func (j *Json) GetIndex(index int) *Json {
a, err := j.Array()
if err == nil {
if len(a) > index {
return &Json{a[index]}
}
}
return &Json{nil}
}
// CheckGetIndex returns a pointer to a new `Json` object
// for `index` in its `array` representation, and a `bool`
// indicating success or failure
//
// useful for chained operations when success is important:
//
// if data, ok := js.Get("top_level").CheckGetIndex(0); ok {
// log.Println(data)
// }
func (j *Json) CheckGetIndex(index int) (*Json, bool) {
a, err := j.Array()
if err == nil {
if len(a) > index {
return &Json{a[index]}, true
}
}
return nil, false
}
// SetIndex modifies `Json` array by `index` and `value`
// for `index` in its `array` representation
func (j *Json) SetIndex(index int, val any) {
a, err := j.Array()
if err == nil {
if len(a) > index {
a[index] = val
}
}
}
// CheckGet returns a pointer to a new `Json` object and
// a `bool` identifying success or failure
//
// useful for chained operations when success is important:
//
// if data, ok := js.Get("top_level").CheckGet("inner"); ok {
// log.Println(data)
// }
func (j *Json) CheckGet(key string) (*Json, bool) {
m, err := j.Map()
if err == nil {
if val, ok := m[key]; ok {
return &Json{val}, true
}
}
return nil, false
}
// Map type asserts to `map`
func (j *Json) Map() (map[string]any, error) {
if m, ok := (j.data).(map[string]any); ok {
return m, nil
}
return nil, errors.New("type assertion to map[string]any failed")
}
// Array type asserts to an `array`
func (j *Json) Array() ([]any, error) {
if a, ok := (j.data).([]any); ok {
return a, nil
}
return nil, errors.New("type assertion to []any failed")
}
// Bool type asserts to `bool`
func (j *Json) Bool() (bool, error) {
if s, ok := (j.data).(bool); ok {
return s, nil
}
return false, errors.New("type assertion to bool failed")
}
// String type asserts to `string`
func (j *Json) String() (string, error) {
if s, ok := (j.data).(string); ok {
return s, nil
}
return "", errors.New("type assertion to string failed")
}
// Bytes type asserts to `[]byte`
func (j *Json) Bytes() ([]byte, error) {
if s, ok := (j.data).(string); ok {
return []byte(s), nil
}
return nil, errors.New("type assertion to []byte failed")
}
// StringArray type asserts to an `array` of `string`
func (j *Json) StringArray() ([]string, error) {
arr, err := j.Array()
if err != nil {
return nil, err
}
retArr := make([]string, 0, len(arr))
for _, a := range arr {
if a == nil {
retArr = append(retArr, "")
continue
}
s, ok := a.(string)
if !ok {
return nil, err
}
retArr = append(retArr, s)
}
return retArr, nil
}
// MustArray guarantees the return of a `[]any` (with optional default)
//
// useful when you want to iterate over array values in a succinct manner:
//
// for i, v := range js.Get("results").MustArray() {
// fmt.Println(i, v)
// }
func (j *Json) MustArray(args ...[]any) []any {
var def []any
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustArray() received too many arguments %d", len(args))
}
a, err := j.Array()
if err == nil {
return a
}
return def
}
// MustMap guarantees the return of a `map[string]any` (with optional default)
//
// useful when you want to iterate over map values in a succinct manner:
//
// for k, v := range js.Get("dictionary").MustMap() {
// fmt.Println(k, v)
// }
func (j *Json) MustMap(args ...map[string]any) map[string]any {
var def map[string]any
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustMap() received too many arguments %d", len(args))
}
a, err := j.Map()
if err == nil {
return a
}
return def
}
// MustString guarantees the return of a `string` (with optional default)
//
// useful when you explicitly want a `string` in a single value return context:
//
// myFunc(js.Get("param1").MustString(), js.Get("optional_param").MustString("my_default"))
func (j *Json) MustString(args ...string) string {
var def string
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustString() received too many arguments %d", len(args))
}
s, err := j.String()
if err == nil {
return s
}
return def
}
// MustStringArray guarantees the return of a `[]string` (with optional default)
//
// useful when you want to iterate over array values in a succinct manner:
//
// for i, s := range js.Get("results").MustStringArray() {
// fmt.Println(i, s)
// }
func (j *Json) MustStringArray(args ...[]string) []string {
var def []string
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustStringArray() received too many arguments %d", len(args))
}
a, err := j.StringArray()
if err == nil {
return a
}
return def
}
// MustInt guarantees the return of an `int` (with optional default)
//
// useful when you explicitly want an `int` in a single value return context:
//
// myFunc(js.Get("param1").MustInt(), js.Get("optional_param").MustInt(5150))
func (j *Json) MustInt(args ...int) int {
var def int
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustInt() received too many arguments %d", len(args))
}
i, err := j.Int()
if err == nil {
return i
}
return def
}
// MustFloat64 guarantees the return of a `float64` (with optional default)
//
// useful when you explicitly want a `float64` in a single value return context:
//
// myFunc(js.Get("param1").MustFloat64(), js.Get("optional_param").MustFloat64(5.150))
func (j *Json) MustFloat64(args ...float64) float64 {
var def float64
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustFloat64() received too many arguments %d", len(args))
}
f, err := j.Float64()
if err == nil {
return f
}
return def
}
// MustBool guarantees the return of a `bool` (with optional default)
//
// useful when you explicitly want a `bool` in a single value return context:
//
// myFunc(js.Get("param1").MustBool(), js.Get("optional_param").MustBool(true))
func (j *Json) MustBool(args ...bool) bool {
var def bool
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustBool() received too many arguments %d", len(args))
}
b, err := j.Bool()
if err == nil {
return b
}
return def
}
// MustInt64 guarantees the return of an `int64` (with optional default)
//
// useful when you explicitly want an `int64` in a single value return context:
//
// myFunc(js.Get("param1").MustInt64(), js.Get("optional_param").MustInt64(5150))
func (j *Json) MustInt64(args ...int64) int64 {
var def int64
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustInt64() received too many arguments %d", len(args))
}
i, err := j.Int64()
if err == nil {
return i
}
return def
}
// MustUInt64 guarantees the return of an `uint64` (with optional default)
//
// useful when you explicitly want an `uint64` in a single value return context:
//
// myFunc(js.Get("param1").MustUint64(), js.Get("optional_param").MustUint64(5150))
func (j *Json) MustUint64(args ...uint64) uint64 {
var def uint64
switch len(args) {
case 0:
case 1:
def = args[0]
default:
log.Panicf("MustUint64() received too many arguments %d", len(args))
}
i, err := j.Uint64()
if err == nil {
return i
}
return def
}
// MarshalYAML implements yaml.Marshaller.
func (j *Json) MarshalYAML() (any, error) {
return j.data, nil
}
// UnmarshalYAML implements yaml.Unmarshaller.
func (j *Json) UnmarshalYAML(unmarshal func(any) error) error {
var data any
if err := unmarshal(&data); err != nil {
return err
}
j.data = data
return nil
}
@@ -0,0 +1,90 @@
package simplejson
import (
"bytes"
"encoding/json"
"errors"
"io"
"reflect"
"strconv"
)
// Implements the json.Unmarshaler interface.
func (j *Json) UnmarshalJSON(p []byte) error {
dec := json.NewDecoder(bytes.NewBuffer(p))
dec.UseNumber()
return dec.Decode(&j.data)
}
// NewFromReader returns a *Json by decoding from an io.Reader
func NewFromReader(r io.Reader) (*Json, error) {
j := new(Json)
dec := json.NewDecoder(r)
dec.UseNumber()
err := dec.Decode(&j.data)
return j, err
}
// Float64 coerces into a float64
func (j *Json) Float64() (float64, error) {
switch n := j.data.(type) {
case json.Number:
return n.Float64()
case float32, float64:
return reflect.ValueOf(j.data).Float(), nil
case int, int8, int16, int32, int64:
return float64(reflect.ValueOf(j.data).Int()), nil
case uint, uint8, uint16, uint32, uint64:
return float64(reflect.ValueOf(j.data).Uint()), nil
}
return 0, errors.New("invalid value type")
}
// Int coerces into an int
func (j *Json) Int() (int, error) {
switch n := j.data.(type) {
case json.Number:
i, err := n.Int64()
if err != nil {
return 0, err
}
return int(i), nil
case float32, float64:
return int(reflect.ValueOf(j.data).Float()), nil
case int, int8, int16, int32, int64:
return int(reflect.ValueOf(j.data).Int()), nil
case uint, uint8, uint16, uint32, uint64:
return int(reflect.ValueOf(j.data).Uint()), nil
}
return 0, errors.New("invalid value type")
}
// Int64 coerces into an int64
func (j *Json) Int64() (int64, error) {
switch n := j.data.(type) {
case json.Number:
return n.Int64()
case float32, float64:
return int64(reflect.ValueOf(j.data).Float()), nil
case int, int8, int16, int32, int64:
return reflect.ValueOf(j.data).Int(), nil
case uint, uint8, uint16, uint32, uint64:
return int64(reflect.ValueOf(j.data).Uint()), nil
}
return 0, errors.New("invalid value type")
}
// Uint64 coerces into an uint64
func (j *Json) Uint64() (uint64, error) {
switch n := j.data.(type) {
case json.Number:
return strconv.ParseUint(n.String(), 10, 64)
case float32, float64:
return uint64(reflect.ValueOf(j.data).Float()), nil
case int, int8, int16, int32, int64:
return uint64(reflect.ValueOf(j.data).Int()), nil
case uint, uint8, uint16, uint32, uint64:
return reflect.ValueOf(j.data).Uint(), nil
}
return 0, errors.New("invalid value type")
}
@@ -0,0 +1,274 @@
package simplejson
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestSimplejson(t *testing.T) {
var ok bool
var err error
js, err := NewJson([]byte(`{
"test": {
"string_array": ["asdf", "ghjk", "zxcv"],
"string_array_null": ["abc", null, "efg"],
"array": [1, "2", 3],
"arraywithsubs": [{"subkeyone": 1},
{"subkeytwo": 2, "subkeythree": 3}],
"int": 10,
"float": 5.150,
"string": "simplejson",
"bool": true,
"sub_obj": {"a": 1}
}
}`))
assert.NotEqual(t, nil, js)
assert.Equal(t, nil, err)
_, ok = js.CheckGet("test")
assert.Equal(t, true, ok)
_, ok = js.CheckGet("missing_key")
assert.Equal(t, false, ok)
aws := js.Get("test").Get("arraywithsubs")
assert.NotEqual(t, nil, aws)
var awsval int
awsval, _ = aws.GetIndex(0).Get("subkeyone").Int()
assert.Equal(t, 1, awsval)
awsval, _ = aws.GetIndex(1).Get("subkeytwo").Int()
assert.Equal(t, 2, awsval)
awsval, _ = aws.GetIndex(1).Get("subkeythree").Int()
assert.Equal(t, 3, awsval)
arr := js.Get("test").Get("array")
assert.NotEqual(t, nil, arr)
val, ok := arr.CheckGetIndex(0)
assert.Equal(t, ok, true)
valInt, _ := val.Int()
assert.Equal(t, valInt, 1)
val, ok = arr.CheckGetIndex(1)
assert.Equal(t, ok, true)
valStr, _ := val.String()
assert.Equal(t, valStr, "2")
val, ok = arr.CheckGetIndex(2)
assert.Equal(t, ok, true)
valInt, _ = val.Int()
assert.Equal(t, valInt, 3)
_, ok = arr.CheckGetIndex(3)
assert.Equal(t, ok, false)
i, _ := js.Get("test").Get("int").Int()
assert.Equal(t, 10, i)
f, _ := js.Get("test").Get("float").Float64()
assert.Equal(t, 5.150, f)
s, _ := js.Get("test").Get("string").String()
assert.Equal(t, "simplejson", s)
b, _ := js.Get("test").Get("bool").Bool()
assert.Equal(t, true, b)
mi := js.Get("test").Get("int").MustInt()
assert.Equal(t, 10, mi)
mi2 := js.Get("test").Get("missing_int").MustInt(5150)
assert.Equal(t, 5150, mi2)
ms := js.Get("test").Get("string").MustString()
assert.Equal(t, "simplejson", ms)
ms2 := js.Get("test").Get("missing_string").MustString("fyea")
assert.Equal(t, "fyea", ms2)
ma2 := js.Get("test").Get("missing_array").MustArray([]any{"1", 2, "3"})
assert.Equal(t, ma2, []any{"1", 2, "3"})
msa := js.Get("test").Get("string_array").MustStringArray()
assert.Equal(t, msa[0], "asdf")
assert.Equal(t, msa[1], "ghjk")
assert.Equal(t, msa[2], "zxcv")
msa2 := js.Get("test").Get("string_array").MustStringArray([]string{"1", "2", "3"})
assert.Equal(t, msa2[0], "asdf")
assert.Equal(t, msa2[1], "ghjk")
assert.Equal(t, msa2[2], "zxcv")
msa3 := js.Get("test").Get("missing_array").MustStringArray([]string{"1", "2", "3"})
assert.Equal(t, msa3, []string{"1", "2", "3"})
mm2 := js.Get("test").Get("missing_map").MustMap(map[string]any{"found": false})
assert.Equal(t, mm2, map[string]any{"found": false})
strs, err := js.Get("test").Get("string_array").StringArray()
assert.Equal(t, err, nil)
assert.Equal(t, strs[0], "asdf")
assert.Equal(t, strs[1], "ghjk")
assert.Equal(t, strs[2], "zxcv")
strs2, err := js.Get("test").Get("string_array_null").StringArray()
assert.Equal(t, err, nil)
assert.Equal(t, strs2[0], "abc")
assert.Equal(t, strs2[1], "")
assert.Equal(t, strs2[2], "efg")
gp, _ := js.GetPath("test", "string").String()
assert.Equal(t, "simplejson", gp)
gp2, _ := js.GetPath("test", "int").Int()
assert.Equal(t, 10, gp2)
assert.Equal(t, js.Get("test").Get("bool").MustBool(), true)
js.Set("float2", 300.0)
assert.Equal(t, js.Get("float2").MustFloat64(), 300.0)
js.Set("test2", "setTest")
assert.Equal(t, "setTest", js.Get("test2").MustString())
js.Del("test2")
assert.NotEqual(t, "setTest", js.Get("test2").MustString())
js.Get("test").Get("sub_obj").Set("a", 2)
assert.Equal(t, 2, js.Get("test").Get("sub_obj").Get("a").MustInt())
js.GetPath("test", "sub_obj").Set("a", 3)
assert.Equal(t, 3, js.GetPath("test", "sub_obj", "a").MustInt())
}
func TestStdlibInterfaces(t *testing.T) {
val := new(struct {
Name string `json:"name"`
Params *Json `json:"params"`
})
val2 := new(struct {
Name string `json:"name"`
Params *Json `json:"params"`
})
raw := `{"name":"myobject","params":{"string":"simplejson"}}`
assert.Equal(t, nil, json.Unmarshal([]byte(raw), val))
assert.Equal(t, "myobject", val.Name)
assert.NotEqual(t, nil, val.Params.data)
s, _ := val.Params.Get("string").String()
assert.Equal(t, "simplejson", s)
p, err := json.Marshal(val)
assert.Equal(t, nil, err)
assert.Equal(t, nil, json.Unmarshal(p, val2))
assert.Equal(t, val, val2) // stable
}
func TestSet(t *testing.T) {
js, err := NewJson([]byte(`{}`))
assert.Equal(t, nil, err)
js.Set("baz", "bing")
s, err := js.GetPath("baz").String()
assert.Equal(t, nil, err)
assert.Equal(t, "bing", s)
}
func TestReplace(t *testing.T) {
js, err := NewJson([]byte(`{}`))
assert.Equal(t, nil, err)
err = js.UnmarshalJSON([]byte(`{"baz":"bing"}`))
assert.Equal(t, nil, err)
s, err := js.GetPath("baz").String()
assert.Equal(t, nil, err)
assert.Equal(t, "bing", s)
}
func TestSetPath(t *testing.T) {
js, err := NewJson([]byte(`{}`))
assert.Equal(t, nil, err)
js.SetPath([]string{"foo", "bar"}, "baz")
s, err := js.GetPath("foo", "bar").String()
assert.Equal(t, nil, err)
assert.Equal(t, "baz", s)
}
func TestSetPathNoPath(t *testing.T) {
js, err := NewJson([]byte(`{"some":"data","some_number":1.0,"some_bool":false}`))
assert.Equal(t, nil, err)
f := js.GetPath("some_number").MustFloat64(99.0)
assert.Equal(t, f, 1.0)
js.SetPath([]string{}, map[string]any{"foo": "bar"})
s, err := js.GetPath("foo").String()
assert.Equal(t, nil, err)
assert.Equal(t, "bar", s)
f = js.GetPath("some_number").MustFloat64(99.0)
assert.Equal(t, f, 99.0)
}
func TestPathWillAugmentExisting(t *testing.T) {
js, err := NewJson([]byte(`{"this":{"a":"aa","b":"bb","c":"cc"}}`))
assert.Equal(t, nil, err)
js.SetPath([]string{"this", "d"}, "dd")
cases := []struct {
path []string
outcome string
}{
{
path: []string{"this", "a"},
outcome: "aa",
},
{
path: []string{"this", "b"},
outcome: "bb",
},
{
path: []string{"this", "c"},
outcome: "cc",
},
{
path: []string{"this", "d"},
outcome: "dd",
},
}
for _, tc := range cases {
s, err := js.GetPath(tc.path...).String()
assert.Equal(t, nil, err)
assert.Equal(t, tc.outcome, s)
}
}
func TestPathWillOverwriteExisting(t *testing.T) {
// notice how "a" is 0.1 - but then we'll try to set at path a, foo
js, err := NewJson([]byte(`{"this":{"a":0.1,"b":"bb","c":"cc"}}`))
assert.Equal(t, nil, err)
js.SetPath([]string{"this", "a", "foo"}, "bar")
s, err := js.GetPath("this", "a", "foo").String()
assert.Equal(t, nil, err)
assert.Equal(t, "bar", s)
}
func TestMustJson(t *testing.T) {
js := MustJson([]byte(`{"foo": "bar"}`))
assert.Equal(t, js.Get("foo").MustString(), "bar")
assert.PanicsWithValue(t, "could not unmarshal JSON: \"unexpected EOF\"", func() {
MustJson([]byte(`{`))
})
}
@@ -0,0 +1,48 @@
package main
import (
"context"
"github.com/grafana/grafana-plugin-sdk-go/backend"
"github.com/grafana/grafana-plugin-sdk-go/backend/httpclient"
"github.com/grafana/grafana-plugin-sdk-go/backend/instancemgmt"
elasticsearch "github.com/grafana/grafana/pkg/tsdb/elasticsearch"
)
var (
_ backend.QueryDataHandler = (*Datasource)(nil)
_ backend.CheckHealthHandler = (*Datasource)(nil)
_ backend.CallResourceHandler = (*Datasource)(nil)
)
func NewDatasource(context.Context, backend.DataSourceInstanceSettings) (instancemgmt.Instance, error) {
return &Datasource{
Service: elasticsearch.ProvideService(httpclient.NewProvider()),
}, nil
}
type Datasource struct {
Service *elasticsearch.Service
}
func contextualMiddlewares(ctx context.Context) context.Context {
cfg := backend.GrafanaConfigFromContext(ctx)
responseLimitMiddleware := httpclient.ResponseLimitMiddleware(cfg.ResponseLimit())
ctx = httpclient.WithContextualMiddleware(ctx, responseLimitMiddleware)
return ctx
}
func (d *Datasource) QueryData(ctx context.Context, req *backend.QueryDataRequest) (*backend.QueryDataResponse, error) {
ctx = contextualMiddlewares(ctx)
return d.Service.QueryData(ctx, req)
}
func (d *Datasource) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error {
ctx = contextualMiddlewares(ctx)
return d.Service.CallResource(ctx, req, sender)
}
func (d *Datasource) CheckHealth(ctx context.Context, req *backend.CheckHealthRequest) (*backend.CheckHealthResult, error) {
ctx = contextualMiddlewares(ctx)
return d.Service.CheckHealth(ctx, req)
}
+23
View File
@@ -0,0 +1,23 @@
package main
import (
"os"
"github.com/grafana/grafana-plugin-sdk-go/backend/datasource"
"github.com/grafana/grafana-plugin-sdk-go/backend/log"
)
func main() {
// Start listening to requests sent from Grafana. This call is blocking so
// it won't finish until Grafana shuts down the process or the plugin choose
// to exit by itself using os.Exit. Manage automatically manages life cycle
// of datasource instances. It accepts datasource instance factory as first
// argument. This factory will be automatically called on incoming request
// from Grafana to create different instances of SampleDatasource (per datasource
// ID). When datasource configuration changed Dispose method will be called and
// new datasource instance created using NewSampleDatasource factory.
if err := datasource.Manage("elasticsearch", NewDatasource, datasource.ManageOpts{}); err != nil {
log.DefaultLogger.Error(err.Error())
os.Exit(1)
}
}
@@ -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(31);
expect(enterprisePluginsCategory.plugins.length).toBe(32);
expect(enterprisePluginsCategory.plugins[0].name).toBe('Adobe Analytics');
expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Zendesk');
});
@@ -13,6 +13,7 @@ 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';
@@ -418,6 +419,12 @@ 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,
}),
];
}
@@ -4,8 +4,6 @@ const cloudwatchPlugin = async () =>
await import(/* webpackChunkName: "cloudwatchPlugin" */ 'app/plugins/datasource/cloudwatch/module');
const dashboardDSPlugin = async () =>
await import(/* webpackChunkName "dashboardDSPlugin" */ 'app/plugins/datasource/dashboard/module');
const elasticsearchPlugin = async () =>
await import(/* webpackChunkName: "elasticsearchPlugin" */ 'app/plugins/datasource/elasticsearch/module');
const grafanaPlugin = async () =>
await import(/* webpackChunkName: "grafanaPlugin" */ 'app/plugins/datasource/grafana/module');
const influxdbPlugin = async () =>
@@ -75,7 +73,6 @@ const builtInPlugins: Record<string, System.Module | (() => Promise<System.Modul
// datasources
'core:plugin/cloudwatch': cloudwatchPlugin,
'core:plugin/dashboard': dashboardDSPlugin,
'core:plugin/elasticsearch': elasticsearchPlugin,
'core:plugin/grafana': grafanaPlugin,
'core:plugin/influxdb': influxdbPlugin,
'core:plugin/mixed': mixedPlugin,
@@ -1,10 +1,10 @@
import { memo } from 'react';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { DataSourcePluginOptionsEditorProps, updateDatasourcePluginJsonDataOption } from '@grafana/data';
import { ConnectionConfig } from '@grafana/google-sdk';
import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui';
import { reportInteraction, config } from '@grafana/runtime';
import { Divider, SecureSocksProxySettings } from '@grafana/ui';
import { config, reportInteraction } from '@grafana/runtime';
import { Divider, Field, Input, SecureSocksProxySettings, Stack } from '@grafana/ui';
import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../types/types';
@@ -36,14 +36,33 @@ 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."
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."
isCollapsible
isInitiallyOpen={options.jsonData.enableSecureSocksProxy !== undefined}
isInitiallyOpen={
options.jsonData.enableSecureSocksProxy !== undefined || options.jsonData.universeDomain !== undefined
}
>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
<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>
</ConfigSection>
</>
)}
<Divider />
</>
);
});
@@ -38,6 +38,7 @@ export interface Aggregation {
export interface CloudMonitoringOptions extends DataSourceOptions {
gceDefaultProject?: string;
enableSecureSocksProxy?: boolean;
universeDomain?: string;
}
export interface CloudMonitoringSecureJsonData extends DataSourceSecureJsonData {}
@@ -1,8 +1,7 @@
import { render, screen } from '@testing-library/react';
import { select } from 'react-select-event';
import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { DateHistogram } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { DateHistogramSettingsEditor } from './DateHistogramSettingsEditor';
@@ -4,9 +4,9 @@ import { GroupBase, OptionsOrGroups } from 'react-select';
import { InternalTimeZones, SelectableValue } from '@grafana/data';
import { InlineField, Input, Select, TimeZonePicker } from '@grafana/ui';
import { DateHistogram } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { calendarIntervals } from '../../../../QueryBuilder';
import { DateHistogram } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { useCreatableSelectPersistedBehaviour } from '../../../hooks/useCreatableSelectPersistedBehaviour';
import { changeBucketAggregationSetting } from '../state/actions';
@@ -37,11 +37,11 @@ const hasValue =
const isValidNewOption = (
inputValue: string,
_: SelectableValue<string> | null,
options: OptionsOrGroups<unknown, GroupBase<unknown>>
options: OptionsOrGroups<SelectableValue<string>, GroupBase<SelectableValue<string>>>
) => {
// TODO: would be extremely nice here to allow only template variables and values that are
// valid date histogram's Interval options
const valueExists = (options as Array<SelectableValue<string>>).some(hasValue(inputValue));
const valueExists = options.some(hasValue(inputValue));
// we also don't want users to create "empty" values
return !valueExists && inputValue.trim().length > 0;
};
@@ -3,8 +3,8 @@ import { uniqueId } from 'lodash';
import { useEffect, useRef } from 'react';
import { InlineField, Input, QueryField } from '@grafana/ui';
import { Filters } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Filters } from '../../../../../dataquery.gen';
import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer';
import { AddRemove } from '../../../../AddRemove';
import { changeBucketAggregationSetting } from '../../state/actions';
@@ -1,6 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Filter } from '../../../../../../dataquery.gen';
export const addFilter = createAction('@bucketAggregations/filter/add');
export const removeFilter = createAction<number>('@bucketAggregations/filter/remove');
@@ -1,6 +1,5 @@
import { reducerTester } from 'test/core/redux/reducerTester';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Filter } from '../../../../../../dataquery.gen';
import { reducerTester } from '../../../../../reducerTester';
import { addFilter, changeFilter, removeFilter } from './actions';
import { reducer } from './reducer';
@@ -1,7 +1,6 @@
import { Action } from 'redux';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Filter } from '../../../../../../dataquery.gen';
import { defaultFilter } from '../utils';
import { addFilter, changeFilter, removeFilter } from './actions';
@@ -1,3 +1,3 @@
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Filter } from '../../../../../dataquery.gen';
export const defaultFilter = (): Filter => ({ label: '', query: '*' });
@@ -1,14 +1,7 @@
import { fireEvent, screen } from '@testing-library/react';
import selectEvent from 'react-select-event';
import {
Average,
Derivative,
ElasticsearchDataQuery,
Terms,
TopMetrics,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Average, Derivative, ElasticsearchDataQuery, Terms, TopMetrics } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { renderWithESProvider } from '../../../../test-helpers/render';
import { describeMetric } from '../../../../utils';
@@ -2,15 +2,9 @@ import { uniqueId } from 'lodash';
import { useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { InlineField, Select, Input } from '@grafana/ui';
import {
Terms,
ExtendedStats,
ExtendedStatMetaType,
Percentiles,
MetricAggregation,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { InlineField, Input, Select } from '@grafana/ui';
import { ExtendedStats, MetricAggregation, Percentiles, Terms } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { describeMetric } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
@@ -105,7 +99,7 @@ function createOrderByOptionsForExtendedStats(metric: ExtendedStats): Selectable
if (!metric.meta) {
return [];
}
const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[];
const metaKeys = Object.keys(metric.meta);
return metaKeys
.filter((key) => metric.meta?.[key])
.map((key) => {
@@ -2,8 +2,8 @@ import { uniqueId } from 'lodash';
import { ComponentProps, useRef } from 'react';
import { InlineField, Input } from '@grafana/ui';
import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketAggregation } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
import { changeBucketAggregationSetting } from '../state/actions';
@@ -1,7 +1,6 @@
import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketAggregation } from '../../../../dataquery.gen';
import { defaultGeoHashPrecisionString } from '../../../../queryDef';
import { describeMetric, convertOrderByToMetricId } from '../../../../utils';
import { convertOrderByToMetricId, describeMetric } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils';
@@ -1,10 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
import {
BucketAggregation,
BucketAggregationType,
BucketAggregationWithField,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketAggregation, BucketAggregationType, BucketAggregationWithField } from '../../../../dataquery.gen';
export const addBucketAggregation = createAction<BucketAggregation['id']>('@bucketAggs/add');
export const removeBucketAggregation = createAction<BucketAggregation['id']>('@bucketAggs/remove');
@@ -1,9 +1,4 @@
import {
BucketAggregation,
DateHistogram,
ElasticsearchDataQuery,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketAggregation, DateHistogram, ElasticsearchDataQuery } from '../../../../dataquery.gen';
import { defaultBucketAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
@@ -1,7 +1,6 @@
import { Action } from '@reduxjs/toolkit';
import { BucketAggregation, ElasticsearchDataQuery, Terms } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketAggregation, ElasticsearchDataQuery, Terms } from '../../../../dataquery.gen';
import { defaultBucketAgg } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
@@ -47,11 +46,12 @@ export const createReducer =
}
/*
TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations
in the new selected one (such as field or some settings).
It the future would be nice to have the same behavior but it's hard without a proper definition,
as Elasticsearch will error sometimes if some settings are not compatible.
*/
TODO: The previous version of the query editor was keeping some of the old bucket aggregation's configurations
in the new selected one (such as field or some settings).
It the future would be nice to have the same behavior but it's hard without a proper definition,
as Elasticsearch will error sometimes if some settings are not compatible.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return {
id: bucketAgg.id,
type: action.payload.newType,
@@ -2,10 +2,10 @@ import { css } from '@emotion/css';
import { uniqueId } from 'lodash';
import { Fragment, useEffect } from 'react';
import { Input, InlineLabel } from '@grafana/ui';
import { BucketScript, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { InlineLabel, Input } from '@grafana/ui';
import { useStatelessReducer, useDispatch } from '../../../../../hooks/useStatelessReducer';
import { BucketScript, MetricAggregation } from '../../../../../dataquery.gen';
import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer';
import { AddRemove } from '../../../../AddRemove';
import { MetricPicker } from '../../../../MetricPicker';
import { changeMetricAttribute } from '../../state/actions';
@@ -13,9 +13,9 @@ import { SettingField } from '../SettingField';
import {
addPipelineVariable,
changePipelineVariableMetric,
removePipelineVariable,
renamePipelineVariable,
changePipelineVariableMetric,
} from './state/actions';
import { reducer } from './state/reducer';
@@ -1,5 +1,4 @@
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { PipelineVariable } from '../../../../../../dataquery.gen';
import { reducerTester } from '../../../../../reducerTester';
import {
@@ -1,7 +1,6 @@
import { Action } from '@reduxjs/toolkit';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { PipelineVariable } from '../../../../../../dataquery.gen';
import { defaultPipelineVariable, generatePipelineVariableName } from '../utils';
import {
@@ -1,4 +1,4 @@
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { PipelineVariable } from '../../../../../dataquery.gen';
export const defaultPipelineVariable = (name: string): PipelineVariable => ({ name, pipelineAgg: '' });
@@ -2,11 +2,8 @@ import { uniqueId } from 'lodash';
import { ComponentProps, useState } from 'react';
import { InlineField, Input, TextArea } from '@grafana/ui';
import {
MetricAggregationWithSettings,
MetricAggregationWithInlineScript,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { MetricAggregationWithInlineScript, MetricAggregationWithSettings } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { getScriptValue } from '../../../../utils';
import { SettingKeyOf } from '../../../types';
@@ -33,9 +30,11 @@ export function SettingField<T extends MetricAggregationWithSettings, K extends
const [id] = useState(uniqueId(`es-field-id-`));
const settings = metric.settings;
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
let defaultValue = settings?.[settingName as keyof typeof settings] || '';
if (settingName === 'script') {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
defaultValue = getScriptValue(metric as MetricAggregationWithInlineScript);
}
@@ -2,8 +2,8 @@ import { css } from '@emotion/css';
import { SelectableValue } from '@grafana/data';
import { AsyncMultiSelect, InlineField, SegmentAsync, Select } from '@grafana/ui';
import { TopMetrics } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { TopMetrics } from '../../../../dataquery.gen';
import { useFields } from '../../../../hooks/useFields';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { orderOptions } from '../../BucketAggregationsEditor/utils';
@@ -1,8 +1,8 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getDefaultTimeRange } from '@grafana/data';
import { ElasticsearchDataQuery } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ElasticsearchDataQuery } from '../../../../dataquery.gen';
import { ElasticDatasource } from '../../../../datasource';
import { ElasticsearchProvider } from '../../ElasticsearchQueryContext';
@@ -1,10 +1,10 @@
import { uniqueId } from 'lodash';
import { ComponentProps, useId, useRef, useState } from 'react';
import * as React from 'react';
import { ComponentProps, useId, useRef, useState } from 'react';
import { InlineField, Input, InlineSwitch, Select } from '@grafana/ui';
import { MetricAggregation, ExtendedStat } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { InlineField, InlineSwitch, Input, Select } from '@grafana/ui';
import { ExtendedStat, MetricAggregation } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { extendedStats } from '../../../../queryDef';
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
@@ -1,5 +1,4 @@
import { MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { MetricAggregation } from '../../../../dataquery.gen';
import { extendedStats } from '../../../../queryDef';
const hasValue = (value: string) => (object: { value: string }) => object.value === value;
@@ -1,7 +1,6 @@
import { createAction } from '@reduxjs/toolkit';
import { MetricAggregation, MetricAggregationWithSettings } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { MetricAggregation, MetricAggregationWithSettings } from '../../../../dataquery.gen';
import { MetricAggregationWithMeta } from '../../../../types';
export const addMetric = createAction<MetricAggregation['id']>('@metrics/add');
@@ -1,10 +1,4 @@
import {
MetricAggregation,
ElasticsearchDataQuery,
Derivative,
ExtendedStats,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { Derivative, ElasticsearchDataQuery, ExtendedStats, MetricAggregation } from '../../../../dataquery.gen';
import { defaultMetricAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
@@ -1,7 +1,6 @@
import { Action } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ElasticsearchDataQuery, MetricAggregation } from '../../../../dataquery.gen';
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
@@ -57,6 +56,7 @@ export const reducer = (
It the future would be nice to have the same behavior but it's hard without a proper definition,
as Elasticsearch will error sometimes if some settings are not compatible.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
return {
id: metric.id,
type: action.payload.type,
@@ -2,7 +2,7 @@ import { AnyAction } from '@reduxjs/toolkit';
import { cloneDeep } from 'lodash';
import { Action } from 'redux';
import { StoreState } from 'app/types/store';
import { StoreState } from '../types/store';
type GrafanaReducer<S = StoreState, A extends Action = AnyAction> = (state: S, action: A) => S;
@@ -0,0 +1,22 @@
import { onUpdateDatasourceSecureJsonDataOption, updateDatasourcePluginResetOption } from '@grafana/data';
import { InlineField, SecretInput } from '@grafana/ui';
import { Props } from './ConfigEditor';
export const ApiKeyConfig = (props: Props) => {
const { options } = props;
return (
<InlineField label="API Key" labelWidth={14} interactive tooltip={'API Key authentication'}>
<SecretInput
required
id="config-editor-api-key"
isConfigured={!!options.secureJsonFields?.apiKey}
placeholder="Enter your API key"
width={40}
onReset={() => updateDatasourcePluginResetOption(props, 'apiKey')}
onChange={onUpdateDatasourceSecureJsonDataOption(props, 'apiKey')}
/>
</InlineField>
);
};
@@ -14,14 +14,15 @@ import {
import { config } from '@grafana/runtime';
import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui';
import { ElasticsearchOptions } from '../types';
import { ElasticsearchOptions, ElasticsearchSecureJsonData } from '../types';
import { ApiKeyConfig } from './ApiKeyConfig';
import { DataLinks } from './DataLinks';
import { ElasticDetails } from './ElasticDetails';
import { LogsConfig } from './LogsConfig';
import { coerceOptions, isValidOptions } from './utils';
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>;
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions, ElasticsearchSecureJsonData>;
export const ConfigEditor = (props: Props) => {
const { options, onOptionsChange } = props;
@@ -48,6 +49,16 @@ export const ConfigEditor = (props: Props) => {
authProps.selectedMethod = options.jsonData.sigV4Auth ? 'custom-sigv4' : authProps.selectedMethod;
}
authProps.customMethods = [
{
id: 'custom-api-key',
label: 'API Key',
description: 'API Key authentication',
component: <ApiKeyConfig {...props} />,
},
];
authProps.selectedMethod = options.jsonData.apiKeyAuth ? 'custom-api-key' : authProps.selectedMethod;
return (
<>
{options.access === 'direct' && (
@@ -73,6 +84,7 @@ export const ConfigEditor = (props: Props) => {
jsonData: {
...options.jsonData,
sigV4Auth: method === 'custom-sigv4',
apiKeyAuth: method === 'custom-api-key',
oauthPassThru: method === AuthMethod.OAuthForward,
},
});
@@ -0,0 +1 @@
import '@grafana/plugin-configs/jest/jest-setup';
@@ -0,0 +1,3 @@
import defaultConfig from '@grafana/plugin-configs/jest/jest.config.js';
export default defaultConfig;
@@ -0,0 +1,62 @@
{
"name": "@grafana-plugins/elasticsearch",
"description": "Grafana data source for Elasticsearch",
"private": true,
"version": "12.4.0-pre",
"dependencies": {
"@emotion/css": "11.13.5",
"@grafana/aws-sdk": "0.8.3",
"@grafana/data": "12.4.0-pre",
"@grafana/plugin-ui": "^0.11.1",
"@grafana/runtime": "12.4.0-pre",
"@grafana/schema": "12.4.0-pre",
"@grafana/ui": "12.4.0-pre",
"@reduxjs/toolkit": "2.10.1",
"lodash": "4.17.21",
"lucene": "^2.1.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-redux": "9.2.0",
"react-select": "5.10.2",
"react-use": "17.6.0",
"redux": "5.0.1",
"redux-thunk": "3.1.0",
"rxjs": "7.8.2",
"semver": "7.7.3",
"tslib": "2.8.1"
},
"devDependencies": {
"@grafana/e2e-selectors": "12.4.0-pre",
"@grafana/plugin-configs": "12.4.0-pre",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.6.4",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.20",
"@types/lucene": "^2",
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/semver": "7.7.1",
"jest": "29.7.0",
"react-select-event": "5.5.1",
"ts-node": "10.9.2",
"typescript": "5.9.2",
"webpack": "5.101.0"
},
"peerDependencies": {
"@grafana/runtime": "*"
},
"resolutions": {
"redux": "^5.0.0"
},
"scripts": {
"build": "webpack -c ./webpack.config.ts --env production",
"build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)",
"dev": "webpack -w -c ./webpack.config.ts --env development",
"test": "jest --watch --onlyChanged",
"test:ci": "jest --maxWorkers 4"
},
"packageManager": "yarn@4.11.0"
}
@@ -2,6 +2,7 @@
"type": "datasource",
"name": "Elasticsearch",
"id": "elasticsearch",
"executable": "gpx_elasticsearch",
"category": "logging",
"info": {
"description": "Open source logging & analytics database",
@@ -27,7 +28,8 @@
"name": "Documentation",
"url": "https://grafana.com/docs/grafana/latest/datasources/elasticsearch/"
}
]
],
"version": "%VERSION%"
},
"alerting": true,
"annotations": true,
@@ -36,5 +38,9 @@
"backend": true,
"queryOptions": {
"minInterval": true
},
"dependencies": {
"grafanaDependency": ">=11.6.0",
"plugins": []
}
}
@@ -0,0 +1,9 @@
{
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:plugin", "type:datasource"],
"targets": {
"build": {},
"dev": {}
}
}
@@ -0,0 +1,11 @@
import { createAction } from '@reduxjs/toolkit';
import { StoreState } from '../../types/store';
export type CleanUpAction = (state: StoreState) => void;
export interface CleanUpPayload {
cleanupAction: CleanUpAction;
}
export const cleanUpAction = createAction<CleanUpPayload>('core/cleanUpState');
@@ -0,0 +1,21 @@
import { ReducersMapObject } from '@reduxjs/toolkit';
import { Action as AnyAction, combineReducers } from 'redux';
const addedReducers = {
defaultReducer: (state = {}) => state,
templating: (state = { lastKey: 'key' }) => state,
};
export const addReducer = (newReducers: ReducersMapObject) => {
Object.assign(addedReducers, newReducers);
};
export const createRootReducer = () => {
const appReducer = combineReducers({
...addedReducers,
});
return (state: Parameters<typeof appReducer>[0], action: AnyAction) => {
return appReducer(state, action);
};
};
@@ -0,0 +1,47 @@
import { createListenerMiddleware, configureStore as reduxConfigureStore } from '@reduxjs/toolkit';
import { setupListeners } from '@reduxjs/toolkit/query';
import { Middleware } from 'redux';
import { addReducer, createRootReducer } from '../reducers/root';
import { StoreState } from '../types/store';
import { setStore } from './store';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function addRootReducer(reducers: any) {
// this is ok now because we add reducers before configureStore is called
// in the future if we want to add reducers during runtime
// we'll have to solve this in a more dynamic way
addReducer(reducers);
}
const listenerMiddleware = createListenerMiddleware();
const extraMiddleware: Middleware[] = [];
export function addExtraMiddleware(middleware: Middleware) {
extraMiddleware.push(middleware);
}
export function configureStore(initialState?: Partial<StoreState>) {
const store = reduxConfigureStore({
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ thunk: true, serializableCheck: false, immutableCheck: false }).concat(
listenerMiddleware.middleware,
...extraMiddleware
),
devTools: process.env.NODE_ENV !== 'production',
preloadedState: {
...initialState,
},
});
// this enables "refetchOnFocus" and "refetchOnReconnect" for RTK Query
setupListeners(store.dispatch);
setStore(store);
return store;
}
export type RootState = ReturnType<ReturnType<typeof configureStore>['getState']>;
export type AppDispatch = ReturnType<typeof configureStore>['dispatch'];
@@ -0,0 +1,26 @@
import { Store } from 'redux';
import { StoreState } from '../types/store';
export let store: Store<StoreState>;
export function setStore(newStore: Store<StoreState>) {
store = newStore;
}
export function getState(): StoreState {
if (!store || !store.getState) {
return { defaultReducer: () => ({}), templating: { lastKey: 'key' } }; // used by tests
}
return store.getState();
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function dispatch(action: any) {
if (!store || !store.getState) {
return;
}
return store.dispatch(action);
}
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"types": ["node", "jest", "@testing-library/jest-dom"]
},
"extends": "@grafana/plugin-configs/tsconfig.json",
"include": ["."]
}
@@ -64,6 +64,11 @@ export interface ElasticsearchOptions extends DataSourceJsonData {
sigV4Auth?: boolean;
oauthPassThru?: boolean;
defaultQueryMode?: QueryType;
apiKeyAuth?: boolean;
}
export interface ElasticsearchSecureJsonData {
apiKey?: string;
}
export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document';
@@ -0,0 +1,46 @@
/* eslint-disable no-restricted-imports */
import {
Action,
addListener as addListenerUntyped,
AsyncThunk,
AsyncThunkOptions,
AsyncThunkPayloadCreator,
createAsyncThunk as createAsyncThunkUntyped,
PayloadAction,
TypedAddListener,
} from '@reduxjs/toolkit';
import {
TypedUseSelectorHook,
useDispatch as useDispatchUntyped,
useSelector as useSelectorUntyped,
} from 'react-redux';
import { ThunkDispatch as GenericThunkDispatch, ThunkAction } from 'redux-thunk';
import type { createRootReducer } from '../reducers/root';
import { AppDispatch, RootState } from '../store/configureStore';
import { dispatch as storeDispatch } from '../store/store';
export type StoreState = ReturnType<ReturnType<typeof createRootReducer>>;
/*
* Utility type to get strongly types thunks
*/
export type ThunkResult<R> = ThunkAction<R, StoreState, undefined, PayloadAction<unknown>>;
export type ThunkDispatch = GenericThunkDispatch<StoreState, undefined, Action>;
// Typed useDispatch & useSelector hooks
export const useDispatch: () => AppDispatch = useDispatchUntyped;
export const useSelector: TypedUseSelectorHook<RootState> = useSelectorUntyped;
type DefaultThunkApiConfig = { dispatch: AppDispatch; state: StoreState };
export const createAsyncThunk = <Returned, ThunkArg = void, ThunkApiConfig extends {} = DefaultThunkApiConfig>(
typePrefix: string,
payloadCreator: AsyncThunkPayloadCreator<Returned, ThunkArg, ThunkApiConfig>,
options?: AsyncThunkOptions<ThunkArg, ThunkApiConfig>
): AsyncThunk<Returned, ThunkArg, ThunkApiConfig> =>
createAsyncThunkUntyped<Returned, ThunkArg, ThunkApiConfig>(typePrefix, payloadCreator, options);
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
export const addListener = addListenerUntyped as TypedAddListener<RootState, AppDispatch>;
export const dispatch: AppDispatch = storeDispatch;
@@ -0,0 +1,9 @@
import type { Configuration } from 'webpack';
import grafanaConfig, { type Env } from '@grafana/plugin-configs/webpack.config.ts';
const config = async (env: Env): Promise<Configuration> => {
return await grafanaConfig(env);
};
export default config;
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 38 KiB

+1
View File
@@ -6983,6 +6983,7 @@
"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",
+51 -2
View File
@@ -2610,6 +2610,53 @@ __metadata:
languageName: node
linkType: hard
"@grafana-plugins/elasticsearch@workspace:public/app/plugins/datasource/elasticsearch":
version: 0.0.0-use.local
resolution: "@grafana-plugins/elasticsearch@workspace:public/app/plugins/datasource/elasticsearch"
dependencies:
"@emotion/css": "npm:11.13.5"
"@grafana/aws-sdk": "npm:0.8.3"
"@grafana/data": "npm:12.4.0-pre"
"@grafana/e2e-selectors": "npm:12.4.0-pre"
"@grafana/plugin-configs": "npm:12.4.0-pre"
"@grafana/plugin-ui": "npm:^0.11.1"
"@grafana/runtime": "npm:12.4.0-pre"
"@grafana/schema": "npm:12.4.0-pre"
"@grafana/ui": "npm:12.4.0-pre"
"@reduxjs/toolkit": "npm:2.10.1"
"@testing-library/dom": "npm:10.4.1"
"@testing-library/jest-dom": "npm:6.6.4"
"@testing-library/react": "npm:16.3.0"
"@testing-library/user-event": "npm:14.6.1"
"@types/jest": "npm:29.5.14"
"@types/lodash": "npm:4.17.20"
"@types/lucene": "npm:^2"
"@types/node": "npm:24.10.1"
"@types/react": "npm:18.3.18"
"@types/react-dom": "npm:18.3.5"
"@types/semver": "npm:7.7.1"
jest: "npm:29.7.0"
lodash: "npm:4.17.21"
lucene: "npm:^2.1.1"
react: "npm:18.3.1"
react-dom: "npm:18.3.1"
react-redux: "npm:9.2.0"
react-select: "npm:5.10.2"
react-select-event: "npm:5.5.1"
react-use: "npm:17.6.0"
redux: "npm:5.0.1"
redux-thunk: "npm:3.1.0"
rxjs: "npm:7.8.2"
semver: "npm:7.7.3"
ts-node: "npm:10.9.2"
tslib: "npm:2.8.1"
typescript: "npm:5.9.2"
webpack: "npm:5.101.0"
peerDependencies:
"@grafana/runtime": "*"
languageName: unknown
linkType: soft
"@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor":
version: 0.0.0-use.local
resolution: "@grafana-plugins/grafana-azure-monitor-datasource@workspace:public/app/plugins/datasource/azuremonitor"
@@ -3460,6 +3507,7 @@ __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"
@@ -3470,6 +3518,7 @@ __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"
@@ -11112,7 +11161,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:
@@ -29324,7 +29373,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: