Merge branch 'main' into eledobleefe/analytics-framework-user-test

This commit is contained in:
eledobleefe
2025-11-27 15:55:56 +01:00
135 changed files with 5673 additions and 1531 deletions
+1
View File
@@ -71,6 +71,7 @@ public/css/*.min.css
.vs/
.cursor/
.devcontainer/
.claude/
.eslintcache
.stylelintcache
@@ -4,6 +4,8 @@ import { test, expect, E2ESelectorGroups, DashboardPage, DashboardPageArgs } fro
import testDashboard from '../dashboards/DashboardWithAllConditionalRendering.json';
import { checkRepeatedPanelTitles } from './utils';
test.use({
featureToggles: {
kubernetesDashboards: true,
@@ -93,7 +95,7 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d
test.afterAll(async ({ request }) => {
if (uid) {
await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/default/dashboards/${uid}`);
await request.delete(`/apis/dashboard.grafana.app/v1beta1/namespaces/stacks-12345/dashboards/${uid}`);
}
});
@@ -407,4 +409,52 @@ test.describe('Dashboard - Conditional Rendering - Load and Change', { tag: ['@d
await expect(getTabShowNotMatches(dashboardPage, selectors)).toBeVisible();
await expect(getTabHideNotMatches(dashboardPage, selectors)).not.toBeVisible();
});
test.describe('Variable repeat', () => {
const repeatOptions = ['a', 'b', 'c'];
async function failTestDataRequestForOption(page: Page, option: string) {
await page.route(/\/api\/ds\/query\?.*\bds_type=grafana-testdata-datasource/, async (route) => {
const rawPostData = route.request().postData();
if (!rawPostData) {
return;
}
// the first panel query has a label set to the current variable value
if (JSON.parse(rawPostData).queries[0].labels === `key=${option}`) {
await route.fulfill({ status: 500, body: '{}' });
} else {
await route.continue();
}
});
}
test('Hide when equals, hide when no data', async ({ page, gotoDashboardPage, selectors }) => {
const dashboardPage = await loadDashboard(page, gotoDashboardPage);
await getTab(dashboardPage, selectors, 'repeated items').click();
const optionForHiddenPanels = repeatOptions[0];
await failTestDataRequestForOption(page, optionForHiddenPanels);
await checkRepeatedPanelTitles(
dashboardPage,
selectors,
'Hide panel - ',
[
`custom variable equals ${optionForHiddenPanels} (current = ${optionForHiddenPanels})`,
`no data (current = ${optionForHiddenPanels})`,
],
true
);
const optionsForVisiblePanels = repeatOptions.slice(1);
await checkRepeatedPanelTitles(dashboardPage, selectors, 'Hide panel - ', [
...optionsForVisiblePanels.map((o) => `custom variable equals ${optionForHiddenPanels} (current = ${o})`),
...optionsForVisiblePanels.map((o) => `no data (current = ${o})`),
]);
});
});
});
+10 -4
View File
@@ -97,12 +97,18 @@ export async function checkRepeatedPanelTitles(
dashboardPage: DashboardPage,
selectors: E2ESelectorGroups,
title: string,
options: Array<string | number>
options: Array<string | number>,
expectHidden = false
) {
for (const option of options) {
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${title}${option}`))
).toBeVisible();
const titleLocator = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Panel.title(`${title}${option}`)
);
if (expectHidden) {
await expect(titleLocator).toBeHidden();
} else {
await expect(titleLocator).toBeVisible();
}
}
}
@@ -9,7 +9,8 @@ const NUM_NESTED_DASHBOARDS = 60;
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -6,7 +6,8 @@ test.use({
featureToggles: {
scenes: true,
sharingDashboardImage: true, // Enable the export image feature
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
},
});
@@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
scenes: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -3,7 +3,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
scenes: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ import { SnapshotCreateResponse } from '../../public/app/features/dashboard/serv
test.use({
featureToggles: {
scenes: true,
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_UID = 'HYaGDGIMk';
test.use({
timezoneId: 'Pacific/Easter',
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -7,7 +7,8 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -16,7 +16,8 @@ test.use({
origins: [],
},
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -2,7 +2,8 @@ import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ import testDashboard from '../dashboards/TestDashboard.json';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -52,7 +52,8 @@ async function assertPreviewValues(
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -18,7 +18,8 @@ async function assertPreviewValues(
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -5,7 +5,8 @@ const DASHBOARD_NAME = 'Test variable output';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -4,7 +4,8 @@ const DASHBOARD_UID = 'ZqZnVvFZz';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off
},
});
@@ -4,7 +4,8 @@ const DASHBOARD_UID = 'yBCC3aKGk';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -6,7 +6,8 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
test.use({
featureToggles: {
kubernetesDashboards: process.env.KUBERNETES_DASHBOARDS === 'true',
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
},
});
@@ -3308,6 +3308,170 @@
}
}
},
"panel-37": {
"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": 37,
"links": [],
"title": "Hide panel - custom variable equals a (current = ${myCustomVariable})",
"vizConfig": {
"group": "text",
"kind": "VizConfig",
"spec": {
"fieldConfig": {
"defaults": {},
"overrides": []
},
"options": {
"code": {
"language": "plaintext",
"showLineNumbers": false,
"showMiniMap": false
},
"content": "",
"mode": "markdown"
}
},
"version": "12.2.0-pre"
}
}
},
"panel-38": {
"kind": "Panel",
"spec": {
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"hidden": false,
"query": {
"datasource": {
"name": "PD8C576611E62080A"
},
"group": "grafana-testdata-datasource",
"kind": "DataQuery",
"spec": {
"labels": "key=$myCustomVariable",
"scenarioId": "random_walk",
"seriesCount": 1
},
"version": "v0"
},
"refId": "A"
}
}
],
"queryOptions": {},
"transformations": []
}
},
"description": "",
"id": 38,
"links": [],
"title": "Hide panel - no data (current = ${myCustomVariable})",
"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.2.0-pre"
}
}
},
"panel-4": {
"kind": "Panel",
"spec": {
@@ -5091,6 +5255,80 @@
},
"title": "Tab - hide - time range <7d"
}
},
{
"kind": "TabsLayoutTab",
"spec": {
"layout": {
"kind": "AutoGridLayout",
"spec": {
"columnWidthMode": "standard",
"items": [
{
"kind": "AutoGridLayoutItem",
"spec": {
"conditionalRendering": {
"kind": "ConditionalRenderingGroup",
"spec": {
"condition": "and",
"items": [
{
"kind": "ConditionalRenderingVariable",
"spec": {
"operator": "equals",
"value": "a",
"variable": "myCustomVariable"
}
}
],
"visibility": "hide"
}
},
"element": {
"kind": "ElementReference",
"name": "panel-37"
},
"repeat": {
"mode": "variable",
"value": "myCustomVariable"
}
}
},
{
"kind": "AutoGridLayoutItem",
"spec": {
"conditionalRendering": {
"kind": "ConditionalRenderingGroup",
"spec": {
"condition": "and",
"items": [
{
"kind": "ConditionalRenderingData",
"spec": {
"value": false
}
}
],
"visibility": "hide"
}
},
"element": {
"kind": "ElementReference",
"name": "panel-38"
},
"repeat": {
"mode": "variable",
"value": "myCustomVariable"
}
}
}
],
"maxColumnCount": 3,
"rowHeightMode": "standard"
}
},
"title": "Tab - repeated items"
}
}
]
}
@@ -5122,6 +5360,39 @@
"query": "",
"skipUrlSync": false
}
},
{
"kind": "CustomVariable",
"spec": {
"allowCustomValue": false,
"current": {
"text": "All",
"value": "$__all"
},
"hide": "dontHide",
"includeAll": true,
"multi": false,
"name": "myCustomVariable",
"options": [
{
"selected": false,
"text": "a",
"value": "a"
},
{
"selected": false,
"text": "b",
"value": "b"
},
{
"selected": false,
"text": "c",
"value": "c"
}
],
"query": "a, b, c",
"skipUrlSync": false
}
}
]
},
-5
View File
@@ -1912,11 +1912,6 @@
"count": 4
}
},
"public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/dashboard-scene/inspect/HelpWizard/HelpWizard.tsx": {
"no-restricted-syntax": {
"count": 3
+4
View File
@@ -361,6 +361,10 @@ export interface FeatureToggles {
*/
dashboardNewLayouts?: boolean;
/**
* Use the v2 kubernetes API in the frontend for dashboards
*/
kubernetesDashboardsV2?: boolean;
/**
* Enables undo/redo in dynamic dashboards
*/
dashboardUndoRedo?: boolean;
@@ -312,18 +312,7 @@ export const handyTestingSchema: Spec = {
label: 'Custom Variable',
multi: true,
name: 'customVar',
options: [
{
selected: true,
text: 'option1',
value: 'option1',
},
{
selected: false,
text: 'option2',
value: 'option2',
},
],
options: [],
query: 'option1, option2',
skipUrlSync: false,
allowCustomValue: true,
@@ -490,5 +479,18 @@ export const handyTestingSchema: Spec = {
allowCustomValue: true,
},
},
{
kind: 'SwitchVariable',
spec: {
name: 'switchVar',
label: 'Switch Variable',
description: 'A switch variable',
current: 'false',
enabledValue: 'true',
disabledValue: 'false',
hide: 'dontHide',
skipUrlSync: false,
},
},
],
};
+1 -1
View File
@@ -198,7 +198,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles,
}
func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) {
if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) {
// If dashboards v2 is enabled, we want to use v2beta1 as the default API version.
return []schema.GroupVersion{
dashv2beta1.DashboardResourceInfo.GroupVersion(),
+6
View File
@@ -346,6 +346,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge
if err != nil {
return err
}
if enableZanzanaSync {
b.logger.Info("Enabling hooks for RoleBinding to sync to Zanzana")
roleBindingStore.AfterCreate = b.AfterRoleBindingCreate
roleBindingStore.AfterDelete = b.AfterRoleBindingDelete
roleBindingStore.BeginUpdate = b.BeginRoleBindingUpdate
}
storage[iamv0.RoleBindingInfo.StoragePath()] = roleBindingStore
}
//nolint:staticcheck // not yet migrated to OpenFeature
+302
View File
@@ -0,0 +1,302 @@
package iam
import (
"context"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apiserver/pkg/registry/generic/registry"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
const resourceType = "rolebinding"
// AfterRoleBindingCreate is a post-create hook that writes the role binding to Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingCreate(obj runtime.Object, _ *metav1.CreateOptions) {
if b.zClient == nil {
return
}
rb, ok := obj.(*iamv0.RoleBinding)
if !ok {
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
return
}
operation := "create"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(rb *iamv0.RoleBinding) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
}()
b.logger.Debug("writing role binding to zanzana",
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
for _, roleRef := range rb.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: string(rb.Spec.Subject.Kind),
SubjectName: rb.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
if len(operations) == 0 {
return
}
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: rb.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to write role binding to zanzana",
"err", err,
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
}
}(rb.DeepCopy()) // Pass a copy of the object
}
// AfterRoleBindingDelete is a post-delete hook that removes the role binding from Zanzana (openFGA)
func (b *IdentityAccessManagementAPIBuilder) AfterRoleBindingDelete(obj runtime.Object, _ *metav1.DeleteOptions) {
if b.zClient == nil {
return
}
rb, ok := obj.(*iamv0.RoleBinding)
if !ok {
b.logger.Error("failed to convert object to RoleBinding type", "object", obj)
return
}
operation := "delete"
// Grab a ticket to write to Zanzana
// This limits the amount of concurrent connections to Zanzana
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, operation).Observe(time.Since(wait).Seconds())
go func(rb *iamv0.RoleBinding) {
start := time.Now()
status := "success"
defer func() {
// Release the ticket after write is done
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, operation, status).Observe(time.Since(start).Seconds())
}()
b.logger.Debug("deleting role binding from zanzana",
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
operations := make([]*v1.MutateOperation, 0, len(rb.Spec.RoleRefs))
for _, roleRef := range rb.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: string(rb.Spec.Subject.Kind),
SubjectName: rb.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
if len(operations) == 0 {
return
}
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: rb.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to delete role binding from zanzana",
"err", err,
"namespace", rb.Namespace,
"name", rb.Name,
"subject", rb.Spec.Subject.Name,
"roleRefs", rb.Spec.RoleRefs,
)
}
}(rb.DeepCopy()) // Pass a copy of the object
}
// BeginRoleBindingUpdate is a pre-update hook that prepares zanzana updates.
// It performs the zanzana write after K8s update succeeds.
func (b *IdentityAccessManagementAPIBuilder) BeginRoleBindingUpdate(ctx context.Context, obj, oldObj runtime.Object, options *metav1.UpdateOptions) (registry.FinishFunc, error) {
if b.zClient == nil {
return nil, nil
}
// Extract role bindings from both old and new objects
oldRB, ok := oldObj.(*iamv0.RoleBinding)
if !ok {
return nil, nil
}
newRB, ok := obj.(*iamv0.RoleBinding)
if !ok {
return nil, nil
}
if oldRB.Spec.Subject.Name == newRB.Spec.Subject.Name && roleRefsEqual(oldRB.Spec.RoleRefs, newRB.Spec.RoleRefs) {
return nil, nil // No changes to the role binding
}
if newRB.Spec.Subject.Name == "" {
b.logger.Error("invalid role binding",
"namespace", newRB.Namespace,
"name", newRB.Name,
"subject", newRB.Spec.Subject.Name,
"roleRefs", newRB.Spec.RoleRefs,
)
return nil, nil
}
// Return a finish function that performs the zanzana write only on success
return func(ctx context.Context, success bool) {
if !success {
return
}
wait := time.Now()
b.zTickets <- true
hooksWaitHistogram.WithLabelValues(resourceType, "update").Observe(time.Since(wait).Seconds())
go func() {
start := time.Now()
status := "success"
defer func() {
<-b.zTickets
// Record operation duration and count
hooksDurationHistogram.WithLabelValues(resourceType, "update", status).Observe(time.Since(start).Seconds())
}()
b.logger.Debug("updating role binding in zanzana",
"namespace", newRB.Namespace,
"name", newRB.Name,
"oldSubject", oldRB.Spec.Subject.Name,
"newSubject", newRB.Spec.Subject.Name,
"oldRoleRefs", oldRB.Spec.RoleRefs,
"newRoleRefs", newRB.Spec.RoleRefs,
)
ctx, cancel := context.WithTimeout(context.Background(), defaultWriteTimeout)
defer cancel()
operations := make([]*v1.MutateOperation, 0, len(oldRB.Spec.RoleRefs))
for _, roleRef := range oldRB.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: string(oldRB.Spec.Subject.Kind),
SubjectName: oldRB.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
for _, roleRef := range newRB.Spec.RoleRefs {
operations = append(operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: string(newRB.Spec.Subject.Kind),
SubjectName: newRB.Spec.Subject.Name,
RoleKind: string(roleRef.Kind),
RoleName: roleRef.Name,
},
},
})
}
// Only make the request if there are deletes or writes
if len(operations) == 0 {
b.logger.Debug("no role bindings to update in zanzana", "namespace", newRB.Namespace, "name", newRB.Name)
return
}
err := b.zClient.Mutate(ctx, &v1.MutateRequest{
Namespace: newRB.Namespace,
Operations: operations,
})
if err != nil {
status = "failure"
b.logger.Error("failed to update role binding in zanzana",
"err", err,
"namespace", newRB.Namespace,
"name", newRB.Name,
)
}
}()
}, nil
}
func roleRefsEqual(oldRoleRefs, newRoleRefs []iamv0.RoleBindingspecRoleRef) bool {
if len(oldRoleRefs) != len(newRoleRefs) {
return false
}
oldRoleRefsMap := make(map[string]string)
for _, roleRef := range oldRoleRefs {
oldRoleRefsMap[roleRef.Name] = string(roleRef.Kind)
}
for _, roleRef := range newRoleRefs {
refKind, ok := oldRoleRefsMap[roleRef.Name]
if !ok {
return false
}
if refKind != string(roleRef.Kind) {
return false
}
}
return true
}
@@ -0,0 +1,448 @@
package iam
import (
"context"
"slices"
"sync"
"testing"
"time"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"github.com/stretchr/testify/require"
iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1"
"github.com/grafana/grafana/pkg/infra/log"
v1 "github.com/grafana/grafana/pkg/services/authz/proto/v1"
)
func TestAfterRoleBindingCreate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should create zanzana entry for role binding", func(t *testing.T) {
wg.Add(1)
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
testRoleBinding := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 1)
require.Equal(t, "org-1", req.Namespace)
expectedOperation := &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-1",
},
},
}
actualCreate := req.Operations[0].Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
expectedCreate := expectedOperation.Operation.(*v1.MutateOperation_CreateRoleBinding).CreateRoleBinding
require.Equal(t, expectedCreate.SubjectKind, actualCreate.SubjectKind)
require.Equal(t, expectedCreate.SubjectName, actualCreate.SubjectName)
require.Equal(t, expectedCreate.RoleKind, actualCreate.RoleKind)
require.Equal(t, expectedCreate.RoleName, actualCreate.RoleName)
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBinding}
b.AfterRoleBindingCreate(&roleBinding, nil)
wg.Wait()
})
t.Run("should not write to zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-3",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-3",
},
},
},
}
// Should not panic or error when zClient is nil
builder.AfterRoleBindingCreate(&roleBinding, nil)
})
}
func TestBeginRoleBindingUpdate(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should update zanzana entry when role binding changed", func(t *testing.T) {
wg.Add(1)
oldBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-foo",
},
{
Kind: "role",
Name: "role-2",
},
},
},
}
newBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-bar",
},
},
},
}
testRoleBindingUpdate := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 3)
// Should write new binding and delete old one
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-foo",
},
},
}))
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_CreateRoleBinding{
CreateRoleBinding: &v1.CreateRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-bar",
},
},
}))
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingUpdate}
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.NotNil(t, finishFunc)
finishFunc(context.Background(), true)
wg.Wait()
})
t.Run("should return nil finish func when bindings are identical", func(t *testing.T) {
oldBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
newBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-2",
Namespace: "org-2",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
writeCalled := false
testNoWriteOnNoChange := func(ctx context.Context, req *v1.MutateRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when bindings are identical")
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnNoChange}
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when bindings are identical
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when bindings are identical")
})
t.Run("should return nil finish func when new binding has empty subject name", func(t *testing.T) {
oldBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
newBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-8",
Namespace: "org-8",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "",
Name: "", // Empty name - should cause early return
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
},
},
}
writeCalled := false
testNoWriteOnInvalidBinding := func(ctx context.Context, req *v1.MutateRequest) error {
writeCalled = true
require.Fail(t, "Write should not be called when new binding has empty subject name")
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testNoWriteOnInvalidBinding}
finishFunc, err := b.BeginRoleBindingUpdate(context.Background(), &newBinding, &oldBinding, nil)
require.NoError(t, err)
require.Nil(t, finishFunc) // Should return nil when new binding has empty subject name
// Verify write was never called
time.Sleep(100 * time.Millisecond)
require.False(t, writeCalled, "Write callback should not be called when new binding has empty subject name")
})
}
func TestAfterRoleBindingDelete(t *testing.T) {
var wg sync.WaitGroup
b := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
}
t.Run("should delete zanzana entry for team binding with member permission", func(t *testing.T) {
wg.Add(1)
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-1",
Namespace: "org-1",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-1",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-1",
},
{
Kind: "role",
Name: "role-2",
},
},
},
}
testRoleBindingDelete := func(ctx context.Context, req *v1.MutateRequest) error {
defer wg.Done()
require.NotNil(t, req)
require.Equal(t, "org-1", req.Namespace)
// Should have deletes but no writes
require.NotNil(t, req.Operations)
require.Len(t, req.Operations, 2)
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-1",
},
},
}))
require.True(t, containsOperation(req.Operations, &v1.MutateOperation{
Operation: &v1.MutateOperation_DeleteRoleBinding{
DeleteRoleBinding: &v1.DeleteRoleBindingOperation{
SubjectKind: "user",
SubjectName: "user-1",
RoleKind: "role",
RoleName: "role-2",
},
},
}))
return nil
}
b.zClient = &FakeZanzanaClient{mutateCallback: testRoleBindingDelete}
b.AfterRoleBindingDelete(&roleBinding, nil)
wg.Wait()
})
t.Run("should not delete from zanzana when zClient is nil", func(t *testing.T) {
builder := &IdentityAccessManagementAPIBuilder{
logger: log.NewNopLogger(),
zTickets: make(chan bool, 1),
zClient: nil,
}
roleBinding := iamv0.RoleBinding{
ObjectMeta: metav1.ObjectMeta{
Name: "binding-3",
Namespace: "org-3",
},
Spec: iamv0.RoleBindingSpec{
Subject: iamv0.RoleBindingspecSubject{
Kind: "user",
Name: "user-3",
},
RoleRefs: []iamv0.RoleBindingspecRoleRef{
{
Kind: "role",
Name: "role-3",
},
},
},
}
// Should not panic or error when zClient is nil
builder.AfterRoleBindingDelete(&roleBinding, nil)
})
}
func containsOperation(operations []*v1.MutateOperation, operation *v1.MutateOperation) bool {
return slices.ContainsFunc(operations, func(o *v1.MutateOperation) bool {
switch operation.Operation.(type) {
case *v1.MutateOperation_DeleteRoleBinding:
deleteOperation := operation.Operation.(*v1.MutateOperation_DeleteRoleBinding)
deleteO, ok := o.Operation.(*v1.MutateOperation_DeleteRoleBinding)
if !ok {
return false
}
return deleteO.DeleteRoleBinding.SubjectKind == deleteOperation.DeleteRoleBinding.SubjectKind &&
deleteO.DeleteRoleBinding.SubjectName == deleteOperation.DeleteRoleBinding.SubjectName &&
deleteO.DeleteRoleBinding.RoleKind == deleteOperation.DeleteRoleBinding.RoleKind &&
deleteO.DeleteRoleBinding.RoleName == deleteOperation.DeleteRoleBinding.RoleName
case *v1.MutateOperation_CreateRoleBinding:
createOperation := operation.Operation.(*v1.MutateOperation_CreateRoleBinding)
createO, ok := o.Operation.(*v1.MutateOperation_CreateRoleBinding)
if !ok {
return false
}
return createO.CreateRoleBinding.SubjectKind == createOperation.CreateRoleBinding.SubjectKind &&
createO.CreateRoleBinding.SubjectName == createOperation.CreateRoleBinding.SubjectName &&
createO.CreateRoleBinding.RoleKind == createOperation.CreateRoleBinding.RoleKind &&
createO.CreateRoleBinding.RoleName == createOperation.CreateRoleBinding.RoleName
}
return false
})
}
@@ -15,6 +15,9 @@ type FakeDataSourceService struct {
lastID int64
DataSources []*datasources.DataSource
SimulatePluginFailure bool
// UID -> Headers
DataSourceHeaders map[string]http.Header
}
var _ datasources.DataSourceService = &FakeDataSourceService{}
@@ -152,5 +155,5 @@ func (s *FakeDataSourceService) DecryptedPassword(ctx context.Context, ds *datas
}
func (s *FakeDataSourceService) CustomHeaders(ctx context.Context, ds *datasources.DataSource) (http.Header, error) {
return nil, nil
return s.DataSourceHeaders[ds.UID], nil
}
+7
View File
@@ -579,6 +579,13 @@ var (
FrontendOnly: false, // The restore backend feature changes behavior based on this flag
Owner: grafanaDashboardsSquad,
},
{
Name: "kubernetesDashboardsV2",
Description: "Use the v2 kubernetes API in the frontend for dashboards",
Stage: FeatureStageExperimental,
FrontendOnly: false,
Owner: grafanaDashboardsSquad,
},
{
Name: "dashboardUndoRedo",
Description: "Enables undo/redo in dynamic dashboards",
+1
View File
@@ -80,6 +80,7 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true
dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true
dashboardScene,GA,@grafana/dashboards-squad,false,false,true
dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false
kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false
dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true
unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true
perPanelNonApplicableDrilldowns,experimental,@grafana/dashboards-squad,false,false,true
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
80 dashboardSceneSolo GA @grafana/dashboards-squad false false true
81 dashboardScene GA @grafana/dashboards-squad false false true
82 dashboardNewLayouts experimental @grafana/dashboards-squad false false false
83 kubernetesDashboardsV2 experimental @grafana/dashboards-squad false false false
84 dashboardUndoRedo experimental @grafana/dashboards-squad false false true
85 unlimitedLayoutsNesting experimental @grafana/dashboards-squad false false true
86 perPanelNonApplicableDrilldowns experimental @grafana/dashboards-squad false false true
+4
View File
@@ -259,6 +259,10 @@ const (
// Enables experimental new dashboard layouts
FlagDashboardNewLayouts = "dashboardNewLayouts"
// FlagKubernetesDashboardsV2
// Use the v2 kubernetes API in the frontend for dashboards
FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2"
// FlagPdfTables
// Enables generating table data as PDF in reporting
FlagPdfTables = "pdfTables"
+29 -1
View File
@@ -1911,6 +1911,18 @@
"expression": "true"
}
},
{
"metadata": {
"name": "kubernetesDashboardsV2",
"resourceVersion": "1764236054307",
"creationTimestamp": "2025-11-27T09:34:14Z"
},
"spec": {
"description": "Use the v2 kubernetes API in the frontend for dashboards",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
}
},
{
"metadata": {
"name": "kubernetesExternalGroupMapping",
@@ -3547,6 +3559,22 @@
"expression": "true"
}
},
{
"metadata": {
"name": "v2DashboardAPIVersion",
"resourceVersion": "1762457740470",
"creationTimestamp": "2025-11-06T19:22:05Z",
"deletionTimestamp": "2025-11-27T09:34:14Z",
"annotations": {
"grafana.app/updatedTimestamp": "2025-11-06 19:35:40.470587 +0000 UTC"
}
},
"spec": {
"description": "Enables the v2 dashboard API version",
"stage": "experimental",
"codeowner": "@grafana/dashboards-squad"
}
},
{
"metadata": {
"name": "vizActionsAuth",
@@ -3589,4 +3617,4 @@
}
}
]
}
}
+37
View File
@@ -239,6 +239,30 @@
const CHECK_INTERVAL = 1 * 1000;
function getCookie(name) {
const cookies = document.cookie.split(";").map(c => c.trim());
for (const cookie of cookies) {
if (cookie.startsWith(name + "=")) {
return cookie.substring(name.length + 1);
}
}
return null;
}
function getSessionExpiration() {
const value = getCookie("grafana_session_expiry") || "0";
const realExpiresSeconds = parseInt(value, 10);
const expiresSeconds = Math.max(realExpiresSeconds - 10, 0); // Rotate 10s before the real expiration
const expiration = new Date(expiresSeconds * 1000);
return expiration;
}
async function rotateSession() {
await fetch('/api/user/auth-tokens/rotate', { method: 'POST' });
}
/**
* Fetches boot data from the server. If it returns undefined, it should be retried later.
* Will return a rejected promise on unrecoverable errors.
@@ -295,6 +319,19 @@
function loadBootData() {
return new Promise((resolve, reject) => {
const attemptFetch = async () => {
try {
const sessionExpiration = getSessionExpiration();
const now = new Date();
// If the session has expired, don't continue trying to fetch boot data
if (now >= sessionExpiration) {
await rotateSession();
}
} catch (error) {
// Just ignore any errors in session rotation. The user can just log in again.
console.warn("Failed to rotate session", error);
}
try {
const bootData = await fetchBootData();
@@ -205,11 +205,23 @@ func (w *DatasourceWriter) makeWriter(ctx context.Context, orgID int64, dsUID st
return nil, err
}
// We need to add the writer headers (valid for any data source) and any data-source-specific headers.
headers := make(http.Header)
for k, v := range w.cfg.CustomHeaders {
headers.Add(k, v)
}
dsHeaders, err := w.datasources.CustomHeaders(ctx, ds)
if err != nil {
return nil, fmt.Errorf("failed to get headers for data source: %w", err)
}
for k, values := range dsHeaders {
for _, v := range values {
headers.Add(k, v)
}
}
var backend backendType
if dsUID == string(grafanaCloudPromType) {
backend = grafanaCloudPromType
@@ -56,13 +56,14 @@ func (m *mockHTTPClientProvider) New(options ...sdkhttpclient.Options) (*http.Cl
type testDataSources struct {
dsfakes.FakeDataSourceService
prom1, prom2, prom3 *TestRemoteWriteTarget
prom1, prom2, prom3, prom4 *TestRemoteWriteTarget
}
func (t *testDataSources) Reset() {
t.prom1.Reset()
t.prom2.Reset()
t.prom3.Reset()
t.prom4.Reset()
}
func setupDataSources(t *testing.T) *testDataSources {
@@ -70,7 +71,9 @@ func setupDataSources(t *testing.T) *testDataSources {
prom1: NewTestRemoteWriteTarget(t),
prom2: NewTestRemoteWriteTarget(t),
prom3: NewTestRemoteWriteTarget(t),
prom4: NewTestRemoteWriteTarget(t),
}
res.DataSourceHeaders = make(map[string]http.Header)
t.Cleanup(func() {
res.prom1.Close()
@@ -81,6 +84,9 @@ func setupDataSources(t *testing.T) *testDataSources {
t.Cleanup(func() {
res.prom3.Close()
})
t.Cleanup(func() {
res.prom4.Close()
})
p1, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
Name: "prom-1",
@@ -107,7 +113,7 @@ func setupDataSources(t *testing.T) *testDataSources {
Type: datasources.DS_LOKI,
})
// Add a third Prometheus datasource that uses PDC
// Add a third Prometheus datasource that uses PDC.
p3, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
Name: "prom-3",
UID: "prom-3",
@@ -123,6 +129,21 @@ func setupDataSources(t *testing.T) *testDataSources {
require.True(t, p3.IsSecureSocksDSProxyEnabled())
// Add a fourth Prometheus datasource with headers in the JSON config.
p4, _ := res.AddDataSource(context.Background(), &datasources.AddDataSourceCommand{
Name: "prom-4",
UID: "prom-4",
Type: datasources.DS_PROMETHEUS,
JsonData: simplejson.MustJson([]byte(`{"prometheusType":"Prometheus"}`)),
})
p4.URL = res.prom4.srv.URL
res.prom4.ExpectedPath = "/api/v1/write"
res.DataSourceHeaders["prom-4"] = http.Header{
"X-Scope-OrgID": []string{"test-user"},
"X-Test-Header": []string{"test-value"},
"X-Double-Header": []string{"one", "two", "three"},
}
return res
}
@@ -204,6 +225,45 @@ func TestDatasourceWriter(t *testing.T) {
assert.Equal(t, headers[header2], testDS.prom1.LastHeaders.Get(header2))
})
t.Run("when data source headers are configured, they are passed to the request", func(t *testing.T) {
testDS.Reset()
overwrittenHeader := "X-Test-Header"
cHeaders := map[string]string{
"X-Custom-Header": "test-value",
"X-Another-Header": "another-value",
overwrittenHeader: "overwritten", // Data source headers should be overwritten by custom headers.
}
cfg = DatasourceWriterConfig{
Timeout: time.Second * 5,
DefaultDatasourceUID: "prom-1",
CustomHeaders: cHeaders,
}
writer = NewDatasourceWriter(cfg, testDS, httpclient.NewProvider(), pluginContextProvider, clock.New(), log.New("test"), met)
uid := "prom-4"
err := writer.WriteDatasource(context.Background(), uid, "metric", time.Now(), frames, 1, map[string]string{})
require.NoError(t, err)
dsHeaders := testDS.DataSourceHeaders[uid]
require.Len(t, dsHeaders, 3)
// We're confirming we have a data source header with the same name but different value.
// This one should not be sent in the request.
require.NotEmpty(t, dsHeaders[overwrittenHeader])
require.NotEqual(t, dsHeaders[overwrittenHeader], cHeaders[overwrittenHeader])
// All headers (except for the one that was overwritten) should have been used.
for k, vv := range dsHeaders {
if k != overwrittenHeader {
assert.Equal(t, vv, testDS.prom4.LastHeaders.Values(k))
}
}
for k, v := range cHeaders {
assert.Equal(t, v, testDS.prom4.LastHeaders.Get(k))
}
})
t.Run("when PDC is enabled proxy options are passed to HTTP client provider", func(t *testing.T) {
testDS.Reset()
+2 -1
View File
@@ -1,6 +1,7 @@
package writer
import (
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -37,7 +38,7 @@ func NewTestRemoteWriteTarget(t *testing.T) *TestRemoteWriteTarget {
handler := func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != target.ExpectedPath {
require.Fail(t, "Received unexpected request for endpoint %s", r.URL.Path)
require.Fail(t, fmt.Sprintf("Received unexpected request for endpoint %s", r.URL.Path))
}
target.mtx.Lock()
+9 -3
View File
@@ -581,10 +581,16 @@ func TestMain(m *testing.M) {
// nolint:staticcheck
testSQLStore.cfg.IsFeatureToggleEnabled = features.IsEnabledGlobally
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
skipTruncate := false
if skip, present := os.LookupEnv("SKIP_DB_TRUNCATE"); present {
skipTruncate = strings.ToLower(skip) == "true"
}
if !skipTruncate {
if err := testSQLStore.dialect.TruncateDBTables(testSQLStore.GetEngine()); err != nil {
return nil, err
}
testSQLStore.engine.ResetSequenceGenerator()
}
testSQLStore.engine.ResetSequenceGenerator()
if err := testSQLStore.Reset(); err != nil {
return nil, err
@@ -0,0 +1,208 @@
package migrations_test
import (
"fmt"
"net/http"
"testing"
authlib "github.com/grafana/authlib/types"
"github.com/grafana/grafana/pkg/services/folder"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/runtime/schema"
)
// foldersAndDashboardsTestCase tests the "folders-dashboards" ResourceMigration
type foldersAndDashboardsTestCase struct {
parentFolderUID string
childFolderUID string
dashboardUID string
libPanelUID string
}
// newFoldersAndDashboardsTestCase creates a test case for the compound folders+dashboards migrator
func newFoldersAndDashboardsTestCase() resourceMigratorTestCase {
return &foldersAndDashboardsTestCase{
parentFolderUID: "parent-folder-uid",
childFolderUID: "child-folder-uid",
dashboardUID: "", // Will be generated during setup
libPanelUID: "", // Will be generated during setup
}
}
func (tc *foldersAndDashboardsTestCase) name() string {
return "folders-dashboards"
}
func (tc *foldersAndDashboardsTestCase) resources() []schema.GroupVersionResource {
return []schema.GroupVersionResource{
{
Group: "folder.grafana.app",
Version: "v1beta1",
Resource: "folders",
},
{
Group: "dashboard.grafana.app",
Version: "v1beta1",
Resource: "dashboards",
},
}
}
func (tc *foldersAndDashboardsTestCase) setup(t *testing.T, helper *apis.K8sTestHelper) {
t.Helper()
// Create parent folder
parent := createTestFolder(t, helper, tc.parentFolderUID, "parent-folder", "")
// Create child folder (nested under parent)
child := createTestFolder(t, helper, tc.childFolderUID, "child-folder", parent.UID)
// Create library panel in child folder
tc.libPanelUID = createTestLibraryPanel(t, helper, "Test Library Panel", child.UID)
// Create dashboard with library panel in child folder
tc.dashboardUID = createTestDashboardWithLibraryPanel(t, helper, "dashboard-with-library-panel",
tc.libPanelUID, "Test LP in dashboard", child.UID)
}
func (tc *foldersAndDashboardsTestCase) verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool) {
t.Helper()
// Build maps of UIDs by resource type
folderUIDs := []string{tc.parentFolderUID, tc.childFolderUID}
dashboardUIDs := []string{tc.dashboardUID}
expectedFolderCount := 0
if shouldExist {
expectedFolderCount = len(folderUIDs)
}
orgID := helper.Org1.OrgID
namespace := authlib.OrgNamespaceFormatter(orgID)
// Verify folders
folderCli := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: namespace,
GVR: schema.GroupVersionResource{
Group: "folder.grafana.app",
Version: "v1beta1",
Resource: "folders",
},
})
verifyResourceCount(t, folderCli, expectedFolderCount)
for _, uid := range folderUIDs {
verifyResource(t, folderCli, uid, shouldExist)
}
// Verify dashboards
expectedDashboardCount := 0
if shouldExist {
expectedDashboardCount = len(dashboardUIDs)
}
dashboardCli := helper.GetResourceClient(apis.ResourceClientArgs{
User: helper.Org1.Admin,
Namespace: namespace,
GVR: schema.GroupVersionResource{
Group: "dashboard.grafana.app",
Version: "v1beta1",
Resource: "dashboards",
},
})
verifyResourceCount(t, dashboardCli, expectedDashboardCount)
for _, uid := range dashboardUIDs {
verifyResource(t, dashboardCli, uid, shouldExist)
}
}
// createTestFolder creates a folder with specified UID and optional parent
func createTestFolder(t *testing.T, helper *apis.K8sTestHelper, uid, title, parentUID string) *folder.Folder {
t.Helper()
payload := fmt.Sprintf(`{
"title": "%s",
"uid": "%s"`, title, uid)
if parentUID != "" {
payload += fmt.Sprintf(`,
"parentUid": "%s"`, parentUID)
}
payload += "}"
folderCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/folders",
Body: []byte(payload),
}, &folder.Folder{})
require.NotNil(t, folderCreate.Result)
require.Equal(t, uid, folderCreate.Result.UID)
return folderCreate.Result
}
// createTestLibraryPanel creates a library panel in a folder
func createTestLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, name, folderUID string) string {
t.Helper()
libPanelPayload := fmt.Sprintf(`{
"kind": 1,
"name": "%s",
"folderUid": "%s",
"model": {
"type": "text",
"title": "%s"
}
}`, name, folderUID, name)
libCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/library-elements",
Body: []byte(libPanelPayload),
}, &map[string]interface{}{})
require.NotNil(t, libCreate.Response)
require.Equal(t, http.StatusOK, libCreate.Response.StatusCode)
libPanelUID := (*libCreate.Result)["result"].(map[string]interface{})["uid"].(string)
require.NotEmpty(t, libPanelUID)
return libPanelUID
}
// createTestDashboardWithLibraryPanel creates a dashboard that uses a library panel
func createTestDashboardWithLibraryPanel(t *testing.T, helper *apis.K8sTestHelper, dashTitle, libPanelUID, libPanelName, folderUID string) string {
t.Helper()
dashPayload := fmt.Sprintf(`{
"dashboard": {
"title": "%s",
"panels": [{
"id": 1,
"libraryPanel": {
"uid": "%s",
"name": "%s"
}
}]
},
"folderUid": "%s",
"overwrite": false
}`, dashTitle, libPanelUID, libPanelName, folderUID)
dashCreate := apis.DoRequest(helper, apis.RequestParams{
User: helper.Org1.Admin,
Method: http.MethodPost,
Path: "/api/dashboards/db",
Body: []byte(dashPayload),
}, &map[string]interface{}{})
require.NotNil(t, dashCreate.Response)
require.Equal(t, http.StatusOK, dashCreate.Response.StatusCode)
dashUID := (*dashCreate.Result)["uid"].(string)
require.NotEmpty(t, dashUID)
return dashUID
}
@@ -0,0 +1,210 @@
package migrations_test
import (
"context"
"fmt"
"os"
"testing"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/infra/db"
"github.com/grafana/grafana/pkg/setting"
"github.com/grafana/grafana/pkg/tests/apis"
"github.com/grafana/grafana/pkg/tests/testinfra"
"github.com/grafana/grafana/pkg/tests/testsuite"
"github.com/grafana/grafana/pkg/util/testutil"
"github.com/stretchr/testify/require"
"k8s.io/apimachinery/pkg/api/meta"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
)
func TestMain(m *testing.M) {
testsuite.Run(m)
}
// resourceMigratorTestCase defines the interface for testing a resource migrator.
type resourceMigratorTestCase interface {
// name returns the test case name
name() string
// resources returns the GVRs that this migrator handles
resources() []schema.GroupVersionResource
// setup creates test resources in legacy storage (Mode0)
setup(t *testing.T, helper *apis.K8sTestHelper)
// verify checks that resources exist (or don't exist) in unified storage
verify(t *testing.T, helper *apis.K8sTestHelper, shouldExist bool)
}
// TestIntegrationMigrations verifies that legacy storage data is correctly migrated to unified storage.
// The test follows a three-step process:
// Step 1: inserts legacy data (migration disabled at startup)
// Step 2: verifies that the data is not in unified storage
// Step 3: migration runs at startup, and the test verifies that the data is in unified storage
func TestIntegrationMigrations(t *testing.T) {
testutil.SkipIntegrationTestInShortMode(t)
migrationTestCases := []resourceMigratorTestCase{
newFoldersAndDashboardsTestCase(),
}
runMigrationTestSuite(t, migrationTestCases)
}
// runMigrationTestSuite executes the migration test suite for the given test cases
func runMigrationTestSuite(t *testing.T, testCases []resourceMigratorTestCase) {
if db.IsTestDbSQLite() {
// Share the same SQLite DB file between steps
tmpDir := t.TempDir()
dbPath := tmpDir + "/shared-migration-test-suite.db"
oldVal := os.Getenv("SQLITE_TEST_DB")
require.NoError(t, os.Setenv("SQLITE_TEST_DB", dbPath))
t.Cleanup(func() {
if oldVal == "" {
_ = os.Unsetenv("SQLITE_TEST_DB")
} else {
_ = os.Setenv("SQLITE_TEST_DB", oldVal)
}
})
t.Logf("Using shared database path: %s", dbPath)
}
// Store UIDs created by each test case
type testCaseState struct {
tc resourceMigratorTestCase
}
testStates := make([]testCaseState, len(testCases))
for i, tc := range testCases {
testStates[i].tc = tc
}
// reuse org users throughout the tests
var org1 *apis.OrgUsers
var orgB *apis.OrgUsers
t.Run("Step 1: Create data in legacy", func(t *testing.T) {
// Enforce Mode0 for all migrated resources
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode0,
}
}
}
// Set up test environment with Mode0 (writes only to legacy)
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
})
t.Cleanup(helper.Shutdown)
org1 = &helper.Org1
orgB = &helper.OrgB
for i := range testStates {
state := &testStates[i]
t.Run(state.tc.name(), func(t *testing.T) {
state.tc.setup(t, helper)
// Verify resources were created in legacy storage
state.tc.verify(t, helper, true)
})
}
})
// Set SKIP_DB_TRUNCATE to not truncate the data created in Step 1
oldSkipTruncate := os.Getenv("SKIP_DB_TRUNCATE")
require.NoError(t, os.Setenv("SKIP_DB_TRUNCATE", "true"))
t.Cleanup(func() {
if oldSkipTruncate == "" {
_ = os.Unsetenv("SKIP_DB_TRUNCATE")
} else {
_ = os.Setenv("SKIP_DB_TRUNCATE", oldSkipTruncate)
}
})
t.Run("Step 2: Verify data is NOT in unified storage before the migration", func(t *testing.T) {
// Build unified storage config for Mode5
unifiedConfig := make(map[string]setting.UnifiedStorageConfig)
for _, tc := range testCases {
for _, gvr := range tc.resources() {
resourceKey := fmt.Sprintf("%s.%s", gvr.Resource, gvr.Group)
unifiedConfig[resourceKey] = setting.UnifiedStorageConfig{
DualWriterMode: grafanarest.Mode5,
}
}
}
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: true,
DisableDBCleanup: true,
APIServerStorageType: "unified",
UnifiedStorageConfig: unifiedConfig,
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources don't exist in unified storage yet
state.tc.verify(t, helper, false)
})
}
})
t.Run("Step 3: verify data is migrated to unified storage", func(t *testing.T) {
// Migrations will run automatically at startup and mode 5 is enforced by the config
helper := apis.NewK8sTestHelperWithOpts(t, apis.K8sTestHelperOpts{
GrafanaOpts: testinfra.GrafanaOpts{
// EnableLog: true,
AppModeProduction: true,
DisableAnonymous: true,
DisableDataMigrations: false, // Run migrations at startup
APIServerStorageType: "unified",
},
Org1Users: org1,
OrgBUsers: orgB,
})
t.Cleanup(helper.Shutdown)
for _, state := range testStates {
t.Run(state.tc.name(), func(t *testing.T) {
// Verify resources now exist in unified storage after migration
state.tc.verify(t, helper, true)
})
}
})
}
// verifyResourceCount verifies that the expected number of resources exist in K8s storage
func verifyResourceCount(t *testing.T, client *apis.K8sResourceClient, expectedCount int) {
t.Helper()
l, err := client.Resource.List(context.Background(), metav1.ListOptions{})
require.NoError(t, err)
resources, err := meta.ExtractList(l)
require.NoError(t, err)
require.Equal(t, expectedCount, len(resources))
}
// verifyResource verifies that a resource with the given UID exists in K8s storage
func verifyResource(t *testing.T, client *apis.K8sResourceClient, uid string, shouldExist bool) {
t.Helper()
_, err := client.Resource.Get(context.Background(), uid, metav1.GetOptions{})
if shouldExist {
require.NoError(t, err)
} else {
require.Error(t, err)
}
}
+34 -4
View File
@@ -93,7 +93,18 @@ type K8sTestHelper struct {
userSvc user.Service
}
type K8sTestHelperOpts struct {
testinfra.GrafanaOpts
// If provided, these users will be used instead of creating new ones
Org1Users *OrgUsers
OrgBUsers *OrgUsers
}
func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
return NewK8sTestHelperWithOpts(t, K8sTestHelperOpts{GrafanaOpts: opts})
}
func NewK8sTestHelperWithOpts(t *testing.T, opts K8sTestHelperOpts) *K8sTestHelper {
t.Helper()
// Use GRPC server when not configured
@@ -111,9 +122,12 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
path = opts.DirPath
)
if opts.Dir == "" && opts.DirPath == "" {
dir, path = testinfra.CreateGrafDir(t, opts)
dir, path = testinfra.CreateGrafDir(t, opts.GrafanaOpts)
}
listenerAddress, env, testDB := testinfra.StartGrafanaEnvWithDB(t, dir, path)
if !opts.DisableDBCleanup {
t.Cleanup(testDB.Cleanup)
}
listenerAddress, env := testinfra.StartGrafanaEnv(t, dir, path)
c := &K8sTestHelper{
env: *env,
@@ -143,8 +157,24 @@ func NewK8sTestHelper(t *testing.T, opts testinfra.GrafanaOpts) *K8sTestHelper {
_ = c.CreateOrg(Org1)
_ = c.CreateOrg(Org2)
c.Org1 = c.createTestUsers(Org1)
c.OrgB = c.createTestUsers(Org2)
if opts.Org1Users != nil {
c.Org1 = *opts.Org1Users
c.Org1.Admin.baseURL = listenerAddress
c.Org1.Editor.baseURL = listenerAddress
c.Org1.Viewer.baseURL = listenerAddress
c.Org1.None.baseURL = listenerAddress
} else {
c.Org1 = c.createTestUsers(Org1)
}
if opts.OrgBUsers != nil {
c.OrgB = *opts.OrgBUsers
c.OrgB.Admin.baseURL = listenerAddress
c.OrgB.Editor.baseURL = listenerAddress
c.OrgB.Viewer.baseURL = listenerAddress
c.OrgB.None.baseURL = listenerAddress
} else {
c.OrgB = c.createTestUsers(Org2)
}
c.loadAPIGroups()
+15 -2
View File
@@ -49,6 +49,12 @@ func StartGrafana(t *testing.T, grafDir, cfgPath string) (string, db.DB) {
}
func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv) {
addr, env, testDB := StartGrafanaEnvWithDB(t, grafDir, cfgPath)
t.Cleanup(testDB.Cleanup)
return addr, env
}
func StartGrafanaEnvWithDB(t *testing.T, grafDir, cfgPath string) (string, *server.TestEnv, *sqlutil.TestDB) {
t.Helper()
ctx := context.Background()
@@ -93,7 +99,6 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
// Use proper database type based on the environment variable GRAFANA_TEST_DB in tests
testDB, err := sqlutil.GetTestDB(sqlutil.GetTestDBType())
require.NoError(t, err)
t.Cleanup(testDB.Cleanup)
dbCfg := cfg.Raw.Section("database")
dbCfg.Key("type").SetValue(testDB.DriverName)
@@ -169,7 +174,7 @@ func StartGrafanaEnv(t *testing.T, grafDir, cfgPath string) (string, *server.Tes
t.Logf("Grafana is listening on %s", addr)
return addr, env
return addr, env, testDB
}
// CreateGrafDir creates the Grafana directory.
@@ -538,6 +543,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) {
_, err = section.NewKey("max_page_size_bytes", fmt.Sprintf("%d", opts.UnifiedStorageMaxPageSizeBytes))
require.NoError(t, err)
}
if opts.DisableDataMigrations {
section, err := getOrCreateSection("unified_storage")
require.NoError(t, err)
_, err = section.NewKey("disable_data_migrations", "true")
require.NoError(t, err)
}
if opts.PermittedProvisioningPaths != "" {
_, err = pathsSect.NewKey("permitted_provisioning_paths", opts.PermittedProvisioningPaths)
require.NoError(t, err)
@@ -637,6 +648,8 @@ type GrafanaOpts struct {
EnableSCIM bool
APIServerRuntimeConfig string
DisableControllers bool
DisableDBCleanup bool
DisableDataMigrations bool
SecretsManagerEnableDBMigrations bool
// Allow creating grafana dir beforehand
@@ -39,6 +39,7 @@ export type GrafanaPromRulesOptions = Omit<PromRulesOptions, 'ruleSource' | 'nam
dashboardUid?: string;
panelId?: number;
limitAlerts?: number;
ruleLimit?: number;
contactPoint?: string;
health?: RuleHealth[];
state?: PromAlertingRuleState[];
@@ -93,6 +94,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
state,
type,
groupLimit,
ruleLimit,
limitAlerts,
groupNextToken,
title,
@@ -109,6 +111,7 @@ export const prometheusApi = alertingApi.injectEndpoints({
state: state,
rule_type: type,
limit_alerts: limitAlerts,
rule_limit: ruleLimit?.toFixed(0),
group_limit: groupLimit?.toFixed(0),
group_next_token: groupNextToken,
'search.rule_name': title,
@@ -16,14 +16,13 @@ import { GrafanaRuleListItem } from './GrafanaRuleListItem';
import LoadMoreHelper from './LoadMoreHelper';
import { UnknownRuleListItem } from './components/AlertRuleListItem';
import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader';
import { hasClientSideFilters } from './hooks/grafanaFilter';
import {
GrafanaRuleWithOrigin,
PromRuleWithOrigin,
RuleWithOrigin,
useFilteredRulesIteratorProvider,
} from './hooks/useFilteredRulesIterator';
import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits';
import { FRONTEND_LIST_PAGE_SIZE, getFilteredRulesLimits } from './paginationLimits';
interface FilterViewProps {
filterState: RulesFilter;
@@ -78,10 +77,7 @@ function FilterViewResults({ filterState }: FilterViewProps) {
* ⚠️ Make sure we are returning / using a "iterator" and not an "iterable" since the iterable is only a blueprint
* and the iterator will allow us to exhaust the iterable in a stateful way
*/
const { iterable, abortController } = getFilteredRulesIterator(
filterState,
getSearchApiGroupPageSize(hasClientSideFilters(filterState))
);
const { iterable, abortController } = getFilteredRulesIterator(filterState, getFilteredRulesLimits(filterState));
const rulesBatchIterator = iterable
.pipe(
bufferCountOrTime(FRONTEND_LIST_PAGE_SIZE, 1000),
@@ -7,7 +7,6 @@ import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-ale
import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton';
import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA';
import { shouldUseBackendFilters } from '../featureToggles';
import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource';
import { groups } from '../utils/navigation';
@@ -18,7 +17,7 @@ import { ListGroup } from './components/ListGroup';
import { ListSection } from './components/ListSection';
import { LoadMoreButton } from './components/LoadMoreButton';
import { NoRulesFound } from './components/NoRulesFound';
import { getGrafanaFilter } from './hooks/grafanaFilter';
import { getGrafanaFilter, hasGrafanaClientSideFilters } from './hooks/grafanaFilter';
import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator';
import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups';
import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits';
@@ -36,25 +35,27 @@ export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderP
}
function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) {
const useBackendFilters = shouldUseBackendFilters();
// When backend filters are enabled, groupFilter is handled on the backend
const hasFilters = useBackendFilters ? Boolean(namespaceFilter) : Boolean(groupFilter || namespaceFilter);
const filterState = { namespace: namespaceFilter, groupName: groupFilter };
const { backendFilter } = getGrafanaFilter(filterState);
const hasFilters = Boolean(groupFilter || namespaceFilter);
const needsClientSideFiltering = hasGrafanaClientSideFilters(filterState);
// If there are filters, we don't want to populate the cache to avoid performance issues
// Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({
populateCache: hasFilters ? false : true,
populateCache: needsClientSideFiltering ? false : true,
limitAlerts: 0,
});
// If there are no filters we can match one frontend page to one API page.
// However, if there are filters, we need to fetch more groups from the API to populate one frontend page
const apiGroupPageSize = getApiGroupPageSize(hasFilters);
const apiGroupPageSize = getApiGroupPageSize(needsClientSideFiltering);
const searchGroupName = useBackendFilters ? groupFilter : undefined;
const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize, { searchGroupName })));
const groupsGenerator = useRef(
toIndividualRuleGroups(grafanaGroupsGenerator({ groupLimit: apiGroupPageSize }, backendFilter))
);
useEffect(() => {
const currentGenerator = groupsGenerator.current;
@@ -22,6 +22,27 @@ import {
ruleTypeFilter,
} from './filterPredicates';
/**
* Determines if client-side filtering is needed for data source-managed rules.
*/
export function hasDatasourceClientSideFilters(filterState: Partial<RulesFilter>): boolean {
// Check if any filter that applies to datasource rules is active
return (
(filterState.freeFormWords && filterState.freeFormWords.length > 0) ||
Boolean(filterState.ruleName) ||
Boolean(filterState.ruleState) ||
Boolean(filterState.ruleType) ||
(filterState.dataSourceNames && filterState.dataSourceNames.length > 0) ||
(filterState.labels && filterState.labels.length > 0) ||
Boolean(filterState.ruleHealth) ||
Boolean(filterState.dashboardUid) ||
Boolean(filterState.plugins) ||
Boolean(filterState.contactPoint) ||
Boolean(filterState.namespace) ||
Boolean(filterState.groupName)
);
}
/**
* Builds filter configurations for data source-managed alert rules.
*
@@ -32,12 +32,14 @@ export function buildTitleSearch(filterState: RulesFilter): string | undefined {
* Normalize filter state for case-insensitive matching
* Lowercase free form words, rule name, group name and namespace
*/
export function normalizeFilterState(filterState: RulesFilter): RulesFilter {
export function normalizeFilterState(filterState: Partial<RulesFilter>): RulesFilter {
return {
...filterState,
freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()),
freeFormWords: filterState.freeFormWords?.map((word) => word.toLowerCase()) ?? [],
ruleName: filterState.ruleName?.toLowerCase(),
groupName: filterState.groupName?.toLowerCase(),
namespace: filterState.namespace?.toLowerCase(),
dataSourceNames: filterState.dataSourceNames ?? [],
labels: filterState.labels ?? [],
};
}
@@ -8,7 +8,7 @@ import { Annotation } from '../../utils/constants';
import { getDatasourceAPIUid } from '../../utils/datasource';
import { getFilter } from '../../utils/search';
import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter';
import { getGrafanaFilter, hasGrafanaClientSideFilters } from './grafanaFilter';
jest.mock('../../utils/datasource');
@@ -670,41 +670,41 @@ describe('grafana-managed rules', () => {
});
});
describe('hasClientSideFilters', () => {
describe('hasGrafanaClientSideFilters', () => {
describe('when alertingUIUseBackendFilters is disabled', () => {
testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] });
it('should return false when no filters are applied', () => {
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
});
it('should return true for title-related filters (freeFormWords, ruleName)', () => {
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
});
it('should return true for ruleType filter', () => {
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true);
});
it('should return true for dashboardUid filter', () => {
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true);
});
it('should return true for groupName filter', () => {
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
});
it('should return true for client-side only filters', () => {
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
it('should return false for backend-only filters (state, health, contactPoint)', () => {
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
});
});
@@ -712,36 +712,36 @@ describe('grafana-managed rules', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] });
it('should return false when no filters are applied', () => {
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
});
it('should return false for title-related filters (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
});
it('should return false for ruleType filter (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
});
it('should return false for dashboardUid filter (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
});
it('should return false for groupName filter (handled by backend)', () => {
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
});
it('should return true for client-side only filters', () => {
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
it('should return false for backend-only filters (state, health, contactPoint)', () => {
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
});
});
@@ -750,20 +750,20 @@ describe('grafana-managed rules', () => {
it('should return correct values for all filter types', () => {
// Should return false for: empty, backend-handled (ruleType, dashboardUid), and backend-only filters
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: frontend-handled filters
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
});
@@ -772,20 +772,20 @@ describe('grafana-managed rules', () => {
it('should return correct values for all filter types', () => {
// Should return false for: empty, all backend-handled filters, and backend-only filters
expect(hasClientSideFilters(getFilter({}))).toBe(false);
expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({}))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false);
expect(hasGrafanaClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false);
// Should return true for: always-frontend filters only
expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true);
expect(hasGrafanaClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true);
});
});
});
@@ -24,26 +24,26 @@ import {
/**
* Determines if client-side filtering is needed for Grafana-managed rules.
*/
export function hasClientSideFilters(filterState: RulesFilter): boolean {
export function hasGrafanaClientSideFilters(filterState: Partial<RulesFilter>): boolean {
const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs();
// Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering
const hasActiveRuleFilters =
(ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) ||
(ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) ||
(ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) ||
(ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) ||
(ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) ||
(ruleFilterConfig.labels !== null && filterState.labels.length > 0) ||
(ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) ||
(ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) ||
(ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) ||
(ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint));
(ruleFilterConfig.freeFormWords !== null && Boolean(filterState?.freeFormWords?.length)) ||
(ruleFilterConfig.ruleName !== null && Boolean(filterState?.ruleName)) ||
(ruleFilterConfig.ruleState !== null && Boolean(filterState?.ruleState)) ||
(ruleFilterConfig.ruleType !== null && Boolean(filterState?.ruleType)) ||
(ruleFilterConfig.dataSourceNames !== null && Boolean(filterState?.dataSourceNames?.length)) ||
(ruleFilterConfig.labels !== null && Boolean(filterState?.labels?.length)) ||
(ruleFilterConfig.ruleHealth !== null && Boolean(filterState?.ruleHealth)) ||
(ruleFilterConfig.dashboardUid !== null && Boolean(filterState?.dashboardUid)) ||
(ruleFilterConfig.plugins !== null && Boolean(filterState?.plugins)) ||
(ruleFilterConfig.contactPoint !== null && Boolean(filterState?.contactPoint));
// Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering
const hasActiveGroupFilters =
(groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) ||
(groupFilterConfig.groupName !== null && Boolean(filterState.groupName));
(groupFilterConfig.namespace !== null && Boolean(filterState?.namespace)) ||
(groupFilterConfig.groupName !== null && Boolean(filterState?.groupName));
return hasActiveRuleFilters || hasActiveGroupFilters;
}
@@ -55,7 +55,7 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean {
* The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled,
* while the frontend filter provides client-side matching functions for rules and groups.
*/
export function getGrafanaFilter(filterState: RulesFilter) {
export function getGrafanaFilter(filterState: Partial<RulesFilter>) {
const normalizedFilterState = normalizeFilterState(filterState);
const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs();
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { MergeExclusive } from 'type-fest';
import { DataSourceRulesSourceIdentifier, RuleHealth } from 'app/types/unified-alerting';
import { PromAlertingRuleState, PromRuleGroupDTO } from 'app/types/unified-alerting-dto';
@@ -16,27 +17,23 @@ interface UseGeneratorHookOptions {
limitAlerts?: number;
}
interface FetchGroupsOptions {
groupLimit?: number;
groupNextToken?: string;
}
export function usePrometheusGroupsGenerator() {
const [getGroups] = useLazyGetGroupsQuery();
return useCallback(
async function* (ruleSource: DataSourceRulesSourceIdentifier, groupLimit: number) {
const getRuleSourceGroupsWithCache = async (fetchOptions: FetchGroupsOptions) => {
const getRuleSourceGroupsWithCache = async (fetchOptions: GroupsNextPageOptions) => {
const response = await getGroups({
ruleSource: { uid: ruleSource.uid },
notificationOptions: { showErrorAlert: false },
groupLimit,
...fetchOptions,
}).unwrap();
return response;
};
yield* genericGroupsGenerator(getRuleSourceGroupsWithCache, groupLimit);
yield* genericGroupsGenerator(getRuleSourceGroupsWithCache);
},
[getGroups]
);
@@ -52,8 +49,21 @@ interface GrafanaPromApiFilter {
dashboardUid?: string;
}
interface GrafanaFetchGroupsOptions extends FetchGroupsOptions {
interface GrafanaFetchGroupsOptions extends GroupsNextPageOptions {
filter?: GrafanaPromApiFilter;
groupLimit?: number;
// Limits the number of total rules returned across all groups
// Rounds up to full groups, so the response may contain more rules than the group limit
ruleLimit?: number;
}
export type GrafanaFetchGroupsLimit = MergeExclusive<{ groupLimit: number }, { ruleLimit: number }>;
export type DataSourceFetchGroupsLimit = { groupLimit: number };
export interface FetchGroupsLimitOptions {
grafanaManagedLimit: GrafanaFetchGroupsLimit;
datasourceManagedLimit: DataSourceFetchGroupsLimit;
}
export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions = {}) {
@@ -78,11 +88,16 @@ export function useGrafanaGroupsGenerator(hookOptions: UseGeneratorHookOptions =
);
return useCallback(
async function* (groupLimit: number, filter?: GrafanaPromApiFilter) {
yield* genericGroupsGenerator(
(fetchOptions) => getGroupsAndProvideCache({ ...fetchOptions, filter }),
groupLimit
);
async function* (limit: GrafanaFetchGroupsLimit, filter?: GrafanaPromApiFilter) {
const fetchGroups = (fetchOptions: GroupsNextPageOptions) =>
getGroupsAndProvideCache({
...fetchOptions,
filter,
groupLimit: 'groupLimit' in limit ? limit.groupLimit : undefined,
ruleLimit: 'ruleLimit' in limit ? limit.ruleLimit : undefined,
});
yield* genericGroupsGenerator(fetchGroups);
},
[getGroupsAndProvideCache]
);
@@ -105,21 +120,24 @@ export function toIndividualRuleGroups<TGroup extends PromRuleGroupDTO>(
})();
}
interface GroupsNextPageOptions {
groupNextToken?: string;
}
// Generator lazily provides groups one by one only when needed
// This might look a bit complex but it allows us to have one API for paginated and non-paginated Prometheus data sources
// For unpaginated data sources we fetch everything in one go
// For paginated we fetch the next page when needed
async function* genericGroupsGenerator<TGroup>(
fetchGroups: (options: FetchGroupsOptions) => Promise<PromRulesResponse<TGroup>>,
groupLimit: number
fetchGroups: (options: GroupsNextPageOptions) => Promise<PromRulesResponse<TGroup>>
) {
let response = await fetchGroups({ groupLimit });
let response = await fetchGroups({ groupNextToken: undefined });
yield response.data.groups;
let lastToken: string | undefined = response.data?.groupNextToken;
while (lastToken) {
response = await fetchGroups({ groupNextToken: lastToken, groupLimit: groupLimit });
response = await fetchGroups({ groupNextToken: lastToken });
yield response.data.groups;
lastToken = response.data?.groupNextToken;
}
@@ -26,7 +26,11 @@ import { RulePositionHash, createRulePositionHash } from '../rulePositionHash';
import { getDatasourceFilter } from './datasourceFilter';
import { getGrafanaFilter } from './grafanaFilter';
import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator';
import {
FetchGroupsLimitOptions,
useGrafanaGroupsGenerator,
usePrometheusGroupsGenerator,
} from './prometheusGroupsGenerator';
export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin;
@@ -74,7 +78,7 @@ export function useFilteredRulesIteratorProvider() {
const prometheusGroupsGenerator = usePrometheusGroupsGenerator();
const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ limitAlerts: 0 });
const getFilteredRulesIterable = (filterState: RulesFilter, groupLimit: number): GetIteratorResult => {
const getFilteredRulesIterable = (filterState: RulesFilter, options: FetchGroupsLimitOptions): GetIteratorResult => {
/* this is the abort controller that allows us to stop an AsyncIterable */
const abortController = new AbortController();
@@ -83,7 +87,7 @@ export function useFilteredRulesIteratorProvider() {
const { backendFilter, frontendFilter } = getGrafanaFilter(filterState);
const grafanaRulesGenerator: AsyncIterableX<RuleWithOrigin> = from(
grafanaGroupsGenerator(groupLimit, backendFilter)
grafanaGroupsGenerator(options.grafanaManagedLimit, backendFilter)
).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
@@ -110,7 +114,7 @@ export function useFilteredRulesIteratorProvider() {
const dataSourceGenerators: Array<AsyncIterableX<RuleWithOrigin>> = externalRulesSourcesToFetchFrom.map(
(dataSourceIdentifier) => {
const promGroupsGenerator: AsyncIterableX<RuleWithOrigin> = from(
prometheusGroupsGenerator(dataSourceIdentifier, groupLimit)
prometheusGroupsGenerator(dataSourceIdentifier, options.datasourceManagedLimit.groupLimit)
).pipe(
withAbort(abortController.signal),
concatMap((groups) =>
@@ -0,0 +1,181 @@
import { testWithFeatureToggles } from 'test/test-utils';
import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto';
import { RuleHealth, RulesFilter } from '../search/rulesSearchParser';
import { getFilter } from '../utils/search';
import {
FILTERED_GROUPS_LARGE_API_PAGE_SIZE,
FILTERED_GROUPS_SMALL_API_PAGE_SIZE,
RULE_LIMIT_WITH_BACKEND_FILTERS,
getFilteredRulesLimits,
} from './paginationLimits';
describe('paginationLimits', () => {
describe('getFilteredRulesLimits', () => {
describe('when backend filters are disabled', () => {
testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] });
it('should return small limits when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])('should return small grafana limit + large datasource limit for backend-only filter: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ ruleType: PromRuleType.Alerting },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
{ dashboardUid: 'test-dashboard' },
{ plugins: 'hide' as const },
{ namespace: 'production' },
{ groupName: 'test-group' },
{ namespace: 'production', freeFormWords: ['cpu'] },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
describe('when alertingUIUseBackendFilters is enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] });
it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ ruleType: PromRuleType.Alerting },
{ dashboardUid: 'test-dashboard' },
{ groupName: 'test-group' },
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
{ ruleState: PromAlertingRuleState.Firing, namespace: 'production' },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
describe('when alertingUIUseFullyCompatBackendFilters is enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseFullyCompatBackendFilters'] });
it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ ruleType: PromRuleType.Alerting },
{ dashboardUid: 'test-dashboard' },
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ groupName: 'test-group' },
{ namespace: 'production' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
describe('when both backend filter toggles are enabled', () => {
testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters', 'alertingUIUseFullyCompatBackendFilters'] });
it('should return rule limit for grafana + default limit for datasource when no filters are applied', () => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter({}));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_SMALL_API_PAGE_SIZE });
});
it.each<Partial<RulesFilter>>([
{ freeFormWords: ['cpu'] },
{ ruleName: 'alert' },
{ ruleType: PromRuleType.Alerting },
{ dashboardUid: 'test-dashboard' },
{ groupName: 'test-group' },
{ ruleState: PromAlertingRuleState.Firing },
{ ruleHealth: RuleHealth.Ok },
{ contactPoint: 'slack' },
])(
'should return rule limit for grafana + large limit for datasource when only backend filters are used: %p',
(filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
}
);
it.each<Partial<RulesFilter>>([
{ namespace: 'production' },
{ dataSourceNames: ['prometheus'] },
{ labels: ['severity=critical'] },
])('should return large limits for both when frontend filters are used: %p', (filterState) => {
const { grafanaManagedLimit, datasourceManagedLimit } = getFilteredRulesLimits(getFilter(filterState));
expect(grafanaManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
expect(datasourceManagedLimit).toEqual({ groupLimit: FILTERED_GROUPS_LARGE_API_PAGE_SIZE });
});
});
});
});
@@ -1,3 +1,10 @@
import { shouldUseBackendFilters, shouldUseFullyCompatibleBackendFilters } from '../featureToggles';
import { RulesFilter } from '../search/rulesSearchParser';
import { hasDatasourceClientSideFilters } from './hooks/datasourceFilter';
import { hasGrafanaClientSideFilters } from './hooks/grafanaFilter';
import { FetchGroupsLimitOptions } from './hooks/prometheusGroupsGenerator';
export const FRONTEND_LIST_PAGE_SIZE = 100;
export const FILTERED_GROUPS_LARGE_API_PAGE_SIZE = 2000;
@@ -6,6 +13,8 @@ export const FILTERED_GROUPS_SMALL_API_PAGE_SIZE = 100;
export const DEFAULT_GROUPS_API_PAGE_SIZE = 40;
export const FRONTED_GROUPED_PAGE_SIZE = DEFAULT_GROUPS_API_PAGE_SIZE;
export const RULE_LIMIT_WITH_BACKEND_FILTERS = 100;
export function getApiGroupPageSize(hasFilters: boolean) {
return hasFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : DEFAULT_GROUPS_API_PAGE_SIZE;
}
@@ -13,3 +22,27 @@ export function getApiGroupPageSize(hasFilters: boolean) {
export function getSearchApiGroupPageSize(hasFrontendFilters: boolean) {
return hasFrontendFilters ? FILTERED_GROUPS_LARGE_API_PAGE_SIZE : FILTERED_GROUPS_SMALL_API_PAGE_SIZE;
}
export function getFilteredRulesLimits(filterState: RulesFilter): FetchGroupsLimitOptions {
return {
grafanaManagedLimit: getGrafanaFilterLimits(filterState),
datasourceManagedLimit: {
groupLimit: hasDatasourceClientSideFilters(filterState)
? FILTERED_GROUPS_LARGE_API_PAGE_SIZE
: FILTERED_GROUPS_SMALL_API_PAGE_SIZE,
},
};
}
function getGrafanaFilterLimits(filterState: RulesFilter) {
const backendFiltersEnabled = shouldUseFullyCompatibleBackendFilters() || shouldUseBackendFilters();
const frontendFiltersInUse = hasGrafanaClientSideFilters(filterState);
const onlyBackendFiltersInUse = frontendFiltersInUse === false;
if (backendFiltersEnabled && onlyBackendFiltersInUse) {
return { ruleLimit: RULE_LIMIT_WITH_BACKEND_FILTERS };
}
return { groupLimit: getSearchApiGroupPageSize(frontendFiltersInUse) };
}
@@ -22,7 +22,7 @@ jest.mock('./scopesUtils', () => {
});
const mockScopeServicesState = {
updateNode: jest.fn(),
filterNode: jest.fn(),
selectScope: jest.fn(),
resetSelection: jest.fn(),
nodes: {},
@@ -99,12 +99,12 @@ describe('useRegisterScopesActions', () => {
});
it('should register scope tree actions and return scopesRow when scopes are selected', () => {
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
});
@@ -112,14 +112,14 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('', jest.fn());
});
expect(mockUpdateNode).toHaveBeenCalledWith('', true, '');
expect(mockFilterNode).toHaveBeenCalledWith('', '');
expect(useRegisterActions).toHaveBeenLastCalledWith([rootScopeAction], [[rootScopeAction]]);
expect(result.current.scopesRow).toBeDefined();
// Simulate loading of scopes in the service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
selectedScopes: [{ scopeId: 'scope1', name: 'Scope 1' }],
nodes,
tree,
@@ -151,12 +151,12 @@ describe('useRegisterScopesActions', () => {
});
it('should load next level of scopes', () => {
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
nodes,
tree,
});
@@ -165,7 +165,7 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('', jest.fn(), 'scopes/scope1');
});
expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, '');
expect(mockFilterNode).toHaveBeenCalledWith('scope1', '');
});
it('does not return component if no scopes are selected', () => {
@@ -259,12 +259,12 @@ describe('useRegisterScopesActions', () => {
});
it('should not use global scope search when searching in some deeper scope category', async () => {
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
nodes,
tree,
});
@@ -273,17 +273,17 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('something', jest.fn(), 'scopes/scope1');
});
expect(mockUpdateNode).toHaveBeenCalledWith('scope1', true, 'something');
expect(mockFilterNode).toHaveBeenCalledWith('scope1', 'something');
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
});
it('should not use global scope search if feature flag is off', async () => {
config.featureToggles.scopeSearchAllLevels = false;
const mockUpdateNode = jest.fn();
const mockFilterNode = jest.fn();
// First run with empty scopes in the scopes service
(useScopeServicesState as jest.Mock).mockReturnValue({
...mockScopeServicesState,
updateNode: mockUpdateNode,
filterNode: mockFilterNode,
nodes,
tree,
});
@@ -292,7 +292,7 @@ describe('useRegisterScopesActions', () => {
return useRegisterScopesActions('something', jest.fn(), '');
});
expect(mockUpdateNode).toHaveBeenCalledWith('', true, 'something');
expect(mockFilterNode).toHaveBeenCalledWith('', 'something');
expect(mockScopeServicesState.searchAllNodes).not.toHaveBeenCalled();
});
@@ -58,22 +58,22 @@ export function useRegisterScopesActions(
* @param parentId
*/
function useScopeTreeActions(searchQuery: string, parentId?: string | null) {
const { updateNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
const { filterNode, selectScope, resetSelection, nodes, tree, selectedScopes } = useScopeServicesState();
// Initialize the scopes the first time this runs and reset the scopes that were selected on unmount.
useEffect(() => {
updateNode('', true, '');
filterNode('', '');
resetSelection();
return () => {
resetSelection();
};
}, [updateNode, resetSelection]);
}, [filterNode, resetSelection]);
// Load the next level of scopes when the parentId changes.
useEffect(() => {
const parentScopeId = !parentId || parentId === 'scopes' ? '' : last(parentId.split('/'))!;
updateNode(parentScopeId, true, searchQuery);
}, [updateNode, searchQuery, parentId]);
filterNode(parentScopeId, searchQuery);
}, [filterNode, searchQuery, parentId]);
return useMemo(
() => mapScopesNodesTreeToActions(nodes, tree!, selectedScopes, selectScope),
@@ -14,7 +14,7 @@ export function useScopeServicesState() {
const services = useScopesServices();
if (!services) {
return {
updateNode: () => {},
filterNode: () => Promise.resolve(),
selectScope: () => {},
resetSelection: () => {},
searchAllNodes: () => Promise.resolve([]),
@@ -32,7 +32,7 @@ export function useScopeServicesState() {
},
};
}
const { updateNode, filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } =
const { filterNode, selectScope, resetSelection, searchAllNodes, deselectScope, apply, getScopeNodes } =
services.scopesSelectorService;
const selectorServiceState: ScopesSelectorServiceState | undefined = useObservable(
services.scopesSelectorService.stateObservable ?? new Observable(),
@@ -42,7 +42,6 @@ export function useScopeServicesState() {
return {
getScopeNodes,
filterNode,
updateNode,
selectScope,
resetSelection,
searchAllNodes,
@@ -68,22 +68,29 @@ export class ConditionalRenderingData extends SceneObjectBase<ConditionalRenderi
};
}
private _getObjectDataProvider(): SceneDataProvider | undefined {
private _getPanelFromObject(): VizPanel | undefined {
const object = getObject(this);
if (!object) {
return undefined;
}
let panel: VizPanel | undefined;
if (object instanceof VizPanel) {
return object;
}
for (const val of Object.values(object.state)) {
if (val instanceof VizPanel) {
panel = val;
break;
return val;
}
}
return undefined;
}
private _getObjectDataProvider(): SceneDataProvider | undefined {
const panel = this._getPanelFromObject();
if (!panel) {
return undefined;
}
@@ -133,6 +140,10 @@ export class ConditionalRenderingData extends SceneObjectBase<ConditionalRenderi
}
}
public forceCheck() {
this._check();
}
public renderCmp(): ReactElement {
return <this.Component model={this} key={this.state.key} />;
}
@@ -80,6 +80,10 @@ export class ConditionalRenderingTimeRangeSize extends SceneObjectBase<Condition
}
}
public forceCheck() {
this._check();
}
public renderCmp(): ReactElement {
return <this.Component model={this} key={this.state.key} />;
}
@@ -20,7 +20,7 @@ import { getLowerTranslatedObjectType } from '../object';
import { ConditionalRenderingConditionWrapper } from './ConditionalRenderingConditionWrapper';
import { ConditionalRenderingConditionsSerializerRegistryItem } from './serializers';
import { checkGroup, getObjectType } from './utils';
import { checkGroup, getObject, getObjectType } from './utils';
type VariableConditionValueOperator = '=' | '!=' | '=~' | '!~';
@@ -40,14 +40,6 @@ export class ConditionalRenderingVariable extends SceneObjectBase<ConditionalRen
deserialize: this.deserialize,
};
protected _variableDependency = new VariableDependencyConfig(this, {
onAnyVariableChanged: (v) => {
if (v.state.name === this.state.variable) {
this._check();
}
},
});
public constructor(state: ConditionalRenderingVariableState) {
super(state);
@@ -55,6 +47,20 @@ export class ConditionalRenderingVariable extends SceneObjectBase<ConditionalRen
}
private _activationHandler() {
const object = getObject(this);
if (!object) {
return;
}
this._variableDependency = new VariableDependencyConfig(object, {
onAnyVariableChanged: (v) => {
if (v.state.name === this.state.variable) {
this._check();
}
},
});
this.forEachChild((child) => {
if (!child.isActive) {
this._subs.add(child.activate());
@@ -78,7 +84,13 @@ export class ConditionalRenderingVariable extends SceneObjectBase<ConditionalRen
return undefined;
}
const variable = sceneGraph.getVariables(this).getByName(this.state.variable);
const object = getObject(this);
if (!object) {
return undefined;
}
const variable = sceneGraph.getVariables(object).getByName(this.state.variable);
if (!variable) {
return undefined;
@@ -127,6 +139,10 @@ export class ConditionalRenderingVariable extends SceneObjectBase<ConditionalRen
}
}
public forceCheck() {
this._check();
}
public renderCmp(): ReactElement {
return <this.Component model={this} key={this.state.key} />;
}
@@ -14,6 +14,13 @@ export function getGroup(condition: ConditionalRenderingConditions): Conditional
}
export function getObject(condition: ConditionalRenderingConditions): SceneObject | undefined {
const group = getGroup(condition);
const groupTarget = group.getTarget();
if (groupTarget) {
return groupTarget;
}
return getGroup(condition).parent;
}
@@ -2,7 +2,14 @@ import { lowerCase } from 'lodash';
import { useMemo } from 'react';
import { t } from '@grafana/i18n';
import { SceneComponentProps, sceneGraph, SceneObjectBase, SceneObjectState } from '@grafana/scenes';
import {
SceneComponentProps,
sceneGraph,
SceneObject,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
} from '@grafana/scenes';
import { ConditionalRenderingGroupKind } from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { Stack } from '@grafana/ui';
@@ -33,6 +40,7 @@ export class ConditionalRenderingGroup extends SceneObjectBase<ConditionalRender
private _shouldShow: boolean;
private _shouldMatchAll: boolean;
private _target?: SceneObjectRef<SceneObject>;
public constructor(state: ConditionalRenderingGroupState) {
super(state);
@@ -52,6 +60,19 @@ export class ConditionalRenderingGroup extends SceneObjectBase<ConditionalRender
this.check();
}
public setTarget(target: SceneObject | undefined) {
this._target = target ? target.getRef() : undefined;
this.forceCheck();
}
public getTarget(): SceneObject | undefined {
return this._target?.resolve();
}
public forceCheck() {
this.state.conditions.forEach((condition) => condition.forceCheck());
}
public check() {
// Filter out undefined results
// Because we negate the result if shouldShow is false, we can use `condition.state.result ?? true` directly below
@@ -1,12 +1,13 @@
import { ReactNode } from 'react';
import { SceneObject, useSceneObjectState } from '@grafana/scenes';
import { useSceneObjectState } from '@grafana/scenes';
import { ConditionalRenderingGroup } from '../group/ConditionalRenderingGroup';
import { ConditionalRenderingOverlay } from './ConditionalRenderingOverlay';
let placeholderConditionalRendering: ConditionalRenderingGroup | undefined;
function getPlaceholderConditionalRendering(): ConditionalRenderingGroup {
if (!placeholderConditionalRendering) {
placeholderConditionalRendering = ConditionalRenderingGroup.createEmpty();
@@ -14,13 +15,10 @@ function getPlaceholderConditionalRendering(): ConditionalRenderingGroup {
return placeholderConditionalRendering;
}
export function useIsConditionallyHidden(scene: SceneObject): [boolean, string | undefined, ReactNode | null, boolean] {
const conditionalRenderingToRender =
'conditionalRendering' in scene.state && scene.state.conditionalRendering instanceof ConditionalRenderingGroup
? scene.state.conditionalRendering
: getPlaceholderConditionalRendering();
const { result, renderHidden } = useSceneObjectState(conditionalRenderingToRender, {
export function useIsConditionallyHidden(
conditionalRendering: ConditionalRenderingGroup = getPlaceholderConditionalRendering()
): [boolean, string | undefined, ReactNode | null, boolean] {
const { result, renderHidden } = useSceneObjectState(conditionalRendering, {
shouldActivateOrKeepAlive: true,
});
@@ -1,7 +1,7 @@
import { capitalize, lowerCase } from 'lodash';
import { t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { SceneObject, VizPanel } from '@grafana/scenes';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
import { RowItem } from '../scene/layout-rows/RowItem';
@@ -50,7 +50,7 @@ export function getLowerTranslatedObjectType(type: ObjectsWithConditionalRenderi
export function extractObjectType(object: SceneObject | undefined): ObjectsWithConditionalRendering {
if (!object) {
return 'element';
} else if (object instanceof AutoGridItem) {
} else if (object instanceof AutoGridItem || object instanceof VizPanel) {
return 'panel';
} else if (object instanceof RowItem) {
return 'row';
@@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Sidebar, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { Box, Icon, Sidebar, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { DashboardInteractions } from '../utils/interactions';
@@ -85,6 +85,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
aria-selected={isSelected}
className={styles.container}
onClick={onNodeClicked}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={{ '--depth': depth } as React.CSSProperties}
>
<div className={cx(styles.row, { [styles.rowSelected]: isSelected })}>
@@ -99,7 +100,7 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
</button>
)}
<button
className={cx(styles.nodeName, { [styles.nodeNameClone]: isCloned })}
className={cx(styles.nodeButton, { [styles.nodeButtonClone]: isCloned })}
onDoubleClick={outlineRename.onNameDoubleClicked}
data-testid={selectors.components.PanelEditor.Outline.item(instanceName)}
>
@@ -116,10 +117,10 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
/>
) : (
<>
<Stack direction="row" gap={0.5} alignItems="center" grow={1}>
<span>{instanceName}</span>
<div className={styles.nodeName}>
<Text truncate>{instanceName}</Text>
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
</Stack>
</div>
{isCloned && (
<span>
<Trans i18nKey="dashboard.outline.repeated-item">Repeat</Trans>
@@ -144,9 +145,20 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }
/>
))
) : (
<Text color="secondary" element="li">
<Trans i18nKey="dashboard.outline.tree-item.empty">(empty)</Trans>
</Text>
<li
role="treeitem"
aria-selected={isSelected}
className={styles.container}
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
style={{ '--depth': depth + 1 } as React.CSSProperties}
>
<div className={styles.row}>
<div className={styles.indentation}></div>
<Text color="secondary" italic>
<Trans i18nKey="dashboard.outline.tree-item.empty">(empty)</Trans>
</Text>
</div>
</li>
)}
</ul>
)}
@@ -197,7 +209,7 @@ function getStyles(theme: GrafanaTheme2) {
color: 'inherit',
lineHeight: 0,
}),
nodeName: css({
nodeButton: css({
boxShadow: 'none',
border: 'none',
background: 'transparent',
@@ -215,11 +227,18 @@ function getStyles(theme: GrafanaTheme2) {
textOverflow: 'ellipsis',
},
}),
nodeName: css({
display: 'flex',
gap: theme.spacing(0.5),
flexGrow: 1,
alignItems: 'center',
overflow: 'hidden',
}),
hiddenIcon: css({
color: theme.colors.text.secondary,
marginLeft: theme.spacing(1),
}),
nodeNameClone: css({
nodeButtonClone: css({
color: theme.colors.text.secondary,
cursor: 'not-allowed',
}),
@@ -958,6 +958,10 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
}
}
export function shouldForceV2API(): boolean {
return Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts);
}
export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase<
DashboardDTO | DashboardWithAccessInfo<DashboardV2Spec>
> {
@@ -970,7 +974,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta
this.v1Manager = new DashboardScenePageStateManager(initialState);
this.v2Manager = new DashboardScenePageStateManagerV2(initialState);
this.activeManager = config.featureToggles.dashboardNewLayouts ? this.v2Manager : this.v1Manager;
this.activeManager = shouldForceV2API() ? this.v2Manager : this.v1Manager;
}
private async withVersionHandling<T>(
@@ -1075,7 +1079,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta
public async loadDashboard(options: LoadDashboardOptions): Promise<void> {
if (options.route === DashboardRoutes.New) {
const newDashboardVersion = config.featureToggles.dashboardNewLayouts ? 'v2' : 'v1';
const newDashboardVersion = shouldForceV2API() ? 'v2' : 'v1';
this.setActiveManager(newDashboardVersion);
}
return this.withVersionHandling((manager) => manager.loadDashboard.call(this, options));
@@ -1089,7 +1093,7 @@ export class UnifiedDashboardScenePageStateManager extends DashboardScenePageSta
}
}
public resetActiveManager() {
this.setActiveManager('v1');
this.activeManager = shouldForceV2API() ? this.v2Manager : this.v1Manager;
}
}
@@ -272,7 +272,7 @@ function getStyles(theme: GrafanaTheme2) {
gap: theme.spacing(1),
marginBottom: theme.spacing(1),
float: 'right',
alignItems: 'center',
alignItems: 'flex-start',
}),
timeControls: css({
display: 'flex',
@@ -31,6 +31,7 @@ export interface AutoGridItemState extends SceneObjectState {
variableName?: string;
isHidden?: boolean;
conditionalRendering?: ConditionalRenderingGroup;
repeatedConditionalRendering?: ConditionalRenderingGroup[];
}
export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements DashboardLayoutItem {
@@ -130,7 +131,21 @@ export class AutoGridItem extends SceneObjectBase<AutoGridItemState> implements
}
}
this.setState({ repeatedPanels });
let repeatedConditionalRendering: ConditionalRenderingGroup[] | undefined;
if (this.state.conditionalRendering) {
repeatedConditionalRendering = repeatedPanels.reduce<ConditionalRenderingGroup[]>((acc, panel) => {
const conditionalRendering = this.state.conditionalRendering!.clone();
conditionalRendering.setTarget(panel);
acc.push(conditionalRendering);
return acc;
}, []);
this.state.conditionalRendering.setTarget(panelToRepeat);
}
this.setState({ repeatedPanels, repeatedConditionalRendering });
this._prevRepeatValues = values;
}
@@ -5,6 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data/';
import { LazyLoader, SceneComponentProps, VizPanel } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup';
import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden';
import { useDashboardState } from '../../utils/utils';
import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext';
@@ -17,8 +18,6 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
const { body, repeatedPanels = [], key } = model.useState();
const { draggingKey } = model.getParentGrid().useState();
const { isEditing, preload } = useDashboardState(model);
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] =
useIsConditionallyHidden(model);
const styles = useStyles2(getStyles);
const soloPanelContext = useSoloPanelContext();
const isLazy = useMemo(() => getIsLazy(preload), [preload]);
@@ -29,18 +28,23 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
memo(
({
item,
conditionalRendering,
addDndContainer,
isDragged,
isDragging,
isRepeat = false,
}: {
item: VizPanel;
conditionalRendering?: ConditionalRenderingGroup;
addDndContainer: boolean;
isDragged: boolean;
isDragging: boolean;
isRepeat?: boolean;
}) =>
isConditionallyHidden && !isEditing && !renderHidden ? null : (
}) => {
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay, renderHidden] =
useIsConditionallyHidden(conditionalRendering);
return isConditionallyHidden && !isEditing && !renderHidden ? null : (
<div
{...(addDndContainer
? { ref: model.containerRef, ['data-auto-grid-item-drop-target']: isDragging ? key : undefined }
@@ -78,19 +82,10 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
)
}
</div>
)
);
}
),
[
conditionalRenderingClass,
conditionalRenderingOverlay,
isLazy,
key,
model.containerRef,
styles,
isConditionallyHidden,
isEditing,
renderHidden,
]
[model, isLazy, key, styles, isEditing]
);
if (soloPanelContext) {
@@ -102,10 +97,18 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps<AutoGridItem
return (
<>
<Wrapper item={body} addDndContainer={true} key={body.state.key!} isDragged={isDragged} isDragging={isDragging} />
{repeatedPanels.map((item) => (
<Wrapper
item={body}
conditionalRendering={model.state.conditionalRendering}
addDndContainer={true}
key={body.state.key!}
isDragged={isDragged}
isDragging={isDragging}
/>
{repeatedPanels.map((item, idx) => (
<Wrapper
item={item}
conditionalRendering={model.state.repeatedConditionalRendering?.[idx]}
addDndContainer={false}
key={item.state.key!}
isDragged={isDragged}
@@ -20,8 +20,9 @@ export function RowItemRenderer({ model }: SceneComponentProps<RowItem>) {
const { layout, collapse: isCollapsed, fillScreen, hideHeader: isHeaderHidden, isDropTarget, key } = model.useState();
const isClone = isRepeatCloneOrChildOf(model);
const { isEditing } = useDashboardState(model);
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] =
useIsConditionallyHidden(model);
const [isConditionallyHidden, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(
model.state.conditionalRendering
);
const { isSelected, onSelect, isSelectable } = useElementSelection(key);
const title = useInterpolatedTitle(model);
const { rows } = model.getParentLayout().useState();
@@ -112,7 +112,10 @@ export function performRowRepeats(variable: MultiValueVariable, row: RowItem, co
});
if (!isSourceRow) {
rowClone.state.conditionalRendering?.setTarget(rowClone);
clonedRows.push(rowClone);
} else {
row.state.conditionalRendering?.setTarget(row);
}
}
@@ -292,6 +292,8 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
const conditionalRendering = tab.state.conditionalRendering;
conditionalRendering?.clearParent();
// We need to clear the target since we don't want to point the original tab anymore (if it was set)
conditionalRendering?.setTarget(undefined);
rows.push(
new RowItem({
@@ -29,7 +29,7 @@ export function TabItemRenderer({ model }: SceneComponentProps<TabItem>) {
const href = textUtil.sanitize(locationUtil.getUrlForPartial(location, { [urlKey]: mySlug }));
const styles = useStyles2(getStyles);
const pointerDistance = usePointerDistance();
const [isConditionallyHidden] = useIsConditionallyHidden(model);
const [isConditionallyHidden] = useIsConditionallyHidden(model.state.conditionalRendering);
const isClone = isRepeatCloneOrChildOf(model);
const soloPanelContext = useSoloPanelContext();
@@ -116,7 +116,9 @@ interface TabItemLayoutRendererProps {
export function TabItemLayoutRenderer({ tab, isEditing }: TabItemLayoutRendererProps) {
const { layout, key } = tab.useState();
const styles = useStyles2(getStyles);
const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(tab);
const [_, conditionalRenderingClass, conditionalRenderingOverlay] = useIsConditionallyHidden(
tab.state.conditionalRendering
);
return (
<TabContent
@@ -167,7 +167,10 @@ export function createTabRepeats({
});
if (!isSourceTab) {
tabClone.state.conditionalRendering?.setTarget(tabClone);
repeats.push(tabClone);
} else {
tab.state.conditionalRendering?.setTarget(tab);
}
}
return repeats;
@@ -417,6 +417,8 @@ export class TabsLayoutManager extends SceneObjectBase<TabsLayoutManagerState> i
const conditionalRendering = row.state.conditionalRendering;
conditionalRendering?.clearParent();
// We need to clear the target since we don't want to point the original row anymore (if it was set)
conditionalRendering?.setTarget(undefined);
tabs.push(
new TabItem({
@@ -194,18 +194,7 @@ exports[`transformSceneToSaveModelSchemaV2 should transform scene to save model
"label": "Custom Variable",
"multi": true,
"name": "customVar",
"options": [
{
"selected": true,
"text": "option1",
"value": "option1",
},
{
"selected": false,
"text": "option2",
"value": "option2",
},
],
"options": [],
"query": "option1, option2",
"skipUrlSync": false,
},
@@ -371,23 +371,7 @@ describe('sceneVariablesSetToVariables', () => {
"label": "test-label",
"multi": true,
"name": "test",
"options": [
{
"selected": true,
"text": "test",
"value": "test",
},
{
"selected": false,
"text": "test1",
"value": "test1",
},
{
"selected": true,
"text": "test2",
"value": "test2",
},
],
"options": [],
"query": "test,test1,test2",
"type": "custom",
}
@@ -1161,23 +1145,7 @@ describe('sceneVariablesSetToVariables', () => {
"label": "test-label",
"multi": true,
"name": "test",
"options": [
{
"selected": true,
"text": "test",
"value": "test",
},
{
"selected": false,
"text": "test1",
"value": "test1",
},
{
"selected": true,
"text": "test2",
"value": "test2",
},
],
"options": [],
"query": "test,test1,test2",
"skipUrlSync": false,
},
@@ -66,9 +66,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
if (sceneUtils.isQueryVariable(variable)) {
let options: VariableOption[] = [];
// Not sure if we actually have to still support this option given
// that it's not exposed in the UI
if (transformVariableRefreshToEnum(variable.state.refresh) === 'never' || keepQueryOptions) {
if (keepQueryOptions) {
options = variableValueOptionsToVariableOptions(variable.state);
}
variables.push({
@@ -106,7 +104,7 @@ export function sceneVariablesSetToVariables(set: SceneVariables, keepQueryOptio
// @ts-expect-error
value: variable.state.value,
},
options: variableValueOptionsToVariableOptions(variable.state),
options: [],
query: variable.state.query,
multi: variable.state.isMulti,
allValue: variable.state.allValue,
@@ -319,9 +317,7 @@ export function sceneVariablesSetToSchemaV2Variables(
// Query variable
if (sceneUtils.isQueryVariable(variable)) {
// Not sure if we actually have to still support this option given
// that it's not exposed in the UI
if (transformVariableRefreshToEnum(variable.state.refresh) === 'never' || keepQueryOptions) {
if (keepQueryOptions) {
options = variableValueOptionsToVariableOptions(variable.state);
}
const query = variable.state.query;
@@ -385,13 +381,12 @@ export function sceneVariablesSetToSchemaV2Variables(
// Custom variable
} else if (sceneUtils.isCustomVariable(variable)) {
options = variableValueOptionsToVariableOptions(variable.state);
const customVariable: CustomVariableKind = {
kind: 'CustomVariable',
spec: {
...commonProperties,
current: currentVariableOption,
options,
options: [],
query: variable.state.query,
multi: variable.state.isMulti || false,
allValue: variable.state.allValue,
@@ -14,6 +14,7 @@ import {
AdHocFiltersVariable,
SceneDataTransformer,
SceneGridItem,
SwitchVariable,
} from '@grafana/scenes';
import {
AdhocVariableKind,
@@ -27,6 +28,7 @@ import {
GroupByVariableKind,
IntervalVariableKind,
QueryVariableKind,
SwitchVariableKind,
TextVariableKind,
} from '@grafana/schema/dist/esm/schema/dashboard/v2';
import { handyTestingSchema } from '@grafana/schema/dist/esm/schema/dashboard/v2_examples';
@@ -204,6 +206,14 @@ describe('transformSaveModelSchemaV2ToScene', () => {
sceneVariableClass: AdHocFiltersVariable,
index: 7,
});
validateVariable({
sceneVariable: variables?.state.variables[8],
variableKind: dash.variables[8] as SwitchVariableKind,
scene: scene,
dashSpec: dash,
sceneVariableClass: SwitchVariable,
index: 8,
});
// Annotations
expect(scene.state.$data).toBeInstanceOf(DashboardDataLayerSet);
@@ -371,7 +381,7 @@ describe('transformSaveModelSchemaV2ToScene', () => {
const scene = transformSaveModelSchemaV2ToScene(snapshot);
// check variables were converted to snapshot variables
expect(scene.state.$variables?.state.variables).toHaveLength(8);
expect(scene.state.$variables?.state.variables).toHaveLength(9);
expect(scene.state.$variables?.getByName('customVar')).toBeInstanceOf(SnapshotVariable);
expect(scene.state.$variables?.getByName('adhocVar')).toBeInstanceOf(AdHocFiltersVariable);
expect(scene.state.$variables?.getByName('intervalVar')).toBeInstanceOf(SnapshotVariable);
@@ -33,6 +33,7 @@ import { DashboardDTO, DashboardDataDTO } from 'app/types/dashboard';
import { addPanelsOnLoadBehavior } from '../addToDashboard/addPanelsOnLoadBehavior';
import { dashboardAnalyticsInitializer } from '../behaviors/DashboardAnalyticsInitializerBehavior';
import { shouldForceV2API } from '../pages/DashboardScenePageStateManager';
import { AlertStatesDataLayer } from '../scene/AlertStatesDataLayer';
import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer';
import { DashboardControls } from '../scene/DashboardControls';
@@ -258,7 +259,7 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel,
let annotationLayers: SceneDataLayerProvider[] = [];
let alertStatesLayer: AlertStatesDataLayer | undefined;
const uid = oldModel.uid;
const serializerVersion = config.featureToggles.dashboardNewLayouts && !oldModel.meta.isSnapshot ? 'v2' : 'v1';
const serializerVersion = shouldForceV2API() && !oldModel.meta.isSnapshot ? 'v2' : 'v1';
if (oldModel.meta.isSnapshot) {
variables = createVariablesForSnapshot(oldModel);
@@ -311,6 +311,31 @@ describe('ResponseTransformers', () => {
type: 'query',
query: { refId: 'A', query: 'label_values(grafanacloud_org_info{org_slug="$org_slug"}, org_id)' },
},
{
type: 'switch',
name: 'var9',
label: 'Switch variable',
description: 'Switch variable description',
skipUrlSync: false,
hide: 0,
current: {
value: 'true',
text: 'true',
},
options: [
{
selected: true,
text: 'true',
value: 'true',
},
{
selected: false,
text: 'false',
value: 'false',
},
],
query: '',
},
],
},
panels: [
@@ -523,6 +548,7 @@ describe('ResponseTransformers', () => {
validateVariablesV1ToV2(spec.variables[6], dashboardV1.templating?.list?.[6]);
validateVariablesV1ToV2(spec.variables[7], dashboardV1.templating?.list?.[7]);
validateVariablesV1ToV2(spec.variables[8], dashboardV1.templating?.list?.[8]);
validateVariablesV1ToV2(spec.variables[9], dashboardV1.templating?.list?.[9]);
});
});
@@ -930,6 +956,7 @@ describe('ResponseTransformers', () => {
validateVariablesV1ToV2(dashboardV2.spec.variables[5], dashboard.templating?.list?.[5]);
validateVariablesV1ToV2(dashboardV2.spec.variables[6], dashboard.templating?.list?.[6]);
validateVariablesV1ToV2(dashboardV2.spec.variables[7], dashboard.templating?.list?.[7]);
validateVariablesV1ToV2(dashboardV2.spec.variables[8], dashboard.templating?.list?.[8]);
// annotations
validateAnnotation(dashboard.annotations!.list![0], dashboardV2.spec.annotations[0]);
validateAnnotation(dashboard.annotations!.list![1], dashboardV2.spec.annotations[1]);
@@ -1172,5 +1199,23 @@ describe('ResponseTransformers', () => {
expect(v2.group).toEqual(v1.datasource?.type);
expect(v2.spec.options).toEqual(v1.options);
}
if (v2.kind === 'SwitchVariable') {
// V1 switch variables have options array with exactly 2 options
// First option is enabledValue, second is disabledValue
const options = v1.options ?? [];
const enabledValueRaw = options[0]?.value ?? 'true';
const disabledValueRaw = options[1]?.value ?? 'false';
const enabledValue = Array.isArray(enabledValueRaw) ? enabledValueRaw[0] : enabledValueRaw;
const disabledValue = Array.isArray(disabledValueRaw) ? disabledValueRaw[0] : disabledValueRaw;
// Current value should be a string (not array)
const currentValueRaw = v1.current?.value ?? disabledValue;
const currentValue = Array.isArray(currentValueRaw) ? currentValueRaw[0] : currentValueRaw;
expect(v2.spec.current).toBe(currentValue);
expect(v2.spec.enabledValue).toBe(enabledValue);
expect(v2.spec.disabledValue).toBe(disabledValue);
}
}
});
@@ -34,6 +34,7 @@ import {
IntervalVariableKind,
TextVariableKind,
GroupByVariableKind,
SwitchVariableKind,
LibraryPanelKind,
PanelKind,
GridLayoutItemKind,
@@ -809,6 +810,29 @@ function getVariables(vars: TypedVariableModel[]): DashboardV2Spec['variables']
variables.push(gb);
break;
case 'switch':
// V1 switch variables have options array with exactly 2 options
// First option is typically enabledValue, second is disabledValue
const options = v.options ?? [];
const enabledValueRaw = options[0]?.value ?? 'true';
const disabledValueRaw = options[1]?.value ?? 'false';
const enabledValue = Array.isArray(enabledValueRaw) ? enabledValueRaw[0] : enabledValueRaw;
const disabledValue = Array.isArray(disabledValueRaw) ? disabledValueRaw[0] : disabledValueRaw;
// Current value should be a string (not array)
const currentValueRaw = v.current?.value ?? disabledValue;
const currentValue = Array.isArray(currentValueRaw) ? currentValueRaw[0] : currentValueRaw;
const sw: SwitchVariableKind = {
kind: 'SwitchVariable',
spec: {
...commonProperties,
current: currentValue,
enabledValue,
disabledValue,
},
};
variables.push(sw);
break;
default:
// do not throw error, just log it
console.error(`Variable transformation not implemented: ${v.type}`);
@@ -997,6 +1021,29 @@ function getVariablesV1(vars: DashboardV2Spec['variables']): VariableModel[] {
};
variables.push(av);
break;
case 'SwitchVariable':
const sv: VariableModel = {
...commonProperties,
current: {
text: v.spec.current,
value: v.spec.current,
},
options: [
{
text: v.spec.enabledValue,
value: v.spec.enabledValue,
selected: v.spec.current === v.spec.enabledValue,
},
{
text: v.spec.disabledValue,
value: v.spec.disabledValue,
selected: v.spec.current === v.spec.disabledValue,
},
],
query: '',
};
variables.push(sv);
break;
default:
// do not throw error, just log it
console.error(`Variable transformation not implemented: ${v}`);
@@ -1256,6 +1303,8 @@ function transformToV1VariableTypes(variable: TypedVariableModelV2): VariableTyp
return 'groupby';
case 'AdhocVariable':
return 'adhoc';
case 'SwitchVariable':
return 'switch';
default:
throw new Error(`Unknown variable type: ${variable}`);
}
+4 -1
View File
@@ -16,6 +16,9 @@ export function isV2StoredVersion(version: string | undefined): boolean {
export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') {
const isDashboardSceneEnabled = config.featureToggles.dashboardScene;
const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards;
const isV2DashboardAPIVersionEnabled = config.featureToggles.kubernetesDashboardsV2;
const isDashboardNewLayoutsEnabled = config.featureToggles.dashboardNewLayouts;
const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false';
// Force legacy API when dashboard scene is disabled or explicitly forced
@@ -32,7 +35,7 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') {
if (responseFormat === 'v1') {
return 'v1';
}
if (responseFormat === 'v2') {
if (responseFormat === 'v2' || isV2DashboardAPIVersionEnabled || isDashboardNewLayoutsEnabled) {
return 'v2';
}
return 'unified';
@@ -1,166 +0,0 @@
import { render, screen, waitFor } from 'test/test-utils';
import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1';
import { FilesView } from './FilesView';
jest.mock('app/api/clients/provisioning/v0alpha1', () => ({
useGetRepositoryFilesQuery: jest.fn(),
}));
const mockUseGetRepositoryFilesQuery = jest.mocked(useGetRepositoryFilesQuery);
type RepositoryFilesQueryResult = ReturnType<typeof useGetRepositoryFilesQuery>;
const baseQueryResult = (): RepositoryFilesQueryResult =>
({
currentData: undefined,
data: { items: [] },
endpointName: 'getRepositoryFiles',
error: undefined,
fulfilledTimeStamp: undefined,
isError: false,
isFetching: false,
isLoading: false,
isSuccess: false,
originalArgs: { name: '' },
refetch: jest.fn(),
requestId: 'test-request',
startedTimeStamp: 0,
status: 'uninitialized',
subscriptionOptions: undefined,
unsubscribe: jest.fn(),
}) satisfies RepositoryFilesQueryResult;
const mockRepositoryFilesQuery = (overrides: Partial<RepositoryFilesQueryResult> = {}) => {
mockUseGetRepositoryFilesQuery.mockReturnValue({
...baseQueryResult(),
...overrides,
});
};
const defaultRepository: Repository = {
metadata: { name: 'test-repo' },
spec: {
title: 'Test repository',
type: 'github',
workflows: ['write'],
sync: { enabled: true, target: 'folder' },
github: { branch: 'main' },
},
};
const localRepository: Repository = {
metadata: { name: 'local-repo' },
spec: {
title: 'Local repository',
type: 'local',
workflows: [],
sync: { enabled: true, target: 'folder' },
local: {},
},
};
const renderComponent = (repo: Repository = defaultRepository) => {
return render(<FilesView repo={repo} />);
};
describe('FilesView', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders spinner while loading', () => {
mockRepositoryFilesQuery({ isLoading: true, status: 'pending', data: undefined });
renderComponent();
expect(screen.getByTestId('Spinner')).toBeInTheDocument();
});
it('renders file rows with view and history links when data is available', () => {
mockRepositoryFilesQuery({
isSuccess: true,
status: 'fulfilled',
data: {
items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }],
},
});
renderComponent();
const viewLink = screen.getByRole('link', { name: 'View' });
expect(viewLink).toHaveAttribute('href', '/admin/provisioning/test-repo/file/dashboards/example.json');
const historyLink = screen.getByRole('link', { name: 'History' });
expect(historyLink).toHaveAttribute(
'href',
'/admin/provisioning/test-repo/history/dashboards/example.json?repo_type=github'
);
});
it('filters files using search input', async () => {
const mockItems = [
{ path: 'dashboards/example.json', hash: 'abc', size: '10' },
{ path: 'dashboards/other.yaml', hash: 'def', size: '20' },
];
mockRepositoryFilesQuery({
isSuccess: true,
status: 'fulfilled',
data: {
items: mockItems,
},
});
const { user } = renderComponent();
expect(screen.getAllByRole('row')).toHaveLength(
// +1 for the header row
mockItems.length + 1
);
const input = screen.getByPlaceholderText('Search');
await user.clear(input);
await user.type(input, 'other');
await waitFor(() =>
expect(screen.getAllByRole('row')).toHaveLength(
// +1 for the header row
2
)
);
expect(screen.getByText('dashboards/other.yaml')).toBeInTheDocument();
});
it('hides history link when repository type is not supported', () => {
mockRepositoryFilesQuery({
isSuccess: true,
status: 'fulfilled',
data: {
items: [{ path: 'dashboards/example.json', hash: 'abc', size: '10' }],
},
});
renderComponent(localRepository);
expect(screen.getByRole('link', { name: 'View' })).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument();
});
it('renders plain text and hides actions for .keep files', () => {
mockRepositoryFilesQuery({
isSuccess: true,
status: 'fulfilled',
data: {
items: [{ path: 'dashboards/.keep', hash: 'abc', size: '0' }],
},
});
renderComponent();
expect(screen.getByText('dashboards/.keep')).toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'dashboards/.keep' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'View' })).not.toBeInTheDocument();
expect(screen.queryByRole('link', { name: 'History' })).not.toBeInTheDocument();
});
});
@@ -1,99 +0,0 @@
import { useState } from 'react';
import { Trans, t } from '@grafana/i18n';
import { CellProps, Column, FilterInput, InteractiveTable, LinkButton, Spinner, Stack } from '@grafana/ui';
import { Repository, useGetRepositoryFilesQuery } from 'app/api/clients/provisioning/v0alpha1';
import { PROVISIONING_URL } from '../constants';
import { FileDetails } from '../types';
import { isFileHistorySupported } from './utils';
interface FilesViewProps {
repo: Repository;
}
type FileCell<T extends keyof FileDetails = keyof FileDetails> = CellProps<FileDetails, FileDetails[T]>;
export function FilesView({ repo }: FilesViewProps) {
const name = repo.metadata?.name ?? '';
const query = useGetRepositoryFilesQuery({ name });
const [searchQuery, setSearchQuery] = useState('');
const data = [...(query.data?.items ?? [])].filter((file) =>
file.path.toLowerCase().includes(searchQuery.toLowerCase())
);
const showHistoryBtn = isFileHistorySupported(repo.spec?.type);
const columns: Array<Column<FileDetails>> = [
{
id: 'path',
header: 'Path',
sortType: 'string',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
const isDotKeepFile = getIsDotKeepFile(path);
if (isDotKeepFile) {
return path;
}
return <a href={`${PROVISIONING_URL}/${name}/file/${path}`}>{path}</a>;
},
},
{
id: 'hash',
header: 'Hash',
sortType: 'string',
},
{
id: 'actions',
header: '',
cell: ({ row: { original } }: FileCell<'path'>) => {
const { path } = original;
const isDotKeepFile = getIsDotKeepFile(path);
if (isDotKeepFile) {
return null;
}
return (
<Stack>
{(path.endsWith('.json') || path.endsWith('.yaml') || path.endsWith('.yml')) && (
<LinkButton href={`${PROVISIONING_URL}/${name}/file/${path}`}>
<Trans i18nKey="provisioning.files-view.columns.view">View</Trans>
</LinkButton>
)}
{showHistoryBtn && (
<LinkButton href={`${PROVISIONING_URL}/${name}/history/${path}?repo_type=${repo.spec?.type}`}>
<Trans i18nKey="provisioning.files-view.columns.history">History</Trans>
</LinkButton>
)}
</Stack>
);
},
},
];
if (query.isLoading) {
return (
<Stack justifyContent={'center'} alignItems={'center'}>
<Spinner />
</Stack>
);
}
return (
<Stack grow={1} direction={'column'} gap={2}>
<Stack gap={2}>
<FilterInput
placeholder={t('provisioning.files-view.placeholder-search', 'Search')}
autoFocus={true}
value={searchQuery}
onChange={setSearchQuery}
/>
</Stack>
<InteractiveTable columns={columns} data={data} pageSize={25} getRowId={(f: FileDetails) => String(f.path)} />
</Stack>
);
}
function getIsDotKeepFile(path: string): boolean {
// e.g. 'dashboards/.keep' → true, 'dashboards/example.keep.json' → false
return path.split('/').pop() === '.keep';
}
@@ -4,7 +4,7 @@ import { Trans } from '@grafana/i18n';
import { LinkButton, Stack, Text, TextLink } from '@grafana/ui';
import { useGetRepositoryQuery } from 'app/api/clients/provisioning/v0alpha1';
import { getRepoHref } from '../utils/git';
import { getRepoHrefForProvider } from '../utils/git';
type RepositoryLinkProps = {
name?: string;
@@ -19,7 +19,7 @@ export function RepositoryLink({ name, jobType }: RepositoryLinkProps) {
return null;
}
const repoHref = getRepoHref(repo.spec?.github);
const repoHref = getRepoHrefForProvider(repo.spec);
if (jobType === 'sync') {
return (

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