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