Compare commits

..

3 Commits

Author SHA1 Message Date
Piotr Jamróz c558072c0c Update tests 2026-01-14 14:05:12 +01:00
Piotr Jamróz 2ce89f099f Merge branch 'main' into ifrost/track-local-storage-errors 2026-01-14 13:29:35 +01:00
Piotr Jamróz 829022d488 add save retries and track stats 2026-01-14 12:53:06 +01:00
103 changed files with 3194 additions and 76867 deletions
-2
View File
@@ -121,8 +121,6 @@ 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,11 +103,10 @@ To configure basic settings for the data source, complete the following steps:
1. Set the data source's basic configuration options:
| Name | Description |
| ------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
| **Universe Domain** | The universe domain to connect to. For more information, refer to [Documentation on universe domains](https://docs.cloud.google.com/python/docs/reference/monitoring/latest/google.cloud.monitoring_v3.services.service_monitoring_service.ServiceMonitoringServiceAsyncClient#google_cloud_monitoring_v3_services_service_monitoring_service_ServiceMonitoringServiceAsyncClient_universe_domain). Defaults to `googleapis.com`. |
| Name | Description |
| ----------- | ------------------------------------------------------------------------ |
| **Name** | Sets the name you use to refer to the data source in panels and queries. |
| **Default** | Sets whether the data source is pre-selected for new panels. |
### Provision the data source
@@ -130,7 +129,6 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
secureJsonData:
privateKey: |
-----BEGIN PRIVATE KEY-----
@@ -154,7 +152,6 @@ datasources:
clientEmail: stackdriver@myproject.iam.gserviceaccount.com
authenticationType: jwt
defaultProject: my-project-name
universeDomain: googleapis.com
privateKeyPath: /etc/secrets/gce.pem
```
@@ -169,7 +166,6 @@ datasources:
access: proxy
jsonData:
authenticationType: gce
universeDomain: googleapis.com
```
## Import pre-configured dashboards
@@ -87,7 +87,6 @@ With a Grafana Enterprise license, you also get access to premium data sources,
- [CockroachDB](/grafana/plugins/grafana-cockroachdb-datasource)
- [Databricks](/grafana/plugins/grafana-databricks-datasource)
- [DataDog](/grafana/plugins/grafana-datadog-datasource)
- [IBM Db2](/grafana/plugins/grafana-ibmdb2-datasource)
- [Drone](/grafana/plugins/grafana-drone-datasource)
- [DynamoDB](/grafana/plugins/grafana-dynamodb-datasource/)
- [Dynatrace](/grafana/plugins/grafana-dynatrace-datasource)
+25
View File
@@ -3743,21 +3743,46 @@
"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,7 +82,6 @@ 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,7 +58,6 @@
"d3": "^7.8.5",
"lodash": "4.17.21",
"react": "18.3.1",
"react-table": "^7.8.0",
"react-use": "17.6.0",
"react-virtualized-auto-sizer": "1.0.26",
"tinycolor2": "1.6.0",
@@ -82,7 +81,6 @@
"@types/lodash": "4.17.20",
"@types/node": "24.10.1",
"@types/react": "18.3.18",
"@types/react-table": "^7.7.20",
"@types/react-virtualized-auto-sizer": "1.0.8",
"@types/tinycolor2": "1.4.6",
"babel-jest": "29.7.0",
@@ -1,49 +0,0 @@
import { Meta, StoryObj } from '@storybook/react';
import { createDataFrame } from '@grafana/data';
import { FlameGraphDataContainer } from '../FlameGraph/dataTransform';
import { data } from '../FlameGraph/testData/dataNestedSet';
import { ColorScheme } from '../types';
import FlameGraphCallTreeContainer from './FlameGraphCallTreeContainer';
const meta: Meta<typeof FlameGraphCallTreeContainer> = {
title: 'CallTree',
component: FlameGraphCallTreeContainer,
args: {
colorScheme: ColorScheme.PackageBased,
search: '',
},
decorators: [
(Story) => (
<div style={{ width: '100%', height: '1000px' }}>
<Story />
</div>
),
],
};
export default meta;
export const Basic: StoryObj<typeof meta> = {
render: (args) => {
const dataContainer = new FlameGraphDataContainer(createDataFrame(data), { collapsing: true });
return (
<FlameGraphCallTreeContainer
{...args}
data={dataContainer}
onSymbolClick={(symbol) => {
console.log('Symbol clicked:', symbol);
}}
onSandwich={(item) => {
console.log('Sandwich:', item);
}}
onSearch={(symbol) => {
console.log('Search:', symbol);
}}
/>
);
},
};
File diff suppressed because it is too large Load Diff
@@ -1,580 +0,0 @@
import { FlameGraphDataContainer, LevelItem } from '../FlameGraph/dataTransform';
export interface CallTreeNode {
id: string; // Path-based ID (e.g., "0.2.1")
label: string; // Function name
self: number; // Self value
total: number; // Total value
selfPercent: number; // Self as % of root
totalPercent: number; // Total as % of root
depth: number; // Indentation level
parentId?: string; // Parent node ID
hasChildren: boolean; // Has expandable children
childCount: number; // Number of direct children
subtreeSize: number; // Total number of nodes in subtree (excluding self)
levelItem: LevelItem; // Reference to original data
subRows?: CallTreeNode[]; // Child nodes for react-table useExpanded
isLastChild: boolean; // Whether this is the last child of its parent
// For diff profiles
selfRight?: number;
totalRight?: number;
selfPercentRight?: number;
totalPercentRight?: number;
diffPercent?: number;
}
/**
* Build hierarchical call tree node from the LevelItem structure.
* Each node gets a unique ID based on its path in the tree.
* Children are stored in the subRows property for react-table useExpanded.
*/
export function buildCallTreeNode(
data: FlameGraphDataContainer,
rootItem: LevelItem,
rootTotal: number,
parentId?: string,
parentDepth: number = -1,
childIndex: number = 0
): CallTreeNode {
const nodeId = parentId ? `${parentId}.${childIndex}` : `${childIndex}`;
const depth = parentDepth + 1;
// Get values for current item
const itemIndex = rootItem.itemIndexes[0];
const label = data.getLabel(itemIndex);
const self = data.getSelf(itemIndex);
const total = data.getValue(itemIndex);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemIndex);
totalRight = data.getValueRight(itemIndex);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
// Calculate diff percentage (change from baseline to comparison)
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity; // New in comparison
} else {
diffPercent = 0;
}
}
// Recursively build children
const subRows =
rootItem.children.length > 0
? rootItem.children.map((child, index) => {
const childNode = buildCallTreeNode(data, child, rootTotal, nodeId, depth, index);
// Mark if this is the last child
childNode.isLastChild = index === rootItem.children.length - 1;
return childNode;
})
: undefined;
// Calculate child count and subtree size
const childCount = rootItem.children.length;
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
const node: CallTreeNode = {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: rootItem.children.length > 0,
childCount,
subtreeSize,
levelItem: rootItem,
subRows,
isLastChild: false, // Will be set by parent
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
return node;
}
/**
* Build all call tree nodes from the root level items.
* Returns an array of root nodes, each with their children in subRows.
* This handles cases where there might be multiple root items.
*/
export function buildAllCallTreeNodes(data: FlameGraphDataContainer): CallTreeNode[] {
const levels = data.getLevels();
const rootTotal = levels.length > 0 ? levels[0][0].value : 0;
// Build hierarchical structure for each root item
const rootNodes = levels[0].map((rootItem, index) => buildCallTreeNode(data, rootItem, rootTotal, undefined, -1, index));
return rootNodes;
}
/**
* Build call tree nodes from an array of levels (from mergeParentSubtrees).
* This is used for the callers view where we get LevelItem[][] from getSandwichLevels.
* Unlike buildCallTreeNode which recursively processes children, this function
* processes pre-organized levels and builds the hierarchy from them.
*/
export function buildCallTreeFromLevels(
levels: LevelItem[][],
data: FlameGraphDataContainer,
rootTotal: number
): CallTreeNode[] {
if (levels.length === 0 || levels[0].length === 0) {
return [];
}
// Map to track LevelItem -> CallTreeNode for building relationships
const levelItemToNode = new Map<LevelItem, CallTreeNode>();
// Process each level and build nodes
levels.forEach((level, levelIndex) => {
level.forEach((levelItem, itemIndex) => {
// Get values from data
const itemDataIndex = levelItem.itemIndexes[0];
const label = data.getLabel(itemDataIndex);
const self = data.getSelf(itemDataIndex);
const total = data.getValue(itemDataIndex);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemDataIndex);
totalRight = data.getValueRight(itemDataIndex);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
// Calculate diff percentage
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity;
} else {
diffPercent = 0;
}
}
// Determine parent (if exists)
let parentId: string | undefined;
let depth = levelIndex;
if (levelItem.parents && levelItem.parents.length > 0) {
const parentNode = levelItemToNode.get(levelItem.parents[0]);
if (parentNode) {
parentId = parentNode.id;
depth = parentNode.depth + 1;
}
}
// Generate path-based ID
// For root nodes, use index at level 0
// For child nodes, append index to parent ID
let nodeId: string;
if (!parentId) {
nodeId = `${itemIndex}`;
} else {
// Find index among siblings
const parent = levelItemToNode.get(levelItem.parents![0]);
const siblingIndex = parent?.subRows?.length || 0;
nodeId = `${parentId}.${siblingIndex}`;
}
// Create the node (without children initially)
const node: CallTreeNode = {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: levelItem.children.length > 0,
childCount: levelItem.children.length,
subtreeSize: 0, // Will be calculated later
levelItem,
subRows: undefined,
isLastChild: false,
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
// Add to map
levelItemToNode.set(levelItem, node);
// Add as child to parent
if (levelItem.parents && levelItem.parents.length > 0) {
const parentNode = levelItemToNode.get(levelItem.parents[0]);
if (parentNode) {
if (!parentNode.subRows) {
parentNode.subRows = [];
}
parentNode.subRows.push(node);
// Mark if this is the last child
const isLastChild = parentNode.subRows.length === parentNode.childCount;
node.isLastChild = isLastChild;
}
}
});
});
// Calculate subtreeSize for all nodes (bottom-up)
const calculateSubtreeSize = (node: CallTreeNode): number => {
if (!node.subRows || node.subRows.length === 0) {
node.subtreeSize = 0;
return 0;
}
const size = node.subRows.reduce((sum, child) => {
return sum + calculateSubtreeSize(child) + 1;
}, 0);
node.subtreeSize = size;
return size;
};
// Collect root nodes (level 0)
const rootNodes: CallTreeNode[] = [];
levels[0].forEach((levelItem) => {
const node = levelItemToNode.get(levelItem);
if (node) {
calculateSubtreeSize(node);
rootNodes.push(node);
}
});
return rootNodes;
}
/**
* Recursively collect expanded state for nodes up to a certain depth.
*/
function collectExpandedByDepth(
node: CallTreeNode,
levelsToExpand: number,
expanded: Record<string, boolean>
): void {
if (node.depth < levelsToExpand && node.hasChildren) {
expanded[node.id] = true;
}
if (node.subRows) {
node.subRows.forEach((child) => collectExpandedByDepth(child, levelsToExpand, expanded));
}
}
/**
* Get initial expanded state for the tree.
* Auto-expands first N levels.
*/
export function getInitialExpandedState(nodes: CallTreeNode[], levelsToExpand: number = 2): Record<string, boolean> {
const expanded: Record<string, boolean> = {};
nodes.forEach((node) => {
collectExpandedByDepth(node, levelsToExpand, expanded);
});
return expanded;
}
/**
* Restructure the callers tree to show a specific target node at the root.
* In the callers view, we want to show the target function with its callers as children.
* This function finds the target node and collects all paths that lead to it,
* then restructures them so the target is at the root.
*/
export function restructureCallersTree(
nodes: CallTreeNode[],
targetLabel: string
): { restructuredTree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
// First, find all paths from root to target node
const findPathsToTarget = (
nodes: CallTreeNode[],
targetLabel: string,
currentPath: CallTreeNode[] = []
): CallTreeNode[][] => {
const paths: CallTreeNode[][] = [];
for (const node of nodes) {
const newPath = [...currentPath, node];
if (node.label === targetLabel) {
// Found a path to the target
paths.push(newPath);
}
if (node.subRows && node.subRows.length > 0) {
// Continue searching in children
const childPaths = findPathsToTarget(node.subRows, targetLabel, newPath);
paths.push(...childPaths);
}
}
return paths;
};
const paths = findPathsToTarget(nodes, targetLabel);
if (paths.length === 0) {
// Target not found, return original tree
return { restructuredTree: nodes, targetNode: undefined };
}
// Get the target node from the first path (they should all have the same target node)
const targetNode = paths[0][paths[0].length - 1];
// Now restructure: create a new tree with target at root
// Each path to the target becomes a branch under the target
// For example, if we have: root -> A -> B -> target
// We want: target -> B -> A -> root (inverted)
const buildInvertedChildren = (paths: CallTreeNode[][]): CallTreeNode[] => {
// Group paths by their immediate caller (the node right before target)
const callerGroups = new Map<string, CallTreeNode[][]>();
for (const path of paths) {
if (path.length <= 1) {
// Path is just the target node itself, no callers
continue;
}
// The immediate caller is the node right before the target
const immediateCaller = path[path.length - 2];
const callerKey = immediateCaller.label;
if (!callerGroups.has(callerKey)) {
callerGroups.set(callerKey, []);
}
callerGroups.get(callerKey)!.push(path);
}
// Build nodes for each immediate caller
const callerNodes: CallTreeNode[] = [];
let callerIndex = 0;
for (const [, callerPaths] of callerGroups.entries()) {
// Get the immediate caller node from one of the paths
const immediateCallerNode = callerPaths[0][callerPaths[0].length - 2];
// For this caller, recursively build its callers (from the remaining path)
const remainingPaths = callerPaths.map((path) => path.slice(0, -1)); // Remove target from paths
const grandCallers = buildInvertedChildren(remainingPaths);
// Create a new node for this caller as a child of the target
const newCallerId = `0.${callerIndex}`;
const callerNode: CallTreeNode = {
...immediateCallerNode,
id: newCallerId,
depth: 1,
parentId: '0',
subRows: grandCallers.length > 0 ? grandCallers : undefined,
hasChildren: grandCallers.length > 0,
childCount: grandCallers.length,
isLastChild: callerIndex === callerGroups.size - 1,
};
// Update IDs of grandCallers
if (grandCallers.length > 0) {
grandCallers.forEach((grandCaller, idx) => {
updateNodeIds(grandCaller, newCallerId, idx);
});
}
callerNodes.push(callerNode);
callerIndex++;
}
return callerNodes;
};
// Helper to recursively update node IDs
const updateNodeIds = (node: CallTreeNode, parentId: string, index: number) => {
node.id = `${parentId}.${index}`;
node.parentId = parentId;
node.depth = parentId.split('.').length;
if (node.subRows) {
node.subRows.forEach((child, idx) => {
updateNodeIds(child, node.id, idx);
});
}
};
// Build the inverted children for the target
const invertedChildren = buildInvertedChildren(paths);
// Create the restructured target node as root
const restructuredTarget: CallTreeNode = {
...targetNode,
id: '0',
depth: 0,
parentId: undefined,
subRows: invertedChildren.length > 0 ? invertedChildren : undefined,
hasChildren: invertedChildren.length > 0,
childCount: invertedChildren.length,
subtreeSize: invertedChildren.reduce((sum, child) => sum + child.subtreeSize + 1, 0),
isLastChild: false,
};
return { restructuredTree: [restructuredTarget], targetNode: restructuredTarget };
}
/**
* Build a callers tree directly from sandwich levels data.
* This creates an inverted tree where the target function is at the root
* and its callers are shown as children.
*/
export function buildCallersTreeFromLevels(
levels: LevelItem[][],
targetLabel: string,
data: FlameGraphDataContainer,
rootTotal: number
): { tree: CallTreeNode[]; targetNode: CallTreeNode | undefined } {
if (levels.length === 0) {
return { tree: [], targetNode: undefined };
}
// Find the target node in the levels
let targetLevelIndex = -1;
let targetItem: LevelItem | undefined;
for (let i = 0; i < levels.length; i++) {
for (const item of levels[i]) {
const label = data.getLabel(item.itemIndexes[0]);
if (label === targetLabel) {
targetLevelIndex = i;
targetItem = item;
break;
}
}
if (targetItem) break;
}
if (!targetItem || targetLevelIndex === -1) {
// Target not found
return { tree: [], targetNode: undefined };
}
// Create a map from LevelItem to all items that reference it as a parent
const childrenMap = new Map<LevelItem, LevelItem[]>();
for (const level of levels) {
for (const item of level) {
if (item.parents) {
for (const parent of item.parents) {
if (!childrenMap.has(parent)) {
childrenMap.set(parent, []);
}
childrenMap.get(parent)!.push(item);
}
}
}
}
// Build the inverted tree recursively
// For callers view: the target is root, and parents become children
const buildInvertedNode = (
item: LevelItem,
nodeId: string,
depth: number,
parentId: string | undefined
): CallTreeNode => {
const itemIdx = item.itemIndexes[0];
const label = data.getLabel(itemIdx);
const self = data.getSelf(itemIdx);
const total = data.getValue(itemIdx);
const selfPercent = rootTotal > 0 ? (self / rootTotal) * 100 : 0;
const totalPercent = rootTotal > 0 ? (total / rootTotal) * 100 : 0;
// For diff profiles
let selfRight: number | undefined;
let totalRight: number | undefined;
let selfPercentRight: number | undefined;
let totalPercentRight: number | undefined;
let diffPercent: number | undefined;
if (data.isDiffFlamegraph()) {
selfRight = data.getSelfRight(itemIdx);
totalRight = data.getValueRight(itemIdx);
selfPercentRight = rootTotal > 0 ? (selfRight / rootTotal) * 100 : 0;
totalPercentRight = rootTotal > 0 ? (totalRight / rootTotal) * 100 : 0;
if (self > 0) {
diffPercent = ((selfRight - self) / self) * 100;
} else if (selfRight > 0) {
diffPercent = Infinity;
} else {
diffPercent = 0;
}
}
// In the inverted tree, parents become children (callers)
const callers = item.parents || [];
const subRows =
callers.length > 0
? callers.map((caller, idx) => {
const callerId = `${nodeId}.${idx}`;
const callerNode = buildInvertedNode(caller, callerId, depth + 1, nodeId);
callerNode.isLastChild = idx === callers.length - 1;
return callerNode;
})
: undefined;
const childCount = callers.length;
const subtreeSize = subRows ? subRows.reduce((sum, child) => sum + child.subtreeSize + 1, 0) : 0;
return {
id: nodeId,
label,
self,
total,
selfPercent,
totalPercent,
depth,
parentId,
hasChildren: callers.length > 0,
childCount,
subtreeSize,
levelItem: item,
subRows,
isLastChild: false,
selfRight,
totalRight,
selfPercentRight,
totalPercentRight,
diffPercent,
};
};
// Build tree with target as root
const targetNode = buildInvertedNode(targetItem, '0', 0, undefined);
return { tree: [targetNode], targetNode };
}
@@ -16,7 +16,7 @@ const meta: Meta<typeof FlameGraph> = {
rangeMax: 1,
textAlign: 'left',
colorScheme: ColorScheme.PackageBased,
selectedView: SelectedView.Multi,
selectedView: SelectedView.Both,
search: '',
},
};
@@ -43,13 +43,10 @@ describe('FlameGraph', () => {
setRangeMax={setRangeMax}
onItemFocused={onItemFocused}
textAlign={'left'}
onTextAlignChange={jest.fn()}
onSandwich={onSandwich}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
colorScheme={ColorScheme.ValueBased}
onColorSchemeChange={jest.fn()}
isDiffMode={false}
selectedView={SelectedView.FlameGraph}
search={''}
collapsedMap={container.getCollapsedMap()}
@@ -19,10 +19,8 @@
import { css, cx } from '@emotion/css';
import { useEffect, useState } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, ButtonGroup, Dropdown, Icon, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { Icon } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './colors';
import { PIXELS_PER_LEVEL } from '../constants';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from '../types';
@@ -41,14 +39,11 @@ type Props = {
onItemFocused: (data: ClickedItemData) => void;
focusedItemData?: ClickedItemData;
textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
sandwichItem?: string;
onSandwich: (label: string) => void;
onFocusPillClick: () => void;
onSandwichPillClick: () => void;
colorScheme: ColorScheme | ColorSchemeDiff;
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
showFlameGraphOnly?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
collapsing?: boolean;
@@ -68,14 +63,11 @@ const FlameGraph = ({
onItemFocused,
focusedItemData,
textAlign,
onTextAlignChange,
onSandwich,
sandwichItem,
onFocusPillClick,
onSandwichPillClick,
colorScheme,
onColorSchemeChange,
isDiffMode,
showFlameGraphOnly,
getExtraContextMenuButtons,
collapsing,
@@ -84,7 +76,7 @@ const FlameGraph = ({
collapsedMap,
setCollapsedMap,
}: Props) => {
const styles = useStyles2(getStyles);
const styles = getStyles();
const [levels, setLevels] = useState<LevelItem[][]>();
const [levelsCallers, setLevelsCallers] = useState<LevelItem[][]>();
@@ -183,183 +175,28 @@ const FlameGraph = ({
);
}
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
];
return (
<div className={styles.graph}>
<div className={styles.toolbar}>
<FlameGraphMetadata
data={data}
focusedItem={focusedItemData}
sandwichedLabel={sandwichItem}
totalTicks={totalViewTicks}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
<div className={styles.controls}>
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
<ButtonGroup className={styles.buttonSpacing}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Expand all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
}}
aria-label={'Expand all groups'}
icon={'angle-double-down'}
/>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
}}
aria-label={'Collapse all groups'}
icon={'angle-double-up'}
/>
</ButtonGroup>
<RadioButtonGroup<TextAlign>
size="sm"
options={alignOptions}
value={textAlign}
onChange={onTextAlignChange}
/>
</div>
</div>
<FlameGraphMetadata
data={data}
focusedItem={focusedItemData}
sandwichedLabel={sandwichItem}
totalTicks={totalViewTicks}
onFocusPillClick={onFocusPillClick}
onSandwichPillClick={onSandwichPillClick}
/>
{canvas}
</div>
);
};
type ColorSchemeButtonProps = {
value: ColorScheme | ColorSchemeDiff;
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
// Show a bit different gradient as a way to indicate selected value
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
const getStyles = () => ({
graph: css({
label: 'graph',
overflow: 'auto',
flexGrow: 1,
flexBasis: '50%',
}),
toolbar: css({
label: 'toolbar',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: theme.spacing(1),
}),
controls: css({
label: 'controls',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
buttonSpacing: css({
label: 'buttonSpacing',
marginRight: theme.spacing(1),
}),
colorDot: css({
label: 'colorDot',
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
}),
colorDotDiff: css({
label: 'colorDotDiff',
display: 'flex',
width: '200px',
height: '12px',
color: 'white',
fontSize: 9,
lineHeight: 1.3,
fontWeight: 300,
justifyContent: 'space-between',
padding: '0 2px',
// We have a specific sizing for this so probably makes sense to use hardcoded value here
// eslint-disable-next-line @grafana/no-border-radius-literal
borderRadius: '2px',
}),
colorDotByValue: css({
label: 'colorDotByValue',
background: byValueGradient,
}),
colorDotByPackage: css({
label: 'colorDotByPackage',
background: byPackageGradient,
}),
colorDotDiffDefault: css({
label: 'colorDotDiffDefault',
background: diffDefaultGradient,
}),
colorDotDiffColorBlind: css({
label: 'colorDotDiffColorBlind',
background: diffColorBlindGradient,
}),
sandwichCanvasWrapper: css({
label: 'sandwichCanvasWrapper',
display: 'flex',
File diff suppressed because it is too large Load Diff
@@ -1,18 +1,19 @@
import { css } from '@emotion/css';
import uFuzzy from '@leeoniya/ufuzzy';
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import * as React from 'react';
import { useMeasure } from 'react-use';
import { DataFrame, GrafanaTheme2 } from '@grafana/data';
import { DataFrame, GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
import { ThemeContext } from '@grafana/ui';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraph from './FlameGraph/FlameGraph';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { CollapsedMap, FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import FlameGraphPane from './FlameGraphPane';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { PaneView, SelectedView, ViewMode } from './types';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
import { getAssistantContextFromDataFrame } from './utils';
const ufuzzy = new uFuzzy();
@@ -103,18 +104,17 @@ const FlameGraphContainer = ({
getExtraContextMenuButtons,
showAnalyzeWithAssistant = true,
}: Props) => {
// Shared state across all views
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [search, setSearch] = useState('');
const [selectedView, setSelectedView] = useState(SelectedView.Multi);
const [viewMode, setViewMode] = useState<ViewMode>(ViewMode.Split);
const [leftPaneView, setLeftPaneView] = useState<PaneView>(PaneView.TopTable);
const [rightPaneView, setRightPaneView] = useState<PaneView>(PaneView.FlameGraph);
const [singleView, setSingleView] = useState<PaneView>(PaneView.FlameGraph);
const [selectedView, setSelectedView] = useState(SelectedView.Both);
const [sizeRef, { width: containerWidth }] = useMeasure<HTMLDivElement>();
// Used to trigger reset of pane-specific state (focus, sandwich) when parent reset button is clicked
const [resetKey, setResetKey] = useState(0);
// Track if we temporarily switched away from Both view due to narrow width
const [viewBeforeNarrow, setViewBeforeNarrow] = useState<SelectedView | null>(null);
const [textAlign, setTextAlign] = useState<TextAlign>('left');
// This is a label of the item because in sandwich view we group all items by label and present a merged graph
const [sandwichItem, setSandwichItem] = useState<string>();
const [collapsedMap, setCollapsedMap] = useState(new CollapsedMap());
const theme = useMemo(() => getTheme(), [getTheme]);
const dataContainer = useMemo((): FlameGraphDataContainer | undefined => {
@@ -122,220 +122,157 @@ const FlameGraphContainer = ({
return;
}
return new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
const container = new FlameGraphDataContainer(data, { collapsing: !disableCollapsing }, theme);
setCollapsedMap(container.getCollapsedMap());
return container;
}, [data, theme, disableCollapsing]);
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = getStyles(theme);
const matchedLabels = useLabelSearch(search, dataContainer);
// Handle responsive layout: switch away from Both view when narrow, restore when wide again
// If user resizes window with both as the selected view
useEffect(() => {
if (containerWidth === 0) {
if (
containerWidth > 0 &&
containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH &&
selectedView === SelectedView.Both &&
!vertical
) {
setSelectedView(SelectedView.FlameGraph);
}
}, [selectedView, setSelectedView, containerWidth, vertical]);
const resetFocus = useCallback(() => {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}, [setFocusedItemData, setRangeMax, setRangeMin]);
const resetSandwich = useCallback(() => {
setSandwichItem(undefined);
}, [setSandwichItem]);
useEffect(() => {
if (!keepFocusOnDataChange) {
resetFocus();
resetSandwich();
return;
}
const isNarrow = containerWidth < MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH && !vertical;
if (dataContainer && focusedItemData) {
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
if (isNarrow && selectedView === SelectedView.Multi) {
// Going narrow: save current view and switch to FlameGraph
setViewBeforeNarrow(SelectedView.Multi);
setSelectedView(SelectedView.FlameGraph);
} else if (!isNarrow && viewBeforeNarrow !== null) {
// Going wide again: restore the previous view
setSelectedView(viewBeforeNarrow);
setViewBeforeNarrow(null);
if (item) {
setFocusedItemData({ ...focusedItemData, item });
const levels = dataContainer.getLevels();
const totalViewTicks = levels.length ? levels[0][0].value : 0;
setRangeMin(item.start / totalViewTicks);
setRangeMax((item.start + item.value) / totalViewTicks);
} else {
setFocusedItemData({
...focusedItemData,
item: {
start: 0,
value: 0,
itemIndexes: [],
children: [],
level: 0,
},
});
setRangeMin(0);
setRangeMax(1);
}
}
}, [containerWidth, vertical, selectedView, viewBeforeNarrow]);
}, [dataContainer, keepFocusOnDataChange]); // eslint-disable-line react-hooks/exhaustive-deps
const onSymbolClick = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[setSearch, resetFocus, onTableSymbolClick, search]
);
if (!dataContainer) {
return null;
}
const flameGraph = (
<FlameGraph
data={dataContainer}
rangeMin={rangeMin}
rangeMax={rangeMax}
matchedLabels={matchedLabels}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
setSandwichItem(label);
}}
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
collapsedMap={collapsedMap}
setCollapsedMap={setCollapsedMap}
/>
);
const table = (
<FlameGraphTopTableContainer
data={dataContainer}
onSymbolClick={onSymbolClick}
search={search}
matchedLabels={matchedLabels}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onSearch={(str) => {
if (!str) {
setSearch('');
return;
}
setSearch(`^${escapeStringForRegex(str)}$`);
}}
onTableSort={onTableSort}
colorScheme={colorScheme}
/>
);
let body;
if (showFlameGraphOnly || selectedView === SelectedView.FlameGraph) {
body = (
<FlameGraphPane
paneView={PaneView.FlameGraph}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
body = flameGraph;
} else if (selectedView === SelectedView.TopTable) {
body = (
<FlameGraphPane
paneView={PaneView.TopTable}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
} else if (selectedView === SelectedView.CallTree) {
body = (
<FlameGraphPane
paneView={PaneView.CallTree}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
);
} else if (selectedView === SelectedView.Multi) {
// New view model: support split view with independent pane selections
if (viewMode === ViewMode.Split) {
if (vertical) {
body = (
<div>
<div className={styles.verticalPaneContainer}>
<FlameGraphPane
key="left-pane"
paneView={leftPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
<div className={styles.verticalPaneContainer}>
<FlameGraphPane
key="right-pane"
paneView={rightPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
</div>
);
} else {
body = (
<div className={styles.horizontalContainer}>
<div className={styles.horizontalPaneContainer}>
<FlameGraphPane
key="left-pane"
paneView={leftPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
<div className={styles.horizontalPaneContainer}>
<FlameGraphPane
key="right-pane"
paneView={rightPaneView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
</div>
</div>
);
}
} else {
// Single view mode
body = <div className={styles.tableContainer}>{table}</div>;
} else if (selectedView === SelectedView.Both) {
if (vertical) {
body = (
<div className={styles.singlePaneContainer}>
<FlameGraphPane
key={`single-${singleView}`}
paneView={singleView}
dataContainer={dataContainer}
search={search}
matchedLabels={matchedLabels}
onTableSymbolClick={onTableSymbolClick}
onTextAlignSelected={onTextAlignSelected}
onTableSort={onTableSort}
showFlameGraphOnly={showFlameGraphOnly}
disableCollapsing={disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
viewMode={viewMode}
theme={theme}
setSearch={setSearch}
resetKey={resetKey}
keepFocusOnDataChange={keepFocusOnDataChange}
/>
<div>
<div className={styles.verticalGraphContainer}>{flameGraph}</div>
<div className={styles.verticalTableContainer}>{table}</div>
</div>
);
} else {
body = (
<div className={styles.horizontalContainer}>
<div className={styles.horizontalTableContainer}>{table}</div>
<div className={styles.horizontalGraphContainer}>{flameGraph}</div>
</div>
);
}
@@ -355,24 +292,25 @@ const FlameGraphContainer = ({
setSelectedView(view);
onViewSelected?.(view);
}}
viewMode={viewMode}
setViewMode={setViewMode}
leftPaneView={leftPaneView}
setLeftPaneView={setLeftPaneView}
rightPaneView={rightPaneView}
setRightPaneView={setRightPaneView}
singleView={singleView}
setSingleView={setSingleView}
containerWidth={containerWidth}
onReset={() => {
// Reset search and pane states when user clicks reset button
setSearch('');
setResetKey((k) => k + 1);
resetFocus();
resetSandwich();
}}
showResetButton={Boolean(search)}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
showResetButton={Boolean(focusedItemData || sandwichItem)}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
stickyHeader={Boolean(stickyHeader)}
extraHeaderElements={extraHeaderElements}
vertical={vertical}
isDiffMode={dataContainer.isDiffFlamegraph()}
setCollapsedMap={setCollapsedMap}
collapsedMap={collapsedMap}
assistantContext={data && showAnalyzeWithAssistant ? getAssistantContextFromDataFrame(data) : undefined}
/>
)}
@@ -383,6 +321,18 @@ const FlameGraphContainer = ({
);
};
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
useEffect(() => {
setColorScheme(defaultColorScheme);
}, [defaultColorScheme]);
return [colorScheme, setColorScheme] as const;
}
/**
* Based on the search string it does a fuzzy search over all the unique labels, so we can highlight them later.
*/
@@ -470,6 +420,12 @@ function getStyles(theme: GrafanaTheme2) {
flexGrow: 1,
}),
tableContainer: css({
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
// in explore we need a specific height.
height: 800,
}),
horizontalContainer: css({
label: 'horizontalContainer',
display: 'flex',
@@ -479,20 +435,20 @@ function getStyles(theme: GrafanaTheme2) {
width: '100%',
}),
horizontalPaneContainer: css({
label: 'horizontalPaneContainer',
horizontalGraphContainer: css({
flexBasis: '50%',
}),
horizontalTableContainer: css({
flexBasis: '50%',
maxHeight: 800,
}),
verticalPaneContainer: css({
label: 'verticalPaneContainer',
verticalGraphContainer: css({
marginBottom: theme.spacing(1),
height: 800,
}),
singlePaneContainer: css({
label: 'singlePaneContainer',
verticalTableContainer: css({
height: 800,
}),
};
@@ -3,8 +3,9 @@ import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { CollapsedMap } from './FlameGraph/dataTransform';
import FlameGraphHeader from './FlameGraphHeader';
import { PaneView, SelectedView, ViewMode } from './types';
import { ColorScheme, SelectedView } from './types';
jest.mock('@grafana/assistant', () => ({
useAssistant: jest.fn().mockReturnValue({
@@ -19,30 +20,26 @@ describe('FlameGraphHeader', () => {
function setup(props: Partial<React.ComponentProps<typeof FlameGraphHeader>> = {}) {
const setSearch = jest.fn();
const setSelectedView = jest.fn();
const setViewMode = jest.fn();
const setLeftPaneView = jest.fn();
const setRightPaneView = jest.fn();
const setSingleView = jest.fn();
const onReset = jest.fn();
const onSchemeChange = jest.fn();
const renderResult = render(
<FlameGraphHeader
search={''}
setSearch={setSearch}
selectedView={SelectedView.Multi}
selectedView={SelectedView.Both}
setSelectedView={setSelectedView}
viewMode={ViewMode.Split}
setViewMode={setViewMode}
leftPaneView={PaneView.TopTable}
setLeftPaneView={setLeftPaneView}
rightPaneView={PaneView.FlameGraph}
setRightPaneView={setRightPaneView}
singleView={PaneView.FlameGraph}
setSingleView={setSingleView}
containerWidth={1600}
onReset={onReset}
onTextAlignChange={jest.fn()}
textAlign={'left'}
showResetButton={true}
colorScheme={ColorScheme.ValueBased}
onColorSchemeChange={onSchemeChange}
stickyHeader={false}
isDiffMode={false}
setCollapsedMap={() => {}}
collapsedMap={new CollapsedMap()}
{...props}
/>
);
@@ -53,6 +50,7 @@ describe('FlameGraphHeader', () => {
setSearch,
setSelectedView,
onReset,
onSchemeChange,
},
};
}
@@ -72,4 +70,27 @@ describe('FlameGraphHeader', () => {
await userEvent.click(resetButton);
expect(handlers.onReset).toHaveBeenCalledTimes(1);
});
it('calls on color scheme change when clicked', async () => {
const { handlers } = setup();
const changeButton = screen.getByLabelText(/Change color scheme/);
expect(changeButton).toBeInTheDocument();
await userEvent.click(changeButton);
const byPackageButton = screen.getByText(/By package name/);
expect(byPackageButton).toBeInTheDocument();
await userEvent.click(byPackageButton);
expect(handlers.onSchemeChange).toHaveBeenCalledTimes(1);
});
it('shows diff color scheme switch when diff', async () => {
setup({ isDiffMode: true });
const changeButton = screen.getByLabelText(/Change color scheme/);
expect(changeButton).toBeInTheDocument();
await userEvent.click(changeButton);
expect(screen.getByText(/Default/)).toBeInTheDocument();
expect(screen.getByText(/Color blind/)).toBeInTheDocument();
});
});
@@ -5,29 +5,30 @@ import { useDebounce, usePrevious } from 'react-use';
import { ChatContextItem, OpenAssistantButton } from '@grafana/assistant';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Button, Input, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { Button, ButtonGroup, Dropdown, Input, Menu, RadioButtonGroup, useStyles2 } from '@grafana/ui';
import { byPackageGradient, byValueGradient, diffColorBlindGradient, diffDefaultGradient } from './FlameGraph/colors';
import { CollapsedMap } from './FlameGraph/dataTransform';
import { MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH } from './constants';
import { PaneView, SelectedView, ViewMode } from './types';
import { ColorScheme, ColorSchemeDiff, SelectedView, TextAlign } from './types';
type Props = {
search: string;
setSearch: (search: string) => void;
selectedView: SelectedView;
setSelectedView: (view: SelectedView) => void;
viewMode: ViewMode;
setViewMode: (mode: ViewMode) => void;
leftPaneView: PaneView;
setLeftPaneView: (view: PaneView) => void;
rightPaneView: PaneView;
setRightPaneView: (view: PaneView) => void;
singleView: PaneView;
setSingleView: (view: PaneView) => void;
containerWidth: number;
onReset: () => void;
textAlign: TextAlign;
onTextAlignChange: (align: TextAlign) => void;
showResetButton: boolean;
colorScheme: ColorScheme | ColorSchemeDiff;
onColorSchemeChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
stickyHeader: boolean;
vertical?: boolean;
isDiffMode: boolean;
setCollapsedMap: (collapsedMap: CollapsedMap) => void;
collapsedMap: CollapsedMap;
extraHeaderElements?: React.ReactNode;
@@ -39,20 +40,19 @@ const FlameGraphHeader = ({
setSearch,
selectedView,
setSelectedView,
viewMode,
setViewMode,
leftPaneView,
setLeftPaneView,
rightPaneView,
setRightPaneView,
singleView,
setSingleView,
containerWidth,
onReset,
textAlign,
onTextAlignChange,
showResetButton,
colorScheme,
onColorSchemeChange,
stickyHeader,
extraHeaderElements,
vertical,
isDiffMode,
setCollapsedMap,
collapsedMap,
assistantContext,
}: Props) => {
const styles = useStyles2(getStyles);
@@ -87,25 +87,6 @@ const FlameGraphHeader = ({
/>
</div>
{selectedView === SelectedView.Multi && viewMode === ViewMode.Split && (
<div className={styles.middleContainer}>
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={leftPaneView}
onChange={setLeftPaneView}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={rightPaneView}
onChange={setRightPaneView}
className={styles.buttonSpacing}
/>
</div>
)}
<div className={styles.rightContainer}>
{!!assistantContext?.length && (
<div className={styles.buttonSpacing}>
@@ -130,63 +111,129 @@ const FlameGraphHeader = ({
aria-label={'Reset focus and sandwich state'}
/>
)}
{selectedView === SelectedView.Multi ? (
<>
{viewMode === ViewMode.Single && (
<RadioButtonGroup<PaneView>
size="sm"
options={paneViewOptions}
value={singleView}
onChange={setSingleView}
className={styles.buttonSpacing}
/>
)}
<RadioButtonGroup<ViewMode>
size="sm"
options={viewModeOptions}
value={viewMode}
onChange={setViewMode}
className={styles.buttonSpacing}
/>
</>
) : (
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth, vertical)}
value={selectedView}
onChange={setSelectedView}
className={styles.buttonSpacing}
<ColorSchemeButton value={colorScheme} onChange={onColorSchemeChange} isDiffMode={isDiffMode} />
<ButtonGroup className={styles.buttonSpacing}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Expand all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(false));
}}
aria-label={'Expand all groups'}
icon={'angle-double-down'}
disabled={selectedView === SelectedView.TopTable}
/>
)}
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Collapse all groups'}
onClick={() => {
setCollapsedMap(collapsedMap.setAllCollapsedStatus(true));
}}
aria-label={'Collapse all groups'}
icon={'angle-double-up'}
disabled={selectedView === SelectedView.TopTable}
/>
</ButtonGroup>
<RadioButtonGroup<TextAlign>
size="sm"
disabled={selectedView === SelectedView.TopTable}
options={alignOptions}
value={textAlign}
onChange={onTextAlignChange}
className={styles.buttonSpacing}
/>
<RadioButtonGroup<SelectedView>
size="sm"
options={getViewOptions(containerWidth, vertical)}
value={selectedView}
onChange={setSelectedView}
/>
{extraHeaderElements && <div className={styles.extraElements}>{extraHeaderElements}</div>}
</div>
</div>
);
};
const viewModeOptions: Array<SelectableValue<ViewMode>> = [
{ value: ViewMode.Single, label: 'Single', description: 'Single view' },
{ value: ViewMode.Split, label: 'Split', description: 'Split view' },
];
type ColorSchemeButtonProps = {
value: ColorScheme | ColorSchemeDiff;
onChange: (colorScheme: ColorScheme | ColorSchemeDiff) => void;
isDiffMode: boolean;
};
function ColorSchemeButton(props: ColorSchemeButtonProps) {
// TODO: probably create separate getStyles
const styles = useStyles2(getStyles);
let menu = (
<Menu>
<Menu.Item label="By package name" onClick={() => props.onChange(ColorScheme.PackageBased)} />
<Menu.Item label="By value" onClick={() => props.onChange(ColorScheme.ValueBased)} />
</Menu>
);
const paneViewOptions: Array<SelectableValue<PaneView>> = [
{ value: PaneView.TopTable, label: 'Top Table' },
{ value: PaneView.FlameGraph, label: 'Flame Graph' },
{ value: PaneView.CallTree, label: 'Call Tree' },
// Show a bit different gradient as a way to indicate selected value
const colorDotStyle =
{
[ColorScheme.ValueBased]: styles.colorDotByValue,
[ColorScheme.PackageBased]: styles.colorDotByPackage,
[ColorSchemeDiff.DiffColorBlind]: styles.colorDotDiffColorBlind,
[ColorSchemeDiff.Default]: styles.colorDotDiffDefault,
}[props.value] || styles.colorDotByValue;
let contents = <span className={cx(styles.colorDot, colorDotStyle)} />;
if (props.isDiffMode) {
menu = (
<Menu>
<Menu.Item label="Default (green to red)" onClick={() => props.onChange(ColorSchemeDiff.Default)} />
<Menu.Item label="Color blind (blue to red)" onClick={() => props.onChange(ColorSchemeDiff.DiffColorBlind)} />
</Menu>
);
contents = (
<div className={cx(styles.colorDotDiff, colorDotStyle)}>
<div>-100% (removed)</div>
<div>0%</div>
<div>+100% (added)</div>
</div>
);
}
return (
<Dropdown overlay={menu}>
<Button
variant={'secondary'}
fill={'outline'}
size={'sm'}
tooltip={'Change color scheme'}
onClick={() => {}}
className={styles.buttonSpacing}
aria-label={'Change color scheme'}
>
{contents}
</Button>
</Dropdown>
);
}
const alignOptions: Array<SelectableValue<TextAlign>> = [
{ value: 'left', description: 'Align text left', icon: 'align-left' },
{ value: 'right', description: 'Align text right', icon: 'align-right' },
];
function getViewOptions(width: number, vertical?: boolean): Array<SelectableValue<SelectedView>> {
let viewOptions: Array<{ value: SelectedView; label: string; description: string }> = [
{ value: SelectedView.TopTable, label: 'Top Table', description: 'Only show top table' },
{ value: SelectedView.FlameGraph, label: 'Flame Graph', description: 'Only show flame graph' },
{ value: SelectedView.CallTree, label: 'Call Tree', description: 'Only show call tree' },
];
if (width >= MIN_WIDTH_TO_SHOW_BOTH_TOPTABLE_AND_FLAMEGRAPH || vertical) {
viewOptions.push({
value: SelectedView.Multi,
label: 'Multi',
description: 'Show split or single view with multiple visualizations',
value: SelectedView.Both,
label: 'Both',
description: 'Show both the top table and flame graph',
});
}
@@ -226,12 +273,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'space-between',
alignItems: 'flex-start',
width: '100%',
top: 0,
gap: theme.spacing(1),
marginTop: theme.spacing(1),
position: 'relative',
}),
stickyHeader: css({
zIndex: theme.zIndex.navbarFixed,
@@ -240,20 +285,10 @@ const getStyles = (theme: GrafanaTheme2) => ({
}),
inputContainer: css({
label: 'inputContainer',
flexGrow: 0,
flexGrow: 1,
minWidth: '150px',
maxWidth: '350px',
}),
middleContainer: css({
label: 'middleContainer',
display: 'flex',
alignItems: 'center',
flexWrap: 'wrap',
gap: theme.spacing(1),
position: 'absolute',
left: '50%',
transform: 'translateX(-50%)',
}),
rightContainer: css({
label: 'rightContainer',
display: 'flex',
@@ -274,6 +309,44 @@ const getStyles = (theme: GrafanaTheme2) => ({
padding: '0 5px',
color: theme.colors.text.disabled,
}),
colorDot: css({
label: 'colorDot',
display: 'inline-block',
width: '10px',
height: '10px',
borderRadius: theme.shape.radius.circle,
}),
colorDotDiff: css({
label: 'colorDotDiff',
display: 'flex',
width: '200px',
height: '12px',
color: 'white',
fontSize: 9,
lineHeight: 1.3,
fontWeight: 300,
justifyContent: 'space-between',
padding: '0 2px',
// We have a specific sizing for this so probably makes sense to use hardcoded value here
// eslint-disable-next-line @grafana/no-border-radius-literal
borderRadius: '2px',
}),
colorDotByValue: css({
label: 'colorDotByValue',
background: byValueGradient,
}),
colorDotByPackage: css({
label: 'colorDotByPackage',
background: byPackageGradient,
}),
colorDotDiffDefault: css({
label: 'colorDotDiffDefault',
background: diffDefaultGradient,
}),
colorDotDiffColorBlind: css({
label: 'colorDotDiffColorBlind',
background: diffColorBlindGradient,
}),
extraElements: css({
label: 'extraElements',
marginLeft: theme.spacing(1),
@@ -1,269 +0,0 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { GrafanaTheme2, escapeStringForRegex } from '@grafana/data';
import FlameGraphCallTreeContainer from './CallTree/FlameGraphCallTreeContainer';
import FlameGraph from './FlameGraph/FlameGraph';
import { GetExtraContextMenuButtonsFunction } from './FlameGraph/FlameGraphContextMenu';
import { FlameGraphDataContainer } from './FlameGraph/dataTransform';
import FlameGraphTopTableContainer from './TopTable/FlameGraphTopTableContainer';
import { ClickedItemData, ColorScheme, ColorSchemeDiff, PaneView, SelectedView, TextAlign, ViewMode } from './types';
export type FlameGraphPaneProps = {
paneView: PaneView;
dataContainer: FlameGraphDataContainer;
search: string;
matchedLabels: Set<string> | undefined;
onTableSymbolClick?: (symbol: string) => void;
onTextAlignSelected?: (align: string) => void;
onTableSort?: (sort: string) => void;
showFlameGraphOnly?: boolean;
disableCollapsing?: boolean;
getExtraContextMenuButtons?: GetExtraContextMenuButtonsFunction;
selectedView: SelectedView;
viewMode: ViewMode;
theme: GrafanaTheme2;
setSearch: (search: string) => void;
/** When this key changes, the pane's internal state (focus, sandwich, etc.) will be reset */
resetKey?: number;
/** Whether to preserve focus when the data changes */
keepFocusOnDataChange?: boolean;
};
const FlameGraphPane = ({
paneView,
dataContainer,
search,
matchedLabels,
onTableSymbolClick,
onTextAlignSelected,
onTableSort,
showFlameGraphOnly,
disableCollapsing,
getExtraContextMenuButtons,
selectedView,
viewMode,
theme,
setSearch,
resetKey,
keepFocusOnDataChange,
}: FlameGraphPaneProps) => {
// Pane-specific state - each instance maintains its own
const [focusedItemData, setFocusedItemData] = useState<ClickedItemData>();
const [rangeMin, setRangeMin] = useState(0);
const [rangeMax, setRangeMax] = useState(1);
const [textAlign, setTextAlign] = useState<TextAlign>('left');
const [sandwichItem, setSandwichItem] = useState<string>();
// Initialize collapsedMap from dataContainer to ensure collapsed groups are shown correctly on first render
const [collapsedMap, setCollapsedMap] = useState(() => dataContainer.getCollapsedMap());
const [colorScheme, setColorScheme] = useColorScheme(dataContainer);
const styles = useMemo(() => getStyles(theme), [theme]);
// Re-initialize collapsed map when dataContainer changes (e.g., new data loaded)
// Using useLayoutEffect to ensure collapsed state is applied before browser paint
useLayoutEffect(() => {
setCollapsedMap(dataContainer.getCollapsedMap());
}, [dataContainer]);
// Reset internal state when resetKey changes (triggered by parent's reset button)
useEffect(() => {
if (resetKey !== undefined && resetKey > 0) {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
setSandwichItem(undefined);
}
}, [resetKey]);
// Handle focus preservation or reset when data changes
useEffect(() => {
if (!keepFocusOnDataChange) {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
setSandwichItem(undefined);
return;
}
if (dataContainer && focusedItemData) {
const item = dataContainer.getNodesWithLabel(focusedItemData.label)?.[0];
if (item) {
setFocusedItemData({ ...focusedItemData, item });
const levels = dataContainer.getLevels();
const totalViewTicks = levels.length ? levels[0][0].value : 0;
setRangeMin(item.start / totalViewTicks);
setRangeMax((item.start + item.value) / totalViewTicks);
} else {
setFocusedItemData({
...focusedItemData,
item: {
start: 0,
value: 0,
itemIndexes: [],
children: [],
level: 0,
},
});
setRangeMin(0);
setRangeMax(1);
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dataContainer, keepFocusOnDataChange]);
const resetFocus = useCallback(() => {
setFocusedItemData(undefined);
setRangeMin(0);
setRangeMax(1);
}, []);
const resetSandwich = useCallback(() => {
setSandwichItem(undefined);
}, []);
const onSymbolClick = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[search, setSearch, resetFocus, onTableSymbolClick]
);
// Separate callback for CallTree that doesn't trigger search
const onCallTreeSymbolClick = useCallback(
(symbol: string) => {
onTableSymbolClick?.(symbol);
},
[onTableSymbolClick]
);
// Search callback for CallTree search button
const onCallTreeSearch = useCallback(
(symbol: string) => {
const anchored = `^${escapeStringForRegex(symbol)}$`;
if (search === anchored) {
setSearch('');
} else {
onTableSymbolClick?.(symbol);
setSearch(anchored);
resetFocus();
}
},
[search, setSearch, resetFocus, onTableSymbolClick]
);
const isInSplitView = selectedView === SelectedView.Multi && viewMode === ViewMode.Split;
const isCallTreeInSplitView = isInSplitView && paneView === PaneView.CallTree;
switch (paneView) {
case PaneView.TopTable:
return (
<div className={styles.tableContainer}>
<FlameGraphTopTableContainer
data={dataContainer}
onSymbolClick={onSymbolClick}
search={search}
matchedLabels={matchedLabels}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onSearch={(str) => {
if (!str) {
setSearch('');
return;
}
setSearch(`^${escapeStringForRegex(str)}$`);
}}
onTableSort={onTableSort}
colorScheme={colorScheme}
/>
</div>
);
case PaneView.FlameGraph:
default:
return (
<FlameGraph
data={dataContainer}
rangeMin={rangeMin}
rangeMax={rangeMax}
matchedLabels={matchedLabels}
setRangeMin={setRangeMin}
setRangeMax={setRangeMax}
onItemFocused={(data) => setFocusedItemData(data)}
focusedItemData={focusedItemData}
textAlign={textAlign}
onTextAlignChange={(align) => {
setTextAlign(align);
onTextAlignSelected?.(align);
}}
sandwichItem={sandwichItem}
onSandwich={(label: string) => {
resetFocus();
setSandwichItem(label);
}}
onFocusPillClick={resetFocus}
onSandwichPillClick={resetSandwich}
colorScheme={colorScheme}
onColorSchemeChange={setColorScheme}
isDiffMode={dataContainer.isDiffFlamegraph()}
showFlameGraphOnly={showFlameGraphOnly}
collapsing={!disableCollapsing}
getExtraContextMenuButtons={getExtraContextMenuButtons}
selectedView={selectedView}
search={search}
collapsedMap={collapsedMap}
setCollapsedMap={setCollapsedMap}
/>
);
case PaneView.CallTree:
return (
<div className={styles.tableContainer}>
<FlameGraphCallTreeContainer
data={dataContainer}
onSymbolClick={onCallTreeSymbolClick}
sandwichItem={sandwichItem}
onSandwich={setSandwichItem}
onTableSort={onTableSort}
colorScheme={colorScheme}
search={search}
compact={isCallTreeInSplitView}
onSearch={onCallTreeSearch}
/>
</div>
);
}
};
function useColorScheme(dataContainer: FlameGraphDataContainer | undefined) {
const defaultColorScheme = dataContainer?.isDiffFlamegraph() ? ColorSchemeDiff.Default : ColorScheme.PackageBased;
const [colorScheme, setColorScheme] = useState<ColorScheme | ColorSchemeDiff>(defaultColorScheme);
// This makes sure that if we change the data to/from diff profile we reset the color scheme.
useEffect(() => {
setColorScheme(defaultColorScheme);
}, [defaultColorScheme]);
return [colorScheme, setColorScheme] as const;
}
function getStyles(theme: GrafanaTheme2) {
return {
tableContainer: css({
// This is not ideal for dashboard panel where it creates a double scroll. In a panel it should be 100% but then
// in explore we need a specific height.
height: 800,
}),
};
}
export default FlameGraphPane;
-1
View File
@@ -1,4 +1,3 @@
export { default as FlameGraph, type Props } from './FlameGraphContainer';
export { default as FlameGraphCallTreeContainer } from './CallTree/FlameGraphCallTreeContainer';
export { checkFields, getMessageCheckFieldsResult } from './FlameGraph/dataTransform';
export { data } from './FlameGraph/testData/dataNestedSet';
+1 -13
View File
@@ -20,19 +20,7 @@ export enum SampleUnit {
export enum SelectedView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
Multi = 'multi',
CallTree = 'callTree',
}
export enum ViewMode {
Single = 'single',
Split = 'split',
}
export enum PaneView {
TopTable = 'topTable',
FlameGraph = 'flameGraph',
CallTree = 'callTree',
Both = 'both',
}
export interface TableData {
@@ -1,40 +1,14 @@
import { Decorator } from '@storybook/react';
import { useEffect } from 'react';
import * as React from 'react';
import { createTheme, getThemeById, ThemeContext } from '@grafana/data';
import { GlobalStyles, PortalContainer } from '@grafana/ui';
import { getThemeById, ThemeContext } from '@grafana/data';
import { GlobalStyles } from '@grafana/ui';
interface ThemeableStoryProps {
themeId?: string;
themeId: string;
}
const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<ThemeableStoryProps>) => {
// Always ensure we have a valid theme
const theme = React.useMemo(() => {
const id = themeId || 'dark';
let resolvedTheme = getThemeById(id);
// If getThemeById returns undefined, create a default theme
if (!resolvedTheme) {
console.warn(`Theme '${id}' not found, using default theme`);
resolvedTheme = createTheme({ colors: { mode: id === 'light' ? 'light' : 'dark' } });
}
console.log('withTheme: resolved theme', { id, hasTheme: !!resolvedTheme, hasSpacing: !!resolvedTheme?.spacing });
return resolvedTheme;
}, [themeId]);
// Apply theme to document root for Portals
useEffect(() => {
if (!theme) return;
document.body.style.setProperty('--theme-background', theme.colors.background.primary);
}, [theme]);
if (!theme) {
console.error('withTheme: No theme available!');
return null;
}
const theme = getThemeById(themeId);
const css = `
#storybook-root {
@@ -49,7 +23,6 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
return (
<ThemeContext.Provider value={theme}>
<GlobalStyles />
<PortalContainer />
<style>{css}</style>
{children}
@@ -60,4 +33,4 @@ const ThemeableStory = ({ children, themeId }: React.PropsWithChildren<Themeable
export const withTheme =
(): Decorator =>
// eslint-disable-next-line react/display-name
(story, context) => <ThemeableStory themeId={context.globals?.theme}>{story()}</ThemeableStory>;
(story, context) => <ThemeableStory themeId={context.globals.theme}>{story()}</ThemeableStory>;
@@ -10,7 +10,7 @@
import * as common from '@grafana/schema';
export const pluginVersion = "%VERSION%";
export const pluginVersion = "12.4.0-pre";
export type BucketAggregation = (DateHistogram | Histogram | Terms | Filters | GeoHashGrid | Nested);
@@ -24,24 +24,6 @@ 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)
@@ -53,8 +35,9 @@ func TestIntegrationElasticsearch(t *testing.T) {
ctx := context.Background()
var outgoingRequest *http.Request
outgoingServer := httptest.NewServer(mockElasticsearchHandler(func(r *http.Request) {
outgoingServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
outgoingRequest = r
w.WriteHeader(http.StatusUnauthorized)
}))
t.Cleanup(outgoingServer.Close)
@@ -639,7 +639,7 @@
]
},
"dependencies": {
"grafanaDependency": "\u003e=11.6.0",
"grafanaDependency": "",
"grafanaVersion": "*",
"plugins": [],
"extensions": {
+3 -6
View File
@@ -92,7 +92,7 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque
}, nil
}
url := fmt.Sprintf("%s/v3/projects/%s/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
url := fmt.Sprintf("%v/v3/projects/%v/metricDescriptors", dsInfo.services[cloudMonitor].url, defaultProject)
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
@@ -139,7 +139,6 @@ type datasourceInfo struct {
defaultProject string
clientEmail string
tokenUri string
universeDomain string
services map[string]datasourceService
privateKey string
usingImpersonation bool
@@ -151,7 +150,6 @@ type datasourceJSONData struct {
DefaultProject string `json:"defaultProject"`
ClientEmail string `json:"clientEmail"`
TokenURI string `json:"tokenUri"`
UniverseDomain string `json:"universeDomain"`
UsingImpersonation bool `json:"usingImpersonation"`
ServiceAccountToImpersonate string `json:"serviceAccountToImpersonate"`
}
@@ -181,7 +179,6 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
defaultProject: jsonData.DefaultProject,
clientEmail: jsonData.ClientEmail,
tokenUri: jsonData.TokenURI,
universeDomain: jsonData.UniverseDomain,
usingImpersonation: jsonData.UsingImpersonation,
serviceAccountToImpersonate: jsonData.ServiceAccountToImpersonate,
services: map[string]datasourceService{},
@@ -197,13 +194,13 @@ func newInstanceSettings(httpClientProvider httpclient.Provider) datasource.Inst
return nil, err
}
for name := range routes {
for name, info := range routes {
client, err := newHTTPClient(dsInfo, opts, &httpClientProvider, name)
if err != nil {
return nil, err
}
dsInfo.services[name] = datasourceService{
url: buildURL(name, dsInfo.universeDomain),
url: info.url,
client: client,
}
}
+2 -9
View File
@@ -23,12 +23,12 @@ type routeInfo struct {
var routes = map[string]routeInfo{
cloudMonitor: {
method: "GET",
url: "https://monitoring.",
url: "https://monitoring.googleapis.com",
scopes: []string{cloudMonitorScope},
},
resourceManager: {
method: "GET",
url: "https://cloudresourcemanager.",
url: "https://cloudresourcemanager.googleapis.com",
scopes: []string{resourceManagerScope},
},
}
@@ -68,13 +68,6 @@ func getMiddleware(model *datasourceInfo, routePath string) (httpclient.Middlewa
return tokenprovider.AuthMiddleware(provider), nil
}
func buildURL(route string, universeDomain string) string {
if universeDomain == "" {
universeDomain = "googleapis.com"
}
return routes[route].url + universeDomain
}
func newHTTPClient(model *datasourceInfo, opts httpclient.Options, clientProvider *httpclient.Provider, route string) (*http.Client, error) {
m, err := getMiddleware(model, route)
if err != nil {
@@ -111,7 +111,7 @@ func Test_setRequestVariables(t *testing.T) {
im: &fakeInstance{
services: map[string]datasourceService{
cloudMonitor: {
url: buildURL(cloudMonitor, "googleapis.com"),
url: routes[cloudMonitor].url,
client: &http.Client{},
},
},
@@ -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
+3 -7
View File
@@ -16,6 +16,7 @@ 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
@@ -34,7 +35,6 @@ 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, "elasticsearchImprovedParsing")
improvedParsingEnabled := isFeatureEnabled(c.ctx, featuremgmt.FlagElasticsearchImprovedParsing)
msr, err := c.parser.parseMultiSearchResponse(res.Body, improvedParsingEnabled)
if err != nil {
return nil, err
@@ -197,11 +197,7 @@ func (c *baseClientImpl) createMultiSearchRequests(searchRequests []*SearchReque
func (c *baseClientImpl) getMultiSearchQueryParameters() string {
var qs []string
// 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))
}
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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
func TestClient_ExecuteMultisearch(t *testing.T) {
@@ -1,51 +0,0 @@
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
}
@@ -1,188 +0,0 @@
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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
// setFloatPath converts a string value at the specified path to float64
-14
View File
@@ -88,14 +88,6 @@ 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
@@ -159,11 +151,6 @@ 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,
@@ -179,7 +166,6 @@ func newInstanceSettings(httpClientProvider *httpclient.Provider) datasource.Ins
ConfiguredFields: configuredFields,
Interval: interval,
IncludeFrozen: includeFrozen,
ClusterInfo: clusterInfo,
}
return model, nil
}
@@ -3,8 +3,6 @@ package elasticsearch
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/grafana/grafana-plugin-sdk-go/backend"
@@ -20,26 +18,8 @@ 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,
@@ -48,7 +28,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -58,9 +37,6 @@ 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",
@@ -70,7 +46,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -79,9 +54,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("is empty", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
MaxConcurrentShardRequests: 5,
Interval: "Daily",
@@ -92,7 +64,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -103,9 +74,6 @@ 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",
}
@@ -113,7 +81,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -123,9 +90,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("string maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: "10",
@@ -134,7 +98,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -144,9 +107,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("number maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 10,
@@ -155,7 +115,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -165,9 +124,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("zero maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: 0,
@@ -176,7 +132,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -186,9 +141,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("negative maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: -10,
@@ -197,7 +149,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -207,9 +158,6 @@ 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,
@@ -218,7 +166,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
@@ -228,9 +175,6 @@ func TestNewInstanceSettings(t *testing.T) {
})
t.Run("invalid maxConcurrentShardRequests", func(t *testing.T) {
server := mockElasticsearchServer()
defer server.Close()
dsInfo := datasourceInfo{
TimeField: "@timestamp",
MaxConcurrentShardRequests: "invalid",
@@ -239,7 +183,6 @@ func TestNewInstanceSettings(t *testing.T) {
require.NoError(t, err)
dsSettings := backend.DataSourceInstanceSettings{
URL: server.URL,
JSONData: json.RawMessage(settingsJSON),
}
+1 -8
View File
@@ -28,6 +28,7 @@ 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)
@@ -37,14 +38,6 @@ 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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/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/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/simplejson"
)
func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, error) {
@@ -5,7 +5,7 @@ import (
"fmt"
"strconv"
"github.com/grafana/grafana/pkg/tsdb/elasticsearch/simplejson"
"github.com/grafana/grafana/pkg/components/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.
@@ -1,582 +0,0 @@
// 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
}
@@ -1,90 +0,0 @@
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")
}
@@ -1,274 +0,0 @@
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(`{`))
})
}
@@ -1,48 +0,0 @@
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
@@ -1,23 +0,0 @@
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)
}
}
@@ -1,4 +1,5 @@
import { DataQuery } from '@grafana/data';
import { createMonitoringLogger, MonitoringLogger } from '@grafana/runtime';
import store from 'app/core/store';
import { RichHistoryQuery } from 'app/types/explore';
@@ -26,8 +27,15 @@ jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => backendSrv,
getDataSourceSrv: () => dsMock,
createMonitoringLogger: jest.fn().mockReturnValue({ logWarning: jest.fn() }),
}));
// logger is created at import so we cannot initialize inside the test
const loggerIndex = (createMonitoringLogger as jest.Mock).mock.calls.findIndex(
(args) => args[0] === 'features.query-history.local-storage'
);
const loggerMock: MonitoringLogger = (createMonitoringLogger as jest.Mock).mock.results[loggerIndex]?.value;
interface MockQuery extends DataQuery {
query: string;
}
@@ -75,6 +83,8 @@ describe('RichHistoryLocalStorage', () => {
jest.setSystemTime(now);
storage = new RichHistoryLocalStorage();
await storage.deleteAll();
(loggerMock.logWarning as jest.Mock).mockReset();
});
afterEach(() => {
@@ -223,6 +233,90 @@ describe('RichHistoryLocalStorage', () => {
});
});
describe('quota errors and retries', () => {
it('should rotate and retry saving when QuotaExceededError occurs once', async () => {
const initial = [
{ ts: Date.now(), starred: true, comment: 'starred1', queries: [], datasourceName: 'name-of-dev-test' },
{ ts: Date.now(), starred: false, comment: 'notStarred1', queries: [], datasourceName: 'name-of-dev-test' },
{ ts: Date.now(), starred: true, comment: 'starred2', queries: [], datasourceName: 'name-of-dev-test' },
];
store.setObject(key, initial);
// Spy on setObject to throw once with QuotaExceededError, then call through
const originalSetObject = store.setObject.bind(store);
jest
.spyOn(store, 'setObject')
// first attempt throws and errors
.mockImplementationOnce(() => {
const err = new Error('quota hit');
err.name = 'QuotaExceededError';
throw err;
})
// second attempt calls through
.mockImplementation((k: string, value: unknown) => {
return originalSetObject(k, value);
});
const result = await storage.addToRichHistory({
starred: false,
datasourceUid: 'dev-test',
datasourceName: 'name-of-dev-test',
comment: 'new',
queries: [{ refId: 'A' }],
});
expect(result.richHistoryQuery).toBeDefined();
// After one failure, rotation removes one unstarred entry
const saved = store.getObject<RichHistoryQuery[]>(key)!;
expect(saved).toHaveLength(3);
expect(saved).toMatchObject([
expect.objectContaining({ comment: 'new' }),
expect.objectContaining({ comment: 'starred1' }),
expect.objectContaining({ comment: 'starred2' }),
]);
// Ensure logger was called for the failure, with expected flags
expect(loggerMock.logWarning).toHaveBeenCalled();
const [message, payload] = (loggerMock.logWarning as jest.Mock).mock.calls[0];
expect(message).toContain('Failed to save rich history to local storage');
expect(payload.saveRetriesLeft).toBe('3');
expect(payload.quotaExceededError).toBe('true');
});
it('should throw StorageFull when QuotaExceededError persists for all retries and track attempts', async () => {
store.setObject(key, [
{ ts: Date.now(), starred: false, comment: 'notStarred1', queries: [], datasourceName: 'name-of-dev-test' },
]);
const setSpy = jest.spyOn(store, 'setObject').mockImplementation(() => {
const err = new Error('quota still hit');
err.name = 'QuotaExceededError';
throw err;
});
await expect(
storage.addToRichHistory({
starred: false,
datasourceUid: 'dev-test',
datasourceName: 'name-of-dev-test',
comment: 'new',
queries: [{ refId: 'B' }],
})
).rejects.toMatchObject({ name: 'StorageFull' });
// 4 failed tracking attempts (1 save + 3 retries) should be logged (for each failed try)
expect(loggerMock.logWarning).toHaveBeenCalledTimes(4);
const calls = (loggerMock.logWarning as jest.Mock).mock.calls;
expect(calls[0][0]).toContain('Failed to save rich history to local storage');
expect(calls[0][1].saveRetriesLeft).toBe('3');
expect(calls[1][1].saveRetriesLeft).toBe('2');
expect(calls[2][1].saveRetriesLeft).toBe('1');
expect(calls[3][1].saveRetriesLeft).toBe('0');
setSpy.mockRestore();
});
});
describe('migration', () => {
afterEach(() => {
storage.deleteAll();
@@ -1,7 +1,8 @@
import { find, isEqual, omit } from 'lodash';
import { DataQuery, SelectableValue } from '@grafana/data';
import { RichHistorySearchFilters, RichHistorySettings } from 'app/core/utils/richHistoryTypes';
import { createMonitoringLogger } from '@grafana/runtime';
import { RichHistorySearchFilters, RichHistorySettings, SortOrder } from 'app/core/utils/richHistoryTypes';
import { RichHistoryQuery } from 'app/types/explore';
import store from '../store';
@@ -26,10 +27,18 @@ export type RichHistoryLocalStorageDTO = {
queries: DataQuery[];
};
const logger = createMonitoringLogger('features.query-history.local-storage');
/**
* Local storage implementation for Rich History. It keeps all entries in browser's local storage.
*/
export default class RichHistoryLocalStorage implements RichHistoryStorage {
public static getLocalStorageUsageInBytes(): number {
const richHistory: RichHistoryLocalStorageDTO[] = store.get(RICH_HISTORY_KEY) || '';
// each character is 2 bytes
return richHistory.length * 2;
}
/**
* Return history entries based on provided filters, perform migration and clean up entries not matching retention policy.
*/
@@ -77,21 +86,43 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
throw error;
}
const { queriesToKeep, limitExceeded } = checkLimits(currentRichHistoryDTOs);
let { queriesToKeep, limitExceeded } = cleanUpUnstarredQuery(currentRichHistoryDTOs, MAX_HISTORY_ITEMS);
const updatedHistory: RichHistoryLocalStorageDTO[] = [newRichHistoryQueryDTO, ...queriesToKeep];
let updatedHistory: RichHistoryLocalStorageDTO[] = [newRichHistoryQueryDTO, ...queriesToKeep];
try {
store.setObject(RICH_HISTORY_KEY, updatedHistory);
} catch (error) {
if (error instanceof Error && error.name === 'QuotaExceededError') {
throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`);
} else {
throw error;
let saveRetriesLeft = 3;
let saved = false;
while (!saved && saveRetriesLeft >= 0) {
try {
store.setObject(RICH_HISTORY_KEY, updatedHistory);
saved = true;
} catch (error) {
await this.trackLocalStorageUsage('Failed to save rich history to local storage', {
saveRetriesLeft: saveRetriesLeft.toString(),
quotaExceededError: error instanceof Error && error.name === 'QuotaExceededError' ? 'true' : 'false',
errorMessage: error instanceof Error ? error?.message : 'unknown',
});
if (saveRetriesLeft >= 1) {
saveRetriesLeft--;
const { queriesToKeep: newQueriesToKeep } = cleanUpUnstarredQuery(queriesToKeep, queriesToKeep.length - 1);
updatedHistory = [newRichHistoryQueryDTO, ...newQueriesToKeep];
queriesToKeep = newQueriesToKeep;
continue;
}
if (error instanceof Error && error.name === 'QuotaExceededError') {
throwError(RichHistoryServiceError.StorageFull, `Saving rich history failed: ${error.message}`);
} else {
throw error;
}
}
}
if (limitExceeded) {
await this.trackLocalStorageUsage('Rich history query limit exceeded.');
return {
warning: {
type: RichHistoryStorageWarning.LimitExceeded,
@@ -148,6 +179,33 @@ export default class RichHistoryLocalStorage implements RichHistoryStorage {
})
);
}
private async trackLocalStorageUsage(message: string, additionalInfo?: Record<string, string>) {
const allQueriesCount =
(
await this.getRichHistory({
search: '',
sortOrder: SortOrder.Ascending,
datasourceFilters: [],
starred: false,
})
).total || -1;
const allQueriesSizeInBytes = RichHistoryLocalStorage.getLocalStorageUsageInBytes();
const totalLocalStorageSize = calculateTotalLocalStorageSize();
const localStats = {
totalLocalStorageSize: totalLocalStorageSize?.toString(),
allQueriesSizeInBytes: allQueriesSizeInBytes?.toString(),
allQueriesCount: allQueriesCount?.toString(),
};
logger.logWarning(message, {
...localStats,
...additionalInfo,
});
}
}
function updateRichHistory(
@@ -185,17 +243,20 @@ function cleanUp(richHistory: RichHistoryLocalStorageDTO[]): RichHistoryLocalSto
}
/**
* Ensures the entry can be added. Throws an error if current limit has been hit.
* Ensures the entry can be added.
* Returns queries that should be saved back giving space for one extra query.
*/
export function checkLimits(queriesToKeep: RichHistoryLocalStorageDTO[]): {
export function cleanUpUnstarredQuery(
queriesToKeep: RichHistoryLocalStorageDTO[],
max: number
): {
queriesToKeep: RichHistoryLocalStorageDTO[];
limitExceeded: boolean;
} {
// remove oldest non-starred items to give space for the recent query
let limitExceeded = false;
let current = queriesToKeep.length - 1;
while (current >= 0 && queriesToKeep.length >= MAX_HISTORY_ITEMS) {
while (current >= 0 && queriesToKeep.length >= max) {
if (!queriesToKeep[current].starred) {
queriesToKeep.splice(current, 1);
limitExceeded = true;
@@ -247,3 +308,26 @@ function throwError(name: string, message: string) {
error.name = name;
throw error;
}
function calculateTotalLocalStorageSize() {
try {
let total = 0;
// eslint-disable-next-line
const ls = window.localStorage;
for (let i = 0; i < ls.length; i++) {
const key = ls.key(i);
if (key) {
const value = ls.getItem(key);
if (value) {
total += key.length + value.length;
}
}
}
// each character is 2 bytes
return total * 2;
} catch (e) {
return -1;
}
}
@@ -28,6 +28,7 @@ export type RichHistorySearchFilters = {
// so the resulting timerange from this will be [now - from, now - to].
from?: number;
to?: number;
// true if only starred entries should be returned, false if ALL entries should be returned,
starred: boolean;
page?: number;
};
@@ -53,7 +53,7 @@ describe('buildCategories', () => {
it('should add enterprise phantom plugins', () => {
const enterprisePluginsCategory = categories[3];
expect(enterprisePluginsCategory.title).toBe('Enterprise plugins');
expect(enterprisePluginsCategory.plugins.length).toBe(32);
expect(enterprisePluginsCategory.plugins.length).toBe(31);
expect(enterprisePluginsCategory.plugins[0].name).toBe('Adobe Analytics');
expect(enterprisePluginsCategory.plugins[enterprisePluginsCategory.plugins.length - 1].name).toBe('Zendesk');
});
@@ -13,7 +13,6 @@ import catchpointSvg from 'img/plugins/catchpoint.svg';
import cloudflareJpg from 'img/plugins/cloudflare.jpg';
import cockroachdbJpg from 'img/plugins/cockroachdb.jpg';
import datadogPng from 'img/plugins/datadog.png';
import db2Svg from 'img/plugins/db2.svg';
import droneSvg from 'img/plugins/drone.svg';
import dynatracePng from 'img/plugins/dynatrace.png';
import gitlabSvg from 'img/plugins/gitlab.svg';
@@ -419,12 +418,6 @@ function getEnterprisePhantomPlugins(): DataSourcePluginMeta[] {
name: 'SolarWinds',
imgUrl: solarWindsSvg,
}),
getPhantomPlugin({
id: 'grafana-ibmdb2-datasource',
description: t('datasources.get-enterprise-phantom-plugins.description.ibmdb2-datasource', 'IBM Db2 data source'),
name: 'IBM Db2',
imgUrl: db2Svg,
}),
];
}
@@ -200,7 +200,7 @@ describe('Explore: Query History', () => {
await waitForExplore();
await openQueryHistory();
jest.spyOn(localStorage, 'checkLimits').mockImplementationOnce((queries) => {
jest.spyOn(localStorage, 'cleanUpUnstarredQuery').mockImplementationOnce((queries) => {
return { queriesToKeep: queries, limitExceeded: true };
});
@@ -4,6 +4,8 @@ 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 () =>
@@ -73,6 +75,7 @@ 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, updateDatasourcePluginJsonDataOption } from '@grafana/data';
import { DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { ConnectionConfig } from '@grafana/google-sdk';
import { ConfigSection, DataSourceDescription } from '@grafana/plugin-ui';
import { config, reportInteraction } from '@grafana/runtime';
import { Divider, Field, Input, SecureSocksProxySettings, Stack } from '@grafana/ui';
import { reportInteraction, config } from '@grafana/runtime';
import { Divider, SecureSocksProxySettings } from '@grafana/ui';
import { CloudMonitoringOptions, CloudMonitoringSecureJsonData } from '../../types/types';
@@ -36,33 +36,14 @@ export const ConfigEditor = memo(({ options, onOptionsChange }: Props) => {
<Divider />
<ConfigSection
title="Additional settings"
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy and Universe Domain."
description="Additional settings are optional settings that can be configured for more control over your data source. This includes Secure Socks Proxy."
isCollapsible
isInitiallyOpen={
options.jsonData.enableSecureSocksProxy !== undefined || options.jsonData.universeDomain !== undefined
}
isInitiallyOpen={options.jsonData.enableSecureSocksProxy !== undefined}
>
<Stack direction={'column'}>
<Field noMargin label="Universe Domain">
<Input
width={50}
value={options.jsonData.universeDomain}
onChange={(event) =>
updateDatasourcePluginJsonDataOption(
{ options, onOptionsChange },
'universeDomain',
event.currentTarget.value
)
}
placeholder="googleapis.com"
></Input>
</Field>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
</Stack>
<SecureSocksProxySettings options={options} onOptionsChange={onOptionsChange} />
</ConfigSection>
</>
)}
<Divider />
</>
);
});
@@ -38,7 +38,6 @@ export interface Aggregation {
export interface CloudMonitoringOptions extends DataSourceOptions {
gceDefaultProject?: string;
enableSecureSocksProxy?: boolean;
universeDomain?: string;
}
export interface CloudMonitoringSecureJsonData extends DataSourceSecureJsonData {}
@@ -1,7 +1,8 @@
import { render, screen } from '@testing-library/react';
import { select } from 'react-select-event';
import { DateHistogram } from '../../../../dataquery.gen';
import { DateHistogram } from 'app/plugins/datasource/elasticsearch/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<SelectableValue<string>, GroupBase<SelectableValue<string>>>
options: OptionsOrGroups<unknown, GroupBase<unknown>>
) => {
// TODO: would be extremely nice here to allow only template variables and values that are
// valid date histogram's Interval options
const valueExists = options.some(hasValue(inputValue));
const valueExists = (options as Array<SelectableValue<string>>).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 '../../../../../../dataquery.gen';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const addFilter = createAction('@bucketAggregations/filter/add');
export const removeFilter = createAction<number>('@bucketAggregations/filter/remove');
@@ -1,5 +1,6 @@
import { Filter } from '../../../../../../dataquery.gen';
import { reducerTester } from '../../../../../reducerTester';
import { reducerTester } from 'test/core/redux/reducerTester';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { addFilter, changeFilter, removeFilter } from './actions';
import { reducer } from './reducer';
@@ -1,6 +1,7 @@
import { Action } from 'redux';
import { Filter } from '../../../../../../dataquery.gen';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultFilter } from '../utils';
import { addFilter, changeFilter, removeFilter } from './actions';
@@ -1,3 +1,3 @@
import { Filter } from '../../../../../dataquery.gen';
import { Filter } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const defaultFilter = (): Filter => ({ label: '', query: '*' });
@@ -1,7 +1,14 @@
import { fireEvent, screen } from '@testing-library/react';
import selectEvent from 'react-select-event';
import { Average, Derivative, ElasticsearchDataQuery, Terms, TopMetrics } from '../../../../dataquery.gen';
import {
Average,
Derivative,
ElasticsearchDataQuery,
Terms,
TopMetrics,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { renderWithESProvider } from '../../../../test-helpers/render';
import { describeMetric } from '../../../../utils';
@@ -2,9 +2,15 @@ import { uniqueId } from 'lodash';
import { useRef } from 'react';
import { SelectableValue } from '@grafana/data';
import { InlineField, Input, Select } from '@grafana/ui';
import { InlineField, Select, Input } from '@grafana/ui';
import {
Terms,
ExtendedStats,
ExtendedStatMetaType,
Percentiles,
MetricAggregation,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ExtendedStats, MetricAggregation, Percentiles, Terms } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { describeMetric } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
@@ -99,7 +105,7 @@ function createOrderByOptionsForExtendedStats(metric: ExtendedStats): Selectable
if (!metric.meta) {
return [];
}
const metaKeys = Object.keys(metric.meta);
const metaKeys = Object.keys(metric.meta) as ExtendedStatMetaType[];
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,6 +1,7 @@
import { BucketAggregation } from '../../../../dataquery.gen';
import { BucketAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultGeoHashPrecisionString } from '../../../../queryDef';
import { convertOrderByToMetricId, describeMetric } from '../../../../utils';
import { describeMetric, convertOrderByToMetricId } from '../../../../utils';
import { useQuery } from '../../ElasticsearchQueryContext';
import { bucketAggregationConfig, orderByOptions, orderOptions } from '../utils';
@@ -1,6 +1,10 @@
import { createAction } from '@reduxjs/toolkit';
import { BucketAggregation, BucketAggregationType, BucketAggregationWithField } from '../../../../dataquery.gen';
import {
BucketAggregation,
BucketAggregationType,
BucketAggregationWithField,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const addBucketAggregation = createAction<BucketAggregation['id']>('@bucketAggs/add');
export const removeBucketAggregation = createAction<BucketAggregation['id']>('@bucketAggs/remove');
@@ -1,4 +1,9 @@
import { BucketAggregation, DateHistogram, ElasticsearchDataQuery } from '../../../../dataquery.gen';
import {
BucketAggregation,
DateHistogram,
ElasticsearchDataQuery,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultBucketAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
@@ -1,6 +1,7 @@
import { Action } from '@reduxjs/toolkit';
import { BucketAggregation, ElasticsearchDataQuery, Terms } from '../../../../dataquery.gen';
import { BucketAggregation, ElasticsearchDataQuery, Terms } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultBucketAgg } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeMetricType } from '../../MetricAggregationsEditor/state/actions';
@@ -46,12 +47,11 @@ 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.
*/
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
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.
*/
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 { InlineLabel, Input } from '@grafana/ui';
import { Input, InlineLabel } from '@grafana/ui';
import { BucketScript, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { BucketScript, MetricAggregation } from '../../../../../dataquery.gen';
import { useDispatch, useStatelessReducer } from '../../../../../hooks/useStatelessReducer';
import { useStatelessReducer, useDispatch } 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,4 +1,5 @@
import { PipelineVariable } from '../../../../../../dataquery.gen';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { reducerTester } from '../../../../../reducerTester';
import {
@@ -1,6 +1,7 @@
import { Action } from '@reduxjs/toolkit';
import { PipelineVariable } from '../../../../../../dataquery.gen';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultPipelineVariable, generatePipelineVariableName } from '../utils';
import {
@@ -1,4 +1,4 @@
import { PipelineVariable } from '../../../../../dataquery.gen';
import { PipelineVariable } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
export const defaultPipelineVariable = (name: string): PipelineVariable => ({ name, pipelineAgg: '' });
@@ -2,8 +2,11 @@ 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';
@@ -30,11 +33,9 @@ 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 * as React from 'react';
import { ComponentProps, useId, useRef, useState } from 'react';
import * as React from 'react';
import { InlineField, InlineSwitch, Input, Select } from '@grafana/ui';
import { InlineField, Input, InlineSwitch, Select } from '@grafana/ui';
import { MetricAggregation, ExtendedStat } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { ExtendedStat, MetricAggregation } from '../../../../dataquery.gen';
import { useDispatch } from '../../../../hooks/useStatelessReducer';
import { extendedStats } from '../../../../queryDef';
import { SettingsEditorContainer } from '../../SettingsEditorContainer';
@@ -1,4 +1,5 @@
import { MetricAggregation } from '../../../../dataquery.gen';
import { MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { extendedStats } from '../../../../queryDef';
const hasValue = (value: string) => (object: { value: string }) => object.value === value;
@@ -1,6 +1,7 @@
import { createAction } from '@reduxjs/toolkit';
import { MetricAggregation, MetricAggregationWithSettings } from '../../../../dataquery.gen';
import { MetricAggregation, MetricAggregationWithSettings } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { MetricAggregationWithMeta } from '../../../../types';
export const addMetric = createAction<MetricAggregation['id']>('@metrics/add');
@@ -1,4 +1,10 @@
import { Derivative, ElasticsearchDataQuery, ExtendedStats, MetricAggregation } from '../../../../dataquery.gen';
import {
MetricAggregation,
ElasticsearchDataQuery,
Derivative,
ExtendedStats,
} from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg } from '../../../../queryDef';
import { reducerTester } from '../../../reducerTester';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
@@ -1,6 +1,7 @@
import { Action } from '@reduxjs/toolkit';
import { ElasticsearchDataQuery, MetricAggregation } from '../../../../dataquery.gen';
import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasource/elasticsearch/dataquery.gen';
import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef';
import { removeEmpty } from '../../../../utils';
import { changeEditorTypeAndResetQuery, initQuery } from '../../state';
@@ -56,7 +57,6 @@ 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 '../types/store';
import { StoreState } from 'app/types/store';
type GrafanaReducer<S = StoreState, A extends Action = AnyAction> = (state: S, action: A) => S;
@@ -1,22 +0,0 @@
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,15 +14,14 @@ import {
import { config } from '@grafana/runtime';
import { Alert, SecureSocksProxySettings, Divider, Stack } from '@grafana/ui';
import { ElasticsearchOptions, ElasticsearchSecureJsonData } from '../types';
import { ElasticsearchOptions } 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, ElasticsearchSecureJsonData>;
export type Props = DataSourcePluginOptionsEditorProps<ElasticsearchOptions>;
export const ConfigEditor = (props: Props) => {
const { options, onOptionsChange } = props;
@@ -49,16 +48,6 @@ 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' && (
@@ -84,7 +73,6 @@ export const ConfigEditor = (props: Props) => {
jsonData: {
...options.jsonData,
sigV4Auth: method === 'custom-sigv4',
apiKeyAuth: method === 'custom-api-key',
oauthPassThru: method === AuthMethod.OAuthForward,
},
});
@@ -1 +0,0 @@
import '@grafana/plugin-configs/jest/jest-setup';
@@ -1,3 +0,0 @@
import defaultConfig from '@grafana/plugin-configs/jest/jest.config.js';
export default defaultConfig;
@@ -1,62 +0,0 @@
{
"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,7 +2,6 @@
"type": "datasource",
"name": "Elasticsearch",
"id": "elasticsearch",
"executable": "gpx_elasticsearch",
"category": "logging",
"info": {
"description": "Open source logging & analytics database",
@@ -28,8 +27,7 @@
"name": "Documentation",
"url": "https://grafana.com/docs/grafana/latest/datasources/elasticsearch/"
}
],
"version": "%VERSION%"
]
},
"alerting": true,
"annotations": true,
@@ -38,9 +36,5 @@
"backend": true,
"queryOptions": {
"minInterval": true
},
"dependencies": {
"grafanaDependency": ">=11.6.0",
"plugins": []
}
}
@@ -1,9 +0,0 @@
{
"$schema": "../../../../../node_modules/nx/schemas/project-schema.json",
"projectType": "library",
"tags": ["scope:plugin", "type:datasource"],
"targets": {
"build": {},
"dev": {}
}
}
@@ -1,11 +0,0 @@
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');
@@ -1,21 +0,0 @@
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);
};
};
@@ -1,47 +0,0 @@
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'];
@@ -1,26 +0,0 @@
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);
}
@@ -1,8 +0,0 @@
{
"compilerOptions": {
"jsx": "react-jsx",
"types": ["node", "jest", "@testing-library/jest-dom"]
},
"extends": "@grafana/plugin-configs/tsconfig.json",
"include": ["."]
}
@@ -64,11 +64,6 @@ 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';
@@ -1,46 +0,0 @@
/* 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;
@@ -1,9 +0,0 @@
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;

Some files were not shown because too many files have changed in this diff Show More