Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 894f51a9db | |||
| e57c30681d | |||
| b378907585 | |||
| 62bdae94ed | |||
| 0091b44b2a | |||
| 307e9cdce3 | |||
| 66eb5e35cd | |||
| a95de85062 |
@@ -1,20 +0,0 @@
|
||||
# Plugins App
|
||||
|
||||
API documentation is available at http://localhost:3000/swagger?api=plugins.grafana.app-v0alpha1
|
||||
|
||||
## Codegen
|
||||
|
||||
- Go: `make generate`
|
||||
- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md)
|
||||
|
||||
## Plugin sync
|
||||
|
||||
The plugin sync pushes the plugins loaded from disk to the plugins API.
|
||||
|
||||
To enable, add these feature toggles in your `custom.ini`:
|
||||
|
||||
```ini
|
||||
[feature_toggles]
|
||||
pluginInstallAPISync = true
|
||||
pluginStoreServiceLoading = true
|
||||
```
|
||||
@@ -98,7 +98,7 @@ You can share dashboards in the following ways:
|
||||
- [As a report](#schedule-a-report)
|
||||
- [As a snapshot](#share-a-snapshot)
|
||||
- [As a PDF export](#export-a-dashboard-as-pdf)
|
||||
- [As a JSON file export](#export-a-dashboard-as-code)
|
||||
- [As a JSON file export](#export-a-dashboard-as-json)
|
||||
- [As an image export](#export-a-dashboard-as-an-image)
|
||||
|
||||
When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**.
|
||||
|
||||
@@ -10,7 +10,7 @@ const NUM_NESTED_DASHBOARDS = 60;
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ test.use({
|
||||
scenes: true,
|
||||
sharingDashboardImage: true, // Enable the export image feature
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DashboardLiveTest.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: 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,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: 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,7 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ test.use({
|
||||
featureToggles: {
|
||||
scenes: true,
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ test.use({
|
||||
timezoneId: 'Pacific/Easter',
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ test.use({
|
||||
},
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ async function assertPreviewValues(
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ async function assertPreviewValues(
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const DASHBOARD_UID = 'ZqZnVvFZz';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: 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
|
||||
},
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ const DASHBOARD_UID = 'yBCC3aKGk';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ const PAGE_UNDER_TEST = 'AejrN1AMz';
|
||||
test.use({
|
||||
featureToggles: {
|
||||
kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -2,16 +2,18 @@ import { Locator } from '@playwright/test';
|
||||
|
||||
import { test, expect } from '@grafana/plugin-e2e';
|
||||
|
||||
import { setVisualization } from './vizpicker-utils';
|
||||
|
||||
test.use({
|
||||
featureToggles: {
|
||||
canvasPanelPanZoom: true,
|
||||
},
|
||||
});
|
||||
test.describe('Canvas Panel - Scene Tests', () => {
|
||||
test.beforeEach(async ({ page, gotoDashboardPage }) => {
|
||||
test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => {
|
||||
const dashboardPage = await gotoDashboardPage({});
|
||||
const panelEditPage = await dashboardPage.addPanel();
|
||||
await panelEditPage.setVisualization('Canvas');
|
||||
await setVisualization(panelEditPage, 'Canvas', selectors);
|
||||
|
||||
// Wait for canvas panel to load
|
||||
await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 });
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e';
|
||||
|
||||
// this replaces the panelEditPage.setVisualization method used previously in tests, since it
|
||||
// does not know how to use the updated 12.4 viz picker UI to set the visualization
|
||||
export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => {
|
||||
const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker);
|
||||
await expect(vizPicker, '"Change" button should be visible').toBeVisible();
|
||||
await vizPicker.click();
|
||||
|
||||
const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations'));
|
||||
await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible();
|
||||
await allVizTabBtn.click();
|
||||
|
||||
const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName));
|
||||
await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible();
|
||||
await vizItem.scrollIntoViewIfNeeded();
|
||||
await vizItem.click();
|
||||
|
||||
await expect(vizPicker, '"Change" button should be visible again').toBeVisible();
|
||||
await expect(
|
||||
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||
'Panel header should have the new viz type name'
|
||||
).toHaveText(vizName);
|
||||
};
|
||||
+5
-4
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
|
||||
import { formatExpectError } from '../errors';
|
||||
import { successfulDataQuery } from '../mocks/queries';
|
||||
|
||||
@@ -24,10 +25,10 @@ test.describe(
|
||||
).toContainText(['Field', 'Max', 'Mean', 'Last']);
|
||||
});
|
||||
|
||||
test('table panel data assertions', async ({ panelEditPage }) => {
|
||||
test('table panel data assertions', async ({ panelEditPage, selectors }) => {
|
||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||
await panelEditPage.datasource.set('gdev-testdata');
|
||||
await panelEditPage.setVisualization('Table');
|
||||
await setVisualization(panelEditPage, 'Table', selectors);
|
||||
await panelEditPage.refreshPanel();
|
||||
await expect(
|
||||
panelEditPage.panel.locator,
|
||||
@@ -43,10 +44,10 @@ test.describe(
|
||||
).toContainText(['val1', 'val2', 'val3', 'val4']);
|
||||
});
|
||||
|
||||
test('timeseries panel - table view assertions', async ({ panelEditPage }) => {
|
||||
test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => {
|
||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||
await panelEditPage.datasource.set('gdev-testdata');
|
||||
await panelEditPage.setVisualization('Time series');
|
||||
await setVisualization(panelEditPage, 'Time series', selectors);
|
||||
await panelEditPage.refreshPanel();
|
||||
await panelEditPage.toggleTableView();
|
||||
await expect(
|
||||
|
||||
+26
-25
@@ -1,5 +1,6 @@
|
||||
import { expect, test } from '@grafana/plugin-e2e';
|
||||
|
||||
import { setVisualization } from '../../../panels-suite/vizpicker-utils';
|
||||
import { formatExpectError } from '../errors';
|
||||
import { successfulDataQuery } from '../mocks/queries';
|
||||
import { scenarios } from '../mocks/resources';
|
||||
@@ -53,10 +54,10 @@ test.describe(
|
||||
).toHaveText(scenarios.map((s) => s.name));
|
||||
});
|
||||
|
||||
test('mocked query data response', async ({ panelEditPage, page }) => {
|
||||
test('mocked query data response', async ({ panelEditPage, page, selectors }) => {
|
||||
await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200);
|
||||
await panelEditPage.datasource.set('gdev-testdata');
|
||||
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
|
||||
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
|
||||
await panelEditPage.refreshPanel();
|
||||
await expect(
|
||||
panelEditPage.panel.getErrorIcon(),
|
||||
@@ -75,7 +76,7 @@ test.describe(
|
||||
selectors,
|
||||
page,
|
||||
}) => {
|
||||
await panelEditPage.setVisualization(TABLE_VIZ_NAME);
|
||||
await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors);
|
||||
await expect(
|
||||
panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header),
|
||||
formatExpectError('Expected panel visualization to be set to table')
|
||||
@@ -92,8 +93,8 @@ test.describe(
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('Select time zone in timezone picker', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const axisOptions = await panelEditPage.getCustomOptions('Axis');
|
||||
const timeZonePicker = axisOptions.getSelect('Time zone');
|
||||
|
||||
@@ -101,8 +102,8 @@ test.describe(
|
||||
await expect(timeZonePicker).toHaveSelected('Europe/Stockholm');
|
||||
});
|
||||
|
||||
test('select unit in unit picker', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('select unit in unit picker', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const standardOptions = panelEditPage.getStandardOptions();
|
||||
const unitPicker = standardOptions.getUnitPicker('Unit');
|
||||
|
||||
@@ -111,8 +112,8 @@ test.describe(
|
||||
await expect(unitPicker).toHaveSelected('Pixels');
|
||||
});
|
||||
|
||||
test('enter value in number input', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('enter value in number input', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const lineWith = axisOptions.getNumberInput('Soft min');
|
||||
|
||||
@@ -121,8 +122,8 @@ test.describe(
|
||||
await expect(lineWith).toHaveValue('10');
|
||||
});
|
||||
|
||||
test('enter value in slider', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('enter value in slider', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const graphOptions = panelEditPage.getCustomOptions('Graph styles');
|
||||
const lineWidth = graphOptions.getSliderInput('Line width');
|
||||
|
||||
@@ -131,8 +132,8 @@ test.describe(
|
||||
await expect(lineWidth).toHaveValue('10');
|
||||
});
|
||||
|
||||
test('select value in single value select', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('select value in single value select', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const standardOptions = panelEditPage.getStandardOptions();
|
||||
const colorSchemeSelect = standardOptions.getSelect('Color scheme');
|
||||
|
||||
@@ -140,8 +141,8 @@ test.describe(
|
||||
await expect(colorSchemeSelect).toHaveSelected('Classic palette');
|
||||
});
|
||||
|
||||
test('clear input', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('clear input', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const panelOptions = panelEditPage.getPanelOptions();
|
||||
const title = panelOptions.getTextInput('Title');
|
||||
|
||||
@@ -150,8 +151,8 @@ test.describe(
|
||||
await expect(title).toHaveValue('');
|
||||
});
|
||||
|
||||
test('enter value in input', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('enter value in input', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const panelOptions = panelEditPage.getPanelOptions();
|
||||
const description = panelOptions.getTextInput('Description');
|
||||
|
||||
@@ -160,8 +161,8 @@ test.describe(
|
||||
await expect(description).toHaveValue('This is a panel');
|
||||
});
|
||||
|
||||
test('unchecking switch', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('unchecking switch', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const showBorder = axisOptions.getSwitch('Show border');
|
||||
|
||||
@@ -173,8 +174,8 @@ test.describe(
|
||||
await expect(showBorder).toBeChecked({ checked: false });
|
||||
});
|
||||
|
||||
test('checking switch', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('checking switch', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const showBorder = axisOptions.getSwitch('Show border');
|
||||
|
||||
@@ -183,8 +184,8 @@ test.describe(
|
||||
await expect(showBorder).toBeChecked();
|
||||
});
|
||||
|
||||
test('re-selecting value in radio button group', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const placement = axisOptions.getRadioGroup('Placement');
|
||||
|
||||
@@ -195,8 +196,8 @@ test.describe(
|
||||
await expect(placement).toHaveChecked('Auto');
|
||||
});
|
||||
|
||||
test('selecting value in radio button group', async ({ panelEditPage }) => {
|
||||
await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME);
|
||||
test('selecting value in radio button group', async ({ panelEditPage, selectors }) => {
|
||||
await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors);
|
||||
const axisOptions = panelEditPage.getCustomOptions('Axis');
|
||||
const placement = axisOptions.getRadioGroup('Placement');
|
||||
|
||||
|
||||
+3
-8
@@ -249,6 +249,7 @@ const injectedRtkApi = api
|
||||
permission: queryArg.permission,
|
||||
sort: queryArg.sort,
|
||||
limit: queryArg.limit,
|
||||
ownerReference: queryArg.ownerReference,
|
||||
explain: queryArg.explain,
|
||||
},
|
||||
}),
|
||||
@@ -285,10 +286,6 @@ const injectedRtkApi = api
|
||||
query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Snapshot'],
|
||||
}),
|
||||
getSnapshotSettings: build.query<GetSnapshotSettingsApiResponse, GetSnapshotSettingsApiArg>({
|
||||
query: () => ({ url: `/snapshots/settings` }),
|
||||
providesTags: ['Snapshot'],
|
||||
}),
|
||||
getSnapshot: build.query<GetSnapshotApiResponse, GetSnapshotApiArg>({
|
||||
query: (queryArg) => ({
|
||||
url: `/snapshots/${queryArg.name}`,
|
||||
@@ -680,6 +677,8 @@ export type SearchDashboardsAndFoldersApiArg = {
|
||||
sort?: string;
|
||||
/** number of results to return */
|
||||
limit?: number;
|
||||
/** filter by owner reference in the format {Group}/{Kind}/{Name} */
|
||||
ownerReference?: string;
|
||||
/** add debugging info that may help explain why the result matched */
|
||||
explain?: boolean;
|
||||
};
|
||||
@@ -746,8 +745,6 @@ export type DeleteWithKeyApiArg = {
|
||||
/** unique key returned in create */
|
||||
deleteKey: string;
|
||||
};
|
||||
export type GetSnapshotSettingsApiResponse = /** status 200 undefined */ any;
|
||||
export type GetSnapshotSettingsApiArg = void;
|
||||
export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot;
|
||||
export type GetSnapshotApiArg = {
|
||||
/** name of the Snapshot */
|
||||
@@ -1279,8 +1276,6 @@ export const {
|
||||
useLazyListSnapshotQuery,
|
||||
useCreateSnapshotMutation,
|
||||
useDeleteWithKeyMutation,
|
||||
useGetSnapshotSettingsQuery,
|
||||
useLazyGetSnapshotSettingsQuery,
|
||||
useGetSnapshotQuery,
|
||||
useLazyGetSnapshotQuery,
|
||||
useDeleteSnapshotMutation,
|
||||
|
||||
+4
-4
@@ -356,6 +356,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;
|
||||
@@ -417,10 +421,6 @@ export interface FeatureToggles {
|
||||
*/
|
||||
jitterAlertRulesWithinGroups?: boolean;
|
||||
/**
|
||||
* Enable audit logging with Kubernetes under app platform
|
||||
*/
|
||||
auditLoggingAppPlatform?: boolean;
|
||||
/**
|
||||
* Enable the secrets management API and services under app platform
|
||||
*/
|
||||
secretsManagementAppPlatform?: boolean;
|
||||
|
||||
+2
-6
@@ -48,7 +48,7 @@ describe('MetricsModal', () => {
|
||||
operations: [],
|
||||
};
|
||||
|
||||
setup(query, ['with-labels']);
|
||||
setup(query, ['with-labels'], true);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('with-labels')).toBeInTheDocument();
|
||||
});
|
||||
@@ -220,10 +220,6 @@ function createDatasource(withLabels?: boolean) {
|
||||
// display different results if their labels are selected in the PromVisualQuery
|
||||
if (withLabels) {
|
||||
languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({
|
||||
ALERTS: {
|
||||
type: 'gauge',
|
||||
help: 'alerts help text',
|
||||
},
|
||||
'with-labels': {
|
||||
type: 'with-labels-type',
|
||||
help: 'with-labels-help',
|
||||
@@ -301,7 +297,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m
|
||||
};
|
||||
}
|
||||
|
||||
function setup(query: PromVisualQuery, metrics: string[]) {
|
||||
function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) {
|
||||
const withLabels: boolean = query.labels.length > 0;
|
||||
const datasource = createDatasource(withLabels);
|
||||
const props = createProps(query, datasource, metrics);
|
||||
|
||||
+1
-1
@@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => {
|
||||
|
||||
export const MetricsModal = (props: MetricsModalProps) => {
|
||||
return (
|
||||
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider} timeRange={props.timeRange}>
|
||||
<MetricsModalContextProvider languageProvider={props.datasource.languageProvider}>
|
||||
<MetricsModalContent {...props} />
|
||||
</MetricsModalContextProvider>
|
||||
);
|
||||
|
||||
+4
-20
@@ -4,7 +4,6 @@ import { ReactNode } from 'react';
|
||||
import { TimeRange } from '@grafana/data';
|
||||
|
||||
import { PrometheusLanguageProviderInterface } from '../../../language_provider';
|
||||
import { getMockTimeRange } from '../../../test/mocks/datasource';
|
||||
|
||||
import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext';
|
||||
import { generateMetricData } from './helpers';
|
||||
@@ -26,9 +25,7 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = {
|
||||
// Helper to create wrapper component
|
||||
const createWrapper = (languageProvider = mockLanguageProvider) => {
|
||||
return ({ children }: { children: ReactNode }) => (
|
||||
<MetricsModalContextProvider languageProvider={languageProvider} timeRange={getMockTimeRange()}>
|
||||
{children}
|
||||
</MetricsModalContextProvider>
|
||||
<MetricsModalContextProvider languageProvider={languageProvider}>{children}</MetricsModalContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -170,7 +167,6 @@ describe('MetricsModalContext', () => {
|
||||
|
||||
it('should handle empty metadata response', async () => {
|
||||
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({});
|
||||
(mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']);
|
||||
|
||||
const { result } = renderHook(() => useMetricsModal(), {
|
||||
wrapper: createWrapper(),
|
||||
@@ -180,18 +176,7 @@ describe('MetricsModalContext', () => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.filteredMetricsData).toEqual([
|
||||
{
|
||||
value: 'metric1',
|
||||
type: 'counter',
|
||||
description: 'Test metric',
|
||||
},
|
||||
{
|
||||
value: 'metric2',
|
||||
type: 'counter',
|
||||
description: 'Test metric',
|
||||
},
|
||||
]);
|
||||
expect(result.current.filteredMetricsData).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle metadata fetch error', async () => {
|
||||
@@ -254,7 +239,6 @@ describe('MetricsModalContext', () => {
|
||||
}));
|
||||
|
||||
(mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({
|
||||
ALERTS: { type: 'gauge', help: 'Test alerts help' },
|
||||
test_metric: { type: 'counter', help: 'Test metric' },
|
||||
});
|
||||
|
||||
@@ -266,7 +250,7 @@ describe('MetricsModalContext', () => {
|
||||
expect(result.current.isLoading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.filteredMetricsData).toHaveLength(2);
|
||||
expect(result.current.filteredMetricsData).toHaveLength(1);
|
||||
expect(result.current.selectedTypes).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -334,7 +318,7 @@ describe('MetricsModalContext', () => {
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<MetricsModalContextProvider languageProvider={mockLanguageProvider} timeRange={getMockTimeRange()}>
|
||||
<MetricsModalContextProvider languageProvider={mockLanguageProvider}>
|
||||
<TestComponent />
|
||||
</MetricsModalContextProvider>
|
||||
);
|
||||
|
||||
+3
-13
@@ -52,13 +52,11 @@ const MetricsModalContext = createContext<MetricsModalContextValue | undefined>(
|
||||
|
||||
type MetricsModalContextProviderProps = {
|
||||
languageProvider: PrometheusLanguageProviderInterface;
|
||||
timeRange: TimeRange;
|
||||
};
|
||||
|
||||
export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalContextProviderProps>> = ({
|
||||
children,
|
||||
languageProvider,
|
||||
timeRange,
|
||||
}) => {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [metricsData, setMetricsData] = useState<MetricsData>([]);
|
||||
@@ -113,16 +111,8 @@ export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalConte
|
||||
setIsLoading(true);
|
||||
const metadata = await languageProvider.queryMetricsMetadata(PROMETHEUS_QUERY_BUILDER_MAX_RESULTS);
|
||||
|
||||
// We receive ALERTS metadata in any case
|
||||
if (Object.keys(metadata).length <= 1) {
|
||||
const fetchedMetrics = await languageProvider.queryLabelValues(
|
||||
timeRange,
|
||||
METRIC_LABEL,
|
||||
undefined,
|
||||
PROMETHEUS_QUERY_BUILDER_MAX_RESULTS
|
||||
);
|
||||
const processedData = fetchedMetrics.map((m) => generateMetricData(m, languageProvider));
|
||||
setMetricsData(processedData);
|
||||
if (Object.keys(metadata).length === 0) {
|
||||
setMetricsData([]);
|
||||
} else {
|
||||
const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider));
|
||||
setMetricsData(processedData);
|
||||
@@ -132,7 +122,7 @@ export const MetricsModalContextProvider: FC<PropsWithChildren<MetricsModalConte
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [languageProvider, timeRange]);
|
||||
}, [languageProvider]);
|
||||
|
||||
const debouncedBackendSearch = useMemo(
|
||||
() =>
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
package auditing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Sinkable is a log entry abstraction that can be sent to an audit log sink through the different implementing methods.
|
||||
type Sinkable interface {
|
||||
json.Marshaler
|
||||
KVPairs() []any
|
||||
Time() time.Time
|
||||
}
|
||||
|
||||
// Logger specifies the contract for a specific audit logger.
|
||||
type Logger interface {
|
||||
Log(entry Sinkable) error
|
||||
Close() error
|
||||
Type() string
|
||||
}
|
||||
|
||||
// Implementation inspired by https://github.com/grafana/grafana-app-sdk/blob/main/logging/logger.go
|
||||
type loggerContextKey struct{}
|
||||
|
||||
var (
|
||||
// DefaultLogger is the default Logger if one hasn't been provided in the context.
|
||||
// You may use this to add arbitrary audit logging outside of an API request lifecycle.
|
||||
DefaultLogger Logger = &NoopLogger{}
|
||||
|
||||
contextKey = loggerContextKey{}
|
||||
)
|
||||
|
||||
// FromContext returns the Logger set in the context with Context(), or the DefaultLogger if no Logger is set in the context.
|
||||
// If DefaultLogger is nil, it returns a *NoopLogger so that the return is always valid to call methods on without nil-checking.
|
||||
// You may use this to add arbitrary audit logging outside of an API request lifecycle.
|
||||
func FromContext(ctx context.Context) Logger {
|
||||
if l := ctx.Value(contextKey); l != nil {
|
||||
if logger, ok := l.(Logger); ok {
|
||||
return logger
|
||||
}
|
||||
}
|
||||
|
||||
if DefaultLogger != nil {
|
||||
return DefaultLogger
|
||||
}
|
||||
|
||||
return &NoopLogger{}
|
||||
}
|
||||
|
||||
// Context returns a new context built from the provided context with the provided logger in it.
|
||||
// The Logger added with Context() can be retrieved with FromContext()
|
||||
func Context(ctx context.Context, logger Logger) context.Context {
|
||||
return context.WithValue(ctx, contextKey, logger)
|
||||
}
|
||||
@@ -11,9 +11,9 @@ type NoopBackend struct{}
|
||||
|
||||
func ProvideNoopBackend() audit.Backend { return &NoopBackend{} }
|
||||
|
||||
func (NoopBackend) ProcessEvents(...*auditinternal.Event) bool { return false }
|
||||
func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false }
|
||||
|
||||
func (NoopBackend) Run(<-chan struct{}) error { return nil }
|
||||
func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil }
|
||||
|
||||
func (NoopBackend) Shutdown() {}
|
||||
|
||||
@@ -34,14 +34,3 @@ type NoopPolicyRuleEvaluator struct{}
|
||||
func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig {
|
||||
return audit.RequestAuditConfig{Level: auditinternal.LevelNone}
|
||||
}
|
||||
|
||||
// NoopLogger is a no-op implementation of Logger
|
||||
type NoopLogger struct{}
|
||||
|
||||
func ProvideNoopLogger() Logger { return &NoopLogger{} }
|
||||
|
||||
func (NoopLogger) Type() string { return "noop" }
|
||||
|
||||
func (NoopLogger) Log(Sinkable) error { return nil }
|
||||
|
||||
func (NoopLogger) Close() error { return nil }
|
||||
|
||||
@@ -46,23 +46,14 @@ func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Att
|
||||
}
|
||||
}
|
||||
|
||||
// Logging the response object allows us to get the resource name for create requests.
|
||||
level := auditinternal.LevelMetadata
|
||||
if attrs.GetVerb() == utils.VerbCreate {
|
||||
level = auditinternal.LevelRequestResponse
|
||||
}
|
||||
|
||||
return audit.RequestAuditConfig{
|
||||
Level: level,
|
||||
|
||||
// Only log on StageResponseComplete, to avoid noisy logs.
|
||||
Level: auditinternal.LevelMetadata,
|
||||
OmitStages: []auditinternal.Stage{
|
||||
// Only log on StageResponseComplete
|
||||
auditinternal.StageRequestReceived,
|
||||
auditinternal.StageResponseStarted,
|
||||
auditinternal.StagePanic,
|
||||
},
|
||||
|
||||
// Setting it to true causes extra copying/unmarshalling.
|
||||
OmitManagedFields: false,
|
||||
OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) {
|
||||
require.Equal(t, auditinternal.LevelNone, config.Level)
|
||||
})
|
||||
|
||||
t.Run("return audit level request+response for create requests", func(t *testing.T) {
|
||||
t.Run("return audit level metadata for other resource requests", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attrs := authorizer.AttributesRecord{
|
||||
@@ -67,22 +67,6 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
config := evaluator.EvaluatePolicyRule(attrs)
|
||||
require.Equal(t, auditinternal.LevelRequestResponse, config.Level)
|
||||
})
|
||||
|
||||
t.Run("return audit level metadata for other resource requests", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
attrs := authorizer.AttributesRecord{
|
||||
ResourceRequest: true,
|
||||
Verb: utils.VerbGet,
|
||||
User: &user.DefaultInfo{
|
||||
Name: "test-user",
|
||||
Groups: []string{"test-group"},
|
||||
},
|
||||
}
|
||||
|
||||
config := evaluator.EvaluatePolicyRule(attrs)
|
||||
require.Equal(t, auditinternal.LevelMetadata, config.Level)
|
||||
})
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/grafana/grafana/pkg/configprovider"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -63,6 +62,7 @@ import (
|
||||
"github.com/grafana/grafana/pkg/services/quota"
|
||||
"github.com/grafana/grafana/pkg/services/search/sort"
|
||||
"github.com/grafana/grafana/pkg/services/user"
|
||||
"github.com/grafana/grafana/pkg/setting"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql"
|
||||
"github.com/grafana/grafana/pkg/storage/legacysql/dualwrite"
|
||||
"github.com/grafana/grafana/pkg/storage/unified/apistore"
|
||||
@@ -128,6 +128,7 @@ type DashboardsAPIBuilder struct {
|
||||
}
|
||||
|
||||
func RegisterAPIService(
|
||||
cfg *setting.Cfg,
|
||||
features featuremgmt.FeatureToggles,
|
||||
apiregistration builder.APIRegistrar,
|
||||
dashboardService dashboards.DashboardService,
|
||||
@@ -153,14 +154,7 @@ func RegisterAPIService(
|
||||
publicDashboardService publicdashboards.Service,
|
||||
snapshotService dashboardsnapshots.Service,
|
||||
dashboardActivityChannel live.DashboardActivityChannel,
|
||||
configProvider configprovider.ConfigProvider,
|
||||
) *DashboardsAPIBuilder {
|
||||
cfg, err := configProvider.Get(context.Background())
|
||||
if err != nil {
|
||||
logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
dbp := legacysql.NewDatabaseProvider(sql)
|
||||
namespacer := request.GetNamespaceMapper(cfg)
|
||||
legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
|
||||
@@ -243,7 +237,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(),
|
||||
@@ -753,6 +747,7 @@ func (b *DashboardsAPIBuilder) storageForVersion(
|
||||
ResourceInfo: *snapshots,
|
||||
Service: b.snapshotService,
|
||||
Namespacer: b.namespacer,
|
||||
Options: b.snapshotOptions,
|
||||
}
|
||||
storage[snapshots.StoragePath()] = snapshotLegacyStore
|
||||
storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService)
|
||||
|
||||
@@ -190,6 +190,32 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) *
|
||||
Schema: spec.Int64Property(),
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "ownerReference", // singular
|
||||
In: "query",
|
||||
Description: "filter by owner reference in the format {Group}/{Kind}/{Name}",
|
||||
Required: false,
|
||||
Schema: spec.StringProperty(),
|
||||
Examples: map[string]*spec3.Example{
|
||||
"": {
|
||||
ExampleProps: spec3.ExampleProps{},
|
||||
},
|
||||
"team": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "Team owner reference",
|
||||
Value: "iam.grafana.app/Team/xyz",
|
||||
},
|
||||
},
|
||||
"user": {
|
||||
ExampleProps: spec3.ExampleProps{
|
||||
Summary: "User owner reference",
|
||||
Value: "iam.grafana.app/User/abc",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "explain",
|
||||
@@ -458,6 +484,15 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use
|
||||
})
|
||||
}
|
||||
|
||||
// The ownerReferences filter
|
||||
if vals, ok := queryParams["ownerReference"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
Key: resource.SEARCH_FIELD_OWNER_REFERENCES,
|
||||
Operator: "=",
|
||||
Values: vals,
|
||||
})
|
||||
}
|
||||
|
||||
// The libraryPanel filter
|
||||
if libraryPanel, ok := queryParams["libraryPanel"]; ok {
|
||||
searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{
|
||||
|
||||
@@ -29,8 +29,6 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema
|
||||
createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}`
|
||||
createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema
|
||||
getSettingsRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.SnapshotSharingOptions"].Schema
|
||||
getSettingsRspExample := `{"snapshotsEnabled":true,"externalSnapshotURL":"https://externalurl.com","externalSnapshotName":"external","externalEnabled":true}`
|
||||
|
||||
return &builder.APIRoutes{
|
||||
Namespace: []builder.APIRouteHandler{
|
||||
@@ -169,84 +167,5 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
Path: prefix + "/settings",
|
||||
Spec: &spec3.PathProps{
|
||||
Get: &spec3.Operation{
|
||||
VendorExtensible: spec.VendorExtensible{
|
||||
Extensions: map[string]any{
|
||||
"x-grafana-action": "get",
|
||||
"x-kubernetes-group-version-kind": metav1.GroupVersionKind{
|
||||
Group: dashv0.GROUP,
|
||||
Version: dashv0.VERSION,
|
||||
Kind: "SnapshotSharingOptions",
|
||||
},
|
||||
},
|
||||
},
|
||||
OperationProps: spec3.OperationProps{
|
||||
Tags: tags,
|
||||
OperationId: "getSnapshotSettings",
|
||||
Description: "Get Snapshot sharing settings",
|
||||
Parameters: []*spec3.Parameter{
|
||||
{
|
||||
ParameterProps: spec3.ParameterProps{
|
||||
Name: "namespace",
|
||||
In: "path",
|
||||
Required: true,
|
||||
Example: "default",
|
||||
Description: "workspace",
|
||||
Schema: spec.StringProperty(),
|
||||
},
|
||||
},
|
||||
},
|
||||
Responses: &spec3.Responses{
|
||||
ResponsesProps: spec3.ResponsesProps{
|
||||
StatusCodeResponses: map[int]*spec3.Response{
|
||||
200: {
|
||||
ResponseProps: spec3.ResponseProps{
|
||||
Content: map[string]*spec3.MediaType{
|
||||
"application/json": {
|
||||
MediaTypeProps: spec3.MediaTypeProps{
|
||||
Schema: &getSettingsRsp,
|
||||
Example: getSettingsRspExample,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Handler: func(w http.ResponseWriter, r *http.Request) {
|
||||
user, err := identity.GetRequester(r.Context())
|
||||
if err != nil {
|
||||
errhttp.Write(r.Context(), err, w)
|
||||
return
|
||||
}
|
||||
wrap := &contextmodel.ReqContext{
|
||||
Context: &web.Context{
|
||||
Req: r,
|
||||
Resp: web.NewResponseWriter(r.Method, w),
|
||||
},
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
info, err := authlib.ParseNamespace(vars["namespace"])
|
||||
if err != nil {
|
||||
wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil)
|
||||
return
|
||||
}
|
||||
if info.OrgID != user.GetOrgID() {
|
||||
wrap.JsonApiErr(http.StatusBadRequest,
|
||||
fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.GetOrgID()), nil)
|
||||
return
|
||||
}
|
||||
|
||||
wrap.JSON(http.StatusOK, options)
|
||||
},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package snapshot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"k8s.io/apimachinery/pkg/apis/meta/internalversion"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
@@ -28,6 +29,7 @@ type SnapshotLegacyStore struct {
|
||||
ResourceInfo utils.ResourceInfo
|
||||
Service dashboardsnapshots.Service
|
||||
Namespacer request.NamespaceMapper
|
||||
Options dashV0.SnapshotSharingOptions
|
||||
}
|
||||
|
||||
func (s *SnapshotLegacyStore) New() runtime.Object {
|
||||
@@ -115,6 +117,15 @@ func (s *SnapshotLegacyStore) List(ctx context.Context, options *internalversion
|
||||
}
|
||||
|
||||
func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) {
|
||||
info, err := request.NamespaceInfoFrom(ctx, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = s.checkEnabled(info.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
query := dashboardsnapshots.GetDashboardSnapshotQuery{
|
||||
Key: name,
|
||||
}
|
||||
@@ -129,3 +140,10 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met
|
||||
}
|
||||
return nil, s.ResourceInfo.NewNotFound(name)
|
||||
}
|
||||
|
||||
func (s *SnapshotLegacyStore) checkEnabled(ns string) error {
|
||||
if !s.Options.SnapshotsEnabled {
|
||||
return fmt.Errorf("snapshots not enabled")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -129,6 +129,23 @@ func (b *FolderAPIBuilder) InstallSchema(scheme *runtime.Scheme) error {
|
||||
Version: runtime.APIVersionInternal,
|
||||
})
|
||||
|
||||
// Allow searching by owner reference
|
||||
gvk := gv.WithKind("Folder")
|
||||
err := scheme.AddFieldLabelConversionFunc(
|
||||
gvk,
|
||||
func(label, value string) (string, string, error) {
|
||||
if label == "metadata.name" || label == "metadata.namespace" {
|
||||
return label, value, nil
|
||||
}
|
||||
if label == "search.ownerReference" { // TODO: this should become more general
|
||||
return label, value, nil
|
||||
}
|
||||
return "", "", fmt.Errorf("field label not supported for %s: %s", gvk, label)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// If multiple versions exist, then register conversions from zz_generated.conversion.go
|
||||
// if err := playlist.RegisterConversions(scheme); err != nil {
|
||||
// return err
|
||||
|
||||
Generated
+2
-2
@@ -875,7 +875,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api
|
||||
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel)
|
||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -1537,7 +1537,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac
|
||||
ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService)
|
||||
apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService)
|
||||
dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider)
|
||||
dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel)
|
||||
dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -15,8 +15,6 @@ var _ authorizer.Authorizer = &roleAuthorizer{}
|
||||
|
||||
var orgRoleNoneAsViewerAPIGroups = []string{
|
||||
"productactivation.ext.grafana.com",
|
||||
// playlist can be removed after this issue is resolved: https://github.com/grafana/grafana/issues/115712
|
||||
"playlist.grafana.app",
|
||||
}
|
||||
|
||||
type roleAuthorizer struct{}
|
||||
|
||||
@@ -20,10 +20,9 @@ const (
|
||||
|
||||
// Typed errors
|
||||
var (
|
||||
ErrUserTokenNotFound = errors.New("user token not found")
|
||||
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
|
||||
ErrExternalSessionNotFound = errors.New("external session not found")
|
||||
ErrExternalSessionTokenNotFound = errors.New("session token was nil")
|
||||
ErrUserTokenNotFound = errors.New("user token not found")
|
||||
ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken
|
||||
ErrExternalSessionNotFound = errors.New("external session not found")
|
||||
)
|
||||
|
||||
type (
|
||||
|
||||
@@ -572,6 +572,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",
|
||||
@@ -681,14 +688,6 @@ var (
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "auditLoggingAppPlatform",
|
||||
Description: "Enable audit logging with Kubernetes under app platform",
|
||||
Stage: FeatureStageExperimental,
|
||||
Owner: grafanaOperatorExperienceSquad,
|
||||
HideFromDocs: true,
|
||||
RequiresRestart: true,
|
||||
},
|
||||
{
|
||||
Name: "secretsManagementAppPlatform",
|
||||
Description: "Enable the secrets management API and services under app platform",
|
||||
|
||||
Generated
+1
-1
@@ -79,6 +79,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
|
||||
drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true
|
||||
@@ -94,7 +95,6 @@ kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad
|
||||
cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false
|
||||
alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false
|
||||
jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false
|
||||
auditLoggingAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,true,false
|
||||
secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false
|
||||
alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false
|
||||
|
||||
|
Generated
+4
-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"
|
||||
@@ -275,10 +279,6 @@ const (
|
||||
// Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled.
|
||||
FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups"
|
||||
|
||||
// FlagAuditLoggingAppPlatform
|
||||
// Enable audit logging with Kubernetes under app platform
|
||||
FlagAuditLoggingAppPlatform = "auditLoggingAppPlatform"
|
||||
|
||||
// FlagSecretsManagementAppPlatform
|
||||
// Enable the secrets management API and services under app platform
|
||||
FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform"
|
||||
|
||||
+2
-17
@@ -658,20 +658,6 @@
|
||||
"frontend": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "auditLoggingAppPlatform",
|
||||
"resourceVersion": "1767013056996",
|
||||
"creationTimestamp": "2025-12-29T12:57:36Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Enable audit logging with Kubernetes under app platform",
|
||||
"stage": "experimental",
|
||||
"codeowner": "@grafana/grafana-operator-experience-squad",
|
||||
"requiresRestart": true,
|
||||
"hideFromDocs": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"metadata": {
|
||||
"name": "authZGRPCServer",
|
||||
@@ -2017,9 +2003,8 @@
|
||||
{
|
||||
"metadata": {
|
||||
"name": "kubernetesDashboardsV2",
|
||||
"resourceVersion": "1764236054307",
|
||||
"creationTimestamp": "2025-11-27T09:34:14Z",
|
||||
"deletionTimestamp": "2025-12-05T13:43:57Z"
|
||||
"resourceVersion": "1764664939750",
|
||||
"creationTimestamp": "2025-12-02T08:42:19Z"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Use the v2 kubernetes API in the frontend for dashboards",
|
||||
|
||||
@@ -660,10 +660,6 @@ func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester
|
||||
return externalSessions[0], nil
|
||||
}
|
||||
|
||||
if sessionToken == nil {
|
||||
return nil, auth.ErrExternalSessionTokenNotFound
|
||||
}
|
||||
|
||||
// For regular users, we use the session token ID to fetch the external session
|
||||
return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId)
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
|
||||
apierrors "k8s.io/apimachinery/pkg/api/errors"
|
||||
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
|
||||
"k8s.io/apimachinery/pkg/fields"
|
||||
"k8s.io/apimachinery/pkg/selection"
|
||||
"k8s.io/apiserver/pkg/storage"
|
||||
|
||||
@@ -120,6 +121,22 @@ func toListRequest(k *resourcepb.ResourceKey, opts storage.ListOptions) (*resour
|
||||
if opts.Predicate.Field != nil && !opts.Predicate.Field.Empty() {
|
||||
requirements := opts.Predicate.Field.Requirements()
|
||||
for _, r := range requirements {
|
||||
// NOTE: requires: scheme.AddFieldLabelConversionFunc(
|
||||
if r.Field == "search.ownerReference" {
|
||||
if len(requirements) > 1 {
|
||||
return nil, predicate, apierrors.NewBadRequest("search.ownerReference only supports one requirement")
|
||||
}
|
||||
req.Options.Fields = []*resourcepb.Requirement{{
|
||||
Key: r.Field,
|
||||
Operator: string(r.Operator),
|
||||
Values: []string{r.Value},
|
||||
}}
|
||||
|
||||
// with only one requirement, we do not need to transform the predicate to exclude this pseudo field
|
||||
predicate.Field = fields.Everything()
|
||||
break
|
||||
}
|
||||
|
||||
requirement := &resourcepb.Requirement{Key: r.Field, Operator: string(r.Operator)}
|
||||
if r.Value != "" {
|
||||
requirement.Values = append(requirement.Values, r.Value)
|
||||
|
||||
@@ -101,6 +101,11 @@ type IndexableDocument struct {
|
||||
// metadata, annotations, or external data linked at index time
|
||||
Fields map[string]any `json:"fields,omitempty"`
|
||||
|
||||
// The list of owner references,
|
||||
// each value is of the form {group}/{kind}/{name}
|
||||
// ex: iam.grafana.app/Team/abc-engineering
|
||||
OwnerReferences []string `json:"ownerReferences,omitempty"`
|
||||
|
||||
// Maintain a list of resource references.
|
||||
// Someday this will likely be part of https://github.com/grafana/gamma
|
||||
References ResourceReferences `json:"references,omitempty"`
|
||||
@@ -217,6 +222,10 @@ func NewIndexableDocument(key *resourcepb.ResourceKey, rv int64, obj utils.Grafa
|
||||
if err != nil && tt != nil {
|
||||
doc.Updated = tt.UnixMilli()
|
||||
}
|
||||
for _, owner := range obj.GetOwnerReferences() {
|
||||
gv, _ := schema.ParseGroupVersion(owner.APIVersion)
|
||||
doc.OwnerReferences = append(doc.OwnerReferences, fmt.Sprintf("%s/%s/%s", gv.Group, owner.Kind, owner.Name))
|
||||
}
|
||||
return doc.UpdateCopyFields()
|
||||
}
|
||||
|
||||
@@ -295,6 +304,7 @@ const SEARCH_FIELD_TITLE_PHRASE = "title_phrase" // filtering/sorting on title b
|
||||
const SEARCH_FIELD_DESCRIPTION = "description"
|
||||
const SEARCH_FIELD_TAGS = "tags"
|
||||
const SEARCH_FIELD_LABELS = "labels" // All labels, not a specific one
|
||||
const SEARCH_FIELD_OWNER_REFERENCES = "ownerReferences"
|
||||
|
||||
const SEARCH_FIELD_FOLDER = "folder"
|
||||
const SEARCH_FIELD_CREATED = "created"
|
||||
|
||||
@@ -48,6 +48,10 @@ func TestStandardDocumentBuilder(t *testing.T) {
|
||||
"id": "something"
|
||||
},
|
||||
"managedBy": "repo:something",
|
||||
"ownerReferences": [
|
||||
"iam.grafana.app/Team/engineering",
|
||||
"iam.grafana.app/User/test"
|
||||
],
|
||||
"source": {
|
||||
"path": "path/in/system.json",
|
||||
"checksum": "xyz"
|
||||
|
||||
+33
-5
@@ -16,10 +16,41 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
|
||||
for _, v := range req.Options.Fields {
|
||||
if v.Key == "metadata.name" && v.Operator == `=` {
|
||||
names = v.Values
|
||||
continue
|
||||
}
|
||||
|
||||
// TODO: support other field selectors
|
||||
// Search by owner reference
|
||||
if v.Key == "search.ownerReference" {
|
||||
if len(req.Options.Fields) > 1 {
|
||||
return &resourcepb.ListResponse{
|
||||
Error: NewBadRequestError("multiple fields found"),
|
||||
}
|
||||
}
|
||||
|
||||
results, err := s.Search(ctx, &resourcepb.ResourceSearchRequest{
|
||||
Fields: []string{}, // no extra fields
|
||||
Options: &resourcepb.ListOptions{
|
||||
Key: req.Options.Key,
|
||||
Fields: []*resourcepb.Requirement{{
|
||||
Key: SEARCH_FIELD_OWNER_REFERENCES,
|
||||
Operator: v.Operator,
|
||||
Values: v.Values,
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return &resourcepb.ListResponse{
|
||||
Error: AsErrorResult(err),
|
||||
}
|
||||
}
|
||||
if len(results.Results.Rows) < 1 { // nothing found
|
||||
return &resourcepb.ListResponse{
|
||||
ResourceVersion: 1, // TODO, search result should include when it was indexed
|
||||
}
|
||||
}
|
||||
for _, res := range results.Results.Rows {
|
||||
names = append(names, res.Key.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The required names
|
||||
@@ -42,9 +73,6 @@ func (s *server) tryFieldSelector(ctx context.Context, req *resourcepb.ListReque
|
||||
Value: found.Value,
|
||||
ResourceVersion: found.ResourceVersion,
|
||||
})
|
||||
if found.ResourceVersion > rsp.ResourceVersion {
|
||||
rsp.ResourceVersion = found.ResourceVersion
|
||||
}
|
||||
}
|
||||
}
|
||||
return rsp
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
|
||||
claims "github.com/grafana/authlib/types"
|
||||
"github.com/grafana/dskit/backoff"
|
||||
|
||||
"github.com/grafana/grafana/pkg/apimachinery/utils"
|
||||
"github.com/grafana/grafana/pkg/apimachinery/validation"
|
||||
"github.com/grafana/grafana/pkg/infra/log"
|
||||
|
||||
@@ -13,7 +13,16 @@
|
||||
"grafana.app/repoPath": "path/in/system.json",
|
||||
"grafana.app/repoHash": "xyz",
|
||||
"grafana.app/updatedTimestamp": "2024-07-01T10:11:12Z"
|
||||
}
|
||||
},
|
||||
"ownerReferences": [{
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "Team",
|
||||
"name": "engineering"
|
||||
}, {
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "User",
|
||||
"name": "test"
|
||||
}]
|
||||
},
|
||||
"spec": {
|
||||
"title": "Test Playlist from Unified Storage",
|
||||
|
||||
@@ -1559,17 +1559,20 @@ var termFields = []string{
|
||||
// Convert a "requirement" into a bleve query
|
||||
func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query, *resourcepb.ErrorResult) {
|
||||
switch selection.Operator(req.Operator) {
|
||||
case selection.Equals, selection.DoubleEquals:
|
||||
case selection.Equals:
|
||||
if len(req.Values) == 0 {
|
||||
return query.NewMatchAllQuery(), nil
|
||||
}
|
||||
|
||||
// FIXME: special case for login and email to use term query only because those fields are using keyword analyzer
|
||||
// This should be fixed by using the info from the schema
|
||||
if (req.Key == "login" || req.Key == "email") && len(req.Values) == 1 {
|
||||
tq := bleve.NewTermQuery(req.Values[0])
|
||||
tq.SetField(prefix + req.Key)
|
||||
return tq, nil
|
||||
if len(req.Values) == 1 {
|
||||
switch req.Key {
|
||||
case "login", "email", resource.SEARCH_FIELD_OWNER_REFERENCES:
|
||||
tq := bleve.NewTermQuery(req.Values[0])
|
||||
tq.SetField(prefix + req.Key)
|
||||
return tq, nil
|
||||
}
|
||||
}
|
||||
|
||||
if len(req.Values) == 1 {
|
||||
@@ -1585,11 +1588,6 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
|
||||
|
||||
return query.NewConjunctionQuery(conjuncts), nil
|
||||
|
||||
case selection.NotEquals:
|
||||
case selection.DoesNotExist:
|
||||
case selection.GreaterThan:
|
||||
case selection.LessThan:
|
||||
case selection.Exists:
|
||||
case selection.In:
|
||||
if len(req.Values) == 0 {
|
||||
return query.NewMatchAllQuery(), nil
|
||||
@@ -1622,6 +1620,14 @@ func requirementQuery(req *resourcepb.Requirement, prefix string) (query.Query,
|
||||
boolQuery.AddMust(notEmptyQuery)
|
||||
|
||||
return boolQuery, nil
|
||||
|
||||
// will fall through to the BadRequestError
|
||||
case selection.DoubleEquals:
|
||||
case selection.NotEquals:
|
||||
case selection.DoesNotExist:
|
||||
case selection.GreaterThan:
|
||||
case selection.LessThan:
|
||||
case selection.Exists:
|
||||
}
|
||||
return nil, resource.NewBadRequestError(
|
||||
fmt.Sprintf("unsupported query operation (%s %s %v)", req.Key, req.Operator, req.Values),
|
||||
|
||||
@@ -60,7 +60,7 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_DESCRIPTION, descriptionMapping)
|
||||
|
||||
tagsMapping := &mapping.FieldMapping{
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_TAGS,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
@@ -69,8 +69,18 @@ func getBleveDocMappings(fields resource.SearchableDocumentFields) *mapping.Docu
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: true,
|
||||
DocValues: false,
|
||||
}
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_TAGS, tagsMapping)
|
||||
})
|
||||
|
||||
mapper.AddFieldMappingsAt(resource.SEARCH_FIELD_OWNER_REFERENCES, &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_OWNER_REFERENCES,
|
||||
Type: "text",
|
||||
Analyzer: keyword.Name,
|
||||
Store: false,
|
||||
Index: true,
|
||||
IncludeTermVectors: false,
|
||||
IncludeInAll: false,
|
||||
DocValues: false,
|
||||
})
|
||||
|
||||
folderMapping := &mapping.FieldMapping{
|
||||
Name: resource.SEARCH_FIELD_FOLDER,
|
||||
|
||||
@@ -36,6 +36,7 @@ func TestDocumentMapping(t *testing.T) {
|
||||
Checksum: "ooo",
|
||||
TimestampMillis: 1234,
|
||||
},
|
||||
OwnerReferences: []string{"iam.grafana.app/Team/devops", "iam.grafana.app/User/xyz"},
|
||||
}
|
||||
data.UpdateCopyFields()
|
||||
|
||||
@@ -49,5 +50,5 @@ func TestDocumentMapping(t *testing.T) {
|
||||
|
||||
fmt.Printf("DOC: fields %d\n", len(doc.Fields))
|
||||
fmt.Printf("DOC: size %d\n", doc.Size())
|
||||
require.Equal(t, 17, len(doc.Fields))
|
||||
require.Equal(t, 19, len(doc.Fields))
|
||||
}
|
||||
|
||||
@@ -16,5 +16,9 @@
|
||||
"kind": "repo",
|
||||
"id": "MyGIT"
|
||||
},
|
||||
"managedBy": "repo:MyGIT"
|
||||
"managedBy": "repo:MyGIT",
|
||||
"ownerReferences": [
|
||||
"iam.grafana.app/Team/engineering",
|
||||
"iam.grafana.app/User/test"
|
||||
]
|
||||
}
|
||||
@@ -9,7 +9,16 @@
|
||||
"annotations": {
|
||||
"grafana.app/createdBy": "user:1",
|
||||
"grafana.app/repoName": "MyGIT"
|
||||
}
|
||||
},
|
||||
"ownerReferences": [{
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "Team",
|
||||
"name": "engineering"
|
||||
}, {
|
||||
"apiVersion": "iam.grafana.app/v1alpha1",
|
||||
"kind": "User",
|
||||
"name": "test"
|
||||
}]
|
||||
},
|
||||
"spec": {
|
||||
"title": "test-aaa"
|
||||
|
||||
@@ -2202,3 +2202,79 @@ func TestIntegrationProvisionedFolderPropagatesLabelsAndAnnotations(t *testing.T
|
||||
require.Equal(t, expectedLabels, accessor.GetLabels())
|
||||
require.Equal(t, expectedAnnotations, accessor.GetAnnotations())
|
||||
}
|
||||
|
||||
// Test finding folders with an owner
|
||||
func TestIntegrationFolderWithOwner(t *testing.T) {
|
||||
helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{
|
||||
DisableAnonymous: true,
|
||||
AppModeProduction: true,
|
||||
APIServerStorageType: "unified",
|
||||
UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{
|
||||
folders.RESOURCEGROUP: {
|
||||
DualWriterMode: grafanarest.Mode5,
|
||||
},
|
||||
"dashboards.dashboard.grafana.app": {
|
||||
DualWriterMode: grafanarest.Mode5,
|
||||
},
|
||||
},
|
||||
EnableFeatureToggles: []string{
|
||||
featuremgmt.FlagUnifiedStorageSearch,
|
||||
},
|
||||
})
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvr,
|
||||
})
|
||||
|
||||
// Without owner
|
||||
folder := &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"spec": map[string]any{
|
||||
"title": "Folder without owner",
|
||||
},
|
||||
},
|
||||
}
|
||||
folder.SetName("folderA")
|
||||
out, err := client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, folder.GetName(), out.GetName())
|
||||
|
||||
// with owner
|
||||
folder = &unstructured.Unstructured{
|
||||
Object: map[string]any{
|
||||
"spec": map[string]any{
|
||||
"title": "Folder with owner",
|
||||
},
|
||||
},
|
||||
}
|
||||
folder.SetName("folderB")
|
||||
folder.SetOwnerReferences([]metav1.OwnerReference{{
|
||||
APIVersion: "iam.grafana.app/v0alpha1",
|
||||
Kind: "Team",
|
||||
Name: "engineering",
|
||||
UID: "123456", // required by k8s
|
||||
}})
|
||||
out, err = client.Resource.Create(context.Background(), folder, metav1.CreateOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, folder.GetName(), out.GetName())
|
||||
|
||||
// Get everything
|
||||
results, err := client.Resource.List(context.Background(), metav1.ListOptions{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"folderA", "folderB"}, getNames(results.Items))
|
||||
|
||||
// Find results with a specific owner
|
||||
results, err = client.Resource.List(context.Background(), metav1.ListOptions{
|
||||
FieldSelector: "search.ownerReference=iam.grafana.app/Team/engineering",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"folderB"}, getNames(results.Items))
|
||||
}
|
||||
|
||||
func getNames(items []unstructured.Unstructured) []string {
|
||||
names := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
names = append(names, item.GetName())
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
@@ -1873,6 +1873,25 @@
|
||||
"format": "int64"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "ownerReference",
|
||||
"in": "query",
|
||||
"description": "filter by owner reference in the format {Group}/{Kind}/{Name}",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"examples": {
|
||||
"": {},
|
||||
"team": {
|
||||
"summary": "Team owner reference",
|
||||
"value": "iam.grafana.app/Team/xyz"
|
||||
},
|
||||
"user": {
|
||||
"summary": "User owner reference",
|
||||
"value": "iam.grafana.app/User/abc"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "explain",
|
||||
"in": "query",
|
||||
@@ -2169,43 +2188,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/settings": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Snapshot"
|
||||
],
|
||||
"description": "Get Snapshot sharing settings",
|
||||
"operationId": "getSnapshotSettings",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "namespace",
|
||||
"in": "path",
|
||||
"description": "workspace",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": "default"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {},
|
||||
"example": "{\"snapshotsEnabled\":true,\"externalSnapshotURL\":\"https://externalurl.com\",\"externalSnapshotName\":\"external\",\"externalEnabled\":true}"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"x-grafana-action": "get",
|
||||
"x-kubernetes-group-version-kind": {
|
||||
"group": "dashboard.grafana.app",
|
||||
"version": "v0alpha1",
|
||||
"kind": "SnapshotSharingOptions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/{name}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
|
||||
@@ -426,45 +426,6 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp
|
||||
require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason)
|
||||
})
|
||||
|
||||
t.Run("Check CRUD operations with None role", func(t *testing.T) {
|
||||
// Create a playlist with admin user
|
||||
clientAdmin := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.Admin,
|
||||
GVR: gvr,
|
||||
})
|
||||
created, err := clientAdmin.Resource.Create(context.Background(),
|
||||
helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"),
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientNone := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
User: helper.Org1.None,
|
||||
GVR: gvr,
|
||||
})
|
||||
|
||||
// Now check if None user can perform a Get to start a playlist
|
||||
_, err = clientNone.Resource.Get(context.Background(), created.GetName(), metav1.GetOptions{})
|
||||
require.NoError(t, err)
|
||||
|
||||
// None role can get but can not create edit or delete a playlist
|
||||
_, err = clientNone.Resource.Create(context.Background(),
|
||||
helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"),
|
||||
metav1.CreateOptions{},
|
||||
)
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = clientNone.Resource.Update(context.Background(), created, metav1.UpdateOptions{})
|
||||
require.Error(t, err)
|
||||
|
||||
err = clientNone.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{})
|
||||
require.Error(t, err)
|
||||
|
||||
// delete created resource
|
||||
err = clientAdmin.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("Check k8s client-go List from different org users", func(t *testing.T) {
|
||||
// Check Org1 Viewer
|
||||
client := helper.GetResourceClient(apis.ResourceClientArgs{
|
||||
|
||||
-72
@@ -60,76 +60,4 @@ describe('LogRecordViewerByTimestamp', () => {
|
||||
expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument();
|
||||
expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe('Numeric Value Formatting', () => {
|
||||
it('should format numeric values correctly in AlertInstanceValues', () => {
|
||||
const records: LogRecord[] = [
|
||||
{
|
||||
timestamp: 1681739580000,
|
||||
line: {
|
||||
current: 'Alerting',
|
||||
previous: 'Pending',
|
||||
labels: {},
|
||||
values: {
|
||||
cpu_usage: 42.987654321,
|
||||
memory_mb: 1234567.89,
|
||||
disk_io: 0.001234,
|
||||
request_count: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
|
||||
|
||||
expect(screen.getByText(/cpu_usage/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/memory_mb/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/disk_io/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/request_count/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should format various numeric ranges correctly', () => {
|
||||
const records: LogRecord[] = [
|
||||
{
|
||||
timestamp: 1681739580000,
|
||||
line: {
|
||||
current: 'Alerting',
|
||||
previous: 'Pending',
|
||||
labels: {},
|
||||
values: {
|
||||
small: 0.001,
|
||||
normal: 42.5,
|
||||
large: 123456,
|
||||
boundary_low: 0.01,
|
||||
boundary_high: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
render(<LogRecordViewerByTimestamp records={records} commonLabels={[]} />);
|
||||
|
||||
expect(screen.getByText(/small/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/normal/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/42\.5/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/large/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/boundary_low/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/0\.01/)).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText(/boundary_high/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/10000/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-2
@@ -13,7 +13,6 @@ import { AlertStateTag } from '../AlertStateTag';
|
||||
|
||||
import { ErrorMessageRow } from './ErrorMessageRow';
|
||||
import { LogRecord, omitLabels } from './common';
|
||||
import { formatNumericValue } from './numberFormatter';
|
||||
|
||||
type LogRecordViewerProps = {
|
||||
records: LogRecord[];
|
||||
@@ -183,7 +182,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record<string, number> }
|
||||
return (
|
||||
<>
|
||||
{values.map(([key, value]) => (
|
||||
<AlertLabel key={key} labelKey={key} value={formatNumericValue(value)} />
|
||||
<AlertLabel key={key} labelKey={key} value={String(value)} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
-173
@@ -1,173 +0,0 @@
|
||||
import { formatNumericValue } from './numberFormatter';
|
||||
|
||||
describe('formatNumericValue', () => {
|
||||
describe('Zero and special values', () => {
|
||||
it('should format zero correctly', () => {
|
||||
expect(formatNumericValue(0)).toBe('0');
|
||||
expect(formatNumericValue(-0)).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle NaN', () => {
|
||||
expect(formatNumericValue(NaN)).toBe('NaN');
|
||||
});
|
||||
|
||||
it('should handle Infinity', () => {
|
||||
expect(formatNumericValue(Infinity)).toBe('Infinity');
|
||||
expect(formatNumericValue(-Infinity)).toBe('-Infinity');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very small numbers (scientific notation)', () => {
|
||||
it('should use scientific notation for values less than 1e-2', () => {
|
||||
const result1 = formatNumericValue(1e-3);
|
||||
expect(result1).toMatch(/^1\.000e-3$/i);
|
||||
|
||||
const result2 = formatNumericValue(0.001);
|
||||
expect(result2).toMatch(/^1\.000e-3$/i);
|
||||
|
||||
const result3 = formatNumericValue(0.009);
|
||||
expect(result3).toMatch(/^9\.000e-3$/i);
|
||||
});
|
||||
|
||||
it('should use scientific notation for values just below 1e-2', () => {
|
||||
const result = formatNumericValue(0.00999);
|
||||
expect(result).toMatch(/^9\.990e-3$/i);
|
||||
});
|
||||
|
||||
it('should format the example from requirements correctly', () => {
|
||||
// 1.4153928131348452 has > 4 decimal places, so should use scientific notation
|
||||
const result = formatNumericValue(1.4153928131348452);
|
||||
expect(result).toMatch(/^1\.415e\+0$/i);
|
||||
});
|
||||
|
||||
it('should handle negative very small numbers', () => {
|
||||
const result = formatNumericValue(-1e-3);
|
||||
expect(result).toMatch(/^-1\.000e-3$/i);
|
||||
|
||||
const result2 = formatNumericValue(-0.001);
|
||||
expect(result2).toMatch(/^-1\.000e-3$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Human-readable range (standard notation)', () => {
|
||||
it('should use standard notation for boundary value 1e-2', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
});
|
||||
|
||||
it('should use standard notation for values in readable range', () => {
|
||||
expect(formatNumericValue(0.1)).toBe('0.1');
|
||||
expect(formatNumericValue(1)).toBe('1');
|
||||
expect(formatNumericValue(1.234)).toBe('1.234');
|
||||
expect(formatNumericValue(42.5)).toBe('42.5');
|
||||
});
|
||||
|
||||
it('should limit to 4 decimal places without rounding integer parts', () => {
|
||||
expect(formatNumericValue(123.456)).toBe('123.456');
|
||||
expect(formatNumericValue(1234.567)).toBe('1234.567');
|
||||
expect(formatNumericValue(9999.9)).toBe('9999.9');
|
||||
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
|
||||
});
|
||||
|
||||
it('should use scientific notation for numbers with more than 4 decimal places', () => {
|
||||
// Numbers with > 4 decimals should use scientific notation even in readable range
|
||||
const result1 = formatNumericValue(123.456789);
|
||||
expect(result1).toMatch(/^1\.235e\+2$/i);
|
||||
|
||||
const result2 = formatNumericValue(1.23456789);
|
||||
expect(result2).toMatch(/^1\.235e\+0$/i);
|
||||
|
||||
const result3 = formatNumericValue(42.987654321);
|
||||
expect(result3).toMatch(/^4\.299e\+1$/i);
|
||||
});
|
||||
|
||||
it('should use standard notation for boundary value 1e4', () => {
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
});
|
||||
|
||||
it('should handle negative numbers in readable range', () => {
|
||||
expect(formatNumericValue(-0.1)).toBe('-0.1');
|
||||
expect(formatNumericValue(-123.456)).toBe('-123.456');
|
||||
expect(formatNumericValue(-9999.9)).toBe('-9999.9');
|
||||
});
|
||||
|
||||
it('should use scientific notation for negative numbers with excessive precision', () => {
|
||||
const result = formatNumericValue(-42.987654321);
|
||||
expect(result).toMatch(/^-4\.299e\+1$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Very large numbers (scientific notation)', () => {
|
||||
it('should use scientific notation for values greater than 1e4', () => {
|
||||
const result1 = formatNumericValue(10001);
|
||||
expect(result1).toMatch(/^1\.000e\+4$/i);
|
||||
|
||||
const result2 = formatNumericValue(123456);
|
||||
expect(result2).toMatch(/^1\.235e\+5$/i);
|
||||
});
|
||||
|
||||
it('should handle negative very large numbers', () => {
|
||||
const result = formatNumericValue(-1e5);
|
||||
expect(result).toMatch(/^-1\.000e\+5$/i);
|
||||
|
||||
const result2 = formatNumericValue(-123456);
|
||||
expect(result2).toMatch(/^-1\.235e\+5$/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Edge cases', () => {
|
||||
it('should handle numbers exactly at boundaries', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
|
||||
const justBelow = formatNumericValue(0.009999);
|
||||
expect(justBelow).toMatch(/^9\.999e-3$/i);
|
||||
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
|
||||
const justAbove = formatNumericValue(10001);
|
||||
expect(justAbove).toMatch(/^1\.000e\+4$/i);
|
||||
});
|
||||
|
||||
it('should use scientific notation for very precise decimals with > 4 decimal places', () => {
|
||||
expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i);
|
||||
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
|
||||
expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i);
|
||||
});
|
||||
|
||||
it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => {
|
||||
expect(formatNumericValue(1.2345)).toBe('1.2345');
|
||||
expect(formatNumericValue(0.1234)).toBe('0.1234');
|
||||
expect(formatNumericValue(123.4567)).toBe('123.4567');
|
||||
});
|
||||
});
|
||||
|
||||
describe('countDecimalPlaces edge cases', () => {
|
||||
it('should handle numbers that toString() would convert to scientific notation', () => {
|
||||
const result = formatNumericValue(1e-10);
|
||||
expect(result).toMatch(/^1\.000e-10$/i);
|
||||
|
||||
const result2 = formatNumericValue(1e10);
|
||||
expect(result2).toMatch(/^1\.000e\+10$/i);
|
||||
});
|
||||
|
||||
it('should correctly count decimals for numbers with trailing zeros', () => {
|
||||
expect(formatNumericValue(1.234)).toBe('1.234');
|
||||
expect(formatNumericValue(1.2)).toBe('1.2');
|
||||
expect(formatNumericValue(1.0)).toBe('1');
|
||||
});
|
||||
|
||||
it('should handle boundary values correctly', () => {
|
||||
expect(formatNumericValue(0.01)).toBe('0.01');
|
||||
expect(formatNumericValue(10000)).toBe('10000');
|
||||
|
||||
expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i);
|
||||
expect(formatNumericValue(9999.1234)).toBe('9999.1234');
|
||||
expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i);
|
||||
});
|
||||
|
||||
it('should handle numbers in readable range that have many decimals', () => {
|
||||
expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i);
|
||||
expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i);
|
||||
expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i);
|
||||
});
|
||||
});
|
||||
});
|
||||
-75
@@ -1,75 +0,0 @@
|
||||
const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2;
|
||||
const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4;
|
||||
const MAX_DECIMAL_PLACES = 4;
|
||||
const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals
|
||||
|
||||
const readableRangeFormatter = new Intl.NumberFormat(undefined, {
|
||||
maximumFractionDigits: MAX_DECIMAL_PLACES,
|
||||
useGrouping: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Counts the number of decimal places in a number.
|
||||
* Only processes numbers in readable range (1e-2 to 1e4) to avoid
|
||||
* toString() scientific notation issues for very large/small numbers.
|
||||
*
|
||||
* Uses toFixed(10) to ensure standard notation representation.
|
||||
* 10 decimal places is sufficient to detect if a number has > 4 decimal places.
|
||||
*/
|
||||
function countDecimalPlaces(value: number): number {
|
||||
if (Number.isInteger(value)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
// Only count decimals for numbers in readable range
|
||||
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const str = value.toFixed(10);
|
||||
const decimalIndex = str.indexOf('.');
|
||||
|
||||
if (decimalIndex === -1) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Count decimal places, removing trailing zeros
|
||||
const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, '');
|
||||
return decimalPart.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a numeric value for display in alert rule history.
|
||||
* - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places
|
||||
* - For very small values (< 1e-2): uses scientific notation with 4 significant digits
|
||||
* - For very large values (> 1e4): uses scientific notation with 4 significant digits
|
||||
* - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits
|
||||
*
|
||||
* @param value - The number to format
|
||||
* @returns A formatted string representation of the number
|
||||
*/
|
||||
export function formatNumericValue(value: number): string {
|
||||
if (!Number.isFinite(value)) {
|
||||
return String(value);
|
||||
}
|
||||
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
const absValue = Math.abs(value);
|
||||
|
||||
if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) {
|
||||
return value.toExponential(EXPONENTIAL_DECIMALS);
|
||||
}
|
||||
|
||||
const decimalPlaces = countDecimalPlaces(value);
|
||||
|
||||
if (decimalPlaces > MAX_DECIMAL_PLACES) {
|
||||
return value.toExponential(EXPONENTIAL_DECIMALS);
|
||||
}
|
||||
|
||||
return readableRangeFormatter.format(value);
|
||||
}
|
||||
@@ -83,24 +83,6 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro
|
||||
onClick={() => dashboard.openV2SchemaEditor()}
|
||||
/> */}
|
||||
<Sidebar.Divider />
|
||||
<Sidebar.Button
|
||||
style={{ color: '#ff671d' }}
|
||||
icon="comment-alt-message"
|
||||
onClick={() =>
|
||||
window.open(
|
||||
'https://docs.google.com/forms/d/e/1FAIpQLSfDZJM_VlZgRHDx8UPtLWbd9bIBPRxoA28qynTHEYniyPXO6Q/viewform',
|
||||
'_blank'
|
||||
)
|
||||
}
|
||||
title={t(
|
||||
'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience',
|
||||
'Give feedback on the new dashboard editing experience'
|
||||
)}
|
||||
tooltip={t(
|
||||
'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience',
|
||||
'Give feedback on the new dashboard editing experience'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{hasUid && <ShareExportDashboardButton dashboard={dashboard} />}
|
||||
|
||||
@@ -959,7 +959,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan
|
||||
}
|
||||
|
||||
export function shouldForceV2API(): boolean {
|
||||
return Boolean(config.featureToggles.dashboardNewLayouts);
|
||||
return Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts);
|
||||
}
|
||||
|
||||
export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase<
|
||||
|
||||
@@ -345,12 +345,10 @@ export class V2DashboardSerializer
|
||||
// initialize autossigned variable ds references map
|
||||
if (saveModel?.variables) {
|
||||
for (const variable of saveModel.variables) {
|
||||
if (variable) {
|
||||
// for query variables that dont have a ds defined add them to the list
|
||||
if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) {
|
||||
const datasourceType = variable.spec.query.group || undefined;
|
||||
this.defaultDsReferencesMap.variables.set(variable.spec.name, datasourceType);
|
||||
}
|
||||
// for query variables that dont have a ds defined add them to the list
|
||||
if (variable.kind === 'QueryVariable' && !variable.spec.query.datasource?.name) {
|
||||
const datasourceType = variable.spec.query.group || undefined;
|
||||
this.defaultDsReferencesMap.variables.set(variable.spec.name, datasourceType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ export function isV0V1StoredVersion(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';
|
||||
@@ -38,7 +39,7 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') {
|
||||
if (responseFormat === 'v1') {
|
||||
return 'v1';
|
||||
}
|
||||
if (responseFormat === 'v2' || isDashboardNewLayoutsEnabled) {
|
||||
if (responseFormat === 'v2' || isV2DashboardAPIVersionEnabled || isDashboardNewLayoutsEnabled) {
|
||||
return 'v2';
|
||||
}
|
||||
return 'unified';
|
||||
|
||||
@@ -118,7 +118,10 @@ class K8sAPI implements DashboardSnapshotSrv {
|
||||
}
|
||||
|
||||
async getSharingOptions() {
|
||||
return getBackendSrv().get<SnapshotSharingOptions>(this.url + '/settings');
|
||||
// TODO? should this be in a config service, or in the same service?
|
||||
// we have http://localhost:3000/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/default/options
|
||||
// BUT that has an unclear user mapping story still, so lets stick with the existing shared-options endpoint
|
||||
return getBackendSrv().get<SnapshotSharingOptions>('/api/snapshot/shared-options');
|
||||
}
|
||||
|
||||
async getSnapshot(uid: string): Promise<DashboardDTO> {
|
||||
|
||||
@@ -5967,9 +5967,6 @@
|
||||
"name-values-separated-comma": "Values separated by comma",
|
||||
"selection-options": "Selection options"
|
||||
},
|
||||
"dashboard-edit-pane-renderer": {
|
||||
"title-feedback-dashboard-editing-experience": "Give feedback on the new dashboard editing experience"
|
||||
},
|
||||
"dashboard-link-form": {
|
||||
"back-to-list": "Back to list",
|
||||
"label-icon": "Icon",
|
||||
|
||||
Reference in New Issue
Block a user