Merge branch 'main' into eledobleefe/analytics-framework-user-test
This commit is contained in:
@@ -71,6 +71,7 @@ public/css/*.min.css
|
||||
.vs/
|
||||
.cursor/
|
||||
.devcontainer/
|
||||
.claude/
|
||||
|
||||
.eslintcache
|
||||
.stylelintcache
|
||||
|
||||
+51
-1
@@ -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})`),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
+1
@@ -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
|
||||
|
||||
|
Generated
+4
@@ -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
@@ -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 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
+15
-4
@@ -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} />;
|
||||
}
|
||||
|
||||
+4
@@ -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} />;
|
||||
}
|
||||
|
||||
+26
-10
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+22
-1
@@ -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
|
||||
|
||||
+6
-8
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+21
-18
@@ -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({
|
||||
|
||||
+1
-12
@@ -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,
|
||||
},
|
||||
|
||||
+2
-34
@@ -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,
|
||||
|
||||
+11
-1
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user