Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c36d03a1ba | |||
| 7099cae39f | |||
| 6db51cbdb9 | |||
| 82d8d44977 | |||
| 60abd9a159 |
@@ -440,6 +440,7 @@ i18next.config.ts @grafana/grafana-frontend-platform
|
||||
/e2e-playwright/dashboards/TestDashboard.json @grafana/dashboards-squad @grafana/grafana-search-navigate-organise
|
||||
/e2e-playwright/dashboards/TestV2Dashboard.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards/V2DashWithRepeats.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards/V2DashWithRowRepeats.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards/V2DashWithTabRepeats.json @grafana/dashboards-squad
|
||||
/e2e-playwright/dashboards-suite/adhoc-filter-from-panel.spec.ts @grafana/datapro
|
||||
/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @grafana/grafana-search-navigate-organise
|
||||
|
||||
@@ -71,11 +71,6 @@ func convertDashboardSpec_V2alpha1_to_V1beta1(in *dashv2alpha1.DashboardSpec) (m
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to convert panels: %w", err)
|
||||
}
|
||||
// Count total panels including those in collapsed rows
|
||||
totalPanelsConverted := countTotalPanels(panels)
|
||||
if totalPanelsConverted < len(in.Elements) {
|
||||
return nil, fmt.Errorf("some panels were not converted from v2alpha1 to v1beta1")
|
||||
}
|
||||
|
||||
if len(panels) > 0 {
|
||||
dashboard["panels"] = panels
|
||||
@@ -198,29 +193,6 @@ func convertLinksToV1(links []dashv2alpha1.DashboardDashboardLink) []map[string]
|
||||
return result
|
||||
}
|
||||
|
||||
// countTotalPanels counts all panels including those nested in collapsed row panels.
|
||||
func countTotalPanels(panels []interface{}) int {
|
||||
count := 0
|
||||
for _, p := range panels {
|
||||
panel, ok := p.(map[string]interface{})
|
||||
if !ok {
|
||||
count++
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if this is a row panel with nested panels
|
||||
if panelType, ok := panel["type"].(string); ok && panelType == "row" {
|
||||
if nestedPanels, ok := panel["panels"].([]interface{}); ok {
|
||||
count += len(nestedPanels)
|
||||
}
|
||||
// Don't count the row itself as a panel element
|
||||
} else {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
// convertPanelsFromElementsAndLayout converts V2 layout structures to V1 panel arrays.
|
||||
// V1 only supports a flat array of panels with row panels for grouping.
|
||||
// This function dispatches to the appropriate converter based on layout type:
|
||||
|
||||
+4
-22
@@ -290,7 +290,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"percent"
|
||||
@@ -304,7 +304,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -323,15 +323,6 @@
|
||||
}
|
||||
],
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -375,7 +366,7 @@
|
||||
],
|
||||
"legend": {
|
||||
"displayMode": "table",
|
||||
"placement": "right",
|
||||
"placement": "bottom",
|
||||
"showLegend": true,
|
||||
"values": [
|
||||
"value"
|
||||
@@ -389,7 +380,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -408,15 +399,6 @@
|
||||
}
|
||||
],
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -248,7 +248,7 @@
|
||||
"legend": {
|
||||
"values": ["percent"],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
"placement": "bottom"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -256,7 +256,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -272,15 +272,6 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Percent",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "^Backend-(.*)$",
|
||||
"renamePattern": "b-$1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
@@ -320,7 +311,7 @@
|
||||
"legend": {
|
||||
"values": ["value"],
|
||||
"displayMode": "table",
|
||||
"placement": "right"
|
||||
"placement": "bottom"
|
||||
},
|
||||
"pieType": "pie",
|
||||
"reduceOptions": {
|
||||
@@ -328,7 +319,7 @@
|
||||
"fields": "",
|
||||
"values": false
|
||||
},
|
||||
"showLegend": true,
|
||||
"showLegend": false,
|
||||
"strokeWidth": 1,
|
||||
"text": {}
|
||||
},
|
||||
@@ -344,15 +335,6 @@
|
||||
"timeFrom": null,
|
||||
"timeShift": null,
|
||||
"title": "Value",
|
||||
"transformations": [
|
||||
{
|
||||
"id": "renameByRegex",
|
||||
"options": {
|
||||
"regex": "(.*)",
|
||||
"renamePattern": "$1-how-much-wood-could-a-woodchuck-chuck-if-a-woodchuck-could-chuck-wood"
|
||||
}
|
||||
}
|
||||
],
|
||||
"type": "piechart"
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import testV2DashWithRepeats from '../dashboards/V2DashWithRepeats.json';
|
||||
import testV2DashWithRowRepeats from '../dashboards/V2DashWithRowRepeats.json';
|
||||
|
||||
import {
|
||||
checkRepeatedPanelTitles,
|
||||
@@ -10,11 +11,14 @@ import {
|
||||
saveDashboard,
|
||||
importTestDashboard,
|
||||
goToEmbeddedPanel,
|
||||
goToPanelSnapshot,
|
||||
} from './utils';
|
||||
|
||||
const repeatTitleBase = 'repeat - ';
|
||||
const newTitleBase = 'edited rep - ';
|
||||
const repeatOptions = [1, 2, 3, 4];
|
||||
const getTitleInRepeatRow = (rowIndex: number, panelIndex: number) =>
|
||||
`repeated-row-${rowIndex}-repeated-panel-${panelIndex}`;
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
@@ -165,9 +169,7 @@ test.describe(
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
|
||||
@@ -217,9 +219,7 @@ test.describe(
|
||||
)
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
|
||||
.click();
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.DashboardEditPaneSplitter.primaryBody)
|
||||
@@ -405,5 +405,143 @@ test.describe(
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.headerContainer).all()
|
||||
).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('can view repeated panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view repeated panel in a repeated row',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
// make sure the repeated panel is present in multiple rows
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).toBeVisible();
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
.hover();
|
||||
|
||||
await page.keyboard.press('v');
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
const repeatedPanelUrl = page.url();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
// load view panel directly
|
||||
await page.goto(repeatedPanelUrl);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('can view embedded panel in a repeated row', async ({ dashboardPage, selectors, page }) => {
|
||||
const embedPanelTitle = 'embedded-panel';
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view embedded repeated panel in a repeated row',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
.hover();
|
||||
await page.keyboard.press('p+e');
|
||||
|
||||
await goToEmbeddedPanel(page);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
});
|
||||
|
||||
// there is a bug in the Snapshot feature that prevents the next two tests from passing
|
||||
// tracking issue: https://github.com/grafana/grafana/issues/114509
|
||||
test.skip('can view repeated panel inside snapshot', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view repeated panel inside snapshot',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
.hover();
|
||||
await page.keyboard.press('p+s');
|
||||
|
||||
// click "Publish snapshot"
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
|
||||
.click();
|
||||
|
||||
// click "Copy link" button in the snapshot drawer
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
|
||||
.click();
|
||||
|
||||
await goToPanelSnapshot(page);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeVisible();
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(2, 2)))
|
||||
).not.toBeVisible();
|
||||
});
|
||||
test.skip('can view single panel in a repeated row inside snapshot', async ({ dashboardPage, selectors, page }) => {
|
||||
await importTestDashboard(
|
||||
page,
|
||||
selectors,
|
||||
'Custom grid repeats - view single panel inside snapshot',
|
||||
JSON.stringify(testV2DashWithRowRepeats)
|
||||
);
|
||||
|
||||
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1')).hover();
|
||||
// open panel snapshot
|
||||
await page.keyboard.press('p+s');
|
||||
|
||||
// click "Publish snapshot"
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.publishSnapshot)
|
||||
.click();
|
||||
|
||||
// click "Copy link" button
|
||||
await dashboardPage
|
||||
.getByGrafanaSelector(selectors.pages.ShareDashboardDrawer.ShareSnapshot.copyUrlButton)
|
||||
.click();
|
||||
|
||||
await goToPanelSnapshot(page);
|
||||
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('single panel row 1'))
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(getTitleInRepeatRow(1, 1)))
|
||||
).toBeHidden();
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
@@ -218,6 +218,15 @@ export async function goToEmbeddedPanel(page: Page) {
|
||||
await page.goto(soloPanelUrl!);
|
||||
}
|
||||
|
||||
export async function goToPanelSnapshot(page: Page) {
|
||||
// extracting snapshot url from clipboard
|
||||
const snapshotUrl = await page.evaluate(() => navigator.clipboard.readText());
|
||||
|
||||
expect(snapshotUrl).toBeDefined();
|
||||
|
||||
await page.goto(snapshotUrl);
|
||||
}
|
||||
|
||||
export async function moveTab(
|
||||
dashboardPage: DashboardPage,
|
||||
page: Page,
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
{
|
||||
"apiVersion": "dashboard.grafana.app/v2beta1",
|
||||
"kind": "Dashboard",
|
||||
"metadata": {
|
||||
"name": "ad8l8fz",
|
||||
"namespace": "default",
|
||||
"uid": "fLb2na54K8NZHvn8LfWGL1jhZh03Hy0xpV1KzMYgAXEX",
|
||||
"resourceVersion": "1",
|
||||
"generation": 2,
|
||||
"creationTimestamp": "2025-11-25T15:52:42Z",
|
||||
"labels": {
|
||||
"grafana.app/deprecatedInternalID": "20"
|
||||
},
|
||||
"annotations": {
|
||||
"grafana.app/createdBy": "user:aerwo725ot62od",
|
||||
"grafana.app/updatedBy": "user:aerwo725ot62od",
|
||||
"grafana.app/updatedTimestamp": "2025-11-25T15:52:42Z",
|
||||
"grafana.app/folder": ""
|
||||
}
|
||||
},
|
||||
"spec": {
|
||||
"annotations": [
|
||||
{
|
||||
"kind": "AnnotationQuery",
|
||||
"spec": {
|
||||
"builtIn": true,
|
||||
"enable": true,
|
||||
"hide": true,
|
||||
"iconColor": "rgba(0, 211, 255, 1)",
|
||||
"name": "Annotations & Alerts",
|
||||
"query": {
|
||||
"datasource": {
|
||||
"name": "-- Grafana --"
|
||||
},
|
||||
"group": "grafana",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"cursorSync": "Off",
|
||||
"description": "",
|
||||
"editable": true,
|
||||
"elements": {
|
||||
"panel-1": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"hidden": false,
|
||||
"query": {
|
||||
"group": "",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryOptions": {},
|
||||
"transformations": []
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": 4,
|
||||
"links": [],
|
||||
"title": "repeated-row-$c4-repeated-panel-$c3",
|
||||
"vizConfig": {
|
||||
"group": "timeseries",
|
||||
"kind": "VizConfig",
|
||||
"spec": {
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "12.4.0-pre"
|
||||
}
|
||||
}
|
||||
},
|
||||
"panel-2": {
|
||||
"kind": "Panel",
|
||||
"spec": {
|
||||
"data": {
|
||||
"kind": "QueryGroup",
|
||||
"spec": {
|
||||
"queries": [
|
||||
{
|
||||
"kind": "PanelQuery",
|
||||
"spec": {
|
||||
"hidden": false,
|
||||
"query": {
|
||||
"group": "",
|
||||
"kind": "DataQuery",
|
||||
"spec": {},
|
||||
"version": "v0"
|
||||
},
|
||||
"refId": "A"
|
||||
}
|
||||
}
|
||||
],
|
||||
"queryOptions": {},
|
||||
"transformations": []
|
||||
}
|
||||
},
|
||||
"description": "",
|
||||
"id": 2,
|
||||
"links": [],
|
||||
"title": "single panel row $c4",
|
||||
"vizConfig": {
|
||||
"group": "timeseries",
|
||||
"kind": "VizConfig",
|
||||
"spec": {
|
||||
"fieldConfig": {
|
||||
"defaults": {
|
||||
"color": {
|
||||
"mode": "palette-classic"
|
||||
},
|
||||
"custom": {
|
||||
"axisBorderShow": false,
|
||||
"axisCenteredZero": false,
|
||||
"axisColorMode": "text",
|
||||
"axisLabel": "",
|
||||
"axisPlacement": "auto",
|
||||
"barAlignment": 0,
|
||||
"barWidthFactor": 0.6,
|
||||
"drawStyle": "line",
|
||||
"fillOpacity": 0,
|
||||
"gradientMode": "none",
|
||||
"hideFrom": {
|
||||
"legend": false,
|
||||
"tooltip": false,
|
||||
"viz": false
|
||||
},
|
||||
"insertNulls": false,
|
||||
"lineInterpolation": "linear",
|
||||
"lineWidth": 1,
|
||||
"pointSize": 5,
|
||||
"scaleDistribution": {
|
||||
"type": "linear"
|
||||
},
|
||||
"showPoints": "auto",
|
||||
"showValues": false,
|
||||
"spanNulls": false,
|
||||
"stacking": {
|
||||
"group": "A",
|
||||
"mode": "none"
|
||||
},
|
||||
"thresholdsStyle": {
|
||||
"mode": "off"
|
||||
}
|
||||
},
|
||||
"thresholds": {
|
||||
"mode": "absolute",
|
||||
"steps": [
|
||||
{
|
||||
"color": "green",
|
||||
"value": 0
|
||||
},
|
||||
{
|
||||
"color": "red",
|
||||
"value": 80
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"overrides": []
|
||||
},
|
||||
"options": {
|
||||
"legend": {
|
||||
"calcs": [],
|
||||
"displayMode": "list",
|
||||
"placement": "bottom",
|
||||
"showLegend": true
|
||||
},
|
||||
"tooltip": {
|
||||
"hideZeros": false,
|
||||
"mode": "single",
|
||||
"sort": "none"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "12.4.0-pre"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"layout": {
|
||||
"kind": "RowsLayout",
|
||||
"spec": {
|
||||
"rows": [
|
||||
{
|
||||
"kind": "RowsLayoutRow",
|
||||
"spec": {
|
||||
"collapse": false,
|
||||
"layout": {
|
||||
"kind": "GridLayout",
|
||||
"spec": {
|
||||
"items": [
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-1"
|
||||
},
|
||||
"height": 10,
|
||||
"repeat": {
|
||||
"direction": "h",
|
||||
"mode": "variable",
|
||||
"value": "c3"
|
||||
},
|
||||
"width": 24,
|
||||
"x": 0,
|
||||
"y": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "GridLayoutItem",
|
||||
"spec": {
|
||||
"element": {
|
||||
"kind": "ElementReference",
|
||||
"name": "panel-2"
|
||||
},
|
||||
"height": 8,
|
||||
"width": 12,
|
||||
"x": 0,
|
||||
"y": 10
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"repeat": {
|
||||
"mode": "variable",
|
||||
"value": "c4"
|
||||
},
|
||||
"title": "Repeated row $c4"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"links": [],
|
||||
"liveNow": false,
|
||||
"preload": false,
|
||||
"tags": [],
|
||||
"timeSettings": {
|
||||
"autoRefresh": "",
|
||||
"autoRefreshIntervals": ["5s", "10s", "30s", "1m", "5m", "15m", "30m", "1h", "2h", "1d"],
|
||||
"fiscalYearStartMonth": 0,
|
||||
"from": "now-6h",
|
||||
"hideTimepicker": false,
|
||||
"timezone": "browser",
|
||||
"to": "now"
|
||||
},
|
||||
"title": "test-e2e-repeats",
|
||||
"variables": [
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["1", "2", "3", "4"],
|
||||
"value": ["1", "2", "3", "4"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "c1",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "2",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "3",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "4",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"query": "1,2,3,4",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["A", "B", "C", "D"],
|
||||
"value": ["A", "B", "C", "D"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": true,
|
||||
"multi": true,
|
||||
"name": "c2",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "A",
|
||||
"value": "A"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "B",
|
||||
"value": "B"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "C",
|
||||
"value": "C"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "D",
|
||||
"value": "D"
|
||||
}
|
||||
],
|
||||
"query": "A,B,C,D",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["1", "2", "3", "4"],
|
||||
"value": ["1", "2", "3", "4"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": true,
|
||||
"name": "c3",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "2",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "3",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "4",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"query": "1, 2, 3, 4",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"kind": "CustomVariable",
|
||||
"spec": {
|
||||
"allowCustomValue": true,
|
||||
"current": {
|
||||
"text": ["1", "2", "3", "4"],
|
||||
"value": ["1", "2", "3", "4"]
|
||||
},
|
||||
"hide": "dontHide",
|
||||
"includeAll": false,
|
||||
"multi": true,
|
||||
"name": "c4",
|
||||
"options": [
|
||||
{
|
||||
"selected": true,
|
||||
"text": "1",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "2",
|
||||
"value": "2"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "3",
|
||||
"value": "3"
|
||||
},
|
||||
{
|
||||
"selected": true,
|
||||
"text": "4",
|
||||
"value": "4"
|
||||
}
|
||||
],
|
||||
"query": "1, 2, 3, 4",
|
||||
"skipUrlSync": false
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"status": {}
|
||||
}
|
||||
@@ -2657,6 +2657,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/inspector/InspectJSONTab.tsx": {
|
||||
"no-restricted-syntax": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"public/app/features/inspector/InspectStatsTab.tsx": {
|
||||
"@grafana/no-aria-label-selectors": {
|
||||
"count": 1
|
||||
|
||||
@@ -112,7 +112,7 @@ require (
|
||||
github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team
|
||||
github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f // @grafana/observability-traces-and-profiling
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20260109143659-5ff77ad3011a // @grafana/observability-traces-and-profiling
|
||||
github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling
|
||||
github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend
|
||||
|
||||
@@ -1685,8 +1685,8 @@ github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f
|
||||
github.com/grafana/prometheus-alertmanager v0.25.1-0.20260112162805-d29cc9cf7f0f/go.mod h1:AsVdCBeDFN9QbgpJg+8voDAcgsW0RmNvBd70ecMMdC0=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasnuOY813tbMN8i/a3Og=
|
||||
github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f h1:fTlIj5n4x5dU63XHItug7GLjtnaeJdPqBlqg4zlABq0=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20251118081820-ace37f973a0f/go.mod h1:VBNcIhunCZsJ3/mcYx+j7uFf0P/108eiWa+8+Z9ll3o=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20260109143659-5ff77ad3011a h1:8ol+RVtrjm6rFu275xR7ChDzm4nYFNj9gWRO19p9sQI=
|
||||
github.com/grafana/pyroscope/api v1.2.1-0.20260109143659-5ff77ad3011a/go.mod h1:ga4rxVfVsvUKEbmwx4/dryIRwHBYpuwP0mDB81aMR2Y=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrRoT6yV5+wkrOpcszoIsO4+4ds248=
|
||||
github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk=
|
||||
github.com/grafana/saml v0.4.15-0.20240917091248-ae3bbdad8a56 h1:SDGrP81Vcd102L3UJEryRd1eestRw73wt+b8vnVEFe0=
|
||||
|
||||
@@ -1488,6 +1488,7 @@ github.com/shopspring/decimal v1.3.1/go.mod h1:DKyhrW/HYNuLGql+MJL6WCR6knT2jwCFR
|
||||
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636 h1:aSISeOcal5irEhJd1M+IrApc0PdcN7e7Aj4yuEnOrfQ=
|
||||
github.com/shurcooL/go v0.0.0-20200502201357-93f07166e636/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/simonswine/pyroscope/api v0.0.0-20260105145211-3182b395db2f/go.mod h1:ga4rxVfVsvUKEbmwx4/dryIRwHBYpuwP0mDB81aMR2Y=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||
|
||||
@@ -1251,4 +1251,8 @@ export interface FeatureToggles {
|
||||
* Enables profiles exemplars support in profiles drilldown
|
||||
*/
|
||||
profilesExemplars?: boolean;
|
||||
/**
|
||||
* Enables heatmap visualization support for Pyroscope profiles
|
||||
*/
|
||||
profilesHeatmap?: boolean;
|
||||
}
|
||||
|
||||
+12
@@ -16,6 +16,8 @@ export type PyroscopeQueryType = ('metrics' | 'profile' | 'both');
|
||||
|
||||
export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both';
|
||||
|
||||
export type HeatmapQueryType = ('individual' | 'span');
|
||||
|
||||
export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
/**
|
||||
* If set to true, the response will contain annotations
|
||||
@@ -25,10 +27,18 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
* Allows to group the results.
|
||||
*/
|
||||
groupBy: Array<string>;
|
||||
/**
|
||||
* Specifies the type of heatmap query
|
||||
*/
|
||||
heatmapType: (HeatmapQueryType | 'individual');
|
||||
/**
|
||||
* If set to true, exemplars will be requested
|
||||
*/
|
||||
includeExemplars: boolean;
|
||||
/**
|
||||
* If set to true, heatmap data will be requested
|
||||
*/
|
||||
includeHeatmap: boolean;
|
||||
/**
|
||||
* Specifies the query label selectors.
|
||||
*/
|
||||
@@ -53,7 +63,9 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
|
||||
export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = {
|
||||
groupBy: [],
|
||||
heatmapType: 'individual',
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
labelSelector: '{}',
|
||||
spanSelector: [],
|
||||
};
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { VizLegendTable } from './VizLegendTable';
|
||||
import { VizLegendItem } from './types';
|
||||
|
||||
describe('VizLegendTable', () => {
|
||||
const mockItems: VizLegendItem[] = [
|
||||
{ label: 'Series 1', color: 'red', yAxis: 1 },
|
||||
{ label: 'Series 2', color: 'blue', yAxis: 1 },
|
||||
{ label: 'Series 3', color: 'green', yAxis: 1 },
|
||||
];
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(<VizLegendTable items={mockItems} placement="bottom" />);
|
||||
expect(container.querySelector('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders all items', () => {
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" />);
|
||||
expect(screen.getByText('Series 1')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series 2')).toBeInTheDocument();
|
||||
expect(screen.getByText('Series 3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders table headers when items have display values', () => {
|
||||
const itemsWithStats: VizLegendItem[] = [
|
||||
{
|
||||
label: 'Series 1',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
getDisplayValues: () => [
|
||||
{ numeric: 100, text: '100', title: 'Max' },
|
||||
{ numeric: 50, text: '50', title: 'Min' },
|
||||
],
|
||||
},
|
||||
];
|
||||
render(<VizLegendTable items={itemsWithStats} placement="bottom" />);
|
||||
expect(screen.getByText('Max')).toBeInTheDocument();
|
||||
expect(screen.getByText('Min')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders sort icon when sorted', () => {
|
||||
const { container } = render(
|
||||
<VizLegendTable items={mockItems} placement="bottom" sortBy="Name" sortDesc={false} />
|
||||
);
|
||||
expect(container.querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onToggleSort when header is clicked', () => {
|
||||
const onToggleSort = jest.fn();
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={true} />);
|
||||
const header = screen.getByText('Name');
|
||||
header.click();
|
||||
expect(onToggleSort).toHaveBeenCalledWith('Name');
|
||||
});
|
||||
|
||||
it('does not call onToggleSort when not sortable', () => {
|
||||
const onToggleSort = jest.fn();
|
||||
render(<VizLegendTable items={mockItems} placement="bottom" onToggleSort={onToggleSort} isSortable={false} />);
|
||||
const header = screen.getByText('Name');
|
||||
header.click();
|
||||
expect(onToggleSort).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders with long labels', () => {
|
||||
const itemsWithLongLabels: VizLegendItem[] = [
|
||||
{
|
||||
label: 'This is a very long series name that should be scrollable within its table cell',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
},
|
||||
];
|
||||
render(<VizLegendTable items={itemsWithLongLabels} placement="bottom" />);
|
||||
expect(
|
||||
screen.getByText('This is a very long series name that should be scrollable within its table cell')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,112 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { LegendTableItem } from './VizLegendTableItem';
|
||||
import { VizLegendItem } from './types';
|
||||
|
||||
describe('LegendTableItem', () => {
|
||||
const mockItem: VizLegendItem = {
|
||||
label: 'Series 1',
|
||||
color: 'red',
|
||||
yAxis: 1,
|
||||
};
|
||||
|
||||
it('renders without crashing', () => {
|
||||
const { container } = render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(container.querySelector('tr')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label text', () => {
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('Series 1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with long label text', () => {
|
||||
const longLabelItem: VizLegendItem = {
|
||||
...mockItem,
|
||||
label: 'This is a very long series name that should be scrollable in the table cell',
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={longLabelItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(
|
||||
screen.getByText('This is a very long series name that should be scrollable in the table cell')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders stat values when provided', () => {
|
||||
const itemWithStats: VizLegendItem = {
|
||||
...mockItem,
|
||||
getDisplayValues: () => [
|
||||
{ numeric: 100, text: '100', title: 'Max' },
|
||||
{ numeric: 50, text: '50', title: 'Min' },
|
||||
],
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={itemWithStats} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('50')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders right y-axis indicator when yAxis is 2', () => {
|
||||
const rightAxisItem: VizLegendItem = {
|
||||
...mockItem,
|
||||
yAxis: 2,
|
||||
};
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={rightAxisItem} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
expect(screen.getByText('(right y-axis)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onLabelClick when label is clicked', () => {
|
||||
const onLabelClick = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
button.click();
|
||||
expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object));
|
||||
});
|
||||
|
||||
it('does not call onClick when readonly', () => {
|
||||
const onLabelClick = jest.fn();
|
||||
render(
|
||||
<table>
|
||||
<tbody>
|
||||
<LegendTableItem item={mockItem} onLabelClick={onLabelClick} readonly={true} />
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,7 @@ export const LegendTableItem = ({
|
||||
|
||||
return (
|
||||
<tr className={cx(styles.row, className)}>
|
||||
<td className={styles.labelCell}>
|
||||
<td>
|
||||
<span className={styles.itemWrapper}>
|
||||
<VizLegendSeriesIcon
|
||||
color={item.color}
|
||||
@@ -77,26 +77,24 @@ export const LegendTableItem = ({
|
||||
readonly={readonly}
|
||||
lineStyle={item.lineStyle}
|
||||
/>
|
||||
<div className={styles.labelCellInner}>
|
||||
<button
|
||||
disabled={readonly}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onBlur={onMouseOut}
|
||||
onFocus={onMouseOver}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={!readonly ? onClick : undefined}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span className={styles.yAxisLabel}>
|
||||
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
disabled={readonly}
|
||||
type="button"
|
||||
title={item.label}
|
||||
onBlur={onMouseOut}
|
||||
onFocus={onMouseOver}
|
||||
onMouseOver={onMouseOver}
|
||||
onMouseOut={onMouseOut}
|
||||
onClick={!readonly ? onClick : undefined}
|
||||
className={cx(styles.label, item.disabled && styles.labelDisabled)}
|
||||
>
|
||||
{item.label}{' '}
|
||||
{item.yAxis === 2 && (
|
||||
<span className={styles.yAxisLabel}>
|
||||
<Trans i18nKey="grafana-ui.viz-legend.right-axis-indicator">(right y-axis)</Trans>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
{item.getDisplayValues &&
|
||||
@@ -130,28 +128,6 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
background: rowHoverBg,
|
||||
},
|
||||
}),
|
||||
labelCell: css({
|
||||
label: 'LegendLabelCell',
|
||||
maxWidth: 0,
|
||||
width: '100%',
|
||||
minWidth: theme.spacing(16),
|
||||
}),
|
||||
labelCellInner: css({
|
||||
label: 'LegendLabelCellInner',
|
||||
display: 'block',
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
paddingRight: theme.spacing(3),
|
||||
scrollbarWidth: 'none',
|
||||
msOverflowStyle: 'none',
|
||||
maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
|
||||
WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`,
|
||||
'&::-webkit-scrollbar': {
|
||||
display: 'none',
|
||||
},
|
||||
}),
|
||||
label: css({
|
||||
label: 'LegendLabel',
|
||||
whiteSpace: 'nowrap',
|
||||
@@ -159,6 +135,9 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
border: 'none',
|
||||
fontSize: 'inherit',
|
||||
padding: 0,
|
||||
maxWidth: '600px',
|
||||
textOverflow: 'ellipsis',
|
||||
overflow: 'hidden',
|
||||
userSelect: 'text',
|
||||
}),
|
||||
labelDisabled: css({
|
||||
|
||||
@@ -2069,6 +2069,13 @@ var (
|
||||
Owner: grafanaObservabilityTracesAndProfilingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
{
|
||||
Name: "profilesHeatmap",
|
||||
Description: "Enables heatmap visualization support for Pyroscope profiles",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaObservabilityTracesAndProfilingSquad,
|
||||
FrontendOnly: false,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
Generated
+1
@@ -280,3 +280,4 @@ multiPropsVariables,experimental,@grafana/dashboards-squad,false,false,true
|
||||
smoothingTransformation,experimental,@grafana/datapro,false,false,true
|
||||
secretsManagementAppPlatformAwsKeeper,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
profilesExemplars,experimental,@grafana/observability-traces-and-profiling,false,false,false
|
||||
profilesHeatmap,experimental,@grafana/observability-traces-and-profiling,false,false,false
|
||||
|
||||
|
Generated
+4
@@ -789,4 +789,8 @@ const (
|
||||
// FlagProfilesExemplars
|
||||
// Enables profiles exemplars support in profiles drilldown
|
||||
FlagProfilesExemplars = "profilesExemplars"
|
||||
|
||||
// FlagProfilesHeatmap
|
||||
// Enables heatmap visualization support for Pyroscope profiles
|
||||
FlagProfilesHeatmap = "profilesHeatmap"
|
||||
)
|
||||
|
||||
+12
@@ -2955,6 +2955,18 @@
|
||||
"codeowner": "@grafana/observability-traces-and-profiling"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "profilesHeatmap",
|
||||
"resourceVersion": "1767703801452",
|
||||
"creationTimestamp": "2026-01-06T12:50:01Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enables heatmap visualization support for Pyroscope profiles",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/observability-traces-and-profiling"
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "prometheusAzureOverrideAudience",
|
||||
|
||||
@@ -1,43 +1,110 @@
|
||||
package exemplar
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
type Exemplar struct {
|
||||
Id string
|
||||
ProfileId string
|
||||
SpanId string
|
||||
Value float64
|
||||
Timestamp int64
|
||||
Labels map[string]string
|
||||
}
|
||||
|
||||
func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar) *data.Frame {
|
||||
type ExemplarType string
|
||||
|
||||
const (
|
||||
ExemplarTypeProfile ExemplarType = "profile"
|
||||
ExemplarTypeSpan ExemplarType = "span"
|
||||
)
|
||||
|
||||
func CreateExemplarFrame(labels map[string]string, exemplars []*Exemplar, exemplarType ExemplarType, units string) *data.Frame {
|
||||
frame := data.NewFrame("exemplar")
|
||||
frame.Meta = &data.FrameMeta{
|
||||
DataTopic: data.DataTopicAnnotations,
|
||||
}
|
||||
fields := []*data.Field{
|
||||
data.NewField("Time", nil, []time.Time{}),
|
||||
data.NewField("Value", labels, []float64{}), // add labels here?
|
||||
data.NewField("Id", nil, []string{}),
|
||||
|
||||
// Determine display name and which ID to use based on exemplar type
|
||||
displayName := "Profile ID"
|
||||
if exemplarType == ExemplarTypeSpan {
|
||||
displayName = "Span ID"
|
||||
}
|
||||
fields[2].Config = &data.FieldConfig{
|
||||
DisplayName: "Profile ID",
|
||||
|
||||
// Collect all unique label names across all exemplars
|
||||
uniqLabelNames := make(map[string]struct{})
|
||||
for _, e := range exemplars {
|
||||
for name := range e.Labels {
|
||||
uniqLabelNames[name] = struct{}{}
|
||||
}
|
||||
}
|
||||
for name := range labels {
|
||||
fields = append(fields, data.NewField(name, nil, []string{}))
|
||||
uniqLabelNames[name] = struct{}{}
|
||||
}
|
||||
|
||||
// Initialize fields
|
||||
const offset = 3
|
||||
fields := make([]*data.Field, 0, len(uniqLabelNames)+offset)
|
||||
fields = append(fields, data.NewField("Time", nil, make([]time.Time, 0, len(exemplars))))
|
||||
fields = append(fields, data.NewField("Value", labels, make([]float64, 0, len(exemplars)))) // Series labels attached to Value field
|
||||
fields = append(fields, data.NewField("Id", nil, make([]string, 0, len(exemplars))))
|
||||
|
||||
// Configure the Value field with units and display name
|
||||
valueFieldConfig := &data.FieldConfig{
|
||||
DisplayName: "Value",
|
||||
Unit: units,
|
||||
}
|
||||
fields[1].Config = valueFieldConfig
|
||||
|
||||
// Configure the Id field with display name
|
||||
idFieldConfig := &data.FieldConfig{
|
||||
DisplayName: displayName,
|
||||
}
|
||||
fields[2].Config = idFieldConfig
|
||||
|
||||
sortedLabelNames := make([]string, 0, len(uniqLabelNames))
|
||||
for name := range uniqLabelNames {
|
||||
sortedLabelNames = append(sortedLabelNames, name)
|
||||
}
|
||||
sort.Strings(sortedLabelNames)
|
||||
|
||||
// Create fields for all label names
|
||||
for _, name := range sortedLabelNames {
|
||||
fields = append(fields, data.NewField(name, nil, make([]string, 0, len(exemplars))))
|
||||
}
|
||||
|
||||
frame.Fields = fields
|
||||
|
||||
row := make([]any, len(uniqLabelNames)+offset)
|
||||
for _, e := range exemplars {
|
||||
frame.AppendRow(time.UnixMilli(e.Timestamp), e.Value, e.Id)
|
||||
for name, value := range labels {
|
||||
field, _ := frame.FieldByName(name)
|
||||
if field != nil {
|
||||
field.Append(value)
|
||||
}
|
||||
row[0] = time.UnixMilli(e.Timestamp)
|
||||
row[1] = e.Value
|
||||
|
||||
// Use the appropriate ID based on exemplar type
|
||||
if exemplarType == ExemplarTypeSpan {
|
||||
row[2] = e.SpanId
|
||||
} else if exemplarType == ExemplarTypeProfile {
|
||||
row[2] = e.ProfileId
|
||||
}
|
||||
|
||||
// Append label values: prefer exemplar-specific values over series values
|
||||
for idx, name := range sortedLabelNames {
|
||||
// Check if this exemplar has this label
|
||||
if value, ok := e.Labels[name]; ok {
|
||||
row[idx+offset] = value
|
||||
continue
|
||||
}
|
||||
if value, ok := labels[name]; ok {
|
||||
row[idx+offset] = value
|
||||
continue
|
||||
}
|
||||
row[idx+offset] = ""
|
||||
}
|
||||
|
||||
frame.AppendRow(row...)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
|
||||
@@ -6,29 +6,220 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestCreateExemplarFrame(t *testing.T) {
|
||||
func TestCreateExemplarFrame_ProfileType(t *testing.T) {
|
||||
exemplars := []*Exemplar{
|
||||
{Id: "1", Value: 1.0, Timestamp: 100},
|
||||
{Id: "2", Value: 2.0, Timestamp: 200},
|
||||
{ProfileId: "profile-1", SpanId: "span-1", Value: 1.0, Timestamp: 100, Labels: map[string]string{"pod": "pod-1"}},
|
||||
{ProfileId: "profile-2", SpanId: "span-2", Value: 2.0, Timestamp: 200, Labels: map[string]string{"pod": "pod-2"}},
|
||||
}
|
||||
labels := map[string]string{
|
||||
"foo": "bar",
|
||||
"service": "api",
|
||||
}
|
||||
frame := CreateExemplarFrame(labels, exemplars)
|
||||
frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeProfile, "bytes")
|
||||
|
||||
require.Equal(t, "exemplar", frame.Name)
|
||||
require.Equal(t, 4, len(frame.Fields))
|
||||
// Time, Value, Id, service (from labels), pod (from exemplar labels)
|
||||
require.Equal(t, 5, len(frame.Fields))
|
||||
require.Equal(t, "Time", frame.Fields[0].Name)
|
||||
require.Equal(t, "Value", frame.Fields[1].Name)
|
||||
require.Equal(t, "Id", frame.Fields[2].Name)
|
||||
require.Equal(t, "foo", frame.Fields[3].Name)
|
||||
|
||||
// Check that Id field shows Profile ID
|
||||
require.Equal(t, "Profile ID", frame.Fields[2].Config.DisplayName)
|
||||
|
||||
rows, err := frame.RowLen()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, rows)
|
||||
|
||||
row := frame.RowCopy(0)
|
||||
require.Equal(t, 4, len(row))
|
||||
require.Equal(t, 5, len(row))
|
||||
require.Equal(t, 1.0, row[1])
|
||||
require.Equal(t, "1", row[2])
|
||||
require.Equal(t, "bar", row[3])
|
||||
require.Equal(t, "profile-1", row[2]) // Should use ProfileId for profile type
|
||||
}
|
||||
|
||||
func TestCreateExemplarFrame_SpanType(t *testing.T) {
|
||||
exemplars := []*Exemplar{
|
||||
{
|
||||
ProfileId: "profile-1",
|
||||
SpanId: "span-abc123",
|
||||
Value: 100.0,
|
||||
Timestamp: 1000,
|
||||
Labels: map[string]string{
|
||||
"pod": "pod-xyz",
|
||||
"namespace": "prod",
|
||||
"__name__": "cpu",
|
||||
},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{
|
||||
"service": "api",
|
||||
}
|
||||
frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "nanoseconds")
|
||||
|
||||
require.Equal(t, "exemplar", frame.Name)
|
||||
|
||||
// Check Value field configuration
|
||||
valueField := frame.Fields[1]
|
||||
require.Equal(t, "Value", valueField.Name)
|
||||
require.Equal(t, "Value", valueField.Config.DisplayName)
|
||||
require.Equal(t, "nanoseconds", valueField.Config.Unit)
|
||||
|
||||
// Check Id field configuration
|
||||
idField := frame.Fields[2]
|
||||
require.Equal(t, "Id", idField.Name)
|
||||
require.Equal(t, "Span ID", idField.Config.DisplayName)
|
||||
|
||||
// Verify span ID is used for span type
|
||||
rows, err := frame.RowLen()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, rows)
|
||||
|
||||
row := frame.RowCopy(0)
|
||||
require.Equal(t, "span-abc123", row[2]) // Should use SpanId for span type
|
||||
}
|
||||
|
||||
func TestCreateExemplarFrame_AllLabelsIncluded(t *testing.T) {
|
||||
exemplars := []*Exemplar{
|
||||
{
|
||||
ProfileId: "profile-1",
|
||||
SpanId: "span-1",
|
||||
Value: 1.0,
|
||||
Timestamp: 100,
|
||||
Labels: map[string]string{
|
||||
"pod": "pod-1",
|
||||
"__profile_type__": "cpu",
|
||||
"__name__": "process_cpu",
|
||||
},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{
|
||||
"service": "api",
|
||||
}
|
||||
frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "count")
|
||||
|
||||
// Verify all fields are created (including private labels)
|
||||
fieldNames := []string{}
|
||||
for _, field := range frame.Fields {
|
||||
fieldNames = append(fieldNames, field.Name)
|
||||
}
|
||||
|
||||
require.Contains(t, fieldNames, "Time")
|
||||
require.Contains(t, fieldNames, "Value")
|
||||
require.Contains(t, fieldNames, "Id")
|
||||
require.Contains(t, fieldNames, "service")
|
||||
require.Contains(t, fieldNames, "pod")
|
||||
require.Contains(t, fieldNames, "__profile_type__")
|
||||
require.Contains(t, fieldNames, "__name__")
|
||||
}
|
||||
|
||||
func TestCreateExemplarFrame_NoDuplicateFields(t *testing.T) {
|
||||
// Test that labels in both series labels and exemplar labels don't create duplicate fields
|
||||
exemplars := []*Exemplar{
|
||||
{
|
||||
ProfileId: "profile-1",
|
||||
SpanId: "span-1",
|
||||
Value: 1.0,
|
||||
Timestamp: 100,
|
||||
Labels: map[string]string{
|
||||
"pod": "exemplar-pod-123", // Different value than series label
|
||||
"namespace": "prod", // This is only in exemplar labels
|
||||
},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{
|
||||
"service": "api",
|
||||
"pod": "series-pod-456", // This is also in exemplar labels but with different value
|
||||
}
|
||||
frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "short")
|
||||
|
||||
// Count how many fields have each name
|
||||
fieldCounts := make(map[string]int)
|
||||
for _, field := range frame.Fields {
|
||||
fieldCounts[field.Name]++
|
||||
}
|
||||
|
||||
// Each field name should appear exactly once
|
||||
require.Equal(t, 1, fieldCounts["Time"])
|
||||
require.Equal(t, 1, fieldCounts["Value"])
|
||||
require.Equal(t, 1, fieldCounts["Id"])
|
||||
require.Equal(t, 1, fieldCounts["service"])
|
||||
require.Equal(t, 1, fieldCounts["pod"], "pod field should appear exactly once, not duplicated")
|
||||
require.Equal(t, 1, fieldCounts["namespace"])
|
||||
|
||||
// Verify the exemplar-specific pod value is used (not the series value)
|
||||
rows, err := frame.RowLen()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, rows)
|
||||
|
||||
podField, _ := frame.FieldByName("pod")
|
||||
require.NotNil(t, podField)
|
||||
require.Equal(t, "exemplar-pod-123", podField.At(0), "Should use exemplar-specific pod value, not series value")
|
||||
|
||||
// Verify series label is used when exemplar doesn't have the label
|
||||
serviceField, _ := frame.FieldByName("service")
|
||||
require.NotNil(t, serviceField)
|
||||
require.Equal(t, "api", serviceField.At(0))
|
||||
|
||||
// Verify exemplar-only label
|
||||
namespaceField, _ := frame.FieldByName("namespace")
|
||||
require.NotNil(t, namespaceField)
|
||||
require.Equal(t, "prod", namespaceField.At(0))
|
||||
}
|
||||
|
||||
func TestCreateExemplarFrame_ExemplarValueTakesPrecedence(t *testing.T) {
|
||||
// Test that exemplar label values take precedence over series label values
|
||||
exemplars := []*Exemplar{
|
||||
{
|
||||
ProfileId: "profile-1",
|
||||
SpanId: "span-1",
|
||||
Value: 1.0,
|
||||
Timestamp: 100,
|
||||
Labels: map[string]string{
|
||||
"pod": "pod-abc",
|
||||
"node": "node-xyz",
|
||||
"span_name": "my-span",
|
||||
},
|
||||
},
|
||||
{
|
||||
ProfileId: "profile-2",
|
||||
SpanId: "span-2",
|
||||
Value: 2.0,
|
||||
Timestamp: 200,
|
||||
Labels: map[string]string{
|
||||
"pod": "pod-def",
|
||||
"node": "node-uvw",
|
||||
"span_name": "another-span",
|
||||
},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{
|
||||
"service": "api",
|
||||
}
|
||||
frame := CreateExemplarFrame(labels, exemplars, ExemplarTypeSpan, "bytes")
|
||||
|
||||
// Verify we have the correct number of rows
|
||||
rows, err := frame.RowLen()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, rows)
|
||||
|
||||
// Verify each exemplar has its own pod, node, and span_name values
|
||||
podField, _ := frame.FieldByName("pod")
|
||||
require.NotNil(t, podField)
|
||||
require.Equal(t, "pod-abc", podField.At(0))
|
||||
require.Equal(t, "pod-def", podField.At(1))
|
||||
|
||||
nodeField, _ := frame.FieldByName("node")
|
||||
require.NotNil(t, nodeField)
|
||||
require.Equal(t, "node-xyz", nodeField.At(0))
|
||||
require.Equal(t, "node-uvw", nodeField.At(1))
|
||||
|
||||
spanNameField, _ := frame.FieldByName("span_name")
|
||||
require.NotNil(t, spanNameField)
|
||||
require.Equal(t, "my-span", spanNameField.At(0))
|
||||
require.Equal(t, "another-span", spanNameField.At(1))
|
||||
|
||||
// Verify series label is the same for both
|
||||
serviceField, _ := frame.FieldByName("service")
|
||||
require.NotNil(t, serviceField)
|
||||
require.Equal(t, "api", serviceField.At(0))
|
||||
require.Equal(t, "api", serviceField.At(1))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
package heatmap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
)
|
||||
|
||||
// Point represents a single heatmap point with timestamp, bucket minimums, and counts
|
||||
type Point struct {
|
||||
Timestamp int64
|
||||
YMin []float64
|
||||
Counts []int64
|
||||
}
|
||||
|
||||
// generateFrameName creates a unique frame name from labels
|
||||
// If labels are empty, returns "heatmap"
|
||||
// Otherwise returns "heatmap{label1=value1,label2=value2,...}"
|
||||
func generateFrameName(labels map[string]string) string {
|
||||
if len(labels) == 0 {
|
||||
return "heatmap"
|
||||
}
|
||||
|
||||
// Sort label keys for consistent ordering
|
||||
keys := make([]string, 0, len(labels))
|
||||
for k := range labels {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
|
||||
// Build label string
|
||||
pairs := make([]string, 0, len(labels))
|
||||
for _, k := range keys {
|
||||
pairs = append(pairs, fmt.Sprintf("%s=%s", k, labels[k]))
|
||||
}
|
||||
|
||||
return fmt.Sprintf("heatmap{%s}", strings.Join(pairs, ","))
|
||||
}
|
||||
|
||||
// fillMissingTimeSlices ensures continuous time coverage by filling gaps between data points.
|
||||
// This prevents visual gaps in the heatmap. Points are assumed to be in increasing timestamp order.
|
||||
func fillMissingTimeSlices(points []*Point, stepSeconds float64) []*Point {
|
||||
if len(points) == 0 {
|
||||
return points
|
||||
}
|
||||
|
||||
// Determine the common bucket structure (YMin values)
|
||||
// Find the most complete bucket structure across all points
|
||||
templateYMin := points[0].YMin
|
||||
for _, point := range points {
|
||||
if len(point.YMin) > len(templateYMin) {
|
||||
templateYMin = point.YMin
|
||||
}
|
||||
}
|
||||
|
||||
stepMs := int64(stepSeconds * 1000)
|
||||
filled := make([]*Point, 0, len(points)*2) // Estimate: assume some gaps
|
||||
zeroCounts := make([]int64, len(templateYMin))
|
||||
|
||||
// Process first point, normalizing bucket structure if needed
|
||||
firstPoint := points[0]
|
||||
if len(firstPoint.YMin) < len(templateYMin) {
|
||||
paddedCounts := make([]int64, len(templateYMin))
|
||||
copy(paddedCounts, firstPoint.Counts)
|
||||
filled = append(filled, &Point{
|
||||
Timestamp: firstPoint.Timestamp,
|
||||
YMin: templateYMin,
|
||||
Counts: paddedCounts,
|
||||
})
|
||||
} else {
|
||||
filled = append(filled, firstPoint)
|
||||
}
|
||||
|
||||
// Iterate through remaining points and fill gaps as we find them
|
||||
for i := 1; i < len(points); i++ {
|
||||
prevTimestamp := filled[len(filled)-1].Timestamp
|
||||
currTimestamp := points[i].Timestamp
|
||||
|
||||
// Fill any gaps between previous and current point
|
||||
expectedTimestamp := prevTimestamp + stepMs
|
||||
for expectedTimestamp < currTimestamp {
|
||||
filled = append(filled, &Point{
|
||||
Timestamp: expectedTimestamp,
|
||||
YMin: templateYMin,
|
||||
Counts: append([]int64(nil), zeroCounts...), // Copy to avoid sharing
|
||||
})
|
||||
expectedTimestamp += stepMs
|
||||
}
|
||||
|
||||
// Add current point, normalizing bucket structure if needed
|
||||
currPoint := points[i]
|
||||
if len(currPoint.YMin) < len(templateYMin) {
|
||||
paddedCounts := make([]int64, len(templateYMin))
|
||||
copy(paddedCounts, currPoint.Counts)
|
||||
filled = append(filled, &Point{
|
||||
Timestamp: currTimestamp,
|
||||
YMin: templateYMin,
|
||||
Counts: paddedCounts,
|
||||
})
|
||||
} else {
|
||||
filled = append(filled, currPoint)
|
||||
}
|
||||
}
|
||||
|
||||
return filled
|
||||
}
|
||||
|
||||
// CreateHeatmapFrame converts heatmap points to a DataFrame in HeatmapCells format
|
||||
// This creates a sparse representation where each cell is explicitly defined by:
|
||||
// - xMax: time value (timestamp)
|
||||
// - yMin: bucket minimum value
|
||||
// - yMax: bucket maximum value
|
||||
// - count: number of matches in that bucket
|
||||
// - yLayout: bucket layout (0 for linear buckets)
|
||||
//
|
||||
// Parameters:
|
||||
// - labels: metric labels for the heatmap series
|
||||
// - points: data points in increasing timestamp order (may have gaps in time coverage)
|
||||
// - units: unit string for Y-axis values
|
||||
// - stepSeconds: duration of each time bucket in seconds
|
||||
//
|
||||
// The function ensures continuous time coverage by filling gaps between points with zero counts.
|
||||
func CreateHeatmapFrame(labels map[string]string, points []*Point, units string, stepSeconds float64) *data.Frame {
|
||||
frameName := generateFrameName(labels)
|
||||
frame := data.NewFrame(frameName)
|
||||
frame.Meta = &data.FrameMeta{
|
||||
Type: "heatmap-cells",
|
||||
}
|
||||
|
||||
// Calculate total number of cells across all points
|
||||
totalCells := 0
|
||||
for _, point := range points {
|
||||
totalCells += len(point.Counts)
|
||||
}
|
||||
|
||||
// Create data fields in the order expected by heatmap-cells format
|
||||
// Set interval (in milliseconds) on xMax field so frontend can calculate xMin for bucket boundaries
|
||||
intervalMs := int64(stepSeconds * 1000)
|
||||
frame.Fields = data.Fields{
|
||||
data.NewField("xMax", nil, make([]time.Time, 0, totalCells)).SetConfig(&data.FieldConfig{
|
||||
Interval: float64(intervalMs),
|
||||
}),
|
||||
data.NewField("yMin", nil, make([]float64, 0, totalCells)).SetConfig(&data.FieldConfig{
|
||||
Unit: units,
|
||||
}),
|
||||
data.NewField("yMax", nil, make([]float64, 0, totalCells)).SetConfig(&data.FieldConfig{
|
||||
Unit: units,
|
||||
}),
|
||||
data.NewField("count", labels, make([]int64, 0, totalCells)),
|
||||
data.NewField("yLayout", nil, make([]int8, 0, totalCells)),
|
||||
}
|
||||
|
||||
if totalCells == 0 {
|
||||
return frame
|
||||
}
|
||||
|
||||
// Fill missing time slices and normalize bucket structures
|
||||
points = fillMissingTimeSlices(points, stepSeconds)
|
||||
|
||||
// Populate cells: for each time point, create a cell for each bucket
|
||||
for _, point := range points {
|
||||
timestamp := time.UnixMilli(point.Timestamp)
|
||||
for i := 0; i < len(point.Counts); i++ {
|
||||
// Calculate yMax: for bucket i, yMax is yMin of bucket i+1
|
||||
// For the last bucket, use a large value or calculate based on bucket width
|
||||
var yMax float64
|
||||
if i < len(point.YMin)-1 {
|
||||
yMax = point.YMin[i+1]
|
||||
} else {
|
||||
// For the last bucket, calculate based on the previous bucket width
|
||||
if i > 0 {
|
||||
bucketWidth := point.YMin[i] - point.YMin[i-1]
|
||||
yMax = point.YMin[i] + bucketWidth
|
||||
} else {
|
||||
// Single bucket case: use a reasonable default
|
||||
yMax = point.YMin[i] * 2
|
||||
}
|
||||
}
|
||||
|
||||
frame.AppendRow(
|
||||
timestamp,
|
||||
point.YMin[i],
|
||||
yMax,
|
||||
point.Counts[i],
|
||||
int8(0), // 0 indicates linear bucket layout
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return frame
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package heatmap
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGenerateFrameName(t *testing.T) {
|
||||
t.Run("empty labels returns default name", func(t *testing.T) {
|
||||
name := generateFrameName(map[string]string{})
|
||||
require.Equal(t, "heatmap", name)
|
||||
})
|
||||
|
||||
t.Run("single label", func(t *testing.T) {
|
||||
name := generateFrameName(map[string]string{"service": "api"})
|
||||
require.Equal(t, "heatmap{service=api}", name)
|
||||
})
|
||||
|
||||
t.Run("multiple labels sorted", func(t *testing.T) {
|
||||
name := generateFrameName(map[string]string{
|
||||
"service": "api",
|
||||
"env": "prod",
|
||||
"region": "us-west",
|
||||
})
|
||||
// Labels should be sorted alphabetically
|
||||
require.Equal(t, "heatmap{env=prod,region=us-west,service=api}", name)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreateHeatmapFrame(t *testing.T) {
|
||||
t.Run("creates frame with correct metadata", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: now.UnixMilli(),
|
||||
YMin: []float64{0, 100, 200},
|
||||
Counts: []int64{5, 10, 3},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{"service": "api"}
|
||||
|
||||
frame := CreateHeatmapFrame(labels, points, "ns", 15.0)
|
||||
|
||||
require.NotNil(t, frame)
|
||||
require.Equal(t, "heatmap{service=api}", frame.Name)
|
||||
require.NotNil(t, frame.Meta)
|
||||
require.Equal(t, data.FrameType("heatmap-cells"), frame.Meta.Type)
|
||||
})
|
||||
|
||||
t.Run("creates correct fields structure", func(t *testing.T) {
|
||||
timestamp := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp.UnixMilli(),
|
||||
YMin: []float64{0, 100, 200},
|
||||
Counts: []int64{5, 10, 3},
|
||||
},
|
||||
}
|
||||
|
||||
frame := CreateHeatmapFrame(map[string]string{}, points, "ns", 15.0)
|
||||
|
||||
require.Len(t, frame.Fields, 5)
|
||||
require.Equal(t, "xMax", frame.Fields[0].Name)
|
||||
require.Equal(t, "yMin", frame.Fields[1].Name)
|
||||
require.Equal(t, "yMax", frame.Fields[2].Name)
|
||||
require.Equal(t, "count", frame.Fields[3].Name)
|
||||
require.Equal(t, "yLayout", frame.Fields[4].Name)
|
||||
})
|
||||
|
||||
t.Run("correctly expands multiple time points into cells", func(t *testing.T) {
|
||||
timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
timestamp2 := time.Date(2024, 1, 1, 0, 0, 15, 0, time.UTC) // 15 seconds later (1 step)
|
||||
stepDuration := 15.0 // 15 seconds
|
||||
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp1.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{5, 10},
|
||||
},
|
||||
{
|
||||
Timestamp: timestamp2.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
frame := CreateHeatmapFrame(map[string]string{}, points, "ns", stepDuration)
|
||||
|
||||
// Should create 4 cells total (2 time points × 2 buckets, no gaps to fill)
|
||||
require.Equal(t, 4, frame.Fields[0].Len())
|
||||
require.Equal(t, 4, frame.Fields[1].Len())
|
||||
require.Equal(t, 4, frame.Fields[2].Len())
|
||||
require.Equal(t, 4, frame.Fields[3].Len())
|
||||
require.Equal(t, 4, frame.Fields[4].Len())
|
||||
|
||||
// Check xMax values (timestamps) - compare Unix millis to avoid timezone issues
|
||||
xMaxField := frame.Fields[0]
|
||||
require.Equal(t, timestamp1.UnixMilli(), xMaxField.At(0).(time.Time).UnixMilli())
|
||||
require.Equal(t, timestamp1.UnixMilli(), xMaxField.At(1).(time.Time).UnixMilli())
|
||||
require.Equal(t, timestamp2.UnixMilli(), xMaxField.At(2).(time.Time).UnixMilli())
|
||||
require.Equal(t, timestamp2.UnixMilli(), xMaxField.At(3).(time.Time).UnixMilli())
|
||||
|
||||
// Check yMin values (bucket minimums)
|
||||
yMinField := frame.Fields[1]
|
||||
require.Equal(t, float64(0), yMinField.At(0))
|
||||
require.Equal(t, float64(100), yMinField.At(1))
|
||||
require.Equal(t, float64(0), yMinField.At(2))
|
||||
require.Equal(t, float64(100), yMinField.At(3))
|
||||
|
||||
// Check yMax values (bucket maximums)
|
||||
yMaxField := frame.Fields[2]
|
||||
require.Equal(t, float64(100), yMaxField.At(0)) // yMax for bucket [0-100)
|
||||
require.Equal(t, float64(200), yMaxField.At(1)) // yMax for bucket [100-200)
|
||||
require.Equal(t, float64(100), yMaxField.At(2)) // yMax for bucket [0-100)
|
||||
require.Equal(t, float64(200), yMaxField.At(3)) // yMax for bucket [100-200)
|
||||
|
||||
// Check count values
|
||||
countField := frame.Fields[3]
|
||||
require.Equal(t, int64(5), countField.At(0))
|
||||
require.Equal(t, int64(10), countField.At(1))
|
||||
require.Equal(t, int64(7), countField.At(2))
|
||||
require.Equal(t, int64(12), countField.At(3))
|
||||
|
||||
// Check yLayout values (should all be 0 for linear)
|
||||
yLayoutField := frame.Fields[4]
|
||||
require.Equal(t, int8(0), yLayoutField.At(0))
|
||||
require.Equal(t, int8(0), yLayoutField.At(1))
|
||||
require.Equal(t, int8(0), yLayoutField.At(2))
|
||||
require.Equal(t, int8(0), yLayoutField.At(3))
|
||||
})
|
||||
|
||||
t.Run("attaches labels to count field", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: now.UnixMilli(),
|
||||
YMin: []float64{0},
|
||||
Counts: []int64{5},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{"service": "api", "env": "prod"}
|
||||
|
||||
frame := CreateHeatmapFrame(labels, points, "ns", 15.0)
|
||||
|
||||
countField := frame.Fields[3]
|
||||
require.NotNil(t, countField.Labels)
|
||||
require.Equal(t, "api", countField.Labels["service"])
|
||||
require.Equal(t, "prod", countField.Labels["env"])
|
||||
})
|
||||
|
||||
t.Run("creates unique frame name based on labels", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: now.UnixMilli(),
|
||||
YMin: []float64{0},
|
||||
Counts: []int64{5},
|
||||
},
|
||||
}
|
||||
labels := map[string]string{"service": "api", "env": "prod"}
|
||||
|
||||
frame := CreateHeatmapFrame(labels, points, "ns", 15.0)
|
||||
|
||||
// Frame name should include labels in sorted order
|
||||
require.Equal(t, "heatmap{env=prod,service=api}", frame.Name)
|
||||
})
|
||||
|
||||
t.Run("sets unit on yMin and yMax fields", func(t *testing.T) {
|
||||
now := time.Now()
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: now.UnixMilli(),
|
||||
YMin: []float64{0},
|
||||
Counts: []int64{5},
|
||||
},
|
||||
}
|
||||
|
||||
frame := CreateHeatmapFrame(map[string]string{}, points, "ns", 15.0)
|
||||
|
||||
// yMin field should have units
|
||||
yMinField := frame.Fields[1]
|
||||
require.NotNil(t, yMinField.Config)
|
||||
require.Equal(t, "ns", yMinField.Config.Unit)
|
||||
|
||||
// yMax field should have units
|
||||
yMaxField := frame.Fields[2]
|
||||
require.NotNil(t, yMaxField.Config)
|
||||
require.Equal(t, "ns", yMaxField.Config.Unit)
|
||||
|
||||
// count field should NOT have units (or have empty unit)
|
||||
countField := frame.Fields[3]
|
||||
if countField.Config != nil {
|
||||
require.Empty(t, countField.Config.Unit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles empty points", func(t *testing.T) {
|
||||
frame := CreateHeatmapFrame(map[string]string{}, []*Point{}, "ns", 15.0)
|
||||
|
||||
require.NotNil(t, frame)
|
||||
require.Len(t, frame.Fields, 5)
|
||||
require.Equal(t, 0, frame.Fields[0].Len())
|
||||
require.Equal(t, 0, frame.Fields[1].Len())
|
||||
require.Equal(t, 0, frame.Fields[2].Len())
|
||||
require.Equal(t, 0, frame.Fields[3].Len())
|
||||
require.Equal(t, 0, frame.Fields[4].Len())
|
||||
})
|
||||
|
||||
t.Run("handles varying bucket counts per time point", func(t *testing.T) {
|
||||
timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
timestamp2 := time.Date(2024, 1, 1, 0, 0, 15, 0, time.UTC) // 15 seconds later (1 step)
|
||||
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp1.UnixMilli(),
|
||||
YMin: []float64{0, 100, 200},
|
||||
Counts: []int64{5, 10, 3},
|
||||
},
|
||||
{
|
||||
Timestamp: timestamp2.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
frame := CreateHeatmapFrame(map[string]string{}, points, "ns", 15.0)
|
||||
|
||||
// Should use the most complete bucket structure (3 buckets from first point)
|
||||
// 2 time points × 3 buckets = 6 cells
|
||||
require.Equal(t, 6, frame.Fields[0].Len())
|
||||
})
|
||||
|
||||
t.Run("fills missing time slices with zero counts", func(t *testing.T) {
|
||||
timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
timestamp2 := time.Date(2024, 1, 1, 0, 0, 45, 0, time.UTC) // 45 seconds later (3 steps of 15s)
|
||||
stepDuration := 15.0 // 15 seconds
|
||||
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp1.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{5, 10},
|
||||
},
|
||||
{
|
||||
Timestamp: timestamp2.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
frame := CreateHeatmapFrame(map[string]string{}, points, "ns", stepDuration)
|
||||
|
||||
// Should fill gaps: original 2 points + 2 gap points = 4 points
|
||||
// Each point has 2 buckets, so 4 * 2 = 8 cells total
|
||||
require.Equal(t, 8, frame.Fields[0].Len())
|
||||
|
||||
// Check timestamps are continuous
|
||||
xMaxField := frame.Fields[0]
|
||||
expectedTimestamps := []int64{
|
||||
timestamp1.UnixMilli(), // Original point
|
||||
timestamp1.Add(15 * time.Second).UnixMilli(), // Gap fill
|
||||
timestamp1.Add(30 * time.Second).UnixMilli(), // Gap fill
|
||||
timestamp2.UnixMilli(), // Original point
|
||||
}
|
||||
|
||||
for i, expected := range expectedTimestamps {
|
||||
// Each timestamp should appear twice (once per bucket)
|
||||
require.Equal(t, expected, xMaxField.At(i*2).(time.Time).UnixMilli())
|
||||
require.Equal(t, expected, xMaxField.At(i*2+1).(time.Time).UnixMilli())
|
||||
}
|
||||
|
||||
// Check that gap-filled cells have zero counts
|
||||
countField := frame.Fields[3]
|
||||
require.Equal(t, int64(5), countField.At(0)) // Original
|
||||
require.Equal(t, int64(10), countField.At(1)) // Original
|
||||
require.Equal(t, int64(0), countField.At(2)) // Gap fill
|
||||
require.Equal(t, int64(0), countField.At(3)) // Gap fill
|
||||
require.Equal(t, int64(0), countField.At(4)) // Gap fill
|
||||
require.Equal(t, int64(0), countField.At(5)) // Gap fill
|
||||
require.Equal(t, int64(7), countField.At(6)) // Original
|
||||
require.Equal(t, int64(12), countField.At(7)) // Original
|
||||
})
|
||||
}
|
||||
|
||||
func TestFillMissingTimeSlices(t *testing.T) {
|
||||
t.Run("no gaps returns original points", func(t *testing.T) {
|
||||
timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
timestamp2 := time.Date(2024, 1, 1, 0, 0, 15, 0, time.UTC)
|
||||
stepDuration := 15.0
|
||||
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp1.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{5, 10},
|
||||
},
|
||||
{
|
||||
Timestamp: timestamp2.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
filled := fillMissingTimeSlices(points, stepDuration)
|
||||
|
||||
require.Len(t, filled, 2)
|
||||
require.Equal(t, timestamp1.UnixMilli(), filled[0].Timestamp)
|
||||
require.Equal(t, timestamp2.UnixMilli(), filled[1].Timestamp)
|
||||
})
|
||||
|
||||
t.Run("fills single gap", func(t *testing.T) {
|
||||
timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
timestamp2 := time.Date(2024, 1, 1, 0, 0, 30, 0, time.UTC) // 2 steps later
|
||||
stepDuration := 15.0
|
||||
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp1.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{5, 10},
|
||||
},
|
||||
{
|
||||
Timestamp: timestamp2.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
filled := fillMissingTimeSlices(points, stepDuration)
|
||||
|
||||
require.Len(t, filled, 3)
|
||||
require.Equal(t, timestamp1.UnixMilli(), filled[0].Timestamp)
|
||||
require.Equal(t, timestamp1.Add(15*time.Second).UnixMilli(), filled[1].Timestamp)
|
||||
require.Equal(t, timestamp2.UnixMilli(), filled[2].Timestamp)
|
||||
|
||||
// Check gap point has zero counts
|
||||
require.Equal(t, []int64{0, 0}, filled[1].Counts)
|
||||
require.Equal(t, []float64{0, 100}, filled[1].YMin)
|
||||
})
|
||||
|
||||
t.Run("fills multiple gaps", func(t *testing.T) {
|
||||
timestamp1 := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
timestamp2 := time.Date(2024, 1, 1, 0, 1, 0, 0, time.UTC) // 4 steps later
|
||||
stepDuration := 15.0
|
||||
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp1.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{5, 10},
|
||||
},
|
||||
{
|
||||
Timestamp: timestamp2.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{7, 12},
|
||||
},
|
||||
}
|
||||
|
||||
filled := fillMissingTimeSlices(points, stepDuration)
|
||||
|
||||
require.Len(t, filled, 5)
|
||||
require.Equal(t, timestamp1.UnixMilli(), filled[0].Timestamp)
|
||||
require.Equal(t, timestamp1.Add(15*time.Second).UnixMilli(), filled[1].Timestamp)
|
||||
require.Equal(t, timestamp1.Add(30*time.Second).UnixMilli(), filled[2].Timestamp)
|
||||
require.Equal(t, timestamp1.Add(45*time.Second).UnixMilli(), filled[3].Timestamp)
|
||||
require.Equal(t, timestamp2.UnixMilli(), filled[4].Timestamp)
|
||||
|
||||
// Check all gap points have zero counts
|
||||
for i := 1; i <= 3; i++ {
|
||||
require.Equal(t, []int64{0, 0}, filled[i].Counts)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("handles empty points", func(t *testing.T) {
|
||||
filled := fillMissingTimeSlices([]*Point{}, 15.0)
|
||||
require.Len(t, filled, 0)
|
||||
})
|
||||
|
||||
t.Run("handles single point", func(t *testing.T) {
|
||||
timestamp := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
points := []*Point{
|
||||
{
|
||||
Timestamp: timestamp.UnixMilli(),
|
||||
YMin: []float64{0, 100},
|
||||
Counts: []int64{5, 10},
|
||||
},
|
||||
}
|
||||
|
||||
filled := fillMissingTimeSlices(points, 15.0)
|
||||
|
||||
require.Len(t, filled, 1)
|
||||
require.Equal(t, timestamp.UnixMilli(), filled[0].Timestamp)
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
@@ -36,6 +37,7 @@ type ProfilingClient interface {
|
||||
GetSeries(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, limit *int64, step float64, exemplarType typesv1.ExemplarType) (*SeriesResponse, error)
|
||||
GetProfile(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error)
|
||||
GetSpanProfile(ctx context.Context, profileTypeID string, labelSelector string, spanSelector []string, start int64, end int64, maxNodes *int64) (*ProfileResponse, error)
|
||||
GetHeatmap(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType, limit *int64, includeExemplars bool) (*HeatmapResponse, error)
|
||||
}
|
||||
|
||||
// PyroscopeDatasource is a datasource for querying application performance profiles.
|
||||
|
||||
+15
-2
@@ -19,6 +19,13 @@ const (
|
||||
PyroscopeQueryTypeBoth PyroscopeQueryType = "both"
|
||||
)
|
||||
|
||||
type HeatmapQueryType string
|
||||
|
||||
const (
|
||||
HeatmapQueryTypeIndividual HeatmapQueryType = "individual"
|
||||
HeatmapQueryTypeSpan HeatmapQueryType = "span"
|
||||
)
|
||||
|
||||
type GrafanaPyroscopeDataQuery struct {
|
||||
// Specifies the query label selectors.
|
||||
LabelSelector string `json:"labelSelector"`
|
||||
@@ -34,6 +41,12 @@ type GrafanaPyroscopeDataQuery struct {
|
||||
MaxNodes *int64 `json:"maxNodes,omitempty"`
|
||||
// If set to true, the response will contain annotations
|
||||
Annotations *bool `json:"annotations,omitempty"`
|
||||
// If set to true, exemplars will be requested
|
||||
IncludeExemplars bool `json:"includeExemplars"`
|
||||
// If set to true, heatmap data will be requested
|
||||
IncludeHeatmap bool `json:"includeHeatmap"`
|
||||
// Specifies the type of heatmap query
|
||||
HeatmapType string `json:"heatmapType"`
|
||||
// A unique identifier for the query within the list of targets.
|
||||
// In server side expressions, the refId is used as a variable name to identify results.
|
||||
// By default, the UI will assign A->Z; however setting meaningful names may be useful.
|
||||
@@ -43,8 +56,6 @@ type GrafanaPyroscopeDataQuery struct {
|
||||
// Specify the query flavor
|
||||
// TODO make this required and give it a default
|
||||
QueryType *string `json:"queryType,omitempty"`
|
||||
// If set to true, exemplars will be requested
|
||||
IncludeExemplars bool `json:"includeExemplars"`
|
||||
// For mixed data sources the selected datasource is on the query level.
|
||||
// For non mixed scenarios this is undefined.
|
||||
// TODO find a better way to do this ^ that's friendly to schema
|
||||
@@ -58,5 +69,7 @@ func NewGrafanaPyroscopeDataQuery() *GrafanaPyroscopeDataQuery {
|
||||
LabelSelector: "{}",
|
||||
GroupBy: []string{},
|
||||
IncludeExemplars: false,
|
||||
IncludeHeatmap: false,
|
||||
HeatmapType: "individual",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,9 +55,11 @@ type Point struct {
|
||||
}
|
||||
|
||||
type Exemplar struct {
|
||||
Id string
|
||||
ProfileId string
|
||||
SpanId string
|
||||
Value uint64
|
||||
Timestamp int64
|
||||
Labels []*LabelPair
|
||||
}
|
||||
|
||||
type ProfileResponse struct {
|
||||
@@ -71,6 +73,23 @@ type SeriesResponse struct {
|
||||
Label string
|
||||
}
|
||||
|
||||
type HeatmapPoint struct {
|
||||
Timestamp int64
|
||||
YMin []float64
|
||||
Counts []int64
|
||||
Exemplars []*Exemplar
|
||||
}
|
||||
|
||||
type HeatmapSeries struct {
|
||||
Labels []*LabelPair
|
||||
Points []*HeatmapPoint
|
||||
}
|
||||
|
||||
type HeatmapResponse struct {
|
||||
Series []*HeatmapSeries
|
||||
Units string
|
||||
}
|
||||
|
||||
type PyroscopeClient struct {
|
||||
connectClient querierv1connect.QuerierServiceClient
|
||||
}
|
||||
@@ -150,10 +169,20 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l
|
||||
if len(p.Exemplars) > 0 {
|
||||
points[i].Exemplars = make([]*Exemplar, len(p.Exemplars))
|
||||
for j, e := range p.Exemplars {
|
||||
// Convert API labels to our LabelPair type
|
||||
exemplarLabels := make([]*LabelPair, len(e.Labels))
|
||||
for k, l := range e.Labels {
|
||||
exemplarLabels[k] = &LabelPair{
|
||||
Name: l.Name,
|
||||
Value: l.Value,
|
||||
}
|
||||
}
|
||||
points[i].Exemplars[j] = &Exemplar{
|
||||
Id: e.ProfileId,
|
||||
ProfileId: e.ProfileId,
|
||||
SpanId: e.SpanId,
|
||||
Value: e.Value,
|
||||
Timestamp: e.Timestamp,
|
||||
Labels: exemplarLabels,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -174,6 +203,98 @@ func (c *PyroscopeClient) GetSeries(ctx context.Context, profileTypeID string, l
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) GetHeatmap(ctx context.Context, profileTypeID string, labelSelector string, start int64, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType, limit *int64, includeExemplars bool) (*HeatmapResponse, error) {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetHeatmap", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector)))
|
||||
defer span.End()
|
||||
|
||||
// Determine exemplar type based on includeExemplars flag and query type
|
||||
exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE
|
||||
if includeExemplars {
|
||||
switch queryType {
|
||||
case querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN:
|
||||
exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_SPAN
|
||||
case querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_INDIVIDUAL:
|
||||
exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL
|
||||
}
|
||||
}
|
||||
|
||||
req := connect.NewRequest(&querierv1.SelectHeatmapRequest{
|
||||
ProfileTypeID: profileTypeID,
|
||||
LabelSelector: labelSelector,
|
||||
Start: start,
|
||||
End: end,
|
||||
Step: step,
|
||||
GroupBy: groupBy,
|
||||
QueryType: queryType,
|
||||
Limit: limit,
|
||||
ExemplarType: exemplarType,
|
||||
})
|
||||
|
||||
resp, err := c.connectClient.SelectHeatmap(ctx, req)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, backend.DownstreamErrorf("received error from client while getting heatmap: %w", err)
|
||||
}
|
||||
|
||||
series := make([]*HeatmapSeries, len(resp.Msg.Series))
|
||||
for i, s := range resp.Msg.Series {
|
||||
labels := make([]*LabelPair, len(s.Labels))
|
||||
for j, l := range s.Labels {
|
||||
labels[j] = &LabelPair{
|
||||
Name: l.Name,
|
||||
Value: l.Value,
|
||||
}
|
||||
}
|
||||
|
||||
points := make([]*HeatmapPoint, len(s.Slots))
|
||||
for j, slot := range s.Slots {
|
||||
// Convert []int32 to []int64
|
||||
counts := make([]int64, len(slot.Counts))
|
||||
for k, c := range slot.Counts {
|
||||
counts[k] = int64(c)
|
||||
}
|
||||
|
||||
// Process exemplars if present
|
||||
exemplars := make([]*Exemplar, len(slot.Exemplars))
|
||||
for k, e := range slot.Exemplars {
|
||||
// Convert API labels to our LabelPair type
|
||||
exemplarLabels := make([]*LabelPair, len(e.Labels))
|
||||
for i, l := range e.Labels {
|
||||
exemplarLabels[i] = &LabelPair{
|
||||
Name: l.Name,
|
||||
Value: l.Value,
|
||||
}
|
||||
}
|
||||
exemplars[k] = &Exemplar{
|
||||
ProfileId: e.ProfileId,
|
||||
SpanId: e.SpanId,
|
||||
Value: e.Value,
|
||||
Timestamp: e.Timestamp,
|
||||
Labels: exemplarLabels,
|
||||
}
|
||||
}
|
||||
|
||||
points[j] = &HeatmapPoint{
|
||||
Timestamp: slot.Timestamp,
|
||||
YMin: slot.YMin,
|
||||
Counts: counts,
|
||||
Exemplars: exemplars,
|
||||
}
|
||||
}
|
||||
|
||||
series[i] = &HeatmapSeries{
|
||||
Labels: labels,
|
||||
Points: points,
|
||||
}
|
||||
}
|
||||
|
||||
return &HeatmapResponse{
|
||||
Series: series,
|
||||
Units: getUnits(profileTypeID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PyroscopeClient) GetProfile(ctx context.Context, profileTypeID, labelSelector string, start, end int64, maxNodes *int64) (*ProfileResponse, error) {
|
||||
ctx, span := tracing.DefaultTracer().Start(ctx, "datasource.pyroscope.GetProfile", trace.WithAttributes(attribute.String("profileTypeID", profileTypeID), attribute.String("labelSelector", labelSelector)))
|
||||
defer span.End()
|
||||
|
||||
@@ -40,7 +40,7 @@ func Test_PyroscopeClient(t *testing.T) {
|
||||
|
||||
series := &SeriesResponse{
|
||||
Series: []*Series{
|
||||
{Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{Id: "id1", Value: 3, Timestamp: 1000}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{Id: "id2", Value: 1, Timestamp: 2000}}}}},
|
||||
{Labels: []*LabelPair{{Name: "foo", Value: "bar"}}, Points: []*Point{{Timestamp: int64(1000), Value: 30, Exemplars: []*Exemplar{{ProfileId: "id1", SpanId: "", Value: 3, Timestamp: 1000, Labels: []*LabelPair{}}}}, {Timestamp: int64(2000), Value: 10, Exemplars: []*Exemplar{{ProfileId: "id2", SpanId: "", Value: 1, Timestamp: 2000, Labels: []*LabelPair{}}}}}},
|
||||
},
|
||||
Units: "short",
|
||||
Label: "alloc_objects",
|
||||
@@ -158,6 +158,22 @@ func (f *FakePyroscopeConnectClient) SelectSeries(ctx context.Context, req *conn
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakePyroscopeConnectClient) SelectHeatmap(ctx context.Context, req *connect.Request[querierv1.SelectHeatmapRequest]) (*connect.Response[querierv1.SelectHeatmapResponse], error) {
|
||||
f.Req = req
|
||||
return &connect.Response[querierv1.SelectHeatmapResponse]{
|
||||
Msg: &querierv1.SelectHeatmapResponse{
|
||||
Series: []*typesv1.HeatmapSeries{
|
||||
{
|
||||
Labels: []*typesv1.LabelPair{{Name: "foo", Value: "bar"}},
|
||||
Slots: []*typesv1.HeatmapSlot{
|
||||
{Timestamp: int64(1000), YMin: []float64{0, 100, 200}, Counts: []int32{5, 10, 3}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakePyroscopeConnectClient) SelectMergeProfile(ctx context.Context, c *connect.Request[querierv1.SelectMergeProfileRequest]) (*connect.Response[googlev1.Profile], error) {
|
||||
panic("implement me")
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/live"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/exemplar"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/heatmap"
|
||||
"github.com/xlab/treeprint"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/kinds/dataquery"
|
||||
|
||||
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
)
|
||||
|
||||
@@ -41,6 +43,7 @@ const (
|
||||
queryTypeBoth = string(dataquery.PyroscopeQueryTypeBoth)
|
||||
|
||||
exemplarsFeatureToggle = "profilesExemplars"
|
||||
heatmapFeatureToggle = "profilesHeatmap"
|
||||
)
|
||||
|
||||
var identityTransformation = func(value float64) float64 { return value }
|
||||
@@ -84,6 +87,88 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
|
||||
logger.Error("Failed to parse the MinStep using default", "MinStep", dsJson.MinStep, "function", logEntrypoint())
|
||||
}
|
||||
}
|
||||
|
||||
// Heatmap handling
|
||||
if qm.IncludeHeatmap && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(heatmapFeatureToggle) {
|
||||
heatmapType := querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_INDIVIDUAL
|
||||
if qm.HeatmapType == "span" {
|
||||
heatmapType = querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN
|
||||
}
|
||||
|
||||
// Check if exemplars should be included
|
||||
includeExemplars := qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle)
|
||||
|
||||
stepDuration := math.Max(query.Interval.Seconds(), parsedInterval.Seconds())
|
||||
heatmapResp, err := d.client.GetHeatmap(
|
||||
gCtx,
|
||||
profileTypeId,
|
||||
labelSelector,
|
||||
query.TimeRange.From.UnixMilli(),
|
||||
query.TimeRange.To.UnixMilli(),
|
||||
qm.GroupBy,
|
||||
stepDuration,
|
||||
heatmapType,
|
||||
qm.Limit,
|
||||
includeExemplars,
|
||||
)
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
logger.Error("Querying SelectHeatmap()", "err", err, "function", logEntrypoint())
|
||||
return err
|
||||
}
|
||||
|
||||
responseMutex.Lock()
|
||||
defer responseMutex.Unlock()
|
||||
|
||||
// Determine exemplar type based on heatmap type
|
||||
exemplarType := exemplar.ExemplarTypeProfile
|
||||
if heatmapType == querierv1.HeatmapQueryType_HEATMAP_QUERY_TYPE_SPAN {
|
||||
exemplarType = exemplar.ExemplarTypeSpan
|
||||
}
|
||||
|
||||
for _, series := range heatmapResp.Series {
|
||||
labels := make(map[string]string)
|
||||
for _, label := range series.Labels {
|
||||
labels[label.Name] = label.Value
|
||||
}
|
||||
// Convert HeatmapPoint to heatmap.Point and collect exemplars
|
||||
points := make([]*heatmap.Point, len(series.Points))
|
||||
exemplars := []*exemplar.Exemplar{}
|
||||
for i, p := range series.Points {
|
||||
points[i] = &heatmap.Point{
|
||||
Timestamp: p.Timestamp,
|
||||
YMin: p.YMin,
|
||||
Counts: p.Counts,
|
||||
}
|
||||
// Collect exemplars from this point
|
||||
for _, e := range p.Exemplars {
|
||||
// Convert exemplar labels from slice to map
|
||||
exemplarLabels := make(map[string]string)
|
||||
for _, l := range e.Labels {
|
||||
exemplarLabels[l.Name] = l.Value
|
||||
}
|
||||
exemplars = append(exemplars, &exemplar.Exemplar{
|
||||
ProfileId: e.ProfileId,
|
||||
SpanId: e.SpanId,
|
||||
Value: float64(e.Value),
|
||||
Timestamp: e.Timestamp,
|
||||
Labels: exemplarLabels,
|
||||
})
|
||||
}
|
||||
}
|
||||
heatmapFrame := heatmap.CreateHeatmapFrame(labels, points, heatmapResp.Units, stepDuration)
|
||||
response.Frames = append(response.Frames, heatmapFrame)
|
||||
|
||||
// Create exemplar frame if we have exemplars
|
||||
if len(exemplars) > 0 {
|
||||
exemplarFrame := exemplar.CreateExemplarFrame(labels, exemplars, exemplarType, heatmapResp.Units)
|
||||
response.Frames = append(response.Frames, exemplarFrame)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
exemplarType := typesv1.ExemplarType_EXEMPLAR_TYPE_NONE
|
||||
if qm.IncludeExemplars && backend.GrafanaConfigFromContext(ctx).FeatureToggles().IsEnabled(exemplarsFeatureToggle) {
|
||||
exemplarType = typesv1.ExemplarType_EXEMPLAR_TYPE_INDIVIDUAL
|
||||
@@ -107,6 +192,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
|
||||
}
|
||||
// add the frames to the response.
|
||||
responseMutex.Lock()
|
||||
defer responseMutex.Unlock()
|
||||
withAnnotations := qm.Annotations != nil && *qm.Annotations
|
||||
stepDuration := math.Max(query.Interval.Seconds(), parsedInterval.Seconds())
|
||||
frames, err := seriesToDataFrames(seriesResp, withAnnotations, stepDuration, profileTypeId)
|
||||
@@ -117,7 +203,7 @@ func (d *PyroscopeDatasource) query(ctx context.Context, pCtx backend.PluginCont
|
||||
return err
|
||||
}
|
||||
response.Frames = append(response.Frames, frames...)
|
||||
responseMutex.Unlock()
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
@@ -553,10 +639,17 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
}
|
||||
}
|
||||
for _, e := range point.Exemplars {
|
||||
// Convert exemplar labels from slice to map
|
||||
exemplarLabels := make(map[string]string)
|
||||
for _, l := range e.Labels {
|
||||
exemplarLabels[l.Name] = l.Value
|
||||
}
|
||||
exemplars = append(exemplars, &exemplar.Exemplar{
|
||||
Id: e.Id,
|
||||
ProfileId: e.ProfileId,
|
||||
SpanId: e.SpanId,
|
||||
Value: transformation(float64(e.Value)),
|
||||
Timestamp: e.Timestamp,
|
||||
Labels: exemplarLabels,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -565,7 +658,8 @@ func seriesToDataFrames(resp *SeriesResponse, withAnnotations bool, stepDuration
|
||||
frames = append(frames, frame)
|
||||
|
||||
if len(exemplars) > 0 {
|
||||
frame := exemplar.CreateExemplarFrame(labels, exemplars)
|
||||
// Series queries always use individual profiles
|
||||
frame := exemplar.CreateExemplarFrame(labels, exemplars, exemplar.ExemplarTypeProfile, displayUnit)
|
||||
frames = append(frames, frame)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/grafana/grafana-plugin-sdk-go/backend"
|
||||
"github.com/grafana/grafana-plugin-sdk-go/data"
|
||||
|
||||
querierv1 "github.com/grafana/pyroscope/api/gen/proto/go/querier/v1"
|
||||
typesv1 "github.com/grafana/pyroscope/api/gen/proto/go/types/v1"
|
||||
|
||||
"github.com/grafana/grafana/pkg/tsdb/grafana-pyroscope-datasource/annotation"
|
||||
@@ -660,3 +661,17 @@ func (f *FakeClient) GetSeries(ctx context.Context, profileTypeID, labelSelector
|
||||
Label: "test",
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *FakeClient) GetHeatmap(ctx context.Context, profileTypeID, labelSelector string, start, end int64, groupBy []string, step float64, queryType querierv1.HeatmapQueryType, limit *int64, includeExemplars bool) (*HeatmapResponse, error) {
|
||||
return &HeatmapResponse{
|
||||
Series: []*HeatmapSeries{
|
||||
{
|
||||
Labels: []*LabelPair{{Name: "foo", Value: "bar"}},
|
||||
Points: []*HeatmapPoint{
|
||||
{Timestamp: start, YMin: []float64{0, 100, 200}, Counts: []int64{5, 10, 3}},
|
||||
},
|
||||
},
|
||||
},
|
||||
Units: "nanoseconds",
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ const dummyProps: Props = {
|
||||
showCustom: true,
|
||||
showNodeGraph: true,
|
||||
showFlameGraph: true,
|
||||
showHeatmap: false,
|
||||
splitOpen: jest.fn(),
|
||||
splitted: false,
|
||||
eventBus: new EventBusSrv(),
|
||||
|
||||
@@ -45,6 +45,7 @@ import { CustomContainer } from './CustomContainer';
|
||||
import { ExploreToolbar } from './ExploreToolbar';
|
||||
import { FlameGraphExploreContainer } from './FlameGraph/FlameGraphExploreContainer';
|
||||
import { GraphContainer } from './Graph/GraphContainer';
|
||||
import { HeatmapContainer } from './Heatmap/HeatmapContainer';
|
||||
import LogsContainer from './Logs/LogsContainer';
|
||||
import { LogsSamplePanel } from './Logs/LogsSamplePanel';
|
||||
import { NoData } from './NoData';
|
||||
@@ -409,6 +410,27 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
);
|
||||
}
|
||||
|
||||
renderHeatmapPanel(width: number) {
|
||||
const { queryResponse, timeZone } = this.props;
|
||||
|
||||
return (
|
||||
<ContentOutlineItem panelId="Heatmap" title={t('explore.explore.title-heatmap', 'Heatmap')} icon="fire">
|
||||
<HeatmapContainer
|
||||
data={queryResponse.heatmapFrames}
|
||||
annotations={queryResponse.annotations}
|
||||
height={400}
|
||||
width={width}
|
||||
timeRange={queryResponse.timeRange}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={this.onUpdateTimeRange}
|
||||
splitOpenFn={this.onSplitOpen('heatmap')}
|
||||
loadingState={queryResponse.state}
|
||||
eventBus={this.graphEventBus}
|
||||
/>
|
||||
</ContentOutlineItem>
|
||||
);
|
||||
}
|
||||
|
||||
renderTablePanel(width: number) {
|
||||
const { exploreId, timeZone, eventBus } = this.props;
|
||||
return (
|
||||
@@ -587,6 +609,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
showTrace,
|
||||
showCustom,
|
||||
showNodeGraph,
|
||||
showHeatmap,
|
||||
showFlameGraph,
|
||||
showLogsSample,
|
||||
correlationEditorDetails,
|
||||
@@ -611,6 +634,7 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
queryResponse.rawPrometheusFrames,
|
||||
queryResponse.traceFrames,
|
||||
queryResponse.customFrames,
|
||||
queryResponse.heatmapFrames,
|
||||
].every((e) => e.length === 0);
|
||||
|
||||
let correlationsBox = undefined;
|
||||
@@ -721,6 +745,11 @@ export class Explore extends PureComponent<Props, ExploreState> {
|
||||
{this.renderGraphPanel(width)}
|
||||
</ErrorBoundaryAlert>
|
||||
)}
|
||||
{showHeatmap && (
|
||||
<ErrorBoundaryAlert boundaryName="explore-heatmap-panel">
|
||||
{this.renderHeatmapPanel(width)}
|
||||
</ErrorBoundaryAlert>
|
||||
)}
|
||||
{showRawPrometheus && (
|
||||
<ErrorBoundaryAlert boundaryName="explore-raw-prometheus">
|
||||
{this.renderRawPrometheus(width)}
|
||||
@@ -808,6 +837,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
queryResponse,
|
||||
showNodeGraph,
|
||||
showFlameGraph,
|
||||
showHeatmap,
|
||||
showRawPrometheus,
|
||||
supplementaryQueries,
|
||||
correlationEditorHelperData,
|
||||
@@ -836,6 +866,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
|
||||
showTrace,
|
||||
showCustom,
|
||||
showNodeGraph,
|
||||
showHeatmap,
|
||||
showRawPrometheus,
|
||||
showFlameGraph,
|
||||
splitted: isSplit(state),
|
||||
|
||||
@@ -62,6 +62,7 @@ const setup = (propOverrides = {}) => {
|
||||
customFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
graphResult: null,
|
||||
logsResult: null,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
DataFrame,
|
||||
EventBus,
|
||||
LoadingState,
|
||||
SplitOpen,
|
||||
TimeRange,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { t } from '@grafana/i18n';
|
||||
import { PanelChrome, PanelChromeProps } from '@grafana/ui';
|
||||
|
||||
import { HeatmapExploreContainer } from './HeatmapExploreContainer';
|
||||
|
||||
// Fixed height for each heatmap panel
|
||||
const HEATMAP_HEIGHT = 400;
|
||||
|
||||
interface Props extends Pick<PanelChromeProps, 'statusMessage'> {
|
||||
width: number;
|
||||
height: number;
|
||||
data: DataFrame[];
|
||||
annotations?: DataFrame[];
|
||||
eventBus: EventBus;
|
||||
timeRange: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
onChangeTime: (absoluteRange: AbsoluteTimeRange) => void;
|
||||
splitOpenFn: SplitOpen;
|
||||
loadingState: LoadingState;
|
||||
}
|
||||
|
||||
export const HeatmapContainer = ({
|
||||
data,
|
||||
annotations,
|
||||
eventBus,
|
||||
width,
|
||||
timeRange,
|
||||
timeZone,
|
||||
onChangeTime,
|
||||
splitOpenFn,
|
||||
loadingState,
|
||||
statusMessage,
|
||||
}: Props) => {
|
||||
// Backend already respects query limit parameter, so render all frames
|
||||
return (
|
||||
<>
|
||||
{data.map((frame, index) => (
|
||||
<PanelChrome
|
||||
key={frame.name || `heatmap-${index}`}
|
||||
title={frame.name || t('heatmap.container.title', 'Heatmap')}
|
||||
width={width}
|
||||
height={HEATMAP_HEIGHT}
|
||||
loadingState={loadingState}
|
||||
statusMessage={statusMessage}
|
||||
>
|
||||
{(innerWidth, innerHeight) => (
|
||||
<HeatmapExploreContainer
|
||||
data={[frame]}
|
||||
annotations={annotations}
|
||||
height={innerHeight}
|
||||
width={innerWidth}
|
||||
timeRange={timeRange}
|
||||
timeZone={timeZone}
|
||||
onChangeTime={onChangeTime}
|
||||
splitOpenFn={splitOpenFn}
|
||||
loadingState={loadingState}
|
||||
eventBus={eventBus}
|
||||
/>
|
||||
)}
|
||||
</PanelChrome>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createContext, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
AbsoluteTimeRange,
|
||||
DataFrame,
|
||||
DataLinksContext,
|
||||
EventBus,
|
||||
LoadingState,
|
||||
SplitOpen,
|
||||
TimeRange,
|
||||
TimeZone,
|
||||
} from '@grafana/data';
|
||||
import { PanelRenderer } from '@grafana/runtime';
|
||||
import { TooltipDisplayMode } from '@grafana/schema';
|
||||
|
||||
import { useExploreDataLinkPostProcessor } from '../hooks/useExploreDataLinkPostProcessor';
|
||||
|
||||
// Context to provide splitOpen function to components that need to manually construct explore links
|
||||
export const ExploreSplitOpenContext = createContext<{ splitOpen?: SplitOpen; timeRange?: TimeRange }>({});
|
||||
|
||||
interface Props {
|
||||
data: DataFrame[];
|
||||
annotations?: DataFrame[];
|
||||
height: number;
|
||||
width: number;
|
||||
timeRange: TimeRange;
|
||||
timeZone: TimeZone;
|
||||
loadingState: LoadingState;
|
||||
splitOpenFn: SplitOpen;
|
||||
onChangeTime?: (timeRange: AbsoluteTimeRange) => void;
|
||||
eventBus: EventBus;
|
||||
}
|
||||
|
||||
export function HeatmapExploreContainer({
|
||||
data,
|
||||
annotations,
|
||||
height,
|
||||
width,
|
||||
timeZone,
|
||||
timeRange,
|
||||
onChangeTime,
|
||||
loadingState,
|
||||
splitOpenFn,
|
||||
eventBus,
|
||||
}: Props) {
|
||||
const dataLinkPostProcessor = useExploreDataLinkPostProcessor(splitOpenFn, timeRange);
|
||||
|
||||
const panelOptions = useMemo(
|
||||
() => ({
|
||||
calculate: false, // Data already in heatmap-cells format
|
||||
color: {
|
||||
scheme: 'Spectral',
|
||||
steps: 64,
|
||||
},
|
||||
tooltip: {
|
||||
mode: TooltipDisplayMode.Single,
|
||||
yHistogram: true,
|
||||
showColorScale: true,
|
||||
},
|
||||
legend: {
|
||||
show: true,
|
||||
},
|
||||
exemplars: {
|
||||
color: 'rgba(31, 120, 193, 0.7)', // Standard Grafana blue to match graph series
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
return (
|
||||
<DataLinksContext.Provider value={{ dataLinkPostProcessor }}>
|
||||
<ExploreSplitOpenContext.Provider value={{ splitOpen: splitOpenFn, timeRange }}>
|
||||
<PanelRenderer
|
||||
data={{
|
||||
series: data,
|
||||
annotations,
|
||||
timeRange,
|
||||
state: loadingState,
|
||||
}}
|
||||
pluginId="heatmap"
|
||||
title=""
|
||||
width={width}
|
||||
height={height}
|
||||
onChangeTimeRange={onChangeTime}
|
||||
timeZone={timeZone}
|
||||
options={panelOptions}
|
||||
/>
|
||||
</ExploreSplitOpenContext.Provider>
|
||||
</DataLinksContext.Provider>
|
||||
);
|
||||
}
|
||||
+2
@@ -133,6 +133,8 @@ export default function SpanFlameGraph(props: SpanFlameGraphProps) {
|
||||
uid: profilesDataSourceSettings.uid,
|
||||
},
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual' as const,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -100,6 +100,7 @@ function createEmptyQueryResponse(): ExplorePanelData {
|
||||
traceFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
customFrames: [],
|
||||
tableFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
|
||||
@@ -25,6 +25,7 @@ export const mockExplorePanelData = (props?: MockProps): Observable<ExplorePanel
|
||||
nodeGraphFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
rawPrometheusResult: null,
|
||||
heatmapFrames: [],
|
||||
series: [],
|
||||
state: LoadingState.Done,
|
||||
tableFrames: [],
|
||||
|
||||
@@ -1324,6 +1324,7 @@ const processQueryResponse = (state: ExploreItemState, action: PayloadAction<Que
|
||||
flameGraphFrames,
|
||||
rawPrometheusFrames,
|
||||
customFrames,
|
||||
heatmapFrames,
|
||||
} = response;
|
||||
|
||||
if (error) {
|
||||
@@ -1353,6 +1354,7 @@ const processQueryResponse = (state: ExploreItemState, action: PayloadAction<Que
|
||||
showNodeGraph: !!nodeGraphFrames.length,
|
||||
showRawPrometheus: !!rawPrometheusFrames.length,
|
||||
showFlameGraph: !!flameGraphFrames.length,
|
||||
showHeatmap: !!heatmapFrames.length,
|
||||
showCustom: !!customFrames?.length,
|
||||
clearedAtIndex: state.isLive ? state.clearedAtIndex : null,
|
||||
};
|
||||
|
||||
@@ -88,6 +88,7 @@ export const createEmptyQueryResponse = (): ExplorePanelData => ({
|
||||
traceFrames: [],
|
||||
nodeGraphFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
customFrames: [],
|
||||
tableFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
|
||||
@@ -108,6 +108,7 @@ const createExplorePanelData = (args: Partial<ExplorePanelData>): ExplorePanelDa
|
||||
nodeGraphFrames: [],
|
||||
customFrames: [],
|
||||
flameGraphFrames: [],
|
||||
heatmapFrames: [],
|
||||
rawPrometheusFrames: [],
|
||||
rawPrometheusResult: null,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
|
||||
const traceFrames: DataFrame[] = [];
|
||||
const nodeGraphFrames: DataFrame[] = [];
|
||||
const flameGraphFrames: DataFrame[] = [];
|
||||
const heatmapFrames: DataFrame[] = [];
|
||||
const customFrames: DataFrame[] = [];
|
||||
|
||||
for (const frame of data.series) {
|
||||
@@ -44,6 +45,13 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
|
||||
customFrames.push(frame);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for heatmap-cells type BEFORE the switch statement
|
||||
if (frame.meta?.type === 'heatmap-cells') {
|
||||
heatmapFrames.push(frame);
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (frame.meta?.preferredVisualisationType) {
|
||||
case 'logs':
|
||||
logsFrames.push(frame);
|
||||
@@ -87,6 +95,7 @@ export const decorateWithFrameTypeMetadata = (data: PanelData): ExplorePanelData
|
||||
customFrames,
|
||||
flameGraphFrames,
|
||||
rawPrometheusFrames,
|
||||
heatmapFrames,
|
||||
graphResult: null,
|
||||
tableResult: null,
|
||||
logsResult: null,
|
||||
|
||||
@@ -125,7 +125,7 @@ export function InspectJSONTab({ panel, dashboard, data, onClose }: Props) {
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div className={styles.toolbar} data-testid={selectors.components.PanelInspector.Json.content}>
|
||||
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1" noMargin>
|
||||
<Field label={t('dashboard.inspect-json.select-source', 'Select source')} className="flex-grow-1">
|
||||
<Select
|
||||
inputId="select-source-dropdown"
|
||||
options={jsonOptions}
|
||||
|
||||
@@ -27,7 +27,7 @@ export const getPanelInspectorStyles2 = (theme: GrafanaTheme2) => {
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
flexGrow: 0,
|
||||
alignItems: 'flex-end',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'flex-end',
|
||||
marginBottom: theme.v1.spacing.sm,
|
||||
}),
|
||||
|
||||
+4
@@ -34,6 +34,8 @@ describe('QueryEditor', () => {
|
||||
maxNodes: 1000,
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -127,6 +129,8 @@ function setup(options: { props: Partial<Props> } = { props: {} }) {
|
||||
groupBy: [],
|
||||
limit: 42,
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
}}
|
||||
datasource={setupDs()}
|
||||
onChange={onChange}
|
||||
|
||||
+28
@@ -5,6 +5,7 @@ import { CoreApp, GrafanaTheme2, SelectableValue } from '@grafana/data';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { useStyles2, RadioButtonGroup, MultiSelect, Input, InlineSwitch } from '@grafana/ui';
|
||||
|
||||
import { HeatmapQueryType } from '../dataquery.gen';
|
||||
import { Query } from '../types';
|
||||
|
||||
import { EditorField } from './EditorField';
|
||||
@@ -60,6 +61,9 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
if (query.includeExemplars) {
|
||||
collapsedInfo.push(`With exemplars`);
|
||||
}
|
||||
if (query.includeHeatmap) {
|
||||
collapsedInfo.push(`Heatmap: ${query.heatmapType || 'individual'}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={0} direction="column">
|
||||
@@ -156,6 +160,30 @@ export function QueryOptions({ query, onQueryChange, app, labels }: Props) {
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
{config.featureToggles.profilesHeatmap && (
|
||||
<>
|
||||
<EditorField label={'Heatmap'} tooltip={<>Include heatmap visualization of profile data over time.</>}>
|
||||
<InlineSwitch
|
||||
value={query.includeHeatmap || false}
|
||||
onChange={(event: React.SyntheticEvent<HTMLInputElement>) => {
|
||||
onQueryChange({ ...query, includeHeatmap: event.currentTarget.checked });
|
||||
}}
|
||||
/>
|
||||
</EditorField>
|
||||
{query.includeHeatmap && (
|
||||
<EditorField label={'Heatmap Type'} tooltip={<>Select the type of heatmap aggregation.</>}>
|
||||
<RadioButtonGroup
|
||||
options={[
|
||||
{ value: 'individual', label: 'Individual', description: 'Show individual profile samples' },
|
||||
{ value: 'span', label: 'Span', description: 'Aggregate by span duration' },
|
||||
]}
|
||||
value={query.heatmapType || 'individual'}
|
||||
onChange={(value) => onQueryChange({ ...query, heatmapType: value as HeatmapQueryType })}
|
||||
/>
|
||||
</EditorField>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</QueryOptionGroup>
|
||||
</Stack>
|
||||
|
||||
@@ -46,6 +46,11 @@ composableKinds: DataQuery: {
|
||||
annotations?: bool
|
||||
// If set to true, exemplars will be requested
|
||||
includeExemplars: bool | *false
|
||||
// If set to true, heatmap data will be requested
|
||||
includeHeatmap: bool | *false
|
||||
// Specifies the type of heatmap query
|
||||
heatmapType: #HeatmapQueryType | *"individual"
|
||||
#HeatmapQueryType: "individual" | "span" @cuetsy(kind="type")
|
||||
}
|
||||
}]
|
||||
lenses: []
|
||||
|
||||
@@ -14,6 +14,8 @@ export type PyroscopeQueryType = ('metrics' | 'profile' | 'both');
|
||||
|
||||
export const defaultPyroscopeQueryType: PyroscopeQueryType = 'both';
|
||||
|
||||
export type HeatmapQueryType = ('individual' | 'span');
|
||||
|
||||
export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
/**
|
||||
* If set to true, the response will contain annotations
|
||||
@@ -23,10 +25,18 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
* Allows to group the results.
|
||||
*/
|
||||
groupBy: Array<string>;
|
||||
/**
|
||||
* Specifies the type of heatmap query
|
||||
*/
|
||||
heatmapType: (HeatmapQueryType | 'individual');
|
||||
/**
|
||||
* If set to true, exemplars will be requested
|
||||
*/
|
||||
includeExemplars: boolean;
|
||||
/**
|
||||
* If set to true, heatmap data will be requested
|
||||
*/
|
||||
includeHeatmap: boolean;
|
||||
/**
|
||||
* Specifies the query label selectors.
|
||||
*/
|
||||
@@ -51,7 +61,9 @@ export interface GrafanaPyroscopeDataQuery extends common.DataQuery {
|
||||
|
||||
export const defaultGrafanaPyroscopeDataQuery: Partial<GrafanaPyroscopeDataQuery> = {
|
||||
groupBy: [],
|
||||
heatmapType: 'individual',
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
labelSelector: '{}',
|
||||
spanSelector: [],
|
||||
};
|
||||
|
||||
@@ -44,6 +44,8 @@ describe('Pyroscope data source', () => {
|
||||
profileTypeId: '',
|
||||
groupBy: [''],
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
},
|
||||
]);
|
||||
expect(queries).toMatchObject([
|
||||
@@ -120,6 +122,8 @@ describe('normalizeQuery', () => {
|
||||
profileTypeId: 'cpu',
|
||||
refId: '',
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
});
|
||||
expect(normalized).toMatchObject({
|
||||
labelSelector: '{app="myapp"}',
|
||||
@@ -148,6 +152,8 @@ const defaultQuery = (query: Partial<Query>): Query => {
|
||||
profileTypeId: '',
|
||||
queryType: defaultPyroscopeQueryType,
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
...query,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -130,6 +130,8 @@ export class PyroscopeDataSource extends DataSourceWithBackend<Query, PyroscopeD
|
||||
profileTypeId: '',
|
||||
groupBy: [],
|
||||
includeExemplars: false,
|
||||
includeHeatmap: false,
|
||||
heatmapType: 'individual',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactElement, useEffect, useRef, useState, ReactNode } from 'react';
|
||||
import { ReactElement, useContext, useEffect, useRef, useState, ReactNode } from 'react';
|
||||
import * as React from 'react';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
PanelData,
|
||||
} from '@grafana/data';
|
||||
import { HeatmapCellLayout } from '@grafana/schema';
|
||||
import { TooltipDisplayMode, useTheme2 } from '@grafana/ui';
|
||||
import { TextLink, TooltipDisplayMode, useTheme2 } from '@grafana/ui';
|
||||
import {
|
||||
VizTooltipContent,
|
||||
VizTooltipFooter,
|
||||
@@ -25,9 +25,8 @@ import {
|
||||
} from '@grafana/ui/internal';
|
||||
import { ColorScale } from 'app/core/components/ColorScale/ColorScale';
|
||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||
import { ExploreSplitOpenContext } from 'app/features/explore/Heatmap/HeatmapExploreContainer';
|
||||
import { readHeatmapRowsCustomMeta } from 'app/features/transformers/calculateHeatmap/heatmap';
|
||||
import { getDisplayValuesAndLinks } from 'app/features/visualization/data-hover/DataHoverView';
|
||||
import { ExemplarTooltip } from 'app/features/visualization/data-hover/ExemplarTooltip';
|
||||
|
||||
import { getDataLinks, getFieldActions } from '../status-history/utils';
|
||||
import { isTooltipScrollable } from '../timeseries/utils';
|
||||
@@ -59,25 +58,194 @@ interface HeatmapTooltipProps {
|
||||
canExecuteActions?: boolean;
|
||||
}
|
||||
|
||||
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
|
||||
if (props.seriesIdx === 2) {
|
||||
const dispValuesAndLinks = getDisplayValuesAndLinks(props.dataRef.current!.exemplars!, props.dataIdxs[2]!);
|
||||
// Custom exemplar tooltip that renders field values with inline links
|
||||
const HeatmapExemplarTooltip = ({
|
||||
exemplarFrame,
|
||||
rowIndex,
|
||||
isPinned,
|
||||
maxHeight,
|
||||
}: {
|
||||
exemplarFrame: PanelData['series'][0];
|
||||
rowIndex: number;
|
||||
isPinned: boolean;
|
||||
maxHeight?: number;
|
||||
}) => {
|
||||
const { splitOpen, timeRange } = useContext(ExploreSplitOpenContext);
|
||||
|
||||
if (dispValuesAndLinks == null) {
|
||||
return null;
|
||||
// Get visible fields (excluding private labels starting with __)
|
||||
const visibleFields = exemplarFrame.fields.filter(
|
||||
(f) => !Boolean(f.config.custom?.hideFrom?.tooltip) && !f.name.startsWith('__')
|
||||
);
|
||||
|
||||
if (visibleFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Find time field
|
||||
const timeField = visibleFields.find((f) => f.name === 'Time');
|
||||
const timeValue = timeField
|
||||
? formattedValueToString(
|
||||
timeField.display ? timeField.display(timeField.values[rowIndex]) : { text: `${timeField.values[rowIndex]}` }
|
||||
)
|
||||
: '';
|
||||
|
||||
// Prepare fields to display (excluding time)
|
||||
const displayFields = visibleFields.filter((f) => f !== timeField);
|
||||
|
||||
const theme = useTheme2();
|
||||
|
||||
// Helper to check if this is a Span ID field (not Profile ID)
|
||||
const isSpanIdField = (field: Field) => {
|
||||
return field.config.displayName === 'Span ID';
|
||||
};
|
||||
|
||||
// Helper to check if a label name needs quoting
|
||||
// Label names with non-alphanumeric characters (except _) need to be quoted
|
||||
const needsQuoting = (labelName: string): boolean => {
|
||||
// Valid unquoted label names: start with letter or underscore, followed by alphanumeric or underscore
|
||||
return !/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(labelName);
|
||||
};
|
||||
|
||||
// Helper to quote a label name if needed
|
||||
const quoteLabelName = (labelName: string): string => {
|
||||
if (needsQuoting(labelName)) {
|
||||
// Escape any quotes in the label name itself, then wrap in quotes
|
||||
return `"${labelName.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
return labelName;
|
||||
};
|
||||
|
||||
// Helper to escape label values for Pyroscope label selector
|
||||
// Need to escape backslashes and quotes
|
||||
const escapeLabelValue = (value: string): string => {
|
||||
return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
||||
};
|
||||
|
||||
// Helper to manually generate Explore query for span profile
|
||||
const handleSpanIdClick = (spanId: string) => {
|
||||
if (!splitOpen || !timeRange) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { displayValues, links } = dispValuesAndLinks;
|
||||
// Extract profileTypeId from __profile_type__ field
|
||||
const profileTypeField = exemplarFrame.fields.find((f) => f.name === '__profile_type__');
|
||||
const profileTypeId = profileTypeField ? String(profileTypeField.values[rowIndex]) : '';
|
||||
|
||||
// Collect all label fields (excluding Time, Value, Id, and private labels starting with __)
|
||||
const labelFields = exemplarFrame.fields.filter(
|
||||
(f) => f.name !== 'Time' && f.name !== 'Value' && f.name !== 'Id' && !f.name.startsWith('__')
|
||||
);
|
||||
|
||||
// Build label selector with properly escaped values and quoted label names if needed
|
||||
// Format: {label1="value1", "label-2"="value2", ...}
|
||||
const labelParts = labelFields.map((field) => {
|
||||
const value = field.values[rowIndex];
|
||||
const quotedLabelName = quoteLabelName(field.name);
|
||||
const escapedValue = escapeLabelValue(String(value));
|
||||
return `${quotedLabelName}="${escapedValue}"`;
|
||||
});
|
||||
const labelSelector = labelParts.length > 0 ? `{${labelParts.join(', ')}}` : '';
|
||||
|
||||
// Get timestamp from Time field and create a narrow time window around it (+/- 30 seconds)
|
||||
const timeMs = timeField?.values[rowIndex];
|
||||
const timestamp = timeMs instanceof Date ? timeMs.getTime() : timeMs;
|
||||
|
||||
// Create a 60-second window centered on the exemplar (30s before and after)
|
||||
const windowMs = 30 * 1000; // 30 seconds in milliseconds
|
||||
const narrowRange = {
|
||||
from: new Date(timestamp - windowMs).toISOString(),
|
||||
to: new Date(timestamp + windowMs).toISOString(),
|
||||
};
|
||||
|
||||
// Construct the query for span profile
|
||||
const query = {
|
||||
queryType: 'profile',
|
||||
spanSelector: [spanId],
|
||||
labelSelector,
|
||||
profileTypeId,
|
||||
groupBy: [],
|
||||
};
|
||||
|
||||
// Open in explore with the span profile query and narrow time range
|
||||
splitOpen({
|
||||
queries: [query],
|
||||
range: narrowRange,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<VizTooltipWrapper>
|
||||
<VizTooltipHeader
|
||||
item={{
|
||||
label: 'Exemplar',
|
||||
value: timeValue,
|
||||
}}
|
||||
isPinned={isPinned}
|
||||
/>
|
||||
<VizTooltipContent items={[]} isPinned={isPinned} maxHeight={maxHeight} scrollable={maxHeight != null}>
|
||||
<div style={{ padding: `${theme.spacing(1)} 0` }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
|
||||
<tbody>
|
||||
{displayFields.map((field, i) => {
|
||||
const value = field.values[rowIndex];
|
||||
const fieldDisplay = field.display ? field.display(value) : { text: `${value}`, numeric: +value };
|
||||
const fieldName = getFieldDisplayName(field, exemplarFrame);
|
||||
const valueString = formattedValueToString(fieldDisplay);
|
||||
|
||||
// Check if this is a Span ID field that should have a link
|
||||
const isSpanId = isSpanIdField(field);
|
||||
const hasLink = isSpanId && splitOpen;
|
||||
|
||||
return (
|
||||
<tr key={i}>
|
||||
<td
|
||||
style={{
|
||||
padding: `${theme.spacing(0.25)} ${theme.spacing(2)} ${theme.spacing(0.25)} 0`,
|
||||
fontWeight: theme.typography.fontWeightMedium,
|
||||
}}
|
||||
>
|
||||
{fieldName}:
|
||||
</td>
|
||||
<td style={{ padding: `${theme.spacing(0.25)} 0` }}>
|
||||
{hasLink ? (
|
||||
<TextLink
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleSpanIdClick(valueString);
|
||||
}}
|
||||
external={false}
|
||||
weight="medium"
|
||||
inline={false}
|
||||
>
|
||||
{valueString}
|
||||
</TextLink>
|
||||
) : (
|
||||
valueString
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</VizTooltipContent>
|
||||
</VizTooltipWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export const HeatmapTooltip = (props: HeatmapTooltipProps) => {
|
||||
if (props.seriesIdx === 2) {
|
||||
const exemplarFrame = props.dataRef.current!.exemplars!;
|
||||
const rowIndex = props.dataIdxs[2]!;
|
||||
|
||||
return (
|
||||
<ExemplarTooltip
|
||||
items={displayValues.map((dispVal) => ({
|
||||
label: dispVal.name,
|
||||
value: dispVal.valueString,
|
||||
}))}
|
||||
links={links}
|
||||
maxHeight={props.maxHeight}
|
||||
<HeatmapExemplarTooltip
|
||||
exemplarFrame={exemplarFrame}
|
||||
rowIndex={rowIndex}
|
||||
isPinned={props.isPinned}
|
||||
maxHeight={props.maxHeight}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -95,7 +95,34 @@ export function prepareHeatmapData({
|
||||
|
||||
cacheFieldDisplayNames(frames);
|
||||
|
||||
const exemplars = annotations?.find((f) => f.name === 'exemplar');
|
||||
// Helper function to check if two label sets match
|
||||
const labelsMatch = (labels1: Record<string, string> | undefined, labels2: Record<string, string> | undefined) => {
|
||||
if (!labels1 && !labels2) {
|
||||
return true;
|
||||
}
|
||||
if (!labels1 || !labels2) {
|
||||
return false;
|
||||
}
|
||||
const keys1 = Object.keys(labels1);
|
||||
const keys2 = Object.keys(labels2);
|
||||
if (keys1.length !== keys2.length) {
|
||||
return false;
|
||||
}
|
||||
return keys1.every((key) => labels1[key] === labels2[key]);
|
||||
};
|
||||
|
||||
// Find the first heatmap frame to get its labels
|
||||
const heatmapFrame = frames.find((f) => f.meta?.type === DataFrameType.HeatmapCells);
|
||||
const heatmapLabels = heatmapFrame?.fields.find((f) => f.name === 'count')?.labels;
|
||||
|
||||
// Find the exemplar frame that matches the heatmap frame's labels
|
||||
const exemplars = annotations?.find((f) => {
|
||||
if (f.name !== 'exemplar') {
|
||||
return false;
|
||||
}
|
||||
const valueField = f.fields.find((field) => field.name === 'Value');
|
||||
return labelsMatch(heatmapLabels, valueField?.labels);
|
||||
});
|
||||
|
||||
exemplars?.fields.forEach((field) => {
|
||||
field.getLinks = getLinksSupplier(exemplars, field, field.state?.scopedVars ?? {}, replaceVariables);
|
||||
|
||||
@@ -215,6 +215,7 @@ export interface ExploreItemState {
|
||||
showTrace?: boolean;
|
||||
showNodeGraph?: boolean;
|
||||
showFlameGraph?: boolean;
|
||||
showHeatmap?: boolean;
|
||||
showCustom?: boolean;
|
||||
|
||||
/**
|
||||
@@ -281,6 +282,7 @@ export interface ExplorePanelData extends PanelData {
|
||||
nodeGraphFrames: DataFrame[];
|
||||
rawPrometheusFrames: DataFrame[];
|
||||
flameGraphFrames: DataFrame[];
|
||||
heatmapFrames: DataFrame[];
|
||||
graphResult: DataFrame[] | null;
|
||||
tableResult: DataFrame[] | null;
|
||||
logsResult: LogsModel | null;
|
||||
|
||||
Reference in New Issue
Block a user