From cad9e1c5f38b5badde8055a34ed25387c47c5f8d Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 17 Nov 2025 15:26:33 +0100 Subject: [PATCH 001/125] Actions: Only run CodeQL analysis on main/release branches (#114021) * Actions: Only run CodeQL analysis on main/release branches * disble cache in go action --- .github/workflows/codeql-analysis.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index b06c637112d..925268df84a 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -8,7 +8,9 @@ name: "CodeQL checks" on: workflow_dispatch: push: - branches: ['**'] # run on all branches + branches: + - main + - release-*.*.* paths-ignore: - '**/*.cue' - '**/*.json' @@ -74,6 +76,7 @@ jobs: name: Set go version uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 with: + cache: false go-version-file: go.mod # Initializes the CodeQL tools for scanning. From fcceff99e2d8d8bdea9d77782ff6f12286a6fd08 Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Mon, 17 Nov 2025 06:36:36 -0800 Subject: [PATCH 002/125] CandleStick: Add timeRangePan (#113888) * Candlestick: Add timeRangePan * Add tests * Rename test file * Optimize time range pan tests --- .../panels-suite/candlestick.spec.ts | 112 ++++++++++++++++++ .../panel/candlestick/CandlestickPanel.tsx | 2 + 2 files changed, 114 insertions(+) create mode 100644 e2e-playwright/panels-suite/candlestick.spec.ts diff --git a/e2e-playwright/panels-suite/candlestick.spec.ts b/e2e-playwright/panels-suite/candlestick.spec.ts new file mode 100644 index 00000000000..ad29f64ab47 --- /dev/null +++ b/e2e-playwright/panels-suite/candlestick.spec.ts @@ -0,0 +1,112 @@ +import { test, expect } from '@grafana/plugin-e2e'; + +const DASHBOARD_UID = 'MP-Di9F7k'; + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: Candlestick X-axis panning', { tag: ['@panels', '@candlestick'] }, () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const candlestickPanel = page.locator('.uplot').first(); + await expect(candlestickPanel, 'panel rendered').toBeVisible(); + + const xAxis = candlestickPanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; + }); + + await test.step('Capture initial time range', async () => { + const candlestickPanel = page.locator('.uplot').first(); + const xAxis = candlestickPanel.locator('.u-axis').first(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx index e566a8445c6..4f5f7a46b23 100644 --- a/public/app/plugins/panel/candlestick/CandlestickPanel.tsx +++ b/public/app/plugins/panel/candlestick/CandlestickPanel.tsx @@ -12,6 +12,7 @@ import { KeyboardPlugin, TooltipPlugin2, UPlotConfigBuilder, + XAxisInteractionAreaPlugin, usePanelContext, useTheme2, } from '@grafana/ui'; @@ -276,6 +277,7 @@ export const CandlestickPanel = ({ {cursorSync !== DashboardCursorSync.Off && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( Date: Mon, 17 Nov 2025 07:02:08 -0800 Subject: [PATCH 003/125] TimeSeries: Improve e2e for time range pan (#113981) Timeseries: Improve e2e for time range pan --- .../panels-suite/timeseries.spec.ts | 27 +++++++------------ 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/e2e-playwright/panels-suite/timeseries.spec.ts b/e2e-playwright/panels-suite/timeseries.spec.ts index fb2124f7dda..d7cd61eb092 100644 --- a/e2e-playwright/panels-suite/timeseries.spec.ts +++ b/e2e-playwright/panels-suite/timeseries.spec.ts @@ -9,8 +9,13 @@ test.use({ }); test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@timeseries'] }, () => { - test('cursor changes to grab hand over x-axis', async ({ gotoDashboardPage, page }) => { - await test.step('Load dashboard and verify cursor changes to grab', async () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); const timeseriesPanel = page.locator('.uplot').first(); @@ -23,23 +28,13 @@ test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@tim const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; }); - }); - - test('drag right pans backward in time, drag left pans forward', async ({ gotoDashboardPage, page, selectors }) => { - let centerX: number; - let centerY: number; - let initialFromTime: number; - let initialToTime: number; - - const dashboardPage = await test.step('Load dashboard and capture initial time range', async () => { - const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + await test.step('Capture initial time range', async () => { const timeseriesPanel = page.locator('.uplot').first(); - await expect(timeseriesPanel, 'panel rendered').toBeVisible(); - const xAxis = timeseriesPanel.locator('.u-axis').first(); - await expect(xAxis, 'x-axis rendered').toBeVisible(); const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); await timePickerButton.click(); @@ -61,8 +56,6 @@ test.describe('Panels test: TimeSeries X-axis panning', { tag: ['@panels', '@tim centerX = axisBox.x + axisBox.width / 2; centerY = axisBox.y + axisBox.height / 2; - - return dashboardPage; }); await test.step('Drag right pans backward in time', async () => { From c7e8291bd1916fe0f641aff6497bccce6b8c4758 Mon Sep 17 00:00:00 2001 From: Alexa Vargas <239999+axelavargas@users.noreply.github.com> Date: Mon, 17 Nov 2025 16:10:19 +0100 Subject: [PATCH 004/125] Dashboard Library: Add basic unit test to suggested dashboards flow (#113825) * Create unit tests for communityDashboardHelper * Add unit test for autoMapDatasources * Create unit test for DashboardCard * --wip-- [skip ci] * Fix test * fix linting * Add unit test to the dashboardLibraryApi * update codeowners for dashboard library code * fix: improve test coverage and fix failing tests - Fix image handling tests in DashboardCard to verify actual behavior - Fix UID filtering test in autoMapDatasources to use correct mock data - Add test for dimThumbnail prop - Add test for undefined inputs edge case - All 82 tests now passing * render function modified * merge with template dashboard modifications. tests modified --------- Co-authored-by: nmarrs Co-authored-by: Juan Cabanas --- .github/CODEOWNERS | 1 + .../CommunityDashboardMappingForm.test.tsx | 262 +++++++++++++ .../DashboardLibrary/DashboardCard.test.tsx | 349 +++++++++++++++++ .../DashboardLibrary/DashboardCard.tsx | 4 +- .../api/dashboardLibraryApi.test.ts | 263 +++++++++++++ .../api/dashboardLibraryApi.ts | 2 +- .../utils/autoMapDatasources.test.ts | 368 ++++++++++++++++++ .../utils/communityDashboardHelpers.test.ts | 310 +++++++++++++++ 8 files changed, 1557 insertions(+), 2 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.test.ts create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3cb63cf9969..39a480c8a85 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1101,6 +1101,7 @@ eslint-suppressions.json @grafanabot # Grafana Sharing Squad /public/app/features/dashboard-scene/sharing/ @grafana/sharing-squad /public/app/features/dashboard/components/ShareModal/ @grafana/sharing-squad +/public/app/features/dashboard/dashgrid/DashboardLibrary/ @grafana/sharing-squad /public/app/features/manage-dashboards/components/SnapshotListTable.tsx @grafana/sharing-squad /pkg/services/dashboardsnapshots/ @grafana/sharing-squad /public/app/features/explore/QueryLibrary/ @grafana/sharing-squad diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.test.tsx new file mode 100644 index 00000000000..a59122a6ca7 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/CommunityDashboardMappingForm.test.tsx @@ -0,0 +1,262 @@ +import { screen, waitFor } from '@testing-library/react'; +import { render } from 'test/test-utils'; + +import { DataSourceInput, DashboardInput, InputType } from 'app/features/manage-dashboards/state/reducers'; + +import { CommunityDashboardMappingForm } from './CommunityDashboardMappingForm'; +import { CONTENT_KINDS, ContentKind, EVENT_LOCATIONS, EventLocation } from './interactions'; +import { InputMapping } from './utils/autoMapDatasources'; + +interface CommunityDashboardMappingFormProps { + dashboardName: string; + libraryItemId: string; + eventLocation: EventLocation; + contentKind: ContentKind; + datasourceTypes: string[]; + unmappedDsInputs: DataSourceInput[]; + constantInputs: DashboardInput[]; + existingMappings: InputMapping[]; + onBack: () => void; + onPreview: (mappings: InputMapping[]) => void; +} + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getInstanceSettings: jest.fn((uid: string) => ({ + uid, + name: `DataSource ${uid}`, + type: 'prometheus', + })), + }), +})); + +jest.mock('app/features/datasources/components/picker/DataSourcePicker', () => ({ + DataSourcePicker: ({ + onChange, + placeholder, + }: { + onChange: (ds: { uid: string; name: string; type: string }) => void; + placeholder?: string; + }) => ( + + ), +})); + +// Helper functions +const createMockDataSourceInput = (overrides: Partial = {}): DataSourceInput => + ({ + name: 'DS_PROMETHEUS', + pluginId: 'prometheus', + type: InputType.DataSource, + label: 'Prometheus', + value: '', + info: 'Prometheus datasource', + ...overrides, + }) as DataSourceInput; + +const createMockConstantInput = (overrides: Partial = {}): DashboardInput => + ({ + name: 'var_instance', + type: InputType.Constant, + label: 'Instance', + value: 'default', + description: 'Instance name', + info: 'Instance name', + pluginId: undefined, + ...overrides, + }) as DashboardInput; + +const createMockExistingMapping = (overrides: Partial = {}): InputMapping => ({ + name: 'DS_LOKI', + type: 'datasource', + pluginId: 'loki', + value: 'loki-uid', + ...overrides, +}); + +function setupForm(overrides: Partial = {}) { + const defaultProps: CommunityDashboardMappingFormProps = { + dashboardName: 'Test Dashboard', + libraryItemId: '123', + eventLocation: EVENT_LOCATIONS.MODAL_COMMUNITY_TAB, + contentKind: CONTENT_KINDS.COMMUNITY_DASHBOARD, + datasourceTypes: ['prometheus'], + unmappedDsInputs: [], + constantInputs: [], + existingMappings: [], + onBack: jest.fn(), + onPreview: jest.fn(), + ...overrides, + }; + + return render(); +} + +describe('CommunityDashboardMappingForm', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('Rendering', () => { + it('should render description text', () => { + setupForm(); + + expect( + screen.getByText('This dashboard requires datasource configuration. Select datasources for each input below.') + ).toBeInTheDocument(); + }); + + it('should render back button', () => { + setupForm(); + + expect(screen.getByRole('button', { name: /back to dashboards/i })).toBeInTheDocument(); + }); + + it('should render preview button', () => { + setupForm(); + + expect(screen.getByRole('button', { name: /preview dashboard/i })).toBeInTheDocument(); + }); + }); + + describe('Auto-mapped datasources alert', () => { + it('should show alert when existing mappings are provided', () => { + setupForm({ existingMappings: [createMockExistingMapping()] }); + + expect(screen.getByText(/1 datasources were automatically configured/i)).toBeInTheDocument(); + expect(screen.getByText(/loki → DataSource loki-uid/i)).toBeInTheDocument(); + }); + + it('should not show alert when no existing mappings', () => { + setupForm(); + + expect(screen.queryByText(/datasources were automatically configured/i)).not.toBeInTheDocument(); + }); + }); + + describe('Datasource inputs', () => { + it('should render datasource configuration section when unmapped inputs exist', () => { + setupForm({ unmappedDsInputs: [createMockDataSourceInput()] }); + + expect(screen.getByText('Datasource Configuration')).toBeInTheDocument(); + expect(screen.getByText('Prometheus')).toBeInTheDocument(); + }); + + it('should render multiple datasource inputs', () => { + setupForm({ + unmappedDsInputs: [ + createMockDataSourceInput({ name: 'DS_PROM', label: 'Prometheus' }), + createMockDataSourceInput({ name: 'DS_LOKI', label: 'Loki', pluginId: 'loki' }), + ], + }); + + expect(screen.getByText('Prometheus')).toBeInTheDocument(); + expect(screen.getByText('Loki')).toBeInTheDocument(); + }); + + it('should not render datasource section when no unmapped inputs', () => { + setupForm(); + + expect(screen.queryByText('Datasource Configuration')).not.toBeInTheDocument(); + }); + }); + + describe('Constant inputs', () => { + it('should render dashboard variables section when constant inputs exist', () => { + setupForm({ constantInputs: [createMockConstantInput()] }); + + expect(screen.getByText('Dashboard Variables')).toBeInTheDocument(); + expect(screen.getByText('Instance')).toBeInTheDocument(); + }); + + it('should render input field with default value', () => { + setupForm({ constantInputs: [createMockConstantInput({ value: 'my-default-value' })] }); + + expect(screen.getByDisplayValue('my-default-value')).toBeInTheDocument(); + }); + + it('should allow editing constant input values', async () => { + const { user } = setupForm({ constantInputs: [createMockConstantInput()] }); + + const input = screen.getByDisplayValue('default'); + await user.clear(input); + await user.type(input, 'new-value'); + + expect(screen.getByDisplayValue('new-value')).toBeInTheDocument(); + }); + + it('should render multiple constant inputs', () => { + setupForm({ + constantInputs: [ + createMockConstantInput({ name: 'var_instance', label: 'Instance' }), + createMockConstantInput({ name: 'var_env', label: 'Environment', value: 'prod' }), + ], + }); + + expect(screen.getByText('Instance')).toBeInTheDocument(); + expect(screen.getByText('Environment')).toBeInTheDocument(); + expect(screen.getByDisplayValue('prod')).toBeInTheDocument(); + }); + }); + + describe('Button interactions', () => { + it('should call onBack when back button is clicked', async () => { + const mockOnBack = jest.fn(); + const { user } = setupForm({ onBack: mockOnBack }); + + await user.click(screen.getByRole('button', { name: /back to dashboards/i })); + + expect(mockOnBack).toHaveBeenCalledTimes(1); + }); + + it('should enable preview button when no unmapped datasources', () => { + setupForm(); + + expect(screen.getByRole('button', { name: /preview dashboard/i })).toBeEnabled(); + }); + + it('should disable preview button when datasources are not mapped', () => { + setupForm({ unmappedDsInputs: [createMockDataSourceInput()] }); + + expect(screen.getByRole('button', { name: /preview dashboard/i })).toBeDisabled(); + }); + + it('should enable preview button after all datasources are mapped', async () => { + const { user } = setupForm({ unmappedDsInputs: [createMockDataSourceInput()] }); + + expect(screen.getByRole('button', { name: /preview dashboard/i })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: /prometheus datasource/i })); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /preview dashboard/i })).toBeEnabled(); + }); + }); + + it('should call onPreview with all mappings when preview is clicked', async () => { + const mockOnPreview = jest.fn(); + const existingMappings = [createMockExistingMapping()]; + const { user } = setupForm({ onPreview: mockOnPreview, existingMappings }); + + await user.click(screen.getByRole('button', { name: /preview dashboard/i })); + + expect(mockOnPreview).toHaveBeenCalledWith(existingMappings); + }); + + it('should call onPreview with combined mappings including constants', async () => { + const mockOnPreview = jest.fn(); + const constantInputs = [createMockConstantInput({ name: 'var_test', value: 'test-value' })]; + const existingMappings = [createMockExistingMapping()]; + + const { user } = setupForm({ onPreview: mockOnPreview, constantInputs, existingMappings }); + + await user.click(screen.getByRole('button', { name: /preview dashboard/i })); + + expect(mockOnPreview).toHaveBeenCalledWith([ + ...existingMappings, + { name: 'var_test', type: 'constant', value: 'test-value' }, + ]); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx new file mode 100644 index 00000000000..939f1bcdb89 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.test.tsx @@ -0,0 +1,349 @@ +import { screen } from '@testing-library/react'; +import { render } from 'test/test-utils'; + +import { PluginDashboard } from 'app/types/plugins'; + +import { DashboardCard } from './DashboardCard'; +import { GnetDashboard } from './types'; + +// Helper functions for creating mock objects +const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ + dashboardId: 1, + description: 'Test description', + imported: false, + importedRevision: 0, + importedUri: '', + importedUrl: '', + path: '', + pluginId: 'test-plugin', + removed: false, + revision: 1, + slug: 'test-dashboard', + title: 'Test Dashboard', + uid: 'test-uid', + ...overrides, +}); + +const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 123, + name: 'Test Dashboard', + description: 'Test description', + datasource: 'Prometheus', + orgName: 'Test Org', + userName: 'testuser', + publishedAt: '', + updatedAt: '', + downloads: 0, + ...overrides, +}); + +const createMockDetails = (overrides = {}) => ({ + id: '123', + datasource: 'Prometheus', + dependencies: ['Prometheus'], + publishedBy: 'Test Org', + lastUpdate: '1 Jan 2025', + grafanaComUrl: 'https://grafana.com/grafana/dashboards/123-test/', + ...overrides, +}); + +describe('DashboardCard', () => { + const mockOnClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should render title as heading', () => { + const dashboard = createMockPluginDashboard(); + render( + + ); + + expect(screen.getByRole('heading', { name: 'My Dashboard' })).toBeInTheDocument(); + }); + + it('should render image when imageUrl is provided', () => { + const dashboard = createMockPluginDashboard(); + render( + + ); + + const image = screen.getByRole('img', { name: 'Test Dashboard' }); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'https://example.com/image.png'); + }); + + it('should show "No preview available" when imageUrl is not provided', () => { + const dashboard = createMockPluginDashboard(); + render( + + ); + + expect(screen.getByText('No preview available')).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); + + it('should render description when provided', () => { + const dashboard = createMockPluginDashboard({ description: 'My custom description' }); + render( + + ); + + expect(screen.getByText('My custom description')).toBeInTheDocument(); + }); + + it('should not render description when empty', () => { + const dashboard = createMockPluginDashboard({ description: '' }); + render( + + ); + + expect(screen.getByRole('heading', { name: 'Test Dashboard' })).toBeInTheDocument(); + expect(screen.queryByTestId('dashboard-card-description')).not.toBeInTheDocument(); + }); + + describe('Button interactions', () => { + it('should trigger onClick when button is clicked', async () => { + const { user } = render( + + ); + + await user.click(screen.getByRole('button', { name: 'Use dashboard' })); + + expect(mockOnClick).toHaveBeenCalledTimes(1); + }); + + it('should display template button text', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Use template' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Use dashboard' })).not.toBeInTheDocument(); + }); + + it('should display dashboard button text', () => { + render( + + ); + + expect(screen.getByRole('button', { name: 'Use dashboard' })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Use template' })).not.toBeInTheDocument(); + }); + }); + + describe('Badge display', () => { + it('should show datasource-provided badge when flag is true', () => { + render( + + ); + + expect(screen.getByText('Data source provided')).toBeInTheDocument(); + }); + + it('should not show badge when flag is false', () => { + render( + + ); + + expect(screen.queryByText('Data source provided')).not.toBeInTheDocument(); + }); + + it('should not show badge by default', () => { + render( + + ); + + expect(screen.queryByText('Data source provided')).not.toBeInTheDocument(); + }); + }); + + describe('Details tooltip', () => { + it('should show details icon button when details are provided', () => { + const details = createMockDetails(); + render( + + ); + + expect(screen.getByRole('button', { name: 'Details' })).toBeInTheDocument(); + }); + + it('should not show details icon button when details are not provided', () => { + render( + + ); + + expect(screen.queryByRole('button', { name: 'Details' })).not.toBeInTheDocument(); + }); + + it('should display details information in tooltip', async () => { + const details = createMockDetails({ + id: '456', + datasource: 'Loki', + dependencies: ['Loki', 'Prometheus'], + publishedBy: 'Grafana Labs', + lastUpdate: '15 Dec 2024', + grafanaComUrl: 'https://grafana.com/grafana/dashboards/456-loki/', + }); + + const { user } = render( + + ); + + await user.hover(screen.getByRole('button', { name: 'Details' })); + + expect(await screen.findByText('456')).toBeInTheDocument(); + expect(screen.getByText('Loki')).toBeInTheDocument(); + expect(screen.getByText('Loki | Prometheus')).toBeInTheDocument(); + expect(screen.getByText('Grafana Labs')).toBeInTheDocument(); + expect(screen.getByText('15 Dec 2024')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View on Grafana.com' })).toHaveAttribute( + 'href', + 'https://grafana.com/grafana/dashboards/456-loki/' + ); + }); + + it('should not show Grafana.com link when grafanaComUrl is not provided', async () => { + const details = createMockDetails({ grafanaComUrl: undefined }); + + const { user } = render( + + ); + + await user.hover(screen.getByRole('button', { name: 'Details' })); + + expect(await screen.findByText('123')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'View on Grafana.com' })).not.toBeInTheDocument(); + }); + }); + + describe('Image handling', () => { + it('should render image correctly for logo vs thumbnail', () => { + const { rerender } = render( + + ); + + let image = screen.getByRole('img'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'https://example.com/logo.png'); + + rerender( + + ); + + image = screen.getByRole('img'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'https://example.com/screenshot.png'); + }); + + it('should render image when dimThumbnail is true', () => { + render( + + ); + + const image = screen.getByRole('img'); + expect(image).toBeInTheDocument(); + expect(image).toHaveAttribute('src', 'https://example.com/screenshot.png'); + }); + }); + + describe('GnetDashboard support', () => { + it('should render with GnetDashboard', () => { + const dashboard = createMockGnetDashboard({ name: 'Community Dashboard' }); + render( + + ); + + expect(screen.getByRole('heading', { name: 'Community Dashboard' })).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx index 0f308b0a4ad..4d992320c09 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/DashboardCard.tsx @@ -77,7 +77,9 @@ function DashboardCardComponent({
{dashboard.description && ( - {dashboard.description} + + {dashboard.description} + )}
diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts new file mode 100644 index 00000000000..c662b90e372 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.test.ts @@ -0,0 +1,263 @@ +import { BackendSrv, getBackendSrv } from '@grafana/runtime'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; +import { PluginDashboard } from 'app/types/plugins'; + +import { GnetDashboard } from '../types'; + +import { + fetchCommunityDashboard, + fetchCommunityDashboards, + fetchProvisionedDashboards, + FetchCommunityDashboardsParams, + GnetDashboardResponse, +} from './dashboardLibraryApi'; + +jest.mock('@grafana/runtime', () => ({ + getBackendSrv: jest.fn(), +})); + +const mockGetBackendSrv = getBackendSrv as jest.MockedFunction; + +// Helper to create mock BackendSrv +const createMockBackendSrv = (overrides: Partial = {}): BackendSrv => + ({ + get: jest.fn(), + ...overrides, + }) as unknown as BackendSrv; + +// Helper functions for creating mock objects +const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 1, + name: 'Test Dashboard', + description: 'Test Description', + downloads: 100, + datasource: 'Prometheus', + ...overrides, +}); + +const createMockPluginDashboard = (overrides: Partial = {}): PluginDashboard => ({ + dashboardId: 1, + uid: 'dash-uid', + title: 'Test Dashboard', + pluginId: 'prometheus', + path: 'dashboards/test.json', + description: 'Test plugin dashboard', + imported: false, + importedRevision: 0, + importedUri: '', + importedUrl: '', + removed: false, + revision: 1, + slug: 'test-dashboard', + ...overrides, +}); + +const defaultFetchParams: FetchCommunityDashboardsParams = { + orderBy: 'downloads', + direction: 'desc', + page: 1, + pageSize: 10, + includeLogo: true, + includeScreenshots: true, +}; + +describe('dashboardLibraryApi', () => { + let mockGet: jest.MockedFunction; + let consoleWarnSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + mockGet = jest.fn(); + mockGetBackendSrv.mockReturnValue(createMockBackendSrv({ get: mockGet })); + consoleWarnSpy = jest.spyOn(console, 'warn').mockImplementation(); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + jest.clearAllMocks(); + consoleWarnSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + }); + + describe('fetchCommunityDashboards', () => { + it('should fetch community dashboards with correct query parameters', async () => { + const mockDashboards = [createMockGnetDashboard({ id: 1 }), createMockGnetDashboard({ id: 2 })]; + const mockResponse = { + page: 1, + pages: 5, + items: mockDashboards, + }; + + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboards(defaultFetchParams); + + expect(mockGet).toHaveBeenCalledWith( + '/api/gnet/dashboards?orderBy=downloads&direction=desc&page=1&pageSize=10&includeLogo=1&includeScreenshots=true', + undefined, + undefined, + { showErrorAlert: false } + ); + + expect(result).toEqual({ + page: 1, + pages: 5, + items: mockDashboards, + }); + }); + + it('should include dataSourceSlugIn when provided', async () => { + mockGet.mockResolvedValue({ page: 1, pages: 1, items: [] }); + + await fetchCommunityDashboards({ + ...defaultFetchParams, + dataSourceSlugIn: 'prometheus', + }); + + expect(mockGet).toHaveBeenCalledWith( + expect.stringContaining('dataSourceSlugIn=prometheus'), + undefined, + undefined, + { showErrorAlert: false } + ); + }); + + it('should include filter when provided', async () => { + mockGet.mockResolvedValue({ page: 1, pages: 1, items: [] }); + + await fetchCommunityDashboards({ + ...defaultFetchParams, + filter: 'kubernetes', + }); + + expect(mockGet).toHaveBeenCalledWith(expect.stringContaining('filter=kubernetes'), undefined, undefined, { + showErrorAlert: false, + }); + }); + + it('should handle unexpected response format and return empty array', async () => { + const mockResponse = { + page: 1, + pages: 1, + }; + + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboards(defaultFetchParams); + + expect(consoleWarnSpy).toHaveBeenCalledWith('Unexpected API response format from Grafana.com:', mockResponse); + expect(result).toEqual({ + page: 1, + pages: 1, + items: [], + }); + }); + + it('should use fallback values when page/pages are missing', async () => { + const items = [createMockGnetDashboard()]; + + mockGet.mockResolvedValue({ + items, + }); + + const result = await fetchCommunityDashboards({ + ...defaultFetchParams, + page: 3, + }); + + expect(result.page).toBe(3); + expect(result.pages).toBe(1); + }); + }); + + describe('fetchCommunityDashboard', () => { + it('should fetch a single dashboard by gnetId', async () => { + const gnetId = 12345; + const mockResponse: GnetDashboardResponse = { + json: { + title: 'Test Dashboard', + panels: [], + schemaVersion: 41, + } as DashboardJson, + dependencies: { + items: [ + { + pluginSlug: 'prometheus', + pluginTypeCode: 'datasource', + }, + ], + }, + }; + + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboard(gnetId); + + expect(mockGet).toHaveBeenCalledWith('/api/gnet/dashboards/12345'); + expect(result).toEqual(mockResponse); + }); + + it('should handle dashboard without dependencies', async () => { + const gnetId = 999; + const mockResponse: GnetDashboardResponse = { + json: { + title: 'Simple Dashboard', + panels: [], + schemaVersion: 41, + } as DashboardJson, + }; + + mockGet.mockResolvedValue(mockResponse); + + const result = await fetchCommunityDashboard(gnetId); + + expect(result).toEqual(mockResponse); + expect(result.dependencies).toBeUndefined(); + }); + }); + + describe('fetchProvisionedDashboards', () => { + it('should fetch provisioned dashboards for a datasource type', async () => { + const datasourceType = 'prometheus'; + const mockDashboards: PluginDashboard[] = [ + createMockPluginDashboard({ uid: 'dash-1', title: 'Dashboard 1' }), + createMockPluginDashboard({ uid: 'dash-2', title: 'Dashboard 2' }), + ]; + + mockGet.mockResolvedValue(mockDashboards); + + const result = await fetchProvisionedDashboards(datasourceType); + + expect(mockGet).toHaveBeenCalledWith('api/plugins/prometheus/dashboards', undefined, undefined, { + showErrorAlert: false, + }); + expect(result).toEqual(mockDashboards); + }); + + it('should return empty array when response is not an array', async () => { + mockGet.mockResolvedValue({ error: 'Not found' }); + + const result = await fetchProvisionedDashboards('unknown-plugin'); + + expect(result).toEqual([]); + }); + + it('should handle API errors gracefully', async () => { + const error = new Error('Network error'); + mockGet.mockRejectedValue(error); + + const result = await fetchProvisionedDashboards('prometheus'); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading provisioned dashboards', error); + expect(result).toEqual([]); + }); + + it('should return empty array for datasource with no provisioned dashboards', async () => { + mockGet.mockResolvedValue([]); + + const result = await fetchProvisionedDashboards('mysql'); + + expect(result).toEqual([]); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts index 6f3a4045368..ac74a089f66 100644 --- a/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/api/dashboardLibraryApi.ts @@ -71,7 +71,7 @@ export async function fetchCommunityDashboards( // Grafana.com API returns format: { page: number, pages: number, items: GnetDashboard[] } // We normalize it to use "dashboards" instead of "items" for consistency - if (result) { + if (result && Array.isArray(result.items)) { return { page: result.page || params.page, pages: result.pages || 1, diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.test.ts new file mode 100644 index 00000000000..88bb5a0213d --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/autoMapDatasources.test.ts @@ -0,0 +1,368 @@ +import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime'; +import { Input } from 'app/features/dashboard/components/DashExportModal/DashboardExporter'; +import { DashboardInput, DataSourceInput, InputType } from 'app/features/manage-dashboards/state/reducers'; + +import { + isDataSourceInput, + tryAutoMapDatasources, + parseConstantInputs, + mapConstantInputs, + mapUserSelectedDatasources, +} from './autoMapDatasources'; + +jest.mock('@grafana/runtime', () => ({ + getDataSourceSrv: jest.fn(), +})); + +const mockGetDataSourceSrv = getDataSourceSrv as jest.MockedFunction; + +// Helper to create partial DataSourceSrv mock +const createMockDataSourceSrv = (overrides: Partial = {}): DataSourceSrv => ({ + get: jest.fn(), + getList: jest.fn(), + getInstanceSettings: jest.fn(), + reload: jest.fn(), + registerRuntimeDataSource: jest.fn(), + ...overrides, +}); + +// Helper functions for creating mock objects +const createMockDataSourceInput = (overrides: Partial = {}): DataSourceInput => + ({ + name: 'DS_PROMETHEUS', + pluginId: 'prometheus', + type: InputType.DataSource, + label: 'Prometheus', + value: '', + info: 'Prometheus datasource', + ...overrides, + }) as DataSourceInput; + +const createMockConstantInput = (overrides: Partial = {}): DashboardInput => + ({ + name: 'var_instance', + type: InputType.Constant, + label: 'Instance', + value: 'default', + description: 'Instance name', + info: 'Instance name', + pluginId: undefined, + ...overrides, + }) as DashboardInput; + +const createMockInput = (overrides: Partial = {}): Input => + ({ + name: 'test_input', + type: 'constant', + label: 'Test', + value: 'default', + description: 'Test input', + ...overrides, + }) as Input; + +describe('autoMapDatasources', () => { + describe('isDataSourceInput', () => { + it('should return true for datasource input with pluginId', () => { + const input = { ...createMockInput({ type: 'datasource' }), pluginId: 'prometheus' }; + + expect(isDataSourceInput(input)).toBe(true); + }); + + it('should return false for constant input', () => { + const input = createMockInput({ type: 'constant' }); + + expect(isDataSourceInput(input)).toBe(false); + }); + + it('should return false for datasource input missing pluginId', () => { + const input = createMockInput({ type: 'datasource' }); + + expect(isDataSourceInput(input)).toBe(false); + }); + }); + + describe('tryAutoMapDatasources', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should auto-map when current datasource matches required type', () => { + const input = createMockDataSourceInput({ pluginId: 'prometheus' }); + const currentDatasourceUid = 'prom-uid'; + + mockGetDataSourceSrv.mockReturnValue( + createMockDataSourceSrv({ + getList: jest.fn().mockReturnValue([ + { uid: 'prom-uid', type: 'prometheus' }, + { uid: 'prom-uid-2', type: 'prometheus' }, + ]), + }) + ); + + const result = tryAutoMapDatasources([input], currentDatasourceUid); + + expect(result.allMapped).toBe(true); + expect(result.mappings).toHaveLength(1); + expect(result.mappings[0]).toEqual({ + name: 'DS_PROMETHEUS', + type: 'datasource', + pluginId: 'prometheus', + value: 'prom-uid', + }); + expect(result.unmappedDsInputs).toHaveLength(0); + }); + + it('should auto-map single compatible datasource when current datasource is same type', () => { + const input = createMockDataSourceInput({ pluginId: 'prometheus' }); + const currentDatasourceUid = 'other-uid'; + + mockGetDataSourceSrv.mockReturnValue( + createMockDataSourceSrv({ + getList: jest.fn().mockReturnValue([{ uid: 'prom-uid', type: 'prometheus' }]), + getInstanceSettings: jest.fn().mockReturnValue({ type: 'prometheus' }), + }) + ); + + const result = tryAutoMapDatasources([input], currentDatasourceUid); + + expect(result.allMapped).toBe(true); + expect(result.mappings).toHaveLength(1); + expect(result.mappings[0].value).toBe('prom-uid'); + }); + + it('should not auto-map single compatible datasource when current datasource is different type', () => { + const input = createMockDataSourceInput({ pluginId: 'prometheus' }); + const currentDatasourceUid = 'loki-uid'; + + mockGetDataSourceSrv.mockReturnValue( + createMockDataSourceSrv({ + getList: jest.fn().mockReturnValue([{ uid: 'prom-uid', type: 'prometheus' }]), + getInstanceSettings: jest.fn().mockReturnValue({ type: 'loki' }), + }) + ); + + const result = tryAutoMapDatasources([input], currentDatasourceUid); + + expect(result.allMapped).toBe(false); + expect(result.mappings).toHaveLength(0); + expect(result.unmappedDsInputs).toHaveLength(1); + }); + + it('should not auto-map when multiple compatible datasources exist', () => { + const input = createMockDataSourceInput({ pluginId: 'prometheus' }); + const currentDatasourceUid = 'loki-uid'; + + mockGetDataSourceSrv.mockReturnValue( + createMockDataSourceSrv({ + getList: jest.fn().mockReturnValue([ + { uid: 'prom-uid-1', type: 'prometheus' }, + { uid: 'prom-uid-2', type: 'prometheus' }, + ]), + }) + ); + + const result = tryAutoMapDatasources([input], currentDatasourceUid); + + expect(result.allMapped).toBe(false); + expect(result.mappings).toHaveLength(0); + expect(result.unmappedDsInputs).toHaveLength(1); + }); + + it('should return unmapped when no compatible datasources exist', () => { + const input = createMockDataSourceInput({ pluginId: 'prometheus' }); + const currentDatasourceUid = 'loki-uid'; + + mockGetDataSourceSrv.mockReturnValue( + createMockDataSourceSrv({ + getList: jest.fn().mockReturnValue([]), + }) + ); + + const result = tryAutoMapDatasources([input], currentDatasourceUid); + + expect(result.allMapped).toBe(false); + expect(result.mappings).toHaveLength(0); + expect(result.unmappedDsInputs).toHaveLength(1); + }); + + it('should handle empty inputs array', () => { + const result = tryAutoMapDatasources([], 'any-uid'); + + expect(result.allMapped).toBe(true); + expect(result.mappings).toHaveLength(0); + expect(result.unmappedDsInputs).toHaveLength(0); + }); + + it('should filter out datasources without UIDs', () => { + const input = createMockDataSourceInput({ pluginId: 'prometheus' }); + const currentDatasourceUid = 'prom-uid'; // Current datasource matches the required type + + mockGetDataSourceSrv.mockReturnValue( + createMockDataSourceSrv({ + getList: jest.fn().mockReturnValue([ + { uid: 'prom-uid', type: 'prometheus' }, + { type: 'prometheus' }, // Missing UID - should be filtered + { uid: undefined, type: 'prometheus' }, // Undefined UID - should be filtered + ]), + }) + ); + + const result = tryAutoMapDatasources([input], currentDatasourceUid); + + // Should auto-map since current datasource matches and there's only one valid datasource with UID + expect(result.allMapped).toBe(true); + expect(result.mappings).toHaveLength(1); + expect(result.mappings[0].value).toBe('prom-uid'); + }); + }); + + describe('parseConstantInputs', () => { + it('should parse constant inputs from __inputs array', () => { + const allInputs: Input[] = [ + createMockInput({ name: 'var_instance', type: 'constant', label: 'Instance', description: 'Instance name' }), + createMockInput({ + name: 'var_env', + type: 'constant', + label: 'Environment', + value: 'prod', + description: 'Environment', + }), + ]; + + const result = parseConstantInputs(allInputs); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ + name: 'var_instance', + label: 'Instance', + description: 'Instance name', + info: 'Instance name', + value: 'default', + type: InputType.Constant, + pluginId: undefined, + }); + }); + + it('should filter out datasource inputs', () => { + const allInputs: Input[] = [ + createMockInput({ name: 'var_instance', type: 'constant', description: 'Instance name' }), + createMockInput({ name: 'DS_PROM', type: 'datasource', description: 'Prometheus datasource' }), + ]; + + const result = parseConstantInputs(allInputs); + + expect(result).toHaveLength(1); + expect(result[0].name).toBe('var_instance'); + }); + + it('should handle empty inputs array', () => { + const result = parseConstantInputs([]); + + expect(result).toHaveLength(0); + }); + + it('should handle null inputs', () => { + const result = parseConstantInputs(null!); + + expect(result).toHaveLength(0); + }); + + it('should handle undefined inputs', () => { + const result = parseConstantInputs(undefined!); + + expect(result).toHaveLength(0); + }); + + it('should use label as fallback when label is missing', () => { + const input = createMockInput({ name: 'var_instance', type: 'constant', label: '' }); + const result = parseConstantInputs([input]); + + expect(result[0].label).toBe('var_instance'); + }); + + it('should use default info when description is missing', () => { + const input = createMockInput({ name: 'var_instance', type: 'constant', description: '' }); + const result = parseConstantInputs([input]); + + expect(result[0].info).toBe('Specify a string constant'); + }); + }); + + describe('mapConstantInputs', () => { + it('should use user-provided values when available', () => { + const constantInputs: DashboardInput[] = [createMockConstantInput()]; + const userValues = { var_instance: 'custom-value' }; + + const result = mapConstantInputs(constantInputs, userValues); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'var_instance', + type: 'constant', + value: 'custom-value', + }); + }); + + it('should fall back to default values when user values not provided', () => { + const constantInputs: DashboardInput[] = [createMockConstantInput()]; + const userValues = {}; + + const result = mapConstantInputs(constantInputs, userValues); + + expect(result[0].value).toBe('default'); + }); + + it('should handle empty constantInputs array', () => { + const result = mapConstantInputs([], {}); + + expect(result).toHaveLength(0); + }); + }); + + describe('mapUserSelectedDatasources', () => { + it('should map user selections to InputMapping format', () => { + const unmappedInputs: DataSourceInput[] = [createMockDataSourceInput()]; + const userSelectedDsMappings = { + DS_PROMETHEUS: { + name: 'DS_PROMETHEUS', + pluginId: 'prometheus', + datasource: { uid: 'selected-uid' }, + }, + }; + + const result = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings); + + expect(result).toHaveLength(1); + expect(result[0]).toEqual({ + name: 'DS_PROMETHEUS', + type: 'datasource', + pluginId: 'prometheus', + value: 'selected-uid', + }); + }); + + it('should handle missing datasource selections', () => { + const unmappedInputs: DataSourceInput[] = [createMockDataSourceInput()]; + const userSelectedDsMappings = {}; + + const result = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings); + + expect(result[0].value).toBe(''); + }); + + it('should handle undefined datasource in selection', () => { + const unmappedInputs: DataSourceInput[] = [createMockDataSourceInput()]; + const userSelectedDsMappings = { + DS_PROMETHEUS: { + name: 'DS_PROMETHEUS', + pluginId: 'prometheus', + datasource: undefined, + }, + }; + + const result = mapUserSelectedDatasources(unmappedInputs, userSelectedDsMappings); + + expect(result[0].value).toBe(''); + }); + }); +}); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts new file mode 100644 index 00000000000..58e77b7d13f --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/utils/communityDashboardHelpers.test.ts @@ -0,0 +1,310 @@ +import { locationService } from '@grafana/runtime'; +import { InputType, DataSourceInput, DashboardInput } from 'app/features/manage-dashboards/state/reducers'; +import { DashboardJson } from 'app/features/manage-dashboards/types'; + +import { DASHBOARD_LIBRARY_ROUTES } from '../../types'; +import { fetchCommunityDashboard } from '../api/dashboardLibraryApi'; +import { CONTENT_KINDS, CREATION_ORIGINS, EVENT_LOCATIONS, SOURCE_ENTRY_POINTS } from '../interactions'; +import { GnetDashboard } from '../types'; + +import { InputMapping, tryAutoMapDatasources, parseConstantInputs } from './autoMapDatasources'; +import { + buildDashboardDetails, + buildGrafanaComUrl, + createSlug, + getLogoUrl, + navigateToTemplate, + onUseCommunityDashboard, +} from './communityDashboardHelpers'; + +jest.mock('../api/dashboardLibraryApi', () => ({ + fetchCommunityDashboard: jest.fn(), +})); + +jest.mock('./autoMapDatasources', () => ({ + ...jest.requireActual('./autoMapDatasources'), + tryAutoMapDatasources: jest.fn(), + parseConstantInputs: jest.fn(), +})); + +// Mock function references +const mockFetchCommunityDashboard = fetchCommunityDashboard as jest.MockedFunction; +const mockTryAutoMapDatasources = tryAutoMapDatasources as jest.MockedFunction; +const mockParseConstantInputs = parseConstantInputs as jest.MockedFunction; + +// Helper functions for creating mock objects +const createMockGnetDashboard = (overrides: Partial = {}): GnetDashboard => ({ + id: 123, + name: 'Test Dashboard', + description: '', + datasource: 'Prometheus', + orgName: 'Test Org', + userName: 'testuser', + publishedAt: '', + updatedAt: '2025-11-05T16:55:41.000Z', + downloads: 0, + ...overrides, +}); + +const createMockDashboardJson = (overrides: Partial = {}): DashboardJson => + ({ + __inputs: [], + title: 'Test Dashboard', + panels: [], + schemaVersion: 41, + uid: 'test-uid', + version: 1, + editable: true, + graphTooltip: 0, + timezone: 'browser', + ...overrides, + }) as DashboardJson; + +describe('communityDashboardHelpers', () => { + describe('createSlug', () => { + it('should convert to lower case', () => { + expect(createSlug('Test')).toBe('test'); + }); + + it('should replace non-alphanumeric characters with hyphens', () => { + expect(createSlug('Test@#example')).toBe('test-example'); + }); + + it('should remove leading and trailing hyphens', () => { + expect(createSlug('-test-')).toBe('test'); + }); + }); + + describe('buildGrafanaComUrl', () => { + it('should build a valid URL', () => { + const gnetDashboard = createMockGnetDashboard({ + id: 1, + name: 'Test', + }); + + expect(buildGrafanaComUrl(gnetDashboard)).toBe('https://grafana.com/grafana/dashboards/1-test/'); + }); + }); + + describe('buildDashboardDetails', () => { + it('should build a valid dashboard details object', () => { + const gnetDashboard = createMockGnetDashboard({ + id: 1, + name: 'Test', + datasource: 'Test', + orgName: 'Org', + updatedAt: '2025-11-05T16:55:41.000Z', + }); + + const result = buildDashboardDetails(gnetDashboard); + + expect(result).toEqual({ + id: '1', + datasource: 'Test', + dependencies: ['Test'], + publishedBy: 'Org', + lastUpdate: expect.any(String), // Date format varies by locale + grafanaComUrl: 'https://grafana.com/grafana/dashboards/1-test/', + }); + + // Verify the date was formatted (not the raw ISO string) + expect(result.lastUpdate).not.toBe('2025-11-05T16:55:41.000Z'); + expect(result.lastUpdate).toContain('2025'); + expect(result.lastUpdate).toMatch(/Nov/); + }); + }); + + describe('getLogoUrl', () => { + it('should return an empty string if no logo is found', () => { + const gnetDashboard = createMockGnetDashboard(); + + expect(getLogoUrl(gnetDashboard)).toBe(''); + }); + + it('should return a valid logo URL', () => { + const gnetDashboard = createMockGnetDashboard({ + logos: { + large: { + content: 'aGVsbG8=', + type: 'image/png', + filename: '/dashboards/abc/large_logo/logo.png', + }, + }, + }); + + expect(getLogoUrl(gnetDashboard)).toBe('data:image/png;base64,aGVsbG8='); + }); + }); + + describe('navigateToTemplate', () => { + it('should navigate to the template route with the correct parameters', () => { + const dashboardTitle = 'Test Dashboard'; + const gnetId = 123; + const datasourceUid = 'test-datasource'; + const mappings: InputMapping[] = []; + const eventLocation = EVENT_LOCATIONS.EMPTY_DASHBOARD; + const contentKind = CONTENT_KINDS.COMMUNITY_DASHBOARD; + + const mockLocationServicePush = jest.fn(); + locationService.push = mockLocationServicePush; + + navigateToTemplate(dashboardTitle, gnetId, datasourceUid, mappings, eventLocation, contentKind); + + expect(mockLocationServicePush).toHaveBeenCalledWith({ + pathname: DASHBOARD_LIBRARY_ROUTES.Template, + search: expect.any(String), + }); + + const callArgs = mockLocationServicePush.mock.calls[0][0]; + const searchParams = new URLSearchParams(callArgs.search); + + expect(searchParams.get('title')).toBe('Test Dashboard'); + expect(searchParams.get('gnetId')).toBe('123'); + expect(searchParams.get('datasource')).toBe('test-datasource'); + expect(searchParams.get('sourceEntryPoint')).toBe(SOURCE_ENTRY_POINTS.DATASOURCE_PAGE); + expect(searchParams.get('creationOrigin')).toBe(CREATION_ORIGINS.DASHBOARD_LIBRARY_COMMUNITY_DASHBOARD); + expect(searchParams.get('contentKind')).toBe(CONTENT_KINDS.COMMUNITY_DASHBOARD); + expect(searchParams.get('eventLocation')).toBe(EVENT_LOCATIONS.EMPTY_DASHBOARD); + expect(searchParams.get('mappings')).toBe('[]'); + }); + }); + + describe('onUseCommunityDashboard', () => { + async function setup(options?: { + dashboard?: Partial; + dashboardJson?: Partial; + autoMapResult?: { + allMapped: boolean; + mappings: InputMapping[]; + unmappedDsInputs: DataSourceInput[]; + }; + constantInputs?: DashboardInput[]; + onShowMapping?: jest.Mock; + }) { + const dashboard = createMockGnetDashboard(options?.dashboard); + const dashboardJson = createMockDashboardJson(options?.dashboardJson); + + mockFetchCommunityDashboard.mockResolvedValue({ json: dashboardJson }); + + mockTryAutoMapDatasources.mockReturnValue( + options?.autoMapResult ?? { + allMapped: true, + mappings: [], + unmappedDsInputs: [], + } + ); + + mockParseConstantInputs.mockReturnValue(options?.constantInputs ?? []); + + await onUseCommunityDashboard({ + dashboard, + datasourceUid: 'test-ds-uid', + datasourceType: 'prometheus', + eventLocation: 'empty_dashboard', + onShowMapping: options?.onShowMapping, + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should navigate directly when all datasources are auto-mapped and no constants', async () => { + await setup({ + autoMapResult: { + allMapped: true, + mappings: [{ name: 'DS_PROM', type: 'datasource', value: 'prom-uid', pluginId: 'prometheus' }], + unmappedDsInputs: [], + }, + }); + + expect(locationService.push).toHaveBeenCalled(); + expect(locationService.push).toHaveBeenCalledWith( + expect.objectContaining({ + pathname: expect.any(String), + search: expect.stringContaining('gnetId=123'), + }) + ); + }); + + it('should show mapping form when datasources are unmapped', async () => { + const mockOnShowMapping = jest.fn(); + + await setup({ + autoMapResult: { + allMapped: false, + mappings: [], + unmappedDsInputs: [ + { + name: 'DS_PROM', + pluginId: 'prometheus', + type: InputType.DataSource, + info: 'prometheus', + value: '', + label: 'Prometheus', + }, + ], + }, + onShowMapping: mockOnShowMapping, + }); + + expect(mockOnShowMapping).toHaveBeenCalled(); + expect(locationService.push).not.toHaveBeenCalled(); + expect(mockOnShowMapping).toHaveBeenCalledWith( + expect.objectContaining({ + dashboardName: 'Test Dashboard', + unmappedDsInputs: expect.arrayContaining([expect.objectContaining({ name: 'DS_PROM' })]), + }) + ); + }); + + it('should show mapping form when constants exist even if datasources are mapped', async () => { + const mockOnShowMapping = jest.fn(); + + await setup({ + autoMapResult: { + allMapped: true, + mappings: [{ name: 'DS_PROM', type: 'datasource', value: 'prom-uid', pluginId: 'prometheus' }], + unmappedDsInputs: [], + }, + constantInputs: [ + { + name: 'var_instance', + label: 'Instance', + description: 'Instance name', + info: 'Enter instance name', + value: 'default', + type: InputType.Constant, + }, + ], + onShowMapping: mockOnShowMapping, + }); + + expect(mockOnShowMapping).toHaveBeenCalled(); + expect(locationService.push).not.toHaveBeenCalled(); + expect(mockOnShowMapping).toHaveBeenCalledWith( + expect.objectContaining({ + dashboardName: 'Test Dashboard', + constantInputs: expect.arrayContaining([expect.objectContaining({ name: 'var_instance' })]), + }) + ); + }); + + it('should handle API errors gracefully', async () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + mockFetchCommunityDashboard.mockRejectedValue(new Error('API failed')); + + await onUseCommunityDashboard({ + dashboard: createMockGnetDashboard(), + datasourceUid: 'test-ds-uid', + datasourceType: 'prometheus', + eventLocation: 'empty_dashboard', + }); + + expect(consoleErrorSpy).toHaveBeenCalledWith('Error loading community dashboard:', expect.any(Error)); + expect(locationService.push).not.toHaveBeenCalled(); + + consoleErrorSpy.mockRestore(); + }); + }); +}); From 7888f19443b6c3c6cc0dd5c5ff16b9f78694b9fa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Fern=C3=A1ndez?= Date: Mon, 17 Nov 2025 16:29:32 +0100 Subject: [PATCH 005/125] FS: custom domain error message (#113779) --- pkg/services/frontend/index.html | 39 +++++++++++++++++++++++++++++--- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/pkg/services/frontend/index.html b/pkg/services/frontend/index.html index 688222f6577..2a8cebebef6 100644 --- a/pkg/services/frontend/index.html +++ b/pkg/services/frontend/index.html @@ -81,7 +81,7 @@ "Segoe UI Symbol"; } - .fs-variant-loader, .fs-variant-error { + .fs-variant-loader, .fs-variant-error, .fs-custom-domain-error { display: contents; } @@ -118,6 +118,10 @@ opacity: 1; } + .fs-custom-domain-error .fs-loader-text { + opacity: 1; + } + .fs-error-icon { fill: var(--fs-color-error); } @@ -151,6 +155,20 @@

Error loading Grafana

+
+ + + + +

Error loading Grafana

+

Please, use your URL custom domain directly

+
@@ -185,6 +203,14 @@ console.error('Failed to report boot error to backend: ', err); }); }; + // Needed to stop retrying boot data when custom domain error occurs + // TODO remove when https://github.com/grafana/grafana/pull/113717 is released in backend + const CUSTOM_DOMAIN_ERROR = "Custom domain error"; + window.__grafana_custom_domain_failed = function(err) { + console.error('Failed to load Grafana due to custom domain issue', err); + document.querySelector('.fs-variant-loader').classList.add('fs-hidden'); + document.querySelector('.fs-custom-domain-error').classList.remove('fs-hidden'); + } window.onload = function() { if (window.__grafana_app_bundle_loaded) { @@ -219,8 +245,12 @@ bootDataUrl.searchParams.append(key, value); } - const resp = await fetch(bootDataUrl); - + const resp = await fetch(bootDataUrl, {redirect: 'manual'}); + // TODO remove when https://github.com/grafana/grafana/pull/113717 is released in backend + if (resp.type === 'opaqueredirect') { + window.__grafana_custom_domain_failed(); + return CUSTOM_DOMAIN_ERROR; + } // manual redirect for custom domains // see pkg/middleware/validate_host.go if (resp.status === 204) { @@ -265,6 +295,9 @@ setLoading(); setTimeout(attemptFetch, CHECK_INTERVAL); return; + // TODO remove when https://github.com/grafana/grafana/pull/113717 is released in backend + } else if (bootData === CUSTOM_DOMAIN_ERROR) { + return; } resolve(bootData); From 749cc735bb79e2059d8ffb6f5206d269ae3e82fd Mon Sep 17 00:00:00 2001 From: Drew Slobodnjak <60050885+drew08t@users.noreply.github.com> Date: Mon, 17 Nov 2025 07:42:54 -0800 Subject: [PATCH 006/125] Timeline: Add timeRangePan (#113890) * Timeline: Add timeRangePan * Add tests for state timeline * Add tests for status history * Fix state timeline test and use same dashboard * Consolidate tests * Optimize time range pan tests --- .../panels-suite/state-timeline.spec.ts | 109 ++++++++++++++++++ .../panels-suite/status-history.spec.ts | 109 ++++++++++++++++++ .../core/components/TimelineChart/utils.ts | 8 +- .../state-timeline/StateTimelinePanel.tsx | 2 + .../status-history/StatusHistoryPanel.tsx | 2 + 5 files changed, 229 insertions(+), 1 deletion(-) diff --git a/e2e-playwright/panels-suite/state-timeline.spec.ts b/e2e-playwright/panels-suite/state-timeline.spec.ts index d57498ab95e..ebdeb8d5398 100644 --- a/e2e-playwright/panels-suite/state-timeline.spec.ts +++ b/e2e-playwright/panels-suite/state-timeline.spec.ts @@ -87,3 +87,112 @@ test.describe('Panels test: StateTimeine', { tag: ['@panels', '@state-timeline'] await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden(); }); }); + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: State Timeline X-axis panning', { tag: ['@panels', '@state-timeline'] }, () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const stateTimelinePanel = page.locator('.uplot').first(); + await expect(stateTimelinePanel, 'panel rendered').toBeVisible(); + + const xAxis = stateTimelinePanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; + }); + + await test.step('Capture initial time range', async () => { + const stateTimelinePanel = page.locator('.uplot').first(); + const xAxis = stateTimelinePanel.locator('.u-axis').first(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/e2e-playwright/panels-suite/status-history.spec.ts b/e2e-playwright/panels-suite/status-history.spec.ts index e6c7cccac67..bece3aa47c7 100644 --- a/e2e-playwright/panels-suite/status-history.spec.ts +++ b/e2e-playwright/panels-suite/status-history.spec.ts @@ -85,3 +85,112 @@ test.describe('Panels test: StatusHistory', { tag: ['@panels', '@status-history' await expect(tooltip, 'tooltip is not shown when disabled').toBeHidden(); }); }); + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: Status History X-axis panning', { tag: ['@panels', '@status-history'] }, () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: DASHBOARD_UID }); + + const statusHistoryPanel = page.locator('.uplot').first(); + await expect(statusHistoryPanel, 'panel rendered').toBeVisible(); + + const xAxis = statusHistoryPanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; + }); + + await test.step('Capture initial time range', async () => { + const statusHistoryPanel = page.locator('.uplot').first(); + const xAxis = statusHistoryPanel.locator('.u-axis').first(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/public/app/core/components/TimelineChart/utils.ts b/public/app/core/components/TimelineChart/utils.ts index a2e9ad366ff..955f1ea11b2 100644 --- a/public/app/core/components/TimelineChart/utils.ts +++ b/public/app/core/components/TimelineChart/utils.ts @@ -156,7 +156,13 @@ export const preparePlotConfigBuilder: UPlotConfigPrepFn = ( isTime: true, orientation: ScaleOrientation.Horizontal, direction: ScaleDirection.Right, - range: coreConfig.xRange, + range: (u) => { + const state = builder.getState(); + if (state.isPanning) { + return [state.min, state.max]; + } + return coreConfig.xRange(u); + }, }); builder.addScale({ diff --git a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx index a68e81fb6d6..4342b64639b 100644 --- a/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx +++ b/public/app/plugins/panel/state-timeline/StateTimelinePanel.tsx @@ -7,6 +7,7 @@ import { EventBusPlugin, TooltipDisplayMode, TooltipPlugin2, + XAxisInteractionAreaPlugin, usePanelContext, useTheme2, } from '@grafana/ui'; @@ -102,6 +103,7 @@ export const StateTimelinePanel = ({ {cursorSync !== DashboardCursorSync.Off && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( Date: Mon, 17 Nov 2025 07:48:32 -0800 Subject: [PATCH 007/125] Heatmap: Add timeRangePan (#113889) * Heatmap: Add timeRangePan * Add tests * Consolidate tests * Optimize time range pan tests --- e2e-playwright/panels-suite/heatmap.spec.ts | 112 +++++++++++++++++- .../plugins/panel/heatmap/HeatmapPanel.tsx | 2 + 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/e2e-playwright/panels-suite/heatmap.spec.ts b/e2e-playwright/panels-suite/heatmap.spec.ts index 4fe163bf23e..13822b60ab0 100644 --- a/e2e-playwright/panels-suite/heatmap.spec.ts +++ b/e2e-playwright/panels-suite/heatmap.spec.ts @@ -1,8 +1,7 @@ import { test, expect } from '@grafana/plugin-e2e'; const DASHBOARD_UID = '5Y0jv6pVz'; - -// test.use(); +const TIME_RANGE_PAN_DASHBOARD_UID = 'YoacZIq7z'; test.describe('Panels test: Heatmap', { tag: ['@panels', '@heatmap'] }, () => { test('renders successfully', async ({ gotoDashboardPage, selectors, page }) => { @@ -34,3 +33,112 @@ test.describe('Panels test: Heatmap', { tag: ['@panels', '@heatmap'] }, () => { // TODO tooltips, legends, and panel editing }); + +test.use({ + featureToggles: { + timeRangePan: true, + }, +}); + +test.describe('Panels test: Heatmap X-axis panning', { tag: ['@panels', '@heatmap'] }, () => { + test('x-axis panning functionality', async ({ gotoDashboardPage, page, selectors }) => { + let centerX: number; + let centerY: number; + let initialFromTime: number; + let initialToTime: number; + + const dashboardPage = await test.step('Load dashboard and verify cursor changes to grab', async () => { + const dashboardPage = await gotoDashboardPage({ uid: TIME_RANGE_PAN_DASHBOARD_UID }); + + const heatmapPanel = page.locator('.uplot').first(); + await expect(heatmapPanel, 'panel rendered').toBeVisible(); + + const xAxis = heatmapPanel.locator('.u-axis').first(); + await expect(xAxis, 'x-axis rendered').toBeVisible(); + + await xAxis.hover(); + + const cursorStyle = await xAxis.evaluate((el: HTMLElement) => window.getComputedStyle(el).cursor); + expect(cursorStyle, 'cursor is grab').toBe('grab'); + + return dashboardPage; + }); + + await test.step('Capture initial time range', async () => { + const heatmapPanel = page.locator('.uplot').first(); + const xAxis = heatmapPanel.locator('.u-axis').first(); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const initialFrom = await fromField.inputValue(); + const initialTo = await toField.inputValue(); + initialFromTime = new Date(initialFrom).getTime(); + initialToTime = new Date(initialTo).getTime(); + + await page.keyboard.press('Escape'); + + const axisBox = await xAxis.boundingBox(); + if (!axisBox) { + throw new Error('X-axis bounding box not found'); + } + + centerX = axisBox.x + axisBox.width / 2; + centerY = axisBox.y + axisBox.height / 2; + }); + + await test.step('Drag right pans backward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX + 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterRightFrom = await fromField.inputValue(); + const afterRightTo = await toField.inputValue(); + const afterRightFromTime = new Date(afterRightFrom).getTime(); + const afterRightToTime = new Date(afterRightTo).getTime(); + + expect(afterRightFromTime, 'panned backward').toBeLessThan(initialFromTime); + expect(afterRightToTime, 'panned backward').toBeLessThan(initialToTime); + + await page.keyboard.press('Escape'); + + initialFromTime = afterRightFromTime; + initialToTime = afterRightToTime; + }); + + await test.step('Drag left pans forward in time', async () => { + await page.mouse.move(centerX, centerY); + await page.mouse.down(); + await page.mouse.move(centerX - 100, centerY); + await page.mouse.up(); + + await page.waitForTimeout(1000); + + const timePickerButton = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.openButton); + await timePickerButton.click(); + + const fromField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.fromField); + const toField = dashboardPage.getByGrafanaSelector(selectors.components.TimePicker.toField); + + const afterLeftFrom = await fromField.inputValue(); + const afterLeftTo = await toField.inputValue(); + const afterLeftFromTime = new Date(afterLeftFrom).getTime(); + const afterLeftToTime = new Date(afterLeftTo).getTime(); + + expect(afterLeftFromTime, 'panned forward').toBeGreaterThan(initialFromTime); + expect(afterLeftToTime, 'panned forward').toBeGreaterThan(initialToTime); + }); + }); +}); diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index 57fee8ebcb8..e3546d7fdf2 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -14,6 +14,7 @@ import { useTheme2, VizLayout, EventBusPlugin, + XAxisInteractionAreaPlugin, } from '@grafana/ui'; import { FacetedData, TimeRange2, TooltipHoverMode } from '@grafana/ui/internal'; import { ColorScale } from 'app/core/components/ColorScale/ColorScale'; @@ -199,6 +200,7 @@ const HeatmapPanelViz = ({ {cursorSync !== DashboardCursorSync.Off && ( )} + {options.tooltip.mode !== TooltipDisplayMode.None && ( Date: Mon, 17 Nov 2025 16:58:43 +0100 Subject: [PATCH 008/125] Feature toggle: Remove addFieldFromCalculationStatFunctions feature toggle (#113757) * Remove addFieldFromCalculationStatFunctions feature toggle The addFieldFromCalculationStatFunctions feature toggle was at GA stage and enabled by default. This commit removes the toggle and makes the cumulative and window functions permanently available in the add field from calculation transformation. Changes: - Removed feature toggle definition from registry.go - Updated CalculateFieldTransformerEditor to always include cumulative and window functions - Removed unused config import - Removed documentation entry - Regenerated all generated files (toggles_gen.go, toggles_gen.csv, toggles_gen.json, featureToggles.gen.ts) * fix merge conflicts --- .../feature-toggles/index.md | 103 +++++++++--------- .../src/types/featureToggles.gen.ts | 5 - pkg/services/featuremgmt/registry.go | 8 -- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 - pkg/services/featuremgmt/toggles_gen.json | 1 + .../CalculateFieldTransformerEditor.tsx | 23 ++-- 7 files changed, 61 insertions(+), 84 deletions(-) diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index ff8bc85b14f..317b60971b3 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -22,58 +22,57 @@ For more information about feature release stages, refer to [Release life cycle Most [generally available](https://grafana.com/docs/release-life-cycle/#general-availability) features are enabled by default. You can disable these feature by setting the feature flag to "false" in the configuration. -| Feature toggle name | Description | Enabled by default | -| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | -| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | -| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes | -| `featureHighlights` | Highlight Grafana Enterprise features | | -| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | -| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | -| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes | -| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes | -| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes | -| `unifiedRequestLog` | Writes error logs to the request logger | Yes | -| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes | -| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | Yes | -| `transformationsRedesign` | Enables the transformations redesign | Yes | -| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes | -| `dashgpt` | Enable AI powered features in dashboards | Yes | -| `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | Yes | -| `addFieldFromCalculationStatFunctions` | Add cumulative and window functions to the add field from calculation transformation | Yes | -| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes | -| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes | -| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes | -| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes | -| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes | -| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes | -| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | | -| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes | -| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes | -| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes | -| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | | -| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes | -| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | Yes | -| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes | -| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes | -| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes | -| `improvedExternalSessionHandling` | Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. | Yes | -| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes | -| `pluginsSriChecks` | Enables SRI checks for plugin assets | | -| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | | -| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes | -| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes | -| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes | -| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch data source | | -| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes | -| `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. | Yes | -| `newLogsPanel` | Enables the new logs panel | Yes | -| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes | -| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes | -| `unifiedNavbars` | Enables unified navbars | | -| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes | -| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | -| `tabularNumbers` | Use fixed-width numbers globally in the UI | | -| `tempoSearchBackendMigration` | Run search queries through the tempo backend | | +| Feature toggle name | Description | Enabled by default | +| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `disableEnvelopeEncryption` | Disable envelope encryption (emergency only) | | +| `publicDashboardsScene` | Enables public dashboard rendering using scenes | Yes | +| `featureHighlights` | Highlight Grafana Enterprise features | | +| `cloudWatchCrossAccountQuerying` | Enables cross-account querying in CloudWatch datasources | Yes | +| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view | Yes | +| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals | Yes | +| `influxdbBackendMigration` | Query InfluxDB InfluxQL without the proxy | Yes | +| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes | +| `unifiedRequestLog` | Writes error logs to the request logger | Yes | +| `logsExploreTableVisualisation` | A table visualisation for logs in Explore | Yes | +| `awsDatasourcesTempCredentials` | Support temporary security credentials in AWS plugins for Grafana Cloud customers | Yes | +| `transformationsRedesign` | Enables the transformations redesign | Yes | +| `awsAsyncQueryCaching` | Enable caching for async queries for Redshift and Athena. Requires that the datasource has caching and async query support enabled | Yes | +| `dashgpt` | Enable AI powered features in dashboards | Yes | +| `kubernetesDashboards` | Use the kubernetes API in the frontend for dashboards | Yes | +| `annotationPermissionUpdate` | Change the way annotation permissions work by scoping them to folders and dashboards. | Yes | +| `dashboardSceneForViewers` | Enables dashboard rendering using Scenes for viewer roles | Yes | +| `dashboardSceneSolo` | Enables rendering dashboards using scenes for solo panels | Yes | +| `dashboardScene` | Enables dashboard rendering using scenes for all roles | Yes | +| `logsInfiniteScrolling` | Enables infinite scrolling for the Logs panel in Explore and Dashboards | Yes | +| `logRowsPopoverMenu` | Enable filtering menu displayed when text of a log line is selected | Yes | +| `alertingQueryOptimization` | Optimizes eligible queries in order to reduce load on datasources | | +| `onPremToCloudMigrations` | Enable the Grafana Migration Assistant, which helps you easily migrate various on-prem resources to your Grafana Cloud stack. | Yes | +| `newPDFRendering` | New implementation for the dashboard-to-PDF rendering | Yes | +| `cloudWatchNewLabelParsing` | Updates CloudWatch label parsing to be more accurate | Yes | +| `pluginProxyPreserveTrailingSlash` | Preserve plugin proxy trailing slash. | | +| `azureMonitorPrometheusExemplars` | Allows configuration of Azure Monitor as a data source that can provide Prometheus exemplars | Yes | +| `ssoSettingsLDAP` | Use the new SSO Settings API to configure LDAP | Yes | +| `cloudWatchRoundUpEndTime` | Round up end time for metric queries to the next minute to avoid missing data | Yes | +| `newFiltersUI` | Enables new combobox style UI for the Ad hoc filters variable in scenes architecture | Yes | +| `alertingQueryAndExpressionsStepMode` | Enables step mode for alerting queries and expressions | Yes | +| `improvedExternalSessionHandling` | Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. | Yes | +| `useSessionStorageForRedirection` | Use session storage for handling the redirection after login | Yes | +| `pluginsSriChecks` | Enables SRI checks for plugin assets | | +| `azureMonitorDisableLogLimit` | Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. | | +| `alertingUIOptimizeReducer` | Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query | Yes | +| `azureMonitorEnableUserAuth` | Enables user auth for Azure Monitor datasource only | Yes | +| `alertingNotificationsStepMode` | Enables simplified step mode in the notifications section | Yes | +| `elasticsearchCrossClusterSearch` | Enables cross cluster search in the Elasticsearch data source | | +| `lokiLabelNamesQueryApi` | Defaults to using the Loki `/labels` API instead of `/series` | Yes | +| `improvedExternalSessionHandlingSAML` | Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly. | Yes | +| `newLogsPanel` | Enables the new logs panel | Yes | +| `alertingMigrationUI` | Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules | Yes | +| `alertingImportYAMLUI` | Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules | Yes | +| `unifiedNavbars` | Enables unified navbars | | +| `grafanaAssistantInProfilesDrilldown` | Enables integration with Grafana Assistant in Profiles Drilldown | Yes | +| `sharingDashboardImage` | Enables image sharing functionality for dashboards | Yes | +| `tabularNumbers` | Use fixed-width numbers globally in the UI | | +| `tempoSearchBackendMigration` | Run search queries through the tempo backend | | ## Public preview feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b9004cb62be..4b5bba51f31 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -327,11 +327,6 @@ export interface FeatureToggles { */ cachingOptimizeSerializationMemoryUsage?: boolean; /** - * Add cumulative and window functions to the add field from calculation transformation - * @default true - */ - addFieldFromCalculationStatFunctions?: boolean; - /** * Enable Grafana to sync configuration and state with a remote Alertmanager. */ alertmanagerRemoteSecondary?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b14026fae4d..7cb093d2665 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -545,14 +545,6 @@ var ( Owner: grafanaOperatorExperienceSquad, FrontendOnly: false, }, - { - Name: "addFieldFromCalculationStatFunctions", - Description: "Add cumulative and window functions to the add field from calculation transformation", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Owner: grafanaDataProSquad, - Expression: "true", // enabled by default - }, { Name: "alertmanagerRemoteSecondary", Description: "Enable Grafana to sync configuration and state with a remote Alertmanager.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 52b5aed5803..f1035d0342d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -72,7 +72,6 @@ queryServiceFromUI,experimental,@grafana/grafana-datasources-core-services,false queryServiceFromExplore,experimental,@grafana/grafana-datasources-core-services,false,false,true cloudWatchBatchQueries,preview,@grafana/aws-datasources,false,false,false cachingOptimizeSerializationMemoryUsage,experimental,@grafana/grafana-operator-experience-squad,false,false,false -addFieldFromCalculationStatFunctions,GA,@grafana/datapro,false,false,true alertmanagerRemoteSecondary,experimental,@grafana/alerting-squad,false,false,false alertingProvenanceLockWrites,experimental,@grafana/alerting-squad,false,false,false alertingUIUseBackendFilters,experimental,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 76382b7f6c4..00ce3b83a20 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -299,10 +299,6 @@ const ( // If enabled, the caching backend gradually serializes query responses for the cache, comparing against the configured `[caching]max_value_mb` value as it goes. This can can help prevent Grafana from running out of memory while attempting to cache very large query responses. FlagCachingOptimizeSerializationMemoryUsage = "cachingOptimizeSerializationMemoryUsage" - // FlagAddFieldFromCalculationStatFunctions - // Add cumulative and window functions to the add field from calculation transformation - FlagAddFieldFromCalculationStatFunctions = "addFieldFromCalculationStatFunctions" - // FlagAlertmanagerRemoteSecondary // Enable Grafana to sync configuration and state with a remote Alertmanager. FlagAlertmanagerRemoteSecondary = "alertmanagerRemoteSecondary" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index aa61dbe16c7..91b71aa37ea 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -8,6 +8,7 @@ "name": "addFieldFromCalculationStatFunctions", "resourceVersion": "1762442825881", "creationTimestamp": "2023-11-03T14:39:58Z", + "deletionTimestamp": "2025-11-12T14:07:50Z", "annotations": { "grafana.app/updatedTimestamp": "2025-11-06 15:27:05.88172 +0000 UTC" } diff --git a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CalculateFieldTransformerEditor.tsx b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CalculateFieldTransformerEditor.tsx index 64d1fffba80..29e2f74f10a 100644 --- a/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CalculateFieldTransformerEditor.tsx +++ b/public/app/features/transformers/editors/CalculateFieldTransformerEditor/CalculateFieldTransformerEditor.tsx @@ -23,7 +23,7 @@ import { defaultWindowOptions, } from '@grafana/data/internal'; import { t } from '@grafana/i18n'; -import { getTemplateSrv, config as cfg } from '@grafana/runtime'; +import { getTemplateSrv } from '@grafana/runtime'; import { InlineField, InlineSwitch, Input, Select } from '@grafana/ui'; import { getTransformationContent } from '../../docs/getTransformationContent'; @@ -74,21 +74,16 @@ export const CalculateFieldTransformerEditor = (props: CalculateFieldTransformer value: CalculateFieldMode.Index, label: t('transformers.calculate-field-transformer-editor.calculation-modes.label.row-index', 'Row index'), }, + { + value: CalculateFieldMode.CumulativeFunctions, + label: t('transformers.calculate-field-transformer-editor.label.cumulative-functions', 'Cumulative functions'), + }, + { + value: CalculateFieldMode.WindowFunctions, + label: t('transformers.calculate-field-transformer-editor.label.window-functions', 'Window functions'), + }, ]; - if (cfg.featureToggles.addFieldFromCalculationStatFunctions) { - calculationModes.push( - { - value: CalculateFieldMode.CumulativeFunctions, - label: t('transformers.calculate-field-transformer-editor.label.cumulative-functions', 'Cumulative functions'), - }, - { - value: CalculateFieldMode.WindowFunctions, - label: t('transformers.calculate-field-transformer-editor.label.window-functions', 'Window functions'), - } - ); - } - useEffect(() => { const ctx = { interpolate: (v: string) => v }; const subscription = of(input) From b79ace082cde356edbe22337057638eebc1ac87e Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Mon, 17 Nov 2025 17:18:12 +0100 Subject: [PATCH 009/125] Scopes: Nested scope navigations (#113394) * Initial nested scope navigation * Seperate sections for scope navigation vs groups * Add ungrouped navigation items * Create tree structure helper for mock scope navigations * Improve generated folder structure and add link to change scope * Update mocks * DevEnv Navigations * Remove mocks * Update button position and add border * Remove subScope from TreeLink * Do unblocking async update * Add loading state * Allow '' for root groups * Formatting * Add unit and integration tests * Update openapi spec * Update tooltip for IconButton * Update i18n * Make code more DRY * Update public/app/dev-utils.ts * update folderUpdate type * Remove isSubScope in facvor of checking subScopeName * Do an early return * Use subScopeName * Remove use of isSubScope * Prevent nested items with the same subScope * Extract filder method to function --------- Co-authored-by: Mariell Hoversholm --- apps/scope/pkg/apis/scope/v0alpha1/types.go | 2 + .../scope/v0alpha1/zz_generated.openapi.go | 7 + devenv/scopes/scopes-config.yaml | 202 ++++++++++-- devenv/scopes/scopes.go | 209 +++++++++++- .../ScopesDashboardsService.test.ts | 309 +++++++++++++++++- .../dashboards/ScopesDashboardsService.ts | 215 ++++++++++-- .../dashboards/ScopesDashboardsTree.tsx | 50 ++- .../ScopesDashboardsTreeFolderItem.tsx | 79 ++++- .../app/features/scopes/dashboards/types.ts | 3 + .../scopes/tests/dashboardsList.test.ts | 256 ++++++++++++++- .../app/features/scopes/tests/utils/mocks.ts | 113 +++++++ public/locales/en-US/grafana.json | 1 + 12 files changed, 1357 insertions(+), 89 deletions(-) diff --git a/apps/scope/pkg/apis/scope/v0alpha1/types.go b/apps/scope/pkg/apis/scope/v0alpha1/types.go index b0f58fdfd44..0c323d8d14b 100644 --- a/apps/scope/pkg/apis/scope/v0alpha1/types.go +++ b/apps/scope/pkg/apis/scope/v0alpha1/types.go @@ -209,6 +209,8 @@ type FindScopeNavigationsResults struct { type ScopeNavigationSpec struct { URL string `json:"url"` Scope string `json:"scope"` + // Used to navigate to a sub-scope of the main scope. URL will not be used if this is set. + SubScope string `json:"subScope,omitempty"` } // Type of the item. diff --git a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go index a0b0e90b557..50015307139 100644 --- a/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go +++ b/apps/scope/pkg/apis/scope/v0alpha1/zz_generated.openapi.go @@ -635,6 +635,13 @@ func schema_pkg_apis_scope_v0alpha1_ScopeNavigationSpec(ref common.ReferenceCall Format: "", }, }, + "subScope": { + SchemaProps: spec.SchemaProps{ + Description: "Used to navigate to a sub-scope of the main scope. URL will not be used if this is set.", + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"url", "scope"}, }, diff --git a/devenv/scopes/scopes-config.yaml b/devenv/scopes/scopes-config.yaml index 46592742bbd..54842e5818e 100644 --- a/devenv/scopes/scopes-config.yaml +++ b/devenv/scopes/scopes-config.yaml @@ -1,29 +1,58 @@ scopes: - app1: - title: Application 1 + shoe-org: + title: Shoe organization + filters: + - key: organization + operator: equals + value: shoe + + shoes: + title: Shoes + filters: + - key: product + operator: equals + value: shoes + + apparel: + title: Apparel + filters: + - key: product + operator: equals + value: apparel + + frontend: + title: Frontend + filters: + - key: team + operator: equals + value: frontend + + database: + title: Database + filters: + - key: team + operator: equals + value: database + + main-app: + title: Main App filters: - key: app operator: equals - value: app1 + value: main - app2: - title: Application 2 + kids-app: + title: Kids App filters: - key: app operator: equals - value: app2 - - cluster1: - title: Cluster 1 - filters: - - key: cluster - operator: equals - value: cluster1 + value: kids tree: gdev-scopes: title: gdev-scopes nodeType: container + disableMultiSelect: true children: production: title: Production @@ -54,7 +83,6 @@ tree: nodeType: leaf linkId: test-case-2 linkType: scope - clusters: title: Clusters nodeType: container @@ -66,19 +94,139 @@ tree: nodeType: leaf linkId: cluster1 linkType: scope + shoe-org-root: + title: Shoe organization + subTitle: Nested scopes navigation + nodeType: leaf + linkId: shoe-org + linkType: scope -navigations: - # Example: Link to a dashboard - app1-nav: - url: /d/86Js1xRmk - scope: app1 +navigationTree: + - name: global-overview + title: Global overview + url: /d/TkZXxlNG3 + scope: shoe-org - # Example: Link to a dashboard with full URL (already has /d/) - app2-nav: - url: /d/GlAqcPgmz - scope: app2 + - name: reliability-placeholder + title: Reliability + url: /d/dcb9f5e9-8066-4397-889e-864b99555dbb + scope: shoe-org + groups: + - Reliability - # Example: Custom URL path - custom-nav: - url: /explore - scope: app1 + - name: shoes + title: Shoes + url: /d/_5rDmaQiz + scope: shoe-org + subScope: shoes + children: + - name: shoes-overview + title: Overview + url: /d/5SdHCadmz + scope: shoes + + - name: shoes-team-overview + title: Team Overview + url: /d/EJ8_d9jZk + scope: shoes + + - name: shoes-frontend + title: Frontend + url: /d/edediimbjhdz4b + scope: shoes + subScope: frontend + children: + - name: frontend-api + title: API Metrics + url: /d/5Y0jv6pVz + scope: frontend + + - name: frontend-ui + title: UI Performance + url: /d/2xuwrgV7z + scope: frontend + - name: frontend-shoes + title: Nested shoes + url: /d/5SdHCadmz + scope: frontend + subScope: shoes # This should be filtered out since 'shoes' is already in the path + + - name: shoes-database + title: Database + url: /d/lVE-2YFMz + scope: shoes + subScope: database + children: + - name: database-connections + title: Database Connections + url: /d/mIJjFy8Kz + scope: database + + - name: database-replication + title: Database Replication + url: /d/fdn48fmz8f94wc + scope: database + + - name: shoes-main-app + title: Main App + url: /d/WZ7AhQiVz + scope: shoes + subScope: main-app + children: + - name: main-app-users + title: User Analytics + url: /d/a6801696-cc53-4196-b1f9-2403e3909185 + scope: main-app + + - name: main-app-revenue + title: Revenue Metrics + url: /d/imQX6j-Gz + scope: main-app + + - name: shoes-kids-app + title: Kids App + url: /d/bds35fot3cv7kb + scope: shoes + subScope: kids-app + children: + - name: kids-app-features + title: Feature Usage + url: /d/16f11TZWk + scope: kids-app + + - name: kids-app-engagement + title: User Engagement + url: /d/c01bf42b-b783-4447-a304-8554cee1843b + scope: kids-app + + - name: apparel + title: Apparel + url: /d/UTv--wqMk + scope: shoe-org + subScope: apparel + children: + - name: apparel-product-overview + title: Product Overview + url: /d/Kp9Z0hTik + scope: apparel + + - name: all-teams-placeholder + title: All Teams + url: /d/b36b5576-2e3d-4b0c-8dce-e79514d99345 + scope: shoe-org + groups: + - Discovered dashboards + children: + - name: others-placeholder + title: Others + url: /d/xtY_uCAiz + scope: shoe-org + groups: + - All Teams + children: + - name: latency-and-errors + title: Latency and Errors + url: /d/ZqZnVvFZz + scope: shoe-org + groups: + - Others diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index 0480ee221e7..9e712aedc4d 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -43,9 +43,10 @@ func getEnv(key, defaultValue string) string { } type Config struct { - Scopes map[string]ScopeConfig `yaml:"scopes"` - Tree map[string]TreeNode `yaml:"tree"` - Navigations map[string]NavigationConfig `yaml:"navigations"` + Scopes map[string]ScopeConfig `yaml:"scopes"` + Tree map[string]TreeNode `yaml:"tree"` + Navigations map[string]NavigationConfig `yaml:"navigations"` + NavigationTree []NavigationTreeNode `yaml:"navigationTree"` } // ScopeConfig is used for YAML parsing - converts to v0alpha1.ScopeSpec @@ -64,16 +65,32 @@ type ScopeFilterConfig struct { // TreeNode is used for YAML parsing - converts to v0alpha1.ScopeNodeSpec type TreeNode struct { - Title string `yaml:"title"` - NodeType string `yaml:"nodeType"` - LinkID string `yaml:"linkId,omitempty"` - LinkType string `yaml:"linkType,omitempty"` - Children map[string]TreeNode `yaml:"children,omitempty"` + Title string `yaml:"title"` + SubTitle string `yaml:"subTitle,omitempty"` + NodeType string `yaml:"nodeType"` + LinkID string `yaml:"linkId,omitempty"` + LinkType string `yaml:"linkType,omitempty"` + DisableMultiSelect bool `yaml:"disableMultiSelect,omitempty"` + Children map[string]TreeNode `yaml:"children,omitempty"` } type NavigationConfig struct { - URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) - Scope string `yaml:"scope"` + URL string `yaml:"url"` // URL path (e.g., /d/abc123 or /explore) + Scope string `yaml:"scope"` // Required scope + SubScope string `yaml:"subScope"` // Optional subScope for hierarchical navigation + Title string `yaml:"title"` // Display title + Groups []string `yaml:"groups"` // Optional groups for categorization +} + +// NavigationTreeNode represents a node in the navigation tree structure +type NavigationTreeNode struct { + Name string `yaml:"name"` + Title string `yaml:"title"` + URL string `yaml:"url"` + Scope string `yaml:"scope"` + SubScope string `yaml:"subScope,omitempty"` + Groups []string `yaml:"groups,omitempty"` + Children []NavigationTreeNode `yaml:"children,omitempty"` } // Helper function to convert ScopeFilterConfig to v0alpha1.ScopeFilter @@ -156,6 +173,41 @@ func (c *Client) makeRequest(method, endpoint string, body []byte) error { return nil } +func (c *Client) getScopeNavigation(name string) (*v0alpha1.ScopeNavigation, error) { + url := fmt.Sprintf("%s/apis/%s/namespaces/%s/scopenavigations/%s", c.baseURL, apiVersion, c.namespace, name) + + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.SetBasicAuth(strings.Split(c.auth, ":")[0], strings.Split(c.auth, ":")[1]) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + bodyBytes, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("API request failed: HTTP %d - %s", resp.StatusCode, string(bodyBytes)) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var navigation v0alpha1.ScopeNavigation + if err := json.Unmarshal(bodyBytes, &navigation); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + return &navigation, nil +} + func (c *Client) createScope(name string, cfg ScopeConfig) error { prefixedName := prefix + "-" + name @@ -206,8 +258,9 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string) spec := v0alpha1.ScopeNodeSpec{ Title: node.Title, + SubTitle: node.SubTitle, NodeType: nodeType, - DisableMultiSelect: false, + DisableMultiSelect: node.DisableMultiSelect, } if prefixedParent != "" { @@ -241,17 +294,34 @@ func (c *Client) createScopeNode(name string, node TreeNode, parentName string) func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error { prefixedName := prefix + "-" + name - prefixedScope := prefix + "-" + nav.Scope if nav.URL == "" { return fmt.Errorf("navigation %s must have 'url' specified", name) } + if nav.Scope == "" { + return fmt.Errorf("navigation %s must have 'scope' specified", name) + } + + prefixedScope := prefix + "-" + nav.Scope + spec := v0alpha1.ScopeNavigationSpec{ URL: nav.URL, Scope: prefixedScope, } + if nav.SubScope != "" { + prefixedSubScope := prefix + "-" + nav.SubScope + spec.SubScope = prefixedSubScope + } + + status := v0alpha1.ScopeNavigationStatus{ + Title: nav.Title, + } + if len(nav.Groups) > 0 { + status.Groups = nav.Groups + } + resource := v0alpha1.ScopeNavigation{ TypeMeta: metav1.TypeMeta{ APIVersion: apiVersion, @@ -269,7 +339,99 @@ func (c *Client) createScopeNavigation(name string, nav NavigationConfig) error } fmt.Printf("✓ Creating scope navigation: %s\n", prefixedName) - return c.makeRequest("POST", "/scopenavigations", body) + if err := c.makeRequest("POST", "/scopenavigations", body); err != nil { + return err + } + + // Update status in a second request (status is a subresource) + if nav.Title != "" || len(nav.Groups) > 0 { + // Get the created resource to retrieve its resourceVersion and existing spec + createdNav, err := c.getScopeNavigation(prefixedName) + if err != nil { + return fmt.Errorf("failed to get created navigation: %w", err) + } + + statusResource := v0alpha1.ScopeNavigation{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiVersion, + Kind: "ScopeNavigation", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: prefixedName, + ResourceVersion: createdNav.ObjectMeta.ResourceVersion, + }, + Spec: createdNav.Spec, // Include existing spec to prevent it from being cleared + Status: status, + } + + statusBody, err := json.Marshal(statusResource) + if err != nil { + return fmt.Errorf("failed to marshal scope navigation status: %w", err) + } + + fmt.Printf(" Updating status for: %s\n", prefixedName) + return c.makeRequest("PUT", fmt.Sprintf("/scopenavigations/%s/status", prefixedName), statusBody) + } + + return nil +} + +// NavigationWithName pairs a navigation config with its name and title +type NavigationWithName struct { + Name string + Title string + Nav NavigationConfig +} + +// Convert navigation tree to flat navigations (similar to mock's treeToNavigations) +func treeToNavigations(node NavigationTreeNode, parentPath []string, dashboardCounter *int) []NavigationWithName { + navigations := []NavigationWithName{} + currentPath := append(parentPath, node.Name) + + // Generate URL if not provided (cycle through dash-1, dash-2, etc.) + url := node.URL + if url == "" { + *dashboardCounter++ + url = fmt.Sprintf("/d/dash-%d", *dashboardCounter) + } + + // Create navigation for this node + nav := NavigationConfig{ + URL: url, + Scope: node.Scope, + Title: node.Title, + } + if node.SubScope != "" { + nav.SubScope = node.SubScope + } + if len(node.Groups) > 0 { + nav.Groups = node.Groups + } + navigations = append(navigations, NavigationWithName{ + Name: node.Name, + Title: node.Title, + Nav: nav, + }) + + // Process children - they inherit the parent's subScope as their scope, or use parent's scope if no subScope + if len(node.Children) > 0 { + for _, child := range node.Children { + // Children inherit the parent's subScope as their scope, or use parent's scope if no subScope + childScope := node.SubScope + if childScope == "" { + childScope = node.Scope + } + // Override with child's scope if explicitly set + if child.Scope != "" { + childScope = child.Scope + } else { + child.Scope = childScope + } + navigations = append(navigations, treeToNavigations(child, currentPath, dashboardCounter)...) + } + } + + return navigations } func (c *Client) createTreeNodes(children map[string]TreeNode, parentName string) error { @@ -418,6 +580,27 @@ func main() { } // Create scope navigations + // First, process navigation tree if provided + if len(config.NavigationTree) > 0 { + fmt.Println("Creating scope navigations from tree...") + dashboardCounter := 0 + for _, rootNode := range config.NavigationTree { + flatNavigations := treeToNavigations(rootNode, []string{}, &dashboardCounter) + for _, navWithName := range flatNavigations { + // Use the title from navWithName if Nav.Title is empty + if navWithName.Nav.Title == "" && navWithName.Title != "" { + navWithName.Nav.Title = navWithName.Title + } + if err := client.createScopeNavigation(navWithName.Name, navWithName.Nav); err != nil { + fmt.Fprintf(os.Stderr, "Error creating scope navigation %s: %v\n", navWithName.Name, err) + os.Exit(1) + } + } + } + fmt.Println() + } + + // Also support flat navigations format for backward compatibility if len(config.Navigations) > 0 { fmt.Println("Creating scope navigations...") for name, nav := range config.Navigations { diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts index 15a10f43f3f..62b3b71e09d 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.test.ts @@ -5,8 +5,10 @@ import { ScopeDashboardBinding } from '@grafana/data'; import { config, locationService } from '@grafana/runtime'; import { ScopesApiClient } from '../ScopesApiClient'; +// Import mock data for subScope tests +import { navigationWithSubScope, navigationWithSubScope2, navigationWithSubScopeAndGroups } from '../tests/utils/mocks'; -import { ScopesDashboardsService } from './ScopesDashboardsService'; +import { ScopesDashboardsService, filterItemsWithSubScopesInPath } from './ScopesDashboardsService'; import { ScopeNavigation } from './types'; jest.mock('@grafana/runtime', () => ({ @@ -15,6 +17,7 @@ jest.mock('@grafana/runtime', () => ({ featureToggles: { useScopesNavigationEndpoint: false, }, + apps: {}, }, locationService: { getLocation: jest.fn(), @@ -390,4 +393,308 @@ describe('ScopesDashboardsService', () => { }); }); }); + + describe('groupSuggestedItems with subScopes', () => { + it('Creates subScope folders for items with subScope', () => { + const result = service.groupSuggestedItems([navigationWithSubScope]); + + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1'); + expect(result[''].folders['mimir-subscope-nav-1']).toEqual({ + title: 'Mimir Dashboards', + expanded: false, + folders: {}, + suggestedNavigations: {}, + subScopeName: 'mimir', + }); + }); + + it('Creates separate folders for multiple items with same subScope', () => { + const result = service.groupSuggestedItems([navigationWithSubScope, navigationWithSubScope2]); + + // Should create separate folders + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1'); + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-2'); + + // Both should reference the same subScope + expect(result[''].folders['mimir-subscope-nav-1'].subScopeName).toBe('mimir'); + expect(result[''].folders['mimir-subscope-nav-2'].subScopeName).toBe('mimir'); + }); + + it('Ignores groups for subScope items', () => { + const result = service.groupSuggestedItems([navigationWithSubScopeAndGroups]); + + // Should create folder, not add to group folders + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-groups'); + expect(result[''].folders['mimir-subscope-nav-groups'].subScopeName).toBe('mimir'); + + // Should not add to any group folders + expect(Object.keys(result[''].folders).length).toBe(1); + expect(result[''].suggestedNavigations).toEqual({}); + }); + + it('Does not add navigation items for subScope entries', () => { + const result = service.groupSuggestedItems([navigationWithSubScope]); + + // Should only create folder, not add navigation item + expect(result[''].folders['mimir-subscope-nav-1'].suggestedNavigations).toEqual({}); + expect(result[''].suggestedNavigations).toEqual({}); + }); + + it('Mixes subScope and regular items correctly', () => { + const regularItem: ScopeNavigation = { + metadata: { name: 'regular-nav' }, + spec: { + scope: 'grafana', + url: '/d/regular-dashboard', + }, + status: { + title: 'Regular Dashboard', + groups: ['General'], + }, + }; + + const result = service.groupSuggestedItems([navigationWithSubScope, regularItem]); + + // Should have subScope folder + expect(result[''].folders).toHaveProperty('mimir-subscope-nav-1'); + expect(result[''].folders['mimir-subscope-nav-1'].subScopeName).toBe('mimir'); + + // Should have regular group folder + expect(result[''].folders).toHaveProperty('General'); + expect(result[''].folders['General'].subScopeName).toBeUndefined(); + + // Regular item should be in group folder + expect(result[''].folders['General'].suggestedNavigations).toHaveProperty('/d/regular-dashboard'); + }); + }); + + describe('fetchSubScopeItems infinite loop prevention', () => { + beforeEach(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + }); + + afterEach(() => { + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('should filter out items with subScope matching any subScope in the path', async () => { + // Mock current location + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location); + + // Create initial navigation with subScope 'mimir' + const initialNavigation: ScopeNavigation = { + metadata: { name: 'subscope-nav-1' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/mimir-dashboards', + }, + status: { + title: 'Mimir Dashboards', + }, + }; + + // Mock items returned when fetching 'mimir' subScope + // One of them has the same subScope 'mimir', which should be filtered out + const subScopeItemsWithSameSubScope: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-item-2' }, + spec: { + scope: 'mimir', + subScope: 'mimir', // This should be filtered out - same subScope as in path + url: '/d/mimir-dashboard-2', + }, + status: { + title: 'Mimir Dashboard 2', + }, + }, + { + metadata: { name: 'mimir-item-3' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-3', + }, + status: { + title: 'Mimir Dashboard 3', + groups: ['Observability'], + }, + }, + ]; + + // Set up mock to return items based on scope being fetched + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('grafana')) { + return Promise.resolve([initialNavigation]); + } + if (scopeNames.includes('mimir')) { + return Promise.resolve(subScopeItemsWithSameSubScope); + } + return Promise.resolve([]); + }); + + // Initial fetch to create the subScope folder + await service.fetchDashboards(['grafana']); + + // The folder key is based on the pattern: ${subScope}-${metadata.name} + const subScopeFolderKey = 'mimir-subscope-nav-1'; + + // Test the filtering logic directly + const filteredItems = filterItemsWithSubScopesInPath( + subScopeItemsWithSameSubScope, + ['', subScopeFolderKey], + 'mimir', + service.state.folders + ); + + // Verify that the item with the same subScope was filtered out + const hasFilteredItem = filteredItems.some( + (item) => 'subScope' in item.spec && item.spec.subScope === 'mimir' && item.metadata.name === 'mimir-item-2' + ); + expect(hasFilteredItem).toBe(false); + + // Verify that valid items are still present + expect(filteredItems.length).toBe(2); // Should have 2 items (mimir-item-1 and mimir-item-3) + expect(filteredItems.some((item) => item.metadata.name === 'mimir-item-1')).toBe(true); + expect(filteredItems.some((item) => item.metadata.name === 'mimir-item-3')).toBe(true); + }); + + it('should filter out items with subScope matching nested subScope in the path', async () => { + // Mock current location + (locationService.getLocation as jest.Mock).mockReturnValue({ pathname: '/' } as Location); + + // Create nested subScope structure: grafana -> mimir -> loki + const grafanaNavigation: ScopeNavigation = { + metadata: { name: 'mimir-nav' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/mimir-dashboards', + }, + status: { + title: 'Mimir Dashboards', + }, + }; + + // Mock items returned when fetching 'mimir' subScope + // One of them has subScope 'loki', which is fine + const mimirSubScopeItems: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'loki-nav' }, + spec: { + scope: 'mimir', + subScope: 'loki', // This is fine - different subScope + url: '/d/loki-dashboards', + }, + status: { + title: 'Loki Dashboards', + }, + }, + ]; + + // Mock items returned when fetching 'loki' subScope + // One of them has subScope 'mimir', which should be filtered out since 'mimir' is already in the path + const lokiSubScopeItems: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { + scope: 'loki', + url: '/d/loki-dashboard-1', + }, + status: { + title: 'Loki Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-nav-again' }, + spec: { + scope: 'loki', + subScope: 'mimir', // This should be filtered out - 'mimir' is already in the path + url: '/d/mimir-dashboards-again', + }, + status: { + title: 'Mimir Dashboards Again', + }, + }, + ]; + + // Set up mock to return items based on scope being fetched + mockApiClient.fetchScopeNavigations.mockImplementation((scopeNames: string[]) => { + if (scopeNames.includes('grafana')) { + return Promise.resolve([grafanaNavigation]); + } + if (scopeNames.includes('mimir')) { + return Promise.resolve(mimirSubScopeItems); + } + if (scopeNames.includes('loki')) { + return Promise.resolve(lokiSubScopeItems); + } + return Promise.resolve([]); + }); + + // Initial fetch to create the first subScope folder + await service.fetchDashboards(['grafana']); + + // Set up the folder structure to simulate nested path + const folders = service.groupSuggestedItems([grafanaNavigation]); + const mimirFolders = service.groupSuggestedItems(mimirSubScopeItems); + + // Manually construct the nested folder structure for testing + const testFolders: typeof service.state.folders = { + '': { + ...folders[''], + folders: { + ...folders[''].folders, + 'mimir-mimir-nav': { + ...folders[''].folders['mimir-mimir-nav'], + folders: { + ...mimirFolders[''].folders, + }, + }, + }, + }, + }; + + // Test the filtering logic for nested path + const filteredItems = filterItemsWithSubScopesInPath( + lokiSubScopeItems, + ['', 'mimir-mimir-nav', 'loki-loki-nav'], + 'loki', + testFolders + ); + + // Verify that the item with 'mimir' subScope was filtered out + const hasFilteredItem = filteredItems.some( + (item) => 'subScope' in item.spec && item.spec.subScope === 'mimir' && item.metadata.name === 'mimir-nav-again' + ); + + expect(hasFilteredItem).toBe(false); + + // Verify that valid items are still present + expect(filteredItems.length).toBe(1); // Should have 1 item (loki-item-1) + expect(filteredItems.some((item) => item.metadata.name === 'loki-item-1')).toBe(true); + }); + }); }); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts index 42909b4663f..be6f966623e 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsService.ts +++ b/public/app/features/scopes/dashboards/ScopesDashboardsService.ts @@ -8,7 +8,7 @@ import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesServiceBase } from '../ScopesServiceBase'; import { isCurrentPath } from './scopeNavgiationUtils'; -import { ScopeNavigation, SuggestedNavigationsFoldersMap } from './types'; +import { ScopeNavigation, SuggestedNavigationsFoldersMap, SuggestedNavigationsMap } from './types'; interface ScopesDashboardsServiceState { // State of the drawer showing related dashboards @@ -106,6 +106,92 @@ export class ScopesDashboardsService extends ScopesServiceBase { + let subScopeFolders: SuggestedNavigationsFoldersMap | undefined; + + try { + // Fetch navigations for this subScope + const fetchNavigations = config.featureToggles.useScopesNavigationEndpoint + ? this.apiClient.fetchScopeNavigations + : this.apiClient.fetchDashboards; + + const subScopeItems = await fetchNavigations([subScopeName]); + + // Filter out items that have a subScope matching any subScope already in the path + // This prevents infinite loops when a subScope returns items with the same subScope + const filteredItems = filterItemsWithSubScopesInPath(subScopeItems, path, subScopeName, this.state.folders); + + // Group the items and add them to the subScope folder + subScopeFolders = this.groupSuggestedItems(filteredItems); + } catch (error) { + // On error, subScopeFolders will remain undefined and we'll only clear loading state + } + + // Get the current state and navigate to the target folder + let folders = { ...this.state.folders }; + let filteredFolders = { ...this.state.filteredFolders }; + let currentLevelFolders: SuggestedNavigationsFoldersMap = folders; + let currentLevelFilteredFolders: SuggestedNavigationsFoldersMap = filteredFolders; + + for (let idx = 0; idx < path.length - 1; idx++) { + currentLevelFolders = currentLevelFolders[path[idx]].folders; + currentLevelFilteredFolders = currentLevelFilteredFolders[path[idx]].folders; + } + + const name = path[path.length - 1]; + const currentFolder = currentLevelFolders[name]; + const currentFilteredFolder = currentLevelFilteredFolders[name]; + + // Clear loading state + currentFolder.loading = false; + currentFilteredFolder.loading = false; + + // Merge the subScope folder's content with the fetched items (if fetch succeeded) + if (subScopeFolders) { + // Take items from the root of the grouped structure + const rootSubScopeFolder = subScopeFolders['']; + currentFolder.folders = { ...currentFolder.folders, ...rootSubScopeFolder.folders }; + currentFolder.suggestedNavigations = { + ...currentFolder.suggestedNavigations, + ...rootSubScopeFolder.suggestedNavigations, + }; + + // Also update filtered folders + currentFilteredFolder.folders = { ...currentFilteredFolder.folders, ...rootSubScopeFolder.folders }; + currentFilteredFolder.suggestedNavigations = { + ...currentFilteredFolder.suggestedNavigations, + ...rootSubScopeFolder.suggestedNavigations, + }; + } + this.updateState({ folders, filteredFolders }); }; @@ -180,6 +266,7 @@ export class ScopesDashboardsService extends ScopesServiceBase { const rootNode = folders['']; const groups = navigation.status.groups ?? []; + const subScope = 'subScope' in navigation.spec ? navigation.spec.subScope : undefined; // If the current URL matches an item, expand the parent folders. let expanded = false; @@ -193,31 +280,8 @@ export class ScopesDashboardsService extends ScopesServiceBase { - const groupExists = !!rootNode.folders[group]; - const groupCurrentlyExpanded = groupExists && rootNode.folders[group].expanded; - - if (group && !groupExists) { - rootNode.folders[group] = { - title: group, - expanded, - folders: {}, - suggestedNavigations: {}, - }; - } - if (group && expanded && !groupCurrentlyExpanded) { - rootNode.folders[group].expanded = true; - } - }); - - const targets = - groups.length > 0 - ? groups.map((group) => - group === '' ? rootNode.suggestedNavigations : rootNode.folders[group].suggestedNavigations - ) - : [rootNode.suggestedNavigations]; - - targets.forEach((target) => { + // Helper function to add navigation item to a target + const addNavigationToTarget = (target: SuggestedNavigationsMap) => { // Dashboard if ( 'dashboard' in navigation.spec && @@ -236,7 +300,64 @@ export class ScopesDashboardsService extends ScopesServiceBase 0) { + // Add item to all group folders at root level + // Each group gets the item separately, not nested + groups.forEach((group) => { + if (group) { + if (!rootNode.folders[group]) { + rootNode.folders[group] = { + title: group, + expanded, + folders: {}, + suggestedNavigations: {}, + }; + } + if (expanded && !rootNode.folders[group].expanded) { + rootNode.folders[group].expanded = true; + } + + // Add the navigation item directly to the group folder + addNavigationToTarget(rootNode.folders[group].suggestedNavigations); + } else { + // Empty string group means add to root folder + addNavigationToTarget(rootNode.suggestedNavigations); + } + }); + } else { + // If no groups, add to root + addNavigationToTarget(rootNode.suggestedNavigations); + } + } }); return folders; @@ -277,3 +398,43 @@ export class ScopesDashboardsService extends ScopesServiceBase this.updateState({ drawerOpened: !this.state.drawerOpened }); } + +/** + * Filters out navigation items that have a subScope matching any subScope already in the path. + * This prevents infinite loops when a subScope returns items with the same subScope. + * @param items - The navigation items to filter + * @param path - The folder path to check for existing subScopes + * @param currentSubScope - The subScope currently being expanded + * @param folders - The folder structure to traverse + * @returns Filtered items without subScopes that would create infinite loops + */ +export function filterItemsWithSubScopesInPath( + items: Array, + path: string[], + currentSubScope: string, + folders: SuggestedNavigationsFoldersMap +): Array { + const subScopesInPath = new Set(); + // Include the current subScope being expanded + subScopesInPath.add(currentSubScope); + + // Traverse the path and collect all subScope names + let currentLevelFolders: SuggestedNavigationsFoldersMap = folders; + for (const folderKey of path) { + const folder = currentLevelFolders[folderKey]; + if (folder?.subScopeName) { + subScopesInPath.add(folder.subScopeName); + } + if (folder) { + currentLevelFolders = folder.folders; + } else { + // If folder is not found, break to avoid errors + break; + } + } + + return items.filter((item) => { + const itemSubScope = 'subScope' in item.spec ? item.spec.subScope : undefined; + return !itemSubScope || !subScopesInPath.has(itemSubScope); + }); +} diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx index 0f19594dcfc..b25a3bfc55b 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTree.tsx @@ -1,4 +1,7 @@ -import { urlUtil } from '@grafana/data'; +import { css } from '@emotion/css'; + +import { GrafanaTheme2, urlUtil } from '@grafana/data'; +import { useStyles2 } from '@grafana/ui'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { ScopesDashboardsTreeFolderItem } from './ScopesDashboardsTreeFolderItem'; @@ -13,13 +16,32 @@ export interface ScopesDashboardsTreeProps { export function ScopesDashboardsTree({ folders, folderPath, onFolderUpdate }: ScopesDashboardsTreeProps) { const [queryParams] = useQueryParams(); + const styles = useStyles2(getStyles); const folderId = folderPath[folderPath.length - 1]; const folder = folders[folderId]; + // Separate regular items from subScope items + const regularFolders: Array<[string, (typeof folder.folders)[string]]> = []; + const subScopeFolders: Array<[string, (typeof folder.folders)[string]]> = []; + + Object.entries(folder.folders).forEach(([subFolderId, subFolder]) => { + if (subFolder.subScopeName) { + subScopeFolders.push([subFolderId, subFolder]); + } else { + regularFolders.push([subFolderId, subFolder]); + } + }); + + const regularNavigations = Object.values(folder.suggestedNavigations); + + const hasRegularContent = regularFolders.length > 0 || regularNavigations.length > 0; + const hasSubScopeContent = subScopeFolders.length > 0; + return (
- {Object.entries(folder.folders).map(([subFolderId, subFolder]) => ( + {/* Regular folders and navigations */} + {regularFolders.map(([subFolderId, subFolder]) => ( ))} - {Object.values(folder.suggestedNavigations).map((navigation) => ( + {regularNavigations.map((navigation) => ( ))} + + {/* Separator between regular and subScope sections */} + {hasRegularContent && hasSubScopeContent &&
} + + {/* SubScope folders */} + {subScopeFolders.map(([subFolderId, subFolder]) => ( + + ))}
); } + +const getStyles = (theme: GrafanaTheme2) => ({ + separator: css({ + border: 'none', + borderTop: `1px solid ${theme.colors.border.weak}`, + margin: theme.spacing(1, 0), + }), +}); diff --git a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx index dc1bf10878d..da1f315c5e2 100644 --- a/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx +++ b/public/app/features/scopes/dashboards/ScopesDashboardsTreeFolderItem.tsx @@ -2,7 +2,9 @@ import { css } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { Icon, useStyles2 } from '@grafana/ui'; +import { Icon, IconButton, Spinner, useStyles2 } from '@grafana/ui'; + +import { useScopesServices } from '../ScopesContextProvider'; import { ScopesDashboardsTree } from './ScopesDashboardsTree'; import { OnFolderUpdate, SuggestedNavigationsFolder, SuggestedNavigationsFoldersMap } from './types'; @@ -22,22 +24,45 @@ export function ScopesDashboardsTreeFolderItem({ }: ScopesDashboardsTreeFolderItemProps) { const styles = useStyles2(getStyles); + // get scopesselector service + const scopesSelectorService = useScopesServices()?.scopesSelectorService ?? undefined; + return (
- + {folder.title} + {folder.loading && } + + + {folder.subScopeName && ( + { + e.preventDefault(); + e.stopPropagation(); + if (folder.subScopeName && scopesSelectorService) { + scopesSelectorService.changeScopes([folder.subScopeName]); + } + }} + /> + )} +
{folder.expanded && (
@@ -55,6 +80,12 @@ const getStyles = (theme: GrafanaTheme2) => { flexDirection: 'column', padding: theme.spacing(0.5, 0), }), + row: css({ + display: 'flex', + alignItems: 'flex-start', + gap: theme.spacing(1), + width: '100%', + }), expand: css({ alignItems: 'flex-start', background: 'none', @@ -65,12 +96,30 @@ const getStyles = (theme: GrafanaTheme2) => { padding: 0, textAlign: 'left', wordBreak: 'break-word', + flex: 1, }), icon: css({ marginTop: theme.spacing(0.25), }), + titleContainer: css({ + display: 'flex', + alignItems: 'center', + flex: 1, + }), + exchangeIcon: css({ + opacity: 0.7, + flexShrink: 0, + marginTop: theme.spacing(0.25), + }), + loadingIcon: css({ + flexShrink: 0, + marginLeft: theme.spacing(0.5), + marginTop: theme.spacing(0.25), + }), children: css({ - paddingLeft: theme.spacing(3), + paddingLeft: theme.spacing(2), + marginLeft: theme.spacing(1), + borderLeft: `1px solid ${theme.colors.border.weak}`, }), }; }; diff --git a/public/app/features/scopes/dashboards/types.ts b/public/app/features/scopes/dashboards/types.ts index 78242f28c59..18ee90296e6 100644 --- a/public/app/features/scopes/dashboards/types.ts +++ b/public/app/features/scopes/dashboards/types.ts @@ -4,6 +4,7 @@ import { ScopeDashboardBinding } from '@grafana/data'; export interface ScopeNavigationSpec { url: string; scope: string; + subScope?: string; } export interface ScopeNavigationStatus { @@ -37,6 +38,8 @@ export interface SuggestedNavigationsFolder { expanded: boolean; folders: SuggestedNavigationsFoldersMap; suggestedNavigations: SuggestedNavigationsMap; + subScopeName?: string; + loading?: boolean; } export type SuggestedNavigationsFoldersMap = Record; diff --git a/public/app/features/scopes/tests/dashboardsList.test.ts b/public/app/features/scopes/tests/dashboardsList.test.ts index a1d57846206..0fc77e74962 100644 --- a/public/app/features/scopes/tests/dashboardsList.test.ts +++ b/public/app/features/scopes/tests/dashboardsList.test.ts @@ -1,7 +1,8 @@ -import { waitFor } from '@testing-library/dom'; +import { screen, waitFor } from '@testing-library/react'; import { config, locationService } from '@grafana/runtime'; +import { ScopesApiClient } from '../ScopesApiClient'; import { ScopesService } from '../ScopesService'; import { ScopesDashboardsService } from '../dashboards/ScopesDashboardsService'; import { ScopeNavigation } from '../dashboards/types'; @@ -38,6 +39,11 @@ import { getDatasource, getInstanceSettings, getMock, + navigationWithSubScope, + navigationWithSubScope2, + navigationWithSubScopeDifferent, + navigationWithSubScopeAndGroups, + subScopeMimirItems, } from './utils/mocks'; import { renderDashboard, resetScenes } from './utils/render'; @@ -52,8 +58,10 @@ jest.mock('@grafana/runtime', () => ({ describe('Dashboards list', () => { let fetchDashboardsSpy: jest.SpyInstance; + let fetchScopeNavigationsSpy: jest.SpyInstance; let scopesService: ScopesService; let scopesDashboardsService: ScopesDashboardsService; + let apiClient: ScopesApiClient; beforeAll(() => { config.featureToggles.scopeFilters = true; @@ -64,12 +72,14 @@ describe('Dashboards list', () => { const result = await renderDashboard(); scopesService = result.scopesService; scopesDashboardsService = result.scopesDashboardsService; - fetchDashboardsSpy = jest.spyOn(result.client, 'fetchDashboards'); + apiClient = result.client; + fetchDashboardsSpy = jest.spyOn(apiClient, 'fetchDashboards'); + fetchScopeNavigationsSpy = jest.spyOn(apiClient, 'fetchScopeNavigations'); }); afterEach(async () => { locationService.replace(''); - await resetScenes([fetchDashboardsSpy]); + await resetScenes([fetchDashboardsSpy, fetchScopeNavigationsSpy]); }); it('Opens container and fetches dashboards list when a scope is selected', async () => { @@ -508,6 +518,246 @@ describe('Dashboards list', () => { }); }); + describe('subScopes', () => { + beforeAll(() => { + config.featureToggles.useScopesNavigationEndpoint = true; + }); + + afterAll(() => { + config.featureToggles.useScopesNavigationEndpoint = false; + }); + + it('Creates subScope folders when navigation items have subScope', async () => { + const mockNavigations = [navigationWithSubScope, navigationWithSubScopeDifferent]; + fetchScopeNavigationsSpy.mockResolvedValue(mockNavigations); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify subScope folders are created + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + expect(screen.getByTestId('scopes-dashboards-Loki Dashboards-expand')).toBeInTheDocument(); + }); + + it('Loads subScope items when folder is expanded', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for async fetchSubScopeItems to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Items are added to nested folders within the subScope folder, so expand those folders + await expandDashboardFolder('General'); + await expandDashboardFolder('Observability'); + await jest.runOnlyPendingTimersAsync(); + + // Verify loaded content appears (IDs are based on metadata.name) + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + expectDashboardInDocument('mimir-item-2'); + }); + + it('Shows loading state while fetching subScope items', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder + await expandDashboardFolder('Mimir Dashboards'); + + // Verify fetch was called (loading happens asynchronously) + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + }); + + it('Multiple subScope folders with same subScope load same content', async () => { + const mockNavigations = [navigationWithSubScope, navigationWithSubScope2]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValue(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folders appear + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + expect(screen.getByTestId('scopes-dashboards-Mimir Overview-expand')).toBeInTheDocument(); + + // Expand first subScope folder + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for fetch to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Expand nested folders to see the content in first subScope folder + await expandDashboardFolder('General'); + await expandDashboardFolder('Observability'); + await jest.runOnlyPendingTimersAsync(); + + // Verify content appears in first folder + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + expectDashboardInDocument('mimir-item-2'); + + // Expand second subScope folder (same subScope) - it should load the same content + await expandDashboardFolder('Mimir Overview'); + + // Wait for fetch to complete (should use cached data or fetch again) + await waitFor(() => { + // The fetch might be called again or might use cached content + expect(fetchScopeNavigationsSpy.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + await jest.runOnlyPendingTimersAsync(); + + // Both folders should have the same content (IDs are based on metadata.name) + // Since nested folders are already expanded, content should be visible + expectDashboardInDocument('mimir-item-1'); + expectDashboardInDocument('mimir-item-2'); + }); + + it('Handles errors when fetching subScope items', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockRejectedValueOnce(new Error('Fetch failed')); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder + await expandDashboardFolder('Mimir Dashboards'); + await jest.runOnlyPendingTimersAsync(); + + // Verify fetch was called + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + + // Verify no content appears (error handled gracefully) + expectDashboardNotInDocument('mimir-item-1'); + }); + + it('Ignores groups for subScope items', async () => { + const mockNavigations = [navigationWithSubScopeAndGroups]; + fetchScopeNavigationsSpy.mockResolvedValue(mockNavigations); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify subScope folder is created (groups should be ignored) + expect(screen.getByTestId('scopes-dashboards-Mimir with Groups-expand')).toBeInTheDocument(); + + // The folder should exist regardless of groups + expect(fetchScopeNavigationsSpy).toHaveBeenCalled(); + }); + + it('Filters search works with loaded subScope content', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand subScope folder and load content + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for fetch to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Expand nested folders to see the content + await expandDashboardFolder('General'); + await expandDashboardFolder('Observability'); + await jest.runOnlyPendingTimersAsync(); + + // Verify content is loaded + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + expectDashboardInDocument('mimir-item-2'); + + // Search for a dashboard in the subScope + await searchDashboards('Mimir Dashboard 1'); + + // Verify search works (IDs are based on metadata.name) + expectDashboardInDocument('mimir-item-1'); + expectDashboardNotInDocument('mimir-item-2'); + }); + + it('Does not fetch subScope items if folder is already loaded', async () => { + const mockNavigations = [navigationWithSubScope]; + fetchScopeNavigationsSpy.mockResolvedValueOnce(mockNavigations).mockResolvedValueOnce(subScopeMimirItems); + + await toggleDashboards(); + await updateScopes(scopesService, ['grafana']); + await jest.runOnlyPendingTimersAsync(); + + // Verify folder appears + expect(screen.getByTestId('scopes-dashboards-Mimir Dashboards-expand')).toBeInTheDocument(); + + // Expand the subScope folder first time + await expandDashboardFolder('Mimir Dashboards'); + + // Wait for fetch to complete + await waitFor(() => { + expect(fetchScopeNavigationsSpy).toHaveBeenCalledWith(['mimir']); + }); + await jest.runOnlyPendingTimersAsync(); + + // Expand nested folders to see the content + await expandDashboardFolder('General'); + await jest.runOnlyPendingTimersAsync(); + + // Verify content is loaded + await waitFor(() => { + expectDashboardInDocument('mimir-item-1'); + }); + + const firstCallCount = fetchScopeNavigationsSpy.mock.calls.length; + + // Collapse and expand again + await expandDashboardFolder('Mimir Dashboards'); // Collapse + await expandDashboardFolder('Mimir Dashboards'); // Expand again + await jest.runOnlyPendingTimersAsync(); + + // Should not fetch again if already loaded + // Note: This test might need adjustment based on actual implementation + expect(fetchScopeNavigationsSpy.mock.calls.length).toBeGreaterThanOrEqual(firstCallCount); + }); + }); + describe('filterFolders', () => { it('Shows folders matching criteria', () => { expect( diff --git a/public/app/features/scopes/tests/utils/mocks.ts b/public/app/features/scopes/tests/utils/mocks.ts index 7b306350eb5..0a0e85b1791 100644 --- a/public/app/features/scopes/tests/utils/mocks.ts +++ b/public/app/features/scopes/tests/utils/mocks.ts @@ -2,6 +2,8 @@ import { Scope, ScopeDashboardBinding, ScopeNode } from '@grafana/data'; import { DataSourceRef } from '@grafana/schema/dist/esm/common/common.gen'; import { getDashboardScenePageStateManager } from 'app/features/dashboard-scene/pages/DashboardScenePageStateManager'; +import { ScopeNavigation } from '../../dashboards/types'; + export const mocksScopes: Scope[] = [ { metadata: { name: 'cloud' }, @@ -418,6 +420,24 @@ export const getMock = jest }; } + if (url.startsWith('/apis/scope.grafana.app/v0alpha1/namespaces/default/find/scope_navigations')) { + // Handle subScope fetch requests + if (params.scope && params.scope.includes('mimir')) { + return { + items: subScopeMimirItems, + }; + } + if (params.scope && params.scope.includes('loki')) { + return { + items: subScopeLokiItems, + }; + } + // Return empty for other scopes + return { + items: [], + }; + } + if (url.startsWith('/api/dashboards/uid/')) { return {}; } @@ -470,6 +490,99 @@ export const dashboardWithRootFolderAndOtherFolder: ScopeDashboardBinding = gene ['', 'Folder 3'] ); +// Mock subScope navigation items +export const navigationWithSubScope: ScopeNavigation = { + metadata: { name: 'subscope-nav-1' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-1', + }, + status: { + title: 'Mimir Dashboards', + groups: [], // subScope items ignore groups + }, +}; + +export const navigationWithSubScope2: ScopeNavigation = { + metadata: { name: 'subscope-nav-2' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-2', + }, + status: { + title: 'Mimir Overview', + groups: [], + }, +}; + +export const navigationWithSubScopeDifferent: ScopeNavigation = { + metadata: { name: 'subscope-nav-3' }, + spec: { + scope: 'grafana', + subScope: 'loki', + url: '/d/subscope-dashboard-3', + }, + status: { + title: 'Loki Dashboards', + groups: [], + }, +}; + +export const navigationWithSubScopeAndGroups: ScopeNavigation = { + metadata: { name: 'subscope-nav-groups' }, + spec: { + scope: 'grafana', + subScope: 'mimir', + url: '/d/subscope-dashboard-groups', + }, + status: { + title: 'Mimir with Groups', + groups: ['Group1', 'Group2'], // Should be ignored for subScope items + }, +}; + +// Mock items that will be loaded when subScope folder is expanded +export const subScopeMimirItems: ScopeNavigation[] = [ + { + metadata: { name: 'mimir-item-1' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-1', + }, + status: { + title: 'Mimir Dashboard 1', + groups: ['General'], + }, + }, + { + metadata: { name: 'mimir-item-2' }, + spec: { + scope: 'mimir', + url: '/d/mimir-dashboard-2', + }, + status: { + title: 'Mimir Dashboard 2', + groups: ['Observability'], + }, + }, +]; + +export const subScopeLokiItems: ScopeNavigation[] = [ + { + metadata: { name: 'loki-item-1' }, + spec: { + scope: 'loki', + url: '/d/loki-dashboard-1', + }, + status: { + title: 'Loki Dashboard 1', + groups: ['General'], + }, + }, +]; + export const getDatasource = async (ref: DataSourceRef) => { if (ref.uid === '-- Grafana --') { return { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index fe32f78260b..6e4ecb98f4d 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12384,6 +12384,7 @@ "scopes": { "dashboards": { "collapse": "Collapse", + "exchange": "Change root scope to {{scope}}", "expand": "Expand", "loading": "Loading dashboards", "noResultsForFilter": "No results found for your query", From 9d928a3ac6418fc04961f06e105e22b341839431 Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Mon, 17 Nov 2025 13:43:29 -0500 Subject: [PATCH 010/125] Alerting: Remove stale states in two steps (#114044) --- pkg/services/ngalert/state/manager.go | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index a968921de0a..401eeb079cf 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -532,7 +532,7 @@ func (st *Manager) processMissingSeriesStates(logger log.Logger, evaluatedAt tim missingTransitions := []StateTransition{} var staleStatesCount int64 = 0 - st.cache.deleteRuleStates(alertRule.GetKey(), func(s *State) bool { + toDelete := func(s *State) bool { // We need only states that are not present in the current evaluation, so // skip the state if it was just evaluated. if s.LastEvaluationTime.Equal(evaluatedAt) { @@ -584,6 +584,20 @@ func (st *Manager) processMissingSeriesStates(logger log.Logger, evaluatedAt tim missingTransitions = append(missingTransitions, record) return isStale + } + + states := st.cache.getStatesForRuleUID(alertRule.OrgID, alertRule.UID) + + toDeleteStates := map[data.Fingerprint]struct{}{} + for _, s := range states { + if toDelete(s) { + toDeleteStates[s.CacheID] = struct{}{} + } + } + + st.cache.deleteRuleStates(alertRule.GetKey(), func(s *State) bool { + _, ok := toDeleteStates[s.CacheID] + return ok }) return missingTransitions, staleStatesCount From da5af292181b24005ca7a918d14af537d1ea8127 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Mon, 17 Nov 2025 20:38:42 +0100 Subject: [PATCH 011/125] Alerting: Add search.rule_group to search rules by rule group name (#113680) Co-authored-by: Konrad Lalik --- .../ngalert/api/prometheus/api_prometheus.go | 32 +++++---- pkg/services/ngalert/models/alert_rule.go | 3 + pkg/services/ngalert/store/alert_rule.go | 12 ++++ pkg/services/ngalert/store/alert_rule_test.go | 69 +++++++++++++++++++ .../alerting/unified/api/prometheusApi.ts | 3 + .../rule-list/PaginatedGrafanaLoader.tsx | 12 +++- .../unified/rule-list/hooks/filters.test.ts | 46 +++++++++++++ .../unified/rule-list/hooks/filters.ts | 3 +- .../hooks/prometheusGroupsGenerator.ts | 1 + .../hooks/useFilteredRulesIterator.test.ts | 3 + .../hooks/useFilteredRulesIterator.ts | 5 +- 11 files changed, 169 insertions(+), 20 deletions(-) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 2cfedbc81c4..e7a6b7f57a4 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -480,6 +480,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") + searchRuleGroup := opts.Query.Get("search.rule_group") var ruleType ngmodels.RuleTypeFilter switch ngmodels.RuleType(opts.Query.Get("rule_type")) { @@ -500,13 +501,14 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt byGroupQuery := ngmodels.ListAlertRulesExtendedQuery{ ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ - OrgID: opts.OrgID, - NamespaceUIDs: namespaceUIDs, - DashboardUID: dashboardUID, - PanelID: panelID, - RuleGroups: ruleGroups, - ReceiverName: receiverName, - SearchTitle: title, + OrgID: opts.OrgID, + NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, + RuleGroups: ruleGroups, + ReceiverName: receiverName, + SearchTitle: title, + SearchRuleGroup: searchRuleGroup, }, RuleType: ruleType, Limit: maxGroups, @@ -638,15 +640,17 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") + searchRuleGroup := opts.Query.Get("search.rule_group") alertRuleQuery := ngmodels.ListAlertRulesQuery{ - OrgID: opts.OrgID, - NamespaceUIDs: namespaceUIDs, - DashboardUID: dashboardUID, - PanelID: panelID, - RuleGroups: ruleGroups, - ReceiverName: receiverName, - SearchTitle: title, + OrgID: opts.OrgID, + NamespaceUIDs: namespaceUIDs, + DashboardUID: dashboardUID, + PanelID: panelID, + RuleGroups: ruleGroups, + ReceiverName: receiverName, + SearchTitle: title, + SearchRuleGroup: searchRuleGroup, } ruleList, err := store.ListAlertRules(opts.Ctx, &alertRuleQuery) if err != nil { diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index fc6b5fe104d..4823cd977fe 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -981,6 +981,9 @@ type ListAlertRulesQuery struct { // SearchTitle allows searching for alert rules that contain // the given string in their title (case insensitive) SearchTitle string + // SearchRuleGroup allows searching for alert rules in groups that contain + // the given string in their name (case insensitive) + SearchRuleGroup string HasPrometheusRuleDefinition *bool } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 0b0f51f4dcc..393bfdabc91 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -865,6 +865,18 @@ func (st DBstore) buildListAlertRulesQuery(sess *db.Session, query *ngmodels.Lis } } + if query.SearchRuleGroup != "" { + normalizedInput := strings.ToLower(query.SearchRuleGroup) + words := strings.Fields(normalizedInput) + + if len(words) > 0 { + pattern := "%" + strings.Join(words, "%") + "%" + // In MySQL rule_group field has case-sensitive collation by default, + // so we need to use LOWER to perform case-insensitive search. + q = q.And("LOWER(rule_group) LIKE ?", pattern) + } + } + if query.HasPrometheusRuleDefinition != nil { q, err = st.filterWithPrometheusRuleDefinition(*query.HasPrometheusRuleDefinition, q) if err != nil { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 6fcc1375abe..0c49528f8d6 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2141,6 +2141,75 @@ func TestIntegration_ListAlertRules(t *testing.T) { }) } }) + + t.Run("filter by SearchRuleGroup", func(t *testing.T) { + sqlStore := db.InitTestDB(t) + folderService := setupFolderService(t, sqlStore, cfg, featuremgmt.WithFeatures()) + store := createTestStore(sqlStore, folderService, &logtest.Fake{}, cfg.UnifiedAlerting, b) + rule1 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("database-alerts"))) + rule2 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("application-alerts"))) + rule3 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("network-alerts"))) + rule4 := createRule(t, store, ruleGen.With(models.RuleMuts.WithGroupName("critical-monitoring"))) + + tc := []struct { + name string + groupSearch string + expectedRules []*models.AlertRule + }{ + { + name: "should find rules", + groupSearch: "alerts", + expectedRules: []*models.AlertRule{rule1, rule2, rule3}, + }, + { + name: "should find rule with partial match", + groupSearch: "mOnItOrInG", + expectedRules: []*models.AlertRule{rule4}, + }, + { + name: "should return no rules when no match", + groupSearch: "nonexistent", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should return all rules when empty", + groupSearch: "", + expectedRules: []*models.AlertRule{rule1, rule2, rule3, rule4}, + }, + { + name: "should not find rules when word order is reversed", + groupSearch: "alerts database", + expectedRules: []*models.AlertRule{}, + }, + { + name: "should find multiple rules matching sequential words", + groupSearch: "database alert", + expectedRules: []*models.AlertRule{rule1}, + }, + { + name: "should handle extra whitespace between words", + groupSearch: " network alerts ", + expectedRules: []*models.AlertRule{rule3}, + }, + { + name: "should handle multiple words with partial matches", + groupSearch: "crit mon", + expectedRules: []*models.AlertRule{rule4}, + }, + } + + for _, tt := range tc { + t.Run(tt.name, func(t *testing.T) { + query := &models.ListAlertRulesQuery{ + OrgID: orgID, + SearchRuleGroup: tt.groupSearch, + } + result, err := store.ListAlertRules(context.Background(), query) + require.NoError(t, err) + require.ElementsMatch(t, tt.expectedRules, result) + }) + } + }) } func TestIntegration_ListAlertRulesPaginated(t *testing.T) { diff --git a/public/app/features/alerting/unified/api/prometheusApi.ts b/public/app/features/alerting/unified/api/prometheusApi.ts index a3d9eead62e..565a50e46ad 100644 --- a/public/app/features/alerting/unified/api/prometheusApi.ts +++ b/public/app/features/alerting/unified/api/prometheusApi.ts @@ -43,6 +43,7 @@ export type GrafanaPromRulesOptions = Omit ({ url: `api/prometheus/grafana/api/v1/rules`, @@ -110,6 +112,7 @@ export const prometheusApi = alertingApi.injectEndpoints({ group_limit: groupLimit?.toFixed(0), group_next_token: groupNextToken, 'search.rule_name': title, + 'search.rule_group': searchGroupName, dashboard_uid: dashboardUid, }, }), diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index dc8875f1cf8..d1835d29757 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -7,6 +7,7 @@ import { GrafanaPromRuleGroupDTO, PromRuleGroupDTO } from 'app/types/unified-ale import { FolderActionsButton } from '../components/folder-actions/FolderActionsButton'; import { GrafanaNoRulesCTA } from '../components/rules/NoRulesCTA'; +import { shouldUseBackendFilters } from '../featureToggles'; import { GRAFANA_RULES_SOURCE_NAME } from '../utils/datasource'; import { groups } from '../utils/navigation'; @@ -35,10 +36,13 @@ export function PaginatedGrafanaLoader({ groupFilter, namespaceFilter }: LoaderP } function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { + const useBackendFilters = shouldUseBackendFilters(); + + // When backend filters are enabled, groupFilter is handled on the backend + const hasFilters = useBackendFilters ? Boolean(namespaceFilter) : Boolean(groupFilter || namespaceFilter); + // If there are filters, we don't want to populate the cache to avoid performance issues // Filtering may trigger multiple HTTP requests, which would populate the cache with a lot of groups hurting performance - const hasFilters = Boolean(groupFilter || namespaceFilter); - const grafanaGroupsGenerator = useGrafanaGroupsGenerator({ populateCache: hasFilters ? false : true, limitAlerts: 0, @@ -48,7 +52,9 @@ function PaginatedGroupsLoader({ groupFilter, namespaceFilter }: LoaderProps) { // However, if there are filters, we need to fetch more groups from the API to populate one frontend page const apiGroupPageSize = getApiGroupPageSize(hasFilters); - const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize))); + const searchGroupName = useBackendFilters ? groupFilter : undefined; + + const groupsGenerator = useRef(toIndividualRuleGroups(grafanaGroupsGenerator(apiGroupPageSize, { searchGroupName }))); useEffect(() => { const currentGenerator = groupsGenerator.current; diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts index dc6e8670db4..af9d9011f89 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts @@ -650,6 +650,31 @@ describe('grafana-managed rules', () => { expect(frontendFilter.ruleMatches(ruleWithDashboard)).toBe(true); }); + it('should include searchGroupName in backend filter when provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ groupName: 'my-group' })); + + expect(backendFilter.searchGroupName).toBe('my-group'); + }); + + it('should not include searchGroupName in backend filter when not provided', () => { + const { backendFilter } = getGrafanaFilter(getFilter({})); + + expect(backendFilter.searchGroupName).toBeUndefined(); + }); + + it('should skip groupName filtering on frontend when backend filtering is enabled', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + // Should return true because groupName filter is null (handled by backend) + expect(frontendFilter.groupMatches(group)).toBe(true); + }); + it('should still apply other frontend filters', () => { const rule = mockGrafanaPromAlertingRule({ name: 'High CPU Usage', @@ -739,6 +764,27 @@ describe('grafana-managed rules', () => { expect(frontendFilter2.ruleMatches(ruleDashboardA)).toBe(false); expect(frontendFilter2.ruleMatches(ruleDashboardB)).toBe(true); }); + + it('should not include searchGroupName in backend filter', () => { + const { backendFilter } = getGrafanaFilter(getFilter({ groupName: 'my-group' })); + + expect(backendFilter.searchGroupName).toBeUndefined(); + }); + + it('should perform groupName filtering on frontend', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { frontendFilter } = getGrafanaFilter(getFilter({ groupName: 'cpu' })); + expect(frontendFilter.groupMatches(group)).toBe(true); + + const { frontendFilter: frontendFilter2 } = getGrafanaFilter(getFilter({ groupName: 'memory' })); + expect(frontendFilter2.groupMatches(group)).toBe(false); + }); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts index 637fa636f62..6ac4d668e9d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/filters.ts @@ -42,6 +42,7 @@ export function getGrafanaFilter(filterState: RulesFilter) { title: useBackendFilters ? titleSearch : undefined, type: useBackendFilters ? normalizedFilterState.ruleType : undefined, dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined, + searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, }; const grafanaFilterProcessingConfig: RuleFilterConfig = { @@ -60,7 +61,7 @@ export function getGrafanaFilter(filterState: RulesFilter) { const grafanaGroupFilterConfig: GroupFilterConfig = { namespace: namespaceFilter, - groupName: groupNameFilter, + groupName: useBackendFilters ? null : groupNameFilter, }; return { diff --git a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts index e7a357c8498..db2d5df077d 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/prometheusGroupsGenerator.ts @@ -47,6 +47,7 @@ interface GrafanaPromApiFilter { health?: RuleHealth[]; contactPoint?: string; title?: string; + searchGroupName?: string; type?: 'alerting' | 'recording'; dashboardUid?: string; } diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts index a622d43b0c6..592485e211c 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts @@ -28,6 +28,7 @@ describe('hasClientSideFilters', () => { expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false); expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(false); }); it('should return true for client-side only filters', () => { @@ -52,6 +53,7 @@ describe('hasClientSideFilters', () => { expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true); }); it('should return true for client-side only filters', () => { @@ -78,6 +80,7 @@ describe('hasClientSideFilters', () => { expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true); }); }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 4bff8bde783..86f593661ea 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -153,12 +153,13 @@ export function hasClientSideFilters(filterState: RulesFilter): boolean { const useBackendFilters = shouldUseBackendFilters(); return ( - // When backend filters are disabled, title search, type filter, and dashboard filter need client-side filtering + // When backend filters are disabled, title search, type filter, dashboard filter, and group name filter need client-side filtering (!useBackendFilters && (filterState.freeFormWords.length > 0 || Boolean(filterState.ruleName) || Boolean(filterState.ruleType) || - Boolean(filterState.dashboardUid))) || + Boolean(filterState.dashboardUid) || + Boolean(filterState.groupName))) || // Client-side only filters: Boolean(filterState.namespace) || filterState.dataSourceNames.length > 0 || From 32da63a20fcabdf60871a8fcb3699db456b712d8 Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Mon, 17 Nov 2025 20:47:37 +0100 Subject: [PATCH 012/125] chore: remove US history pruner feature toggle (#114014) --- packages/grafana-data/src/types/featureToggles.gen.ts | 5 ----- pkg/services/featuremgmt/registry.go | 9 --------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- pkg/storage/unified/sql/backend.go | 11 ----------- pkg/storage/unified/sql/server.go | 3 --- 7 files changed, 2 insertions(+), 34 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 4b5bba51f31..65858e37221 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -889,11 +889,6 @@ export interface FeatureToggles { */ alertingImportYAMLUI?: boolean; /** - * Enables the unified storage history pruner - * @default true - */ - unifiedStorageHistoryPruner?: boolean; - /** * Enables the logs builder mode for the Azure Monitor data source * @default false */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 7cb093d2665..86c5c400381 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1542,15 +1542,6 @@ var ( Owner: grafanaAlertingSquad, Expression: "true", }, - { - Name: "unifiedStorageHistoryPruner", - Description: "Enables the unified storage history pruner", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaSearchAndStorageSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "true", // will be removed soon - }, { Name: "azureMonitorLogsBuilderEditor", Description: "Enables the logs builder mode for the Azure Monitor data source", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f1035d0342d..cb48c884e7d 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -200,7 +200,6 @@ alertRuleRestore,preview,@grafana/alerting-squad,false,false,false infinityRunQueriesInParallel,privatePreview,@grafana/oss-big-tent,false,false,false alertingMigrationUI,GA,@grafana/alerting-squad,false,false,true alertingImportYAMLUI,GA,@grafana/alerting-squad,false,false,true -unifiedStorageHistoryPruner,GA,@grafana/search-and-storage,false,false,false azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,false localeFormatPreference,preview,@grafana/grafana-frontend-platform,false,false,false unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 00ce3b83a20..d96539b7a03 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -810,10 +810,6 @@ const ( // Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules FlagAlertingImportYAMLUI = "alertingImportYAMLUI" - // FlagUnifiedStorageHistoryPruner - // Enables the unified storage history pruner - FlagUnifiedStorageHistoryPruner = "unifiedStorageHistoryPruner" - // FlagAzureMonitorLogsBuilderEditor // Enables the logs builder mode for the Azure Monitor data source FlagAzureMonitorLogsBuilderEditor = "azureMonitorLogsBuilderEditor" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 91b71aa37ea..a5bc3b342a1 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4214,7 +4214,8 @@ "metadata": { "name": "unifiedStorageHistoryPruner", "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-17T10:36:38Z" + "creationTimestamp": "2025-03-17T10:36:38Z", + "deletionTimestamp": "2025-11-17T12:35:33Z" }, "spec": { "description": "Enables the unified storage history pruner", diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index ac7e12a51d0..181782eb606 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -62,10 +62,6 @@ type BackendOptions struct { IsHA bool storageMetrics *resource.StorageMetrics - // If true, the backend will prune history on write events. - // Will be removed once fully rolled out. - withPruner bool - // testing SimulatedNetworkLatency time.Duration // slows down the create transactions by a fixed amount @@ -101,7 +97,6 @@ func NewBackend(opts BackendOptions) (Backend, error) { storageMetrics: opts.storageMetrics, bulkLock: &bulkLock{running: make(map[string]bool)}, simulatedNetworkLatency: opts.SimulatedNetworkLatency, - withPruner: opts.withPruner, lastImportTimeMaxAge: opts.LastImportTimeMaxAge, }, nil } @@ -141,7 +136,6 @@ type backend struct { simulatedNetworkLatency time.Duration historyPruner resource.Pruner - withPruner bool lastImportTimeMaxAge time.Duration lastImportTimeDeletionTime atomic.Time @@ -198,11 +192,6 @@ func (b *backend) initLocked(ctx context.Context) error { } func (b *backend) initPruner(ctx context.Context) error { - if !b.withPruner { - b.log.Debug("using noop history pruner") - b.historyPruner = &resource.NoopPruner{} - return nil - } b.log.Debug("using debounced history pruner") // Initialize history pruner. pruner, err := debouncer.NewGroup(debouncer.DebouncerOpts[resource.PruningKey]{ diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 60fa9b8467a..1c2b02d8637 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -98,15 +98,12 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), opts.Cfg.SectionWithEnvOverrides("resource_api")) - //nolint:staticcheck // not yet migrated to OpenFeature - withPruner := opts.Features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageHistoryPruner) backend, err := NewBackend(BackendOptions{ DBProvider: eDB, Tracer: opts.Tracer, Reg: opts.Reg, IsHA: isHA, - withPruner: withPruner, storageMetrics: opts.StorageMetrics, LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age. }) From 441556d1a357acc828dbd4fe7e3f35011a6f0166 Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Mon, 17 Nov 2025 15:15:46 -0600 Subject: [PATCH 013/125] alerts docs: triage page (#113869) * alerts docs: triage page documentation for the alert triage page * public preview note added * Update alert-triage.md * prettier * removed alerts triage references * rename * image * prettier --- .../alerting/monitor-status/alerts-page.md | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 docs/sources/alerting/monitor-status/alerts-page.md diff --git a/docs/sources/alerting/monitor-status/alerts-page.md b/docs/sources/alerting/monitor-status/alerts-page.md new file mode 100644 index 00000000000..7d848e0e463 --- /dev/null +++ b/docs/sources/alerting/monitor-status/alerts-page.md @@ -0,0 +1,46 @@ +--- +canonical: https://grafana.com/docs/grafana/latest/alerting/monitor-status/alerts-page/ +title: Alerts overview page +description: Use the Alert page to assess, prioritize, and take action on alerts quickly in Grafana Cloud. +weight: 405 +keywords: + - Grafana Cloud + - alerting + - triage + - incidents + - monitoring + - Grafana-managed alerts +labels: + products: + - cloud + - enterprise + - oss +--- + +{{< docs/public-preview product="Alerts page" >}} + +# Alert view + +Grafana Alerting provides a consolidated snapshot of your firing and pending Grafana-managed alerts in a simplified view. For users with complex deployments, it can be difficult to monitor and prioritize critical incidents among a large volume of firing or pending alerts. With the Alert page, you have a view where you can quickly explore and sort your recent alert history and see what alerts require review or action. + +To see your firing and pending alerts in the alert page, go to **Alerts & IRM > Alerting > Alerts**. + +{{< figure src="/media/docs/alerting/alerts-page.png" max-width="750px" alt="Filter your firing and pending alerts in the Alert view." >}} + +## How it works + +The Alerts page only shows alerts from Grafana-managed alert rules. Grafana uses a metric called `GRAFANA_ALERTS`, which is recorded in the default Mimir data source that is provisioned for cloud users. + +{{< admonition type="note" >}} +OSS users need to manually configure this. To configure alert state history for OSS, refer to the configure Loki and Prometheus for alert state documentation. +{{< /admonition >}} + +## Filter alerts in the Alerts page + +The page displays the count of alert rules and instances that are both firing and pending. Use the filters to group your alerts and find specific alerts by label. + +To group alerts by label, click the **Group by** field and select the labels from the dropdown menu. You can select multiple labels for alert grouping. + +To filter by specific label values, click the **Filters** field and select a label to filter by, followed by an expression of what label value you want to use. You can enter multiple label values in your search. + +You can also select a time range from the time picker to further adjust your results. Click the **time range** field and enter an absolute time range or select a period from the quick ranges list to apply a new time window to the display results. From b6784c4354e88329ea5feb36d6644e037b3804a1 Mon Sep 17 00:00:00 2001 From: Kristina Date: Mon, 17 Nov 2025 15:52:41 -0600 Subject: [PATCH 014/125] API Client: Add exact command to run (#113994) --- packages/grafana-api-clients/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/grafana-api-clients/README.md b/packages/grafana-api-clients/README.md index dfc2680926c..4e861d75eda 100644 --- a/packages/grafana-api-clients/README.md +++ b/packages/grafana-api-clients/README.md @@ -19,7 +19,7 @@ First, check if the `group` and the `version` are already present in [openapi_te } ``` -Afterwards, you need to run the `TestIntegrationOpenAPIs` test. Note that it will fail the first time you run it. On the second run, it will generate the corresponding OpenAPI spec, which you can find in [openapi_snapshots](../../pkg/tests/apis/openapi_snapshots). +Afterwards, you need to run the `TestIntegrationOpenAPIs` test with `go test ./pkg/tests/apis -run TestIntegrationOpenAPIs`. Note that it will fail the first time you run it. On the second run, it will generate the corresponding OpenAPI spec, which you can find in [openapi_snapshots](../../pkg/tests/apis/openapi_snapshots). > Note: You don't need to follow these two steps if the `group` you're working with is already in the `openapi_test.go` file. From dd3f085648d6dddfb85a1f04c1f7c0f156717202 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Mon, 17 Nov 2025 17:27:01 -0500 Subject: [PATCH 015/125] Heatmap: Improve error messaging if no numeric fields were provided (#113985) * Heatmap: Improve error messaging if no numeric fields were provided * update i18n --- .../transformers/calculateHeatmap/heatmap.test.ts | 13 +++++++++++++ .../transformers/calculateHeatmap/heatmap.ts | 4 ++++ public/app/plugins/panel/heatmap/HeatmapPanel.tsx | 1 + public/locales/en-US/grafana.json | 3 +++ 4 files changed, 21 insertions(+) diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts index 256cd576ebe..4155a763d4d 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.test.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.test.ts @@ -108,4 +108,17 @@ describe('Heatmap transformer', () => { ] `); }); + + it('throws error if no numeric fields are present', async () => { + expect(() => + rowsToCellsHeatmap({ + frame: toDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [1, 2, 3, 4] }, + { name: 'label', type: FieldType.string, values: ['a', 'b', 'c', 'd'] }, + ], + }), + }) + ).toThrowErrorMatchingInlineSnapshot(`"No numeric fields found for heatmap"`); + }); }); diff --git a/public/app/features/transformers/calculateHeatmap/heatmap.ts b/public/app/features/transformers/calculateHeatmap/heatmap.ts index 7968444302d..49a54bb43f1 100644 --- a/public/app/features/transformers/calculateHeatmap/heatmap.ts +++ b/public/app/features/transformers/calculateHeatmap/heatmap.ts @@ -125,6 +125,10 @@ export function rowsToCellsHeatmap(opts: RowsHeatmapOptions): DataFrame { const xValues = xField.values; const yFields = opts.frame.fields.filter((f, idx) => f.type === FieldType.number && idx > 0); + if (yFields.length === 0) { + throw new Error(t('heatmap.error.no-y-fields', 'No numeric fields found for heatmap')); + } + // similar to initBins() below const len = xValues.length * yFields.length; const xs = new Array(len); diff --git a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx index e3546d7fdf2..4d2762a9be3 100644 --- a/public/app/plugins/panel/heatmap/HeatmapPanel.tsx +++ b/public/app/plugins/panel/heatmap/HeatmapPanel.tsx @@ -55,6 +55,7 @@ export const HeatmapPanel = (props: HeatmapPanelProps) => { timeRange, }); } catch (ex) { + console.error(ex); return { warning: `${ex}` }; } }, [data.series, data.annotations, options, palette, theme, replaceVariables, timeRange]); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6e4ecb98f4d..f690c04b70a 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9153,6 +9153,9 @@ "category-legend": "Legend", "category-tooltip": "Tooltip", "category-y-axis": "Y Axis", + "error": { + "no-y-fields": "No numeric fields found for heatmap" + }, "mode-options": { "label-opacity": "Opacity", "label-scheme": "Scheme" From 7e6b5505f0d02b6921864bf1600cfb0b7d82dee0 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 00:40:35 +0000 Subject: [PATCH 016/125] I18n: Download translations from Crowdin (#114059) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 9 ++++++++- public/locales/de-DE/grafana.json | 9 ++++++++- public/locales/es-ES/grafana.json | 9 ++++++++- public/locales/fr-FR/grafana.json | 9 ++++++++- public/locales/hu-HU/grafana.json | 9 ++++++++- public/locales/id-ID/grafana.json | 9 ++++++++- public/locales/it-IT/grafana.json | 9 ++++++++- public/locales/ja-JP/grafana.json | 9 ++++++++- public/locales/ko-KR/grafana.json | 9 ++++++++- public/locales/nl-NL/grafana.json | 9 ++++++++- public/locales/pl-PL/grafana.json | 9 ++++++++- public/locales/pt-BR/grafana.json | 9 ++++++++- public/locales/pt-PT/grafana.json | 9 ++++++++- public/locales/ru-RU/grafana.json | 9 ++++++++- public/locales/sv-SE/grafana.json | 9 ++++++++- public/locales/tr-TR/grafana.json | 9 ++++++++- public/locales/zh-Hans/grafana.json | 9 ++++++++- public/locales/zh-Hant/grafana.json | 9 ++++++++- 18 files changed, 144 insertions(+), 18 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 8f293ecbfbf..7968b04c1e2 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -5974,7 +5974,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Přidat transformaci" + "add-transformation": "Přidat transformaci", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9222,6 +9225,9 @@ "category-legend": "Legenda", "category-tooltip": "Popisek", "category-y-axis": "Osa Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Neprůhlednost", "label-scheme": "Schéma" @@ -12491,6 +12497,7 @@ "scopes": { "dashboards": { "collapse": "Sbalit", + "exchange": "", "expand": "Rozbalit", "loading": "Načítání nástěnek", "noResultsForFilter": "Nebyly nalezeny žádné výsledky pro váš dotaz", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index b076dd2ea2a..a809649f203 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Transformation hinzufügen" + "add-transformation": "Transformation hinzufügen", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Legende", "category-tooltip": "Tooltip", "category-y-axis": "Y-Achse", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Deckkraft", "label-scheme": "Schema" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Einklappen", + "exchange": "", "expand": "Ausklappen", "loading": "Dashboards werden geladen", "noResultsForFilter": "Keine Ergebnisse für deine Abfrage gefunden", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 6b46b15d4fc..678be0da90c 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Añadir transformación" + "add-transformation": "Añadir transformación", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Leyenda", "category-tooltip": "Descripción emergente", "category-y-axis": "Eje Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opacidad", "label-scheme": "Esquema" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Contraer", + "exchange": "", "expand": "Expandir", "loading": "Cargando paneles de control", "noResultsForFilter": "No se han encontrado resultados para tu consulta", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 310846c1aed..bb06a7ded11 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Ajouter une transformation" + "add-transformation": "Ajouter une transformation", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Légende", "category-tooltip": "Infobulle", "category-y-axis": "Axe Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opacité", "label-scheme": "Schéma" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Réduire", + "exchange": "", "expand": "Développer", "loading": "Chargement des tableaux de bord", "noResultsForFilter": "Aucun résultat n'a été trouvé pour votre requête", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 54d8e7b5a77..2e42c81b7a7 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Transzformáció hozzáadása" + "add-transformation": "Transzformáció hozzáadása", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Jelmagyarázat", "category-tooltip": "Elemleírás", "category-y-axis": "Y tengely", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Átlátszóság", "label-scheme": "Séma" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Összecsukás", + "exchange": "", "expand": "Kibontás", "loading": "Irányítópultok betöltése", "noResultsForFilter": "Nincs találat a lekérdezésre", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index ac2bcbfa3d7..710d5cb1002 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -5908,7 +5908,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Tambahkan transformasi" + "add-transformation": "Tambahkan transformasi", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9114,6 +9117,9 @@ "category-legend": "Legenda", "category-tooltip": "Tooltip", "category-y-axis": "Sumbu Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opasitas", "label-scheme": "Skema" @@ -12326,6 +12332,7 @@ "scopes": { "dashboards": { "collapse": "Ciutkan", + "exchange": "", "expand": "Perluas", "loading": "Memuat dasbor", "noResultsForFilter": "Hasil untuk kueri Anda tidak ditemukan", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 13aac5e963c..7ab4900c16b 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Aggiungi trasformazione" + "add-transformation": "Aggiungi trasformazione", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Legenda", "category-tooltip": "Suggerimento", "category-y-axis": "Asse Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opacità", "label-scheme": "Schema" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Riduci", + "exchange": "", "expand": "Espandi", "loading": "Caricamento dashboard", "noResultsForFilter": "Nessun risultato trovato per la ricerca", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index c4195f35493..b0da2ed08de 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -5908,7 +5908,10 @@ } }, "empty-transformations-message": { - "add-transformation": "変換を追加" + "add-transformation": "変換を追加", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9114,6 +9117,9 @@ "category-legend": "凡例", "category-tooltip": "ツールチップ", "category-y-axis": "Y軸", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "不透明度", "label-scheme": "配色" @@ -12326,6 +12332,7 @@ "scopes": { "dashboards": { "collapse": "折りたたみ表示", + "exchange": "", "expand": "展開", "loading": "ダッシュボードを読み込んでいます", "noResultsForFilter": "クエリに一致する結果が見つかりませんでした。", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 47321b06d08..8da03a382ad 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -5908,7 +5908,10 @@ } }, "empty-transformations-message": { - "add-transformation": "변환 추가" + "add-transformation": "변환 추가", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9114,6 +9117,9 @@ "category-legend": "범례", "category-tooltip": "도구 설명", "category-y-axis": "Y축", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "투명도", "label-scheme": "체계" @@ -12326,6 +12332,7 @@ "scopes": { "dashboards": { "collapse": "접기", + "exchange": "", "expand": "펼치기", "loading": "대시보드 로딩 중", "noResultsForFilter": "쿼리에 대해 찾은 결과 없음", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 265bfd8a8d0..2a517bd97c4 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Transformatie toevoegen" + "add-transformation": "Transformatie toevoegen", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Legenda", "category-tooltip": "Tooltip", "category-y-axis": "Y-as", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Transparantie", "label-scheme": "Palet" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Samenvouwen", + "exchange": "", "expand": "Uitvouwen", "loading": "Dashboards laden", "noResultsForFilter": "Geen resultaten gevonden voor je zoekopdracht", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 785acbb9475..da122b8635f 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -5974,7 +5974,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Dodaj transformację" + "add-transformation": "Dodaj transformację", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9222,6 +9225,9 @@ "category-legend": "Legenda", "category-tooltip": "Podpowiedź", "category-y-axis": "Oś Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Nieprzezroczystość", "label-scheme": "Schemat" @@ -12491,6 +12497,7 @@ "scopes": { "dashboards": { "collapse": "Zwiń", + "exchange": "", "expand": "Rozwiń", "loading": "Ładowanie pulpitów", "noResultsForFilter": "Nie znaleziono wyników dla tego zapytania", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 4b5dade9af0..24f080ff425 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Adicionar transformação" + "add-transformation": "Adicionar transformação", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Legenda", "category-tooltip": "Dica de uso", "category-y-axis": "Eixo Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opacidade", "label-scheme": "Esquema" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Recolher", + "exchange": "", "expand": "Expandir", "loading": "Carregando painéis de controle", "noResultsForFilter": "Nenhum resultado encontrado para sua consulta", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 8c23caa1df6..58cd80cf939 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Adicionar transformação" + "add-transformation": "Adicionar transformação", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Legenda", "category-tooltip": "Descrição", "category-y-axis": "Eixo Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opacidade", "label-scheme": "Esquema" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Recolher", + "exchange": "", "expand": "Expandir", "loading": "A carregar painéis de controlo", "noResultsForFilter": "Não foram encontrados resultados para a sua consulta", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 18f2a4076e1..af03cde5fe8 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -5974,7 +5974,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Добавить преобразование" + "add-transformation": "Добавить преобразование", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9222,6 +9225,9 @@ "category-legend": "Условные обозначения", "category-tooltip": "Подсказка", "category-y-axis": "Ось Y", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Прозрачность", "label-scheme": "Схема" @@ -12491,6 +12497,7 @@ "scopes": { "dashboards": { "collapse": "Свернуть", + "exchange": "", "expand": "Развернуть", "loading": "Загрузка дашбордов", "noResultsForFilter": "По вашему запросу ничего не найдено", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index b812e020369..28cacec2dc8 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Lägg till omvandling" + "add-transformation": "Lägg till omvandling", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Förklaringar", "category-tooltip": "Verktygstips", "category-y-axis": "Y-axel", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opacitet", "label-scheme": "Förvaltning" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Minimera", + "exchange": "", "expand": "Expandera", "loading": "Laddar instrumentpaneler", "noResultsForFilter": "Inga resultat hittades för din fråga", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 623fa1b7b26..0f00a582988 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Dönüşüm ekle" + "add-transformation": "Dönüşüm ekle", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9150,6 +9153,9 @@ "category-legend": "Gösterge", "category-tooltip": "Araç ipucu", "category-y-axis": "Y Ekseni", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "Opaklık", "label-scheme": "Düzen" @@ -12381,6 +12387,7 @@ "scopes": { "dashboards": { "collapse": "Daralt", + "exchange": "", "expand": "Genişlet", "loading": "Panolar yükleniyor", "noResultsForFilter": "Sorgunuz için sonuç bulunamadı", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index d98a7024adf..18a7238b33d 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -5908,7 +5908,10 @@ } }, "empty-transformations-message": { - "add-transformation": "添加转换" + "add-transformation": "添加转换", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9114,6 +9117,9 @@ "category-legend": "图例", "category-tooltip": "工具提示", "category-y-axis": "Y 轴", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "不透明度", "label-scheme": "方案" @@ -12326,6 +12332,7 @@ "scopes": { "dashboards": { "collapse": "收起", + "exchange": "", "expand": "展开", "loading": "正在加载数据面板", "noResultsForFilter": "未找到与您的查询相关的结果", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 94d629e196d..203c2d2f177 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -5908,7 +5908,10 @@ } }, "empty-transformations-message": { - "add-transformation": "新增轉換" + "add-transformation": "新增轉換", + "show-more": "", + "sql-name": "", + "sql-transformation-description": "" }, "general-settings-edit-view": { "editable_options": { @@ -9114,6 +9117,9 @@ "category-legend": "圖例", "category-tooltip": "工具提示", "category-y-axis": "Y 軸", + "error": { + "no-y-fields": "" + }, "mode-options": { "label-opacity": "不透明度", "label-scheme": "方案" @@ -12326,6 +12332,7 @@ "scopes": { "dashboards": { "collapse": "收闔", + "exchange": "", "expand": "展開", "loading": "正在載入儀表板", "noResultsForFilter": "未找到您的查詢結果", From d6e81c6334a8336e655ac8c29eb223bea0d7276e Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 18 Nov 2025 00:57:08 -0500 Subject: [PATCH 017/125] Table: Clean up filter popover layout and improve filter selection UX (#114052) * Table: Clean up filter popover layout * Table: Improve filter selection ux text * remove unused ref * update snapshot test * whoops, we don't want to change the global input suffix padding --- .../components/FilterInput/FilterInput.tsx | 39 +++++---- .../Table/TableNG/Filter/FilterList.tsx | 39 ++++----- .../Table/TableNG/Filter/FilterPopup.tsx | 80 ++++++++++--------- public/locales/en-US/grafana.json | 7 ++ 4 files changed, 91 insertions(+), 74 deletions(-) diff --git a/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx b/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx index 93a238e7d97..08f0ce6234f 100644 --- a/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx +++ b/packages/grafana-ui/src/components/FilterInput/FilterInput.tsx @@ -6,35 +6,40 @@ import { Trans } from '@grafana/i18n'; import { useCombinedRefs } from '../../utils/useCombinedRefs'; import { Button } from '../Button/Button'; import { Icon } from '../Icon/Icon'; -import { Input } from '../Input/Input'; +import { Input, Props as InputProps } from '../Input/Input'; export interface Props extends Omit, 'onChange'> { value: string | undefined; width?: number; onChange: (value: string) => void; escapeRegex?: boolean; + suffix?: InputProps['suffix']; } export const FilterInput = forwardRef( - ({ value, width, onChange, escapeRegex = true, ...restProps }, ref) => { + ({ value, width, onChange, escapeRegex = true, suffix: _suffix, ...restProps }, ref) => { const innerRef = useRef(null); const combinedRef = useCombinedRefs(ref, innerRef); - const suffix = - value !== '' ? ( - - ) : null; + const suffix = ( + <> + {value !== '' && ( + + )} + {_suffix} + + ); return ( selectedItems.length > 0 && items.length > selectedItems.length, [items, selectedItems] ); - const selectCheckLabel = useMemo( - () => (selectedItems.length ? `${selectedItems.length} selected` : `Select all`), - [selectedItems] - ); + const selectCheckLabel = useMemo(() => { + if (!values.length) { + return t('grafana-ui.table.filter.select-all', 'Select all'); + } + if (values.length !== selectedItems.length) { + return t('grafana-ui.table.filter.selected-some-hidden', '{{ numSelected }} selected ({{ numHidden }} hidden)', { + numSelected: values.length, + numHidden: values.length - selectedItems.length, + }); + } + return t('grafana-ui.table.filter.selected', '{{ numSelected }} selected', { + numSelected: values.length, + }); + }, [selectedItems.length, values.length]); const selectCheckDescription = useMemo( () => items.length !== selectedItems.length - ? 'Add all displayed values to the filter' - : 'Remove all displayed values from the filter', + ? t('grafana-ui.table.filter.add-all', 'Add all displayed values to the filter') + : t('grafana-ui.table.filter.remove-all', 'Remove all displayed values from the filter'), [items, selectedItems] ); const styles = useStyles2(getStyles); const theme = useTheme2(); - const gutter = theme.spacing.gridSize; + const gutter = theme.spacing.gridSize / 2; const height = useMemo(() => Math.min(items.length * ITEM_HEIGHT, MIN_HEIGHT) + gutter, [gutter, items.length]); const onCheckedChanged = useCallback( @@ -207,9 +217,8 @@ function ItemRenderer({ index, style, data: { onCheckedChanged, items, values, c const getStyles = (theme: GrafanaTheme2) => ({ filterList: css({ label: 'filterList', - backgroundColor: theme.components.input.background, - border: `1px solid ${theme.colors.border.medium}`, - borderRadius: theme.shape.radius.default, + marginBottom: theme.spacing(0.5), + borderBottom: `1px solid ${theme.colors.border.weak}`, }), filterListRow: css({ label: 'filterListRow', @@ -223,12 +232,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ backgroundColor: theme.colors.action.hover, }, }), - selectDivider: css({ - label: 'selectDivider', - width: '100%', - borderTop: `1px solid ${theme.colors.border.medium}`, - padding: theme.spacing(0.5, 2), - }), noValuesLabel: css({ paddingTop: theme.spacing(1), }), diff --git a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx index de258a44815..40c96d83cb9 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Filter/FilterPopup.tsx @@ -118,27 +118,28 @@ export const FilterPopup = ({ ref={containerRef} > - - {field && } - - - -
+ {field && } - +
+ + } + /> +
- -
+ + + {clearFilterVisible && ( - - - + )} @@ -186,7 +183,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ filterContainer: css({ label: 'filterContainer', width: '100%', - minWidth: '250px', + minWidth: '320px', height: '100%', backgroundColor: theme.colors.background.primary, border: `1px solid ${theme.colors.border.weak}`, @@ -194,14 +191,19 @@ const getStyles = (theme: GrafanaTheme2) => ({ boxShadow: theme.shadows.z3, borderRadius: theme.shape.radius.default, }), - listDivider: css({ - label: 'listDivider', - width: '100%', - borderTop: `1px solid ${theme.colors.border.medium}`, - }), label: css({ marginBottom: 0, }), + inputContainer: css({ + width: 300, + }), + buttonSelectOverrides: css({ + fontSize: 12, + '&:hover, &:focus, &:active': { + color: theme.colors.text.primary, + background: 'transparent', + }, + }), }); const stopPropagation = (event: React.MouseEvent) => { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index f690c04b70a..e2efd17b56c 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9002,6 +9002,13 @@ "copy": "Copy to Clipboard", "csv-counts": "Rows:{{rows}}, Columns:{{columns}}", "csv-placeholder": "Enter CSV here...", + "filter": { + "add-all": "Add all displayed values to the filter", + "remove-all": "Remove all displayed values from the filter", + "select-all": "Select all", + "selected": "{{ numSelected }} selected", + "selected-some-hidden": "{{ numSelected }} selected ({{ numHidden }} hidden)" + }, "filter-placeholder": "Filter values", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Match case", From faabe2e46dbe21558daaabdae1e2e3aa030c490c Mon Sep 17 00:00:00 2001 From: Costa Alexoglou Date: Tue, 18 Nov 2025 09:21:05 +0100 Subject: [PATCH 018/125] feat: add library elements to dash service (#114016) --- .../pkg/migration/conversion/conversion.go | 2 +- .../conversion_data_loss_detection_test.go | 5 +- .../migration/conversion/conversion_test.go | 37 ++++++----- .../pkg/migration/conversion/v0_test.go | 12 ++-- .../pkg/migration/conversion/v1_test.go | 3 +- apps/dashboard/pkg/migration/migrate.go | 18 ++++-- apps/dashboard/pkg/migration/migrate_test.go | 20 ++++-- .../pkg/migration/schemaversion/migrations.go | 17 +++++- .../dashboard/pkg/migration/testutil/mocks.go | 13 ++++ pkg/registry/apis/dashboard/datasources.go | 61 +++++++++++++++++++ pkg/registry/apis/dashboard/mutation_test.go | 2 +- pkg/registry/apis/dashboard/register.go | 8 ++- 12 files changed, 162 insertions(+), 36 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/conversion.go b/apps/dashboard/pkg/migration/conversion/conversion.go index 75b6389ae4f..8d20dcd3837 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion.go +++ b/apps/dashboard/pkg/migration/conversion/conversion.go @@ -11,7 +11,7 @@ import ( "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" ) -func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider) error { +func RegisterConversions(s *runtime.Scheme, dsIndexProvider schemaversion.DataSourceIndexProvider, _ schemaversion.LibraryElementIndexProvider) error { // Wrap the provider once with 10s caching for all conversions. // This prevents repeated DB queries across multiple conversion calls while allowing // the cache to refresh periodically, making it suitable for long-lived singleton usage. diff --git a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection_test.go b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection_test.go index 11bc3931990..f49305996f9 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection_test.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection_test.go @@ -828,11 +828,12 @@ func TestWithConversionValidation_DataLoss(t *testing.T) { func TestDataLossDetectionOnAllInputFiles(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := testutil.NewDataSourceProvider(testutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := testutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Set up conversion scheme scheme := runtime.NewScheme() - err := RegisterConversions(scheme, dsProvider) + err := RegisterConversions(scheme, dsProvider, leProvider) require.NoError(t, err) // Read all files from input directory diff --git a/apps/dashboard/pkg/migration/conversion/conversion_test.go b/apps/dashboard/pkg/migration/conversion/conversion_test.go index 9da0318dc81..e7cbe2f70ef 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_test.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_test.go @@ -33,7 +33,8 @@ import ( func TestConversionMatrixExist(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) versions := []metav1.Object{ &dashv0.Dashboard{Spec: common.Unstructured{Object: map[string]any{"title": "dashboardV0"}}}, @@ -43,7 +44,7 @@ func TestConversionMatrixExist(t *testing.T) { } scheme := runtime.NewScheme() - err := RegisterConversions(scheme, dsProvider) + err := RegisterConversions(scheme, dsProvider, leProvider) require.NoError(t, err) for idx, in := range versions { @@ -85,11 +86,12 @@ func TestDeepCopyValid(t *testing.T) { func TestDashboardConversionToAllVersions(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Set up conversion scheme scheme := runtime.NewScheme() - err := RegisterConversions(scheme, dsProvider) + err := RegisterConversions(scheme, dsProvider, leProvider) require.NoError(t, err) // Read all files from input directory @@ -244,11 +246,12 @@ func TestDashboardConversionToAllVersions(t *testing.T) { func TestMigratedDashboardsConversion(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Set up conversion scheme scheme := runtime.NewScheme() - err := RegisterConversions(scheme, dsProvider) + err := RegisterConversions(scheme, dsProvider, leProvider) require.NoError(t, err) // Read all files from migration package's latest_version directory @@ -378,7 +381,8 @@ func testConversion(t *testing.T, convertedDash metav1.Object, filename, outputD func TestConversionMetrics(t *testing.T) { // Initialize migration with test providers dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Create a test registry for metrics registry := prometheus.NewRegistry() @@ -386,7 +390,7 @@ func TestConversionMetrics(t *testing.T) { // Set up conversion scheme scheme := runtime.NewScheme() - err := RegisterConversions(scheme, dsProvider) + err := RegisterConversions(scheme, dsProvider, leProvider) require.NoError(t, err) tests := []struct { @@ -505,7 +509,8 @@ func TestConversionMetrics(t *testing.T) { // TestConversionMetricsWrapper tests the withConversionMetrics wrapper function func TestConversionMetricsWrapper(t *testing.T) { dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Create a test registry for metrics registry := prometheus.NewRegistry() @@ -673,7 +678,8 @@ func TestSchemaVersionExtraction(t *testing.T) { t.Run(tt.name, func(t *testing.T) { // Test the schema version extraction logic by creating a wrapper and checking the metrics labels dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Create a test registry for metrics registry := prometheus.NewRegistry() @@ -717,7 +723,8 @@ func TestSchemaVersionExtraction(t *testing.T) { // TestConversionLogging tests that conversion-level logging works correctly func TestConversionLogging(t *testing.T) { dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) // Create a test registry for metrics registry := prometheus.NewRegistry() @@ -725,7 +732,7 @@ func TestConversionLogging(t *testing.T) { // Set up conversion scheme scheme := runtime.NewScheme() - err := RegisterConversions(scheme, dsProvider) + err := RegisterConversions(scheme, dsProvider, leProvider) require.NoError(t, err) tests := []struct { @@ -808,7 +815,8 @@ func TestConversionLogging(t *testing.T) { // TestConversionLogLevels tests that appropriate log levels are used func TestConversionLogLevels(t *testing.T) { dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) t.Run("log levels and structured fields verification", func(t *testing.T) { // Create test wrapper to verify logging behavior @@ -879,7 +887,8 @@ func TestConversionLogLevels(t *testing.T) { // TestConversionLoggingFields tests that all expected fields are included in log messages func TestConversionLoggingFields(t *testing.T) { dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) t.Run("verify all log fields are present", func(t *testing.T) { // Test that the conversion wrapper includes all expected structured fields diff --git a/apps/dashboard/pkg/migration/conversion/v0_test.go b/apps/dashboard/pkg/migration/conversion/v0_test.go index 40f118dc9f2..08bf1c550c9 100644 --- a/apps/dashboard/pkg/migration/conversion/v0_test.go +++ b/apps/dashboard/pkg/migration/conversion/v0_test.go @@ -19,7 +19,8 @@ import ( func TestV0ConversionErrorHandling(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) tests := []struct { name string @@ -130,7 +131,8 @@ func TestV0ConversionErrorHandling(t *testing.T) { func TestV0ConversionErrorPropagation(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) t.Run("ConvertDashboard_V0_to_V1beta1 returns error on migration failure", func(t *testing.T) { source := &dashv0.Dashboard{ @@ -203,7 +205,8 @@ func TestV0ConversionErrorPropagation(t *testing.T) { func TestV0ConversionSuccessPaths(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) t.Run("Convert_V0_to_V1beta1 success path returns nil", func(t *testing.T) { source := &dashv0.Dashboard{ @@ -271,7 +274,8 @@ func TestV0ConversionSuccessPaths(t *testing.T) { func TestV0ConversionSecondStepErrors(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) t.Run("Convert_V0_to_V2alpha1 sets status on first step error", func(t *testing.T) { // Create a dashboard that will fail v0->v1beta1 conversion diff --git a/apps/dashboard/pkg/migration/conversion/v1_test.go b/apps/dashboard/pkg/migration/conversion/v1_test.go index c62d5e60d34..c0a9d2e2e6b 100644 --- a/apps/dashboard/pkg/migration/conversion/v1_test.go +++ b/apps/dashboard/pkg/migration/conversion/v1_test.go @@ -18,7 +18,8 @@ import ( func TestV1ConversionErrorHandling(t *testing.T) { // Initialize the migrator with a test data source provider dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) - migration.Initialize(dsProvider) + leProvider := migrationtestutil.NewLibraryElementProvider() + migration.Initialize(dsProvider, leProvider) t.Run("Convert_V1beta1_to_V2alpha1 sets status on conversion error", func(t *testing.T) { // Create a dashboard that will cause conversion to fail diff --git a/apps/dashboard/pkg/migration/migrate.go b/apps/dashboard/pkg/migration/migrate.go index cce80cfe209..9c87a45b2db 100644 --- a/apps/dashboard/pkg/migration/migrate.go +++ b/apps/dashboard/pkg/migration/migrate.go @@ -9,8 +9,8 @@ import ( ) // Initialize provides the migrator singleton with required dependencies and builds the map of migrations. -func Initialize(dsIndexProvider schemaversion.DataSourceIndexProvider) { - migratorInstance.init(dsIndexProvider) +func Initialize(dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) { + migratorInstance.init(dsIndexProvider, leIndexProvider) } // GetDataSourceIndexProvider returns the datasource index provider instance that was initialized. @@ -20,12 +20,20 @@ func GetDataSourceIndexProvider() schemaversion.DataSourceIndexProvider { return migratorInstance.dsIndexProvider } +// GetLibraryElementIndexProvider returns the library element index provider instance that was initialized. +func GetLibraryElementIndexProvider() schemaversion.LibraryElementIndexProvider { + // Wait for initialization to complete + <-migratorInstance.ready + return migratorInstance.leIndexProvider +} + // ResetForTesting resets the migrator singleton for testing purposes. func ResetForTesting() { migratorInstance = &migrator{ migrations: map[int]schemaversion.SchemaVersionMigrationFunc{}, ready: make(chan struct{}), dsIndexProvider: nil, + leIndexProvider: nil, } initOnce = sync.Once{} } @@ -48,12 +56,14 @@ type migrator struct { ready chan struct{} migrations map[int]schemaversion.SchemaVersionMigrationFunc dsIndexProvider schemaversion.DataSourceIndexProvider + leIndexProvider schemaversion.LibraryElementIndexProvider } -func (m *migrator) init(dsIndexProvider schemaversion.DataSourceIndexProvider) { +func (m *migrator) init(dsIndexProvider schemaversion.DataSourceIndexProvider, leIndexProvider schemaversion.LibraryElementIndexProvider) { initOnce.Do(func() { m.dsIndexProvider = dsIndexProvider - m.migrations = schemaversion.GetMigrations(dsIndexProvider) + m.leIndexProvider = leIndexProvider + m.migrations = schemaversion.GetMigrations(dsIndexProvider, leIndexProvider) close(m.ready) }) } diff --git a/apps/dashboard/pkg/migration/migrate_test.go b/apps/dashboard/pkg/migration/migrate_test.go index 5e993624e09..8bb150f91ca 100644 --- a/apps/dashboard/pkg/migration/migrate_test.go +++ b/apps/dashboard/pkg/migration/migrate_test.go @@ -29,7 +29,9 @@ const DEV_DASHBOARDS_OUTPUT_DIR = "testdata/dev-dashboards-output" func TestMigrate(t *testing.T) { // Reset the migration singleton and use the same datasource provider as the frontend test to ensure consistency ResetForTesting() - Initialize(migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)) + dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) + leProvider := migrationtestutil.NewLibraryElementProvider() + Initialize(dsProvider, leProvider) t.Run("minimum version check", func(t *testing.T) { err := Migrate(context.Background(), map[string]interface{}{ @@ -45,7 +47,9 @@ func TestMigrate(t *testing.T) { func TestMigrateSingleVersion(t *testing.T) { // Use the same datasource provider as the frontend test to ensure consistency - Initialize(migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)) + dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) + leProvider := migrationtestutil.NewLibraryElementProvider() + Initialize(dsProvider, leProvider) runSingleVersionMigrationTests(t, SINGLE_VERSION_OUTPUT_DIR) } @@ -212,7 +216,9 @@ func loadDashboard(t *testing.T, path string) map[string]interface{} { // TestSchemaMigrationMetrics tests that schema migration metrics are recorded correctly func TestSchemaMigrationMetrics(t *testing.T) { // Initialize migration with test providers - Initialize(migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)) + dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) + leProvider := migrationtestutil.NewLibraryElementProvider() + Initialize(dsProvider, leProvider) // Create a test registry for metrics registry := prometheus.NewRegistry() @@ -296,7 +302,9 @@ func TestSchemaMigrationMetrics(t *testing.T) { // TestSchemaMigrationLogging tests that schema migration logging works correctly func TestSchemaMigrationLogging(t *testing.T) { - Initialize(migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig)) + dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.StandardTestConfig) + leProvider := migrationtestutil.NewLibraryElementProvider() + Initialize(dsProvider, leProvider) tests := []struct { name string @@ -413,7 +421,9 @@ func TestMigrateDevDashboards(t *testing.T) { // Reset the migration singleton and use the dev dashboard datasource provider // to match the frontend devDashboardDataSources configuration ResetForTesting() - Initialize(migrationtestutil.NewDataSourceProvider(migrationtestutil.DevDashboardConfig)) + dsProvider := migrationtestutil.NewDataSourceProvider(migrationtestutil.DevDashboardConfig) + leProvider := migrationtestutil.NewLibraryElementProvider() + Initialize(dsProvider, leProvider) runDevDashboardMigrationTests(t, schemaversion.LATEST_VERSION, DEV_DASHBOARDS_OUTPUT_DIR) } diff --git a/apps/dashboard/pkg/migration/schemaversion/migrations.go b/apps/dashboard/pkg/migration/schemaversion/migrations.go index 0a7be846474..3e55b25b55c 100644 --- a/apps/dashboard/pkg/migration/schemaversion/migrations.go +++ b/apps/dashboard/pkg/migration/schemaversion/migrations.go @@ -27,12 +27,27 @@ type DataSourceIndexProvider interface { Index(ctx context.Context) *DatasourceIndex } +type LibraryElementInfo struct { + UID string + Name string + Kind int64 + Type string + Description string + FolderUID string +} + +type LibraryElementIndexProvider interface { + + // GetLibraryElementInfo returns library element information for use in migrations. + GetLibraryElementInfo(ctx context.Context) []LibraryElementInfo +} + type PanelPluginInfo struct { ID string Version string } -func GetMigrations(dsIndexProvider DataSourceIndexProvider) map[int]SchemaVersionMigrationFunc { +func GetMigrations(dsIndexProvider DataSourceIndexProvider, _ LibraryElementIndexProvider) map[int]SchemaVersionMigrationFunc { return map[int]SchemaVersionMigrationFunc{ 2: V2, 3: V3, diff --git a/apps/dashboard/pkg/migration/testutil/mocks.go b/apps/dashboard/pkg/migration/testutil/mocks.go index 49c1df0f403..6c8b4709137 100644 --- a/apps/dashboard/pkg/migration/testutil/mocks.go +++ b/apps/dashboard/pkg/migration/testutil/mocks.go @@ -6,6 +6,19 @@ import ( "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" ) +// EmptyLibraryElementProvider provides an empty library element list for tests +type EmptyLibraryElementProvider struct{} + +// NewLibraryElementProvider creates a new empty library element provider for tests +func NewLibraryElementProvider() *EmptyLibraryElementProvider { + return &EmptyLibraryElementProvider{} +} + +// GetLibraryElementInfo returns an empty list for tests +func (p *EmptyLibraryElementProvider) GetLibraryElementInfo(_ context.Context) []schemaversion.LibraryElementInfo { + return []schemaversion.LibraryElementInfo{} +} + // DataSourceConfig defines different test configurations type DataSourceConfig string diff --git a/pkg/registry/apis/dashboard/datasources.go b/pkg/registry/apis/dashboard/datasources.go index c3cb18efdcc..742dc76aad8 100644 --- a/pkg/registry/apis/dashboard/datasources.go +++ b/pkg/registry/apis/dashboard/datasources.go @@ -4,8 +4,11 @@ import ( "context" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/datasources" + "github.com/grafana/grafana/pkg/services/libraryelements" + "github.com/grafana/grafana/pkg/services/libraryelements/model" ) type datasourceIndexProvider struct { @@ -74,3 +77,61 @@ func (d *datasourceIndexProvider) Index(ctx context.Context) *schemaversion.Data return index } + +type libraryElementIndexProvider struct { + libraryElementService libraryelements.Service +} + +func (l *libraryElementIndexProvider) GetLibraryElementInfo(ctx context.Context) []schemaversion.LibraryElementInfo { + if l.libraryElementService == nil { + return []schemaversion.LibraryElementInfo{} + } + + nsInfo, err := request.NamespaceInfoFrom(ctx, true) + if err != nil { + return []schemaversion.LibraryElementInfo{} + } + + user := &identity.StaticRequester{ + OrgID: nsInfo.OrgID, + OrgRole: identity.RoleAdmin, + } + + const perPage = 1_000 + info := make([]schemaversion.LibraryElementInfo, 0) + // For some reason the index starts at page 1 here: + // https://github.com/grafana/grafana/blob/main/pkg/services/libraryelements/database.go#L418 + page := 1 + for { + result, err := l.libraryElementService.GetAllElements(ctx, user, model.SearchLibraryElementsQuery{ + PerPage: perPage, + Page: page, + }) + if err != nil { + return []schemaversion.LibraryElementInfo{} + } + + for _, elem := range result.Elements { + info = append(info, schemaversion.LibraryElementInfo{ + UID: elem.UID, + Name: elem.Name, + Kind: elem.Kind, + Type: elem.Type, + Description: elem.Description, + FolderUID: elem.FolderUID, + }) + } + + if len(result.Elements) < perPage { + break + } + page++ + + // Bound pages to avoid inf loops + if page > 100 { + break + } + } + + return info +} diff --git a/pkg/registry/apis/dashboard/mutation_test.go b/pkg/registry/apis/dashboard/mutation_test.go index ad34c3b3218..b95ae2519b5 100644 --- a/pkg/registry/apis/dashboard/mutation_test.go +++ b/pkg/registry/apis/dashboard/mutation_test.go @@ -22,7 +22,7 @@ import ( ) func TestDashboardAPIBuilder_Mutate(t *testing.T) { - migration.Initialize(testutil.NewDataSourceProvider(testutil.StandardTestConfig)) + migration.Initialize(testutil.NewDataSourceProvider(testutil.StandardTestConfig), testutil.NewLibraryElementProvider()) tests := []struct { name string inputObj runtime.Object diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index 1cbd289ddaa..f784fc9c014 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -177,13 +177,15 @@ func RegisterAPIService( migration.RegisterMetrics(reg) migration.Initialize(&datasourceIndexProvider{ datasourceService: datasourceService, + }, &libraryElementIndexProvider{ + libraryElementService: libraryPanels, }) apiregistration.RegisterAPI(builder) return builder } -func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceIndexProvider, resourcePermissionsSvc *dynamic.NamespaceableResourceInterface) *DashboardsAPIBuilder { - migration.Initialize(datasourceProvider) +func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, folderClientProvider client.K8sHandlerProvider, datasourceProvider schemaversion.DataSourceIndexProvider, libraryElementProvider schemaversion.LibraryElementIndexProvider, resourcePermissionsSvc *dynamic.NamespaceableResourceInterface) *DashboardsAPIBuilder { + migration.Initialize(datasourceProvider, libraryElementProvider) return &DashboardsAPIBuilder{ minRefreshInterval: "10s", accessClient: ac, @@ -231,7 +233,7 @@ func (b *DashboardsAPIBuilder) InstallSchema(scheme *runtime.Scheme) error { } // Register the explicit conversions - if err := conversion.RegisterConversions(scheme, migration.GetDataSourceIndexProvider()); err != nil { + if err := conversion.RegisterConversions(scheme, migration.GetDataSourceIndexProvider(), migration.GetLibraryElementIndexProvider()); err != nil { return err } From a553256b461d5e27c8263f4603333eff3da9da2c Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:00:40 +0100 Subject: [PATCH 019/125] CI: run publish artifacts on self-hosted runner (#114068) run publish artifacts on self-hosted runner --- .github/workflows/release-build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release-build.yml b/.github/workflows/release-build.yml index 315c46f82c9..e1935fb46ad 100644 --- a/.github/workflows/release-build.yml +++ b/.github/workflows/release-build.yml @@ -212,6 +212,7 @@ jobs: run-id: ${{ github.run_id }} bucket-path: ${{ needs.setup.outputs.version }}_${{ github.run_id }} environment: prod + runs-on: ubuntu-x64-small publish-dockerhub: if: github.ref_name == 'main' From 4c75ea918a5e7fdbf775beb583cd01c134768a41 Mon Sep 17 00:00:00 2001 From: Ihor Yeromin Date: Tue, 18 Nov 2025 10:01:45 +0100 Subject: [PATCH 020/125] Transformations: Wrap sql expression tile under FF (#114030) * Transformations: Add empty state placeholder with SQL expression and transformation cards - Extract TransformationCard and SqlExpressionCard into separate reusable components - Add new empty transformations placeholder UI behind transformationsEmptyPlaceholder feature toggle - Add auto-scroll to SQL expression when navigating from transformations tab - Add analytics tracking for transformation interactions - Split EmptyTransformationsMessage into Legacy and New variants for easier toggle removal * add tests --- .../EmptyTransformationsMessage.test.tsx | 59 ++++++++++++++----- .../EmptyTransformationsMessage.tsx | 10 +++- .../PanelDataTransformationsTab.test.tsx | 30 +++++++++- 3 files changed, 78 insertions(+), 21 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx index 4dab09c2d75..a506f923730 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx @@ -3,13 +3,10 @@ import userEvent from '@testing-library/user-event'; import { standardTransformersRegistry } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; +import config from 'app/core/config'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; -import { LegacyEmptyTransformationsMessage, NewEmptyTransformationsMessage } from './EmptyTransformationsMessage'; - -jest.mock('@grafana/runtime', () => ({ - ...jest.requireActual('@grafana/runtime'), -})); +import { EmptyTransformationsMessage, LegacyEmptyTransformationsMessage } from './EmptyTransformationsMessage'; describe('EmptyTransformationsMessage', () => { standardTransformersRegistry.setInit(getStandardTransformers); @@ -20,6 +17,11 @@ describe('EmptyTransformationsMessage', () => { beforeEach(() => { jest.clearAllMocks(); + + // Set up feature toggles + config.featureToggles = config.featureToggles || {}; + config.featureToggles.transformationsEmptyPlaceholder = false; + config.featureToggles.sqlExpressions = true; }); describe('LegacyEmptyTransformationsMessage', () => { @@ -41,10 +43,16 @@ describe('EmptyTransformationsMessage', () => { }); }); - describe('NewEmptyTransformationsMessage', () => { - it('should render transformation cards when both onGoToQueries and onAddTransformation are provided', () => { + describe('EmptyTransformationsMessage (new UI)', () => { + beforeEach(() => { + config.featureToggles.transformationsEmptyPlaceholder = true; + }); + + it('should render SQL expression card and transformation cards when sqlExpressions toggle is enabled', () => { + config.featureToggles.sqlExpressions = true; + render( - { expect(screen.getByText('Filter data by values')).toBeInTheDocument(); }); - it('should call onShowPicker when "Show more" button is clicked', async () => { - const user = userEvent.setup(); + it('should not show SQL expression card when sqlExpressions toggle is disabled', () => { + config.featureToggles.sqlExpressions = false; + render( - ); - const button = screen.getByTestId(selectors.components.Transforms.addTransformationButton); - await user.click(button); + expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument(); + // But should still show transformation cards + expect(screen.getByText('Organize fields by name')).toBeInTheDocument(); + }); - expect(onShowPicker).toHaveBeenCalledTimes(1); + it('should call onGoToQueries when SQL expression card is clicked', async () => { + config.featureToggles.sqlExpressions = true; + const user = userEvent.setup(); + + render( + + ); + + const sqlCard = screen.getByTestId('go-to-queries-button'); + const button = sqlCard.querySelector('button'); + await user.click(button!); + + expect(onGoToQueries).toHaveBeenCalledTimes(1); }); it('should not show SQL transformation card when onGoToQueries is not provided', () => { - render(); + render(); expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument(); }); it('should not show transformation cards grid when neither onGoToQueries nor onAddTransformation are provided', () => { - render(); + render(); expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx index c5b069e577a..841076ef910 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -25,6 +25,9 @@ const TRANSFORMATION_IDS = [ DataTransformerID.filterByValue, ]; +const GRID_COLUMNS_WITH_SQL = 5; +const GRID_COLUMNS_WITHOUT_SQL = 4; + export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) { return ( @@ -89,12 +92,15 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) props.onShowPicker(); }; + const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions; + const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL; + return ( {(hasAddTransformation || hasGoToQueries) && ( - - {hasGoToQueries && ( + + {showSqlCard && ( { expect(reduce).toBeNull(); }); - it('renders SQL transformation card in empty state when feature toggle is enabled', async () => { - const originalFeatureToggle = config.featureToggles.transformationsEmptyPlaceholder; + it('renders SQL transformation card in empty state when feature toggles are enabled', async () => { + const originalTransformationsToggle = config.featureToggles.transformationsEmptyPlaceholder; + const originalSqlToggle = config.featureToggles.sqlExpressions; + config.featureToggles.transformationsEmptyPlaceholder = true; + config.featureToggles.sqlExpressions = true; try { const modelMock = createModelMock(mockData); @@ -179,7 +182,28 @@ describe('PanelDataTransformationsTab', () => { expect(screen.getByText('SQL Expressions')).toBeInTheDocument(); expect(screen.getByTestId('go-to-queries-button')).toBeInTheDocument(); } finally { - config.featureToggles.transformationsEmptyPlaceholder = originalFeatureToggle; + config.featureToggles.transformationsEmptyPlaceholder = originalTransformationsToggle; + config.featureToggles.sqlExpressions = originalSqlToggle; + } + }); + + it('does not render SQL transformation card when sqlExpressions toggle is disabled', async () => { + const originalTransformationsToggle = config.featureToggles.transformationsEmptyPlaceholder; + const originalSqlToggle = config.featureToggles.sqlExpressions; + + config.featureToggles.transformationsEmptyPlaceholder = true; + config.featureToggles.sqlExpressions = false; + + try { + const modelMock = createModelMock(mockData); + render(); + + // Should not show SQL transformation card + expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument(); + expect(screen.queryByTestId('go-to-queries-button')).not.toBeInTheDocument(); + } finally { + config.featureToggles.transformationsEmptyPlaceholder = originalTransformationsToggle; + config.featureToggles.sqlExpressions = originalSqlToggle; } }); }); From c7727eb25c2091d131a10a3af7c49c9b57620f0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 18 Nov 2025 10:09:55 +0100 Subject: [PATCH 021/125] Scopes: Scope input UI update (#114002) * Progress * Update * Progress * Fixes * Update * update e2e * e2e fix * More e2e fixes * fix --- .../dashboard-cujs/adhoc-filters-cujs.spec.ts | 2 +- .../dashboard-navigation.spec.ts | 8 +- .../dashboard-cujs/scope-cujs.spec.ts | 17 +- .../dashboard-cujs/scope-redirect.spec.ts | 1 + .../AppChrome/TopBar/SingleTopBar.tsx | 4 + .../TopSearchBarCommandPaletteTrigger.tsx | 13 -- .../AppChrome/TopBar/useChromeHeaderHeight.ts | 6 +- .../features/scopes/selector/ScopesInput.tsx | 200 +++++++++++------- .../scopes/selector/ScopesSelector.tsx | 18 +- .../features/scopes/tests/selector.test.ts | 4 +- .../features/scopes/tests/utils/actions.ts | 1 + .../features/scopes/tests/utils/assertions.ts | 6 +- .../features/scopes/tests/utils/selectors.ts | 2 +- public/locales/en-US/grafana.json | 6 +- 14 files changed, 160 insertions(+), 128 deletions(-) diff --git a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts index 1e21ac8eba6..038fe099eeb 100644 --- a/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/adhoc-filters-cujs.spec.ts @@ -169,7 +169,7 @@ test.describe( await setScopes(page); - await expect(scopesSelectorInput).toHaveValue(/.+/); + await expect(scopesSelectorInput).toHaveAttribute('data-value', /.+/); expect(await adHocFilterPills.count()).toBe(3); diff --git a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts index 008ca89ef7f..008e1e5538c 100644 --- a/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts +++ b/e2e-playwright/dashboard-cujs/dashboard-navigation.spec.ts @@ -43,7 +43,7 @@ test.describe( await setScopes(page); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); const firstDbName = await scopesDashboards.first().textContent(); const scopeDashboardsCount = await scopesDashboards.count(); @@ -60,7 +60,7 @@ test.describe( await setScopes(page); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); // assert the panel is visible and has the correct value const markdownContent = await getMarkdownHTMLContent(dashboardPage, selectors); @@ -94,7 +94,7 @@ test.describe( await setScopes(page, { title: 'CUJ Dashboard 3', uid: NAVIGATE_TO }); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); const pills = await adhocFilterPills.allTextContents(); const processedPills = pills @@ -138,7 +138,7 @@ test.describe( await setScopes(page, { title: 'CUJ Dashboard 2', uid: 'cuj-dashboard-2' }); - await expect(scopeSelectorInput).toHaveValue(/.+/); + await expect(scopeSelectorInput).toHaveAttribute('data-value', /.+/); const pillCount = await adhocFilterPills.count(); const pillTexts = await adhocFilterPills.allTextContents(); diff --git a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts index 7d8d202cf41..54ec3ca7a8b 100644 --- a/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-cujs.spec.ts @@ -40,7 +40,7 @@ test.describe( await test.step('1.View and select any scope', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version @@ -63,14 +63,13 @@ test.describe( await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version - expect.soft(scopesSelector).toHaveValue(scopeTitle); + expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitle); }); await test.step('2.Select a scope across multiple types of production entities', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); - + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version @@ -94,14 +93,14 @@ test.describe( await applyScopes(page, USE_LIVE_DATA ? undefined : selectedScopes); //used only in mocked scopes version - await expect.soft(scopesSelector).toHaveValue(scopeTitles.join(' + ')); + await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeTitles.join(' + ')); }); await test.step('3.View and select a recently viewed scope', async () => { // this step depends on the previous ones because they set recent scopes await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); //used only in mocked scopes version @@ -114,13 +113,13 @@ test.describe( await recentScope.click(); - await expect.soft(scopesSelector).toHaveValue(scopeName!.replace(', ', ' + ')); + await expect.soft(scopesSelector).toHaveAttribute('data-value', scopeName!.replace(', ', ' + ')); }); await test.step('4.View and select a scope configured by any team', async () => { await gotoDashboardPage({ uid: DASHBOARD_UNDER_TEST }); - expect.soft(scopesSelector).toHaveValue(''); + expect.soft(scopesSelector).toHaveAttribute('data-value', ''); const scopes = testScopes(); await openScopesSelector(page, USE_LIVE_DATA ? undefined : scopes); @@ -143,7 +142,7 @@ test.describe( await applyScopes(page, USE_LIVE_DATA ? undefined : []); //used only in mocked scopes version - expect.soft(scopesSelector).toHaveValue(new RegExp(`^${scopeTitle}`)); + expect.soft(scopesSelector).toHaveAttribute('data-value', new RegExp(`^${scopeTitle}`)); }); await test.step('5.View pre-completed production entity values as I type', async () => { diff --git a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts index 1e3d5f36711..c0e6e55b5b0 100644 --- a/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts +++ b/e2e-playwright/dashboard-cujs/scope-redirect.spec.ts @@ -161,6 +161,7 @@ test.describe('Scope Redirect Functionality', () => { await test.step('Deselect scopes and verify no redirect', async () => { // Click the clear button to remove all scopes + await page.getByTestId('scopes-selector-input').hover(); await page.getByTestId('scopes-selector-input-clear').click(); // Should stay on the same dashboard (cuj-dashboard-3), not redirect diff --git a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx index bf3681d9361..d61dfdb46de 100644 --- a/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx +++ b/public/app/core/components/AppChrome/TopBar/SingleTopBar.tsx @@ -12,6 +12,7 @@ import { useGrafana } from 'app/core/context/GrafanaContext'; import { useMediaQueryMinWidth } from 'app/core/hooks/useMediaQueryMinWidth'; import { HOME_NAV_ID } from 'app/core/reducers/navModel'; import { contextSrv } from 'app/core/services/context_srv'; +import { ScopesSelector } from 'app/features/scopes/selector/ScopesSelector'; import { useSelector } from 'app/types/store'; import { Branding } from '../../Branding/Branding'; @@ -60,6 +61,8 @@ export const SingleTopBar = memo(function SingleTopBar({ const breadcrumbs = buildBreadcrumbs(sectionNav, pageNav, homeNav); const unifiedHistoryEnabled = config.featureToggles.unifiedHistory; const isSmallScreen = !useMediaQueryMinWidth('sm'); + const isLargeScreen = useMediaQueryMinWidth('lg'); + const topLevelScopes = !showToolbarLevel && isLargeScreen && scopes?.state.enabled; return ( <> @@ -78,6 +81,7 @@ export const SingleTopBar = memo(function SingleTopBar({ )} + {topLevelScopes ? : undefined} {!showToolbarLevel && breadcrumbActions} diff --git a/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx b/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx index 3f36302a7f5..7d177c9b4a8 100644 --- a/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx +++ b/public/app/core/components/AppChrome/TopBar/TopSearchBarCommandPaletteTrigger.tsx @@ -112,18 +112,5 @@ const getStyles = (theme: GrafanaTheme2) => { '&:focus-visible': getFocusStyles(theme), }, ]), - - button: css({ - // height: 32, - width: '100%', - textAlign: 'center', - - '> *': { - width: '100%', - textAlign: 'center', - justifyContent: 'center', - gap: '1ch', - }, - }), }; }; diff --git a/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts b/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts index fa3555741b6..3b1763b1901 100644 --- a/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts +++ b/public/app/core/components/AppChrome/TopBar/useChromeHeaderHeight.ts @@ -15,7 +15,7 @@ export function useChromeHeaderLevels() { const state = chrome.state.getValue(); const scopes = useScopes(); - const isLargeScreen = useMediaQueryMinWidth('xl'); + const isLargeScreen = useMediaQueryMinWidth('lg'); const [headerLevels, setHeaderLevels] = useState( getHeaderLevelsGivenState(state, scopes?.state.enabled, isLargeScreen) @@ -46,8 +46,8 @@ function getHeaderLevelsGivenState( return 0; } - // Always use two levels scopes is enabled - if (scopesEnabled) { + // // Use 2 levels if scopes is enabled on smaller screens + if (scopesEnabled && !isLargeScreen) { return 2; } diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index 39b9edd8295..aaf54adb8d0 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -1,10 +1,9 @@ -import { css } from '@emotion/css'; -import { useEffect, useMemo, useState } from 'react'; -import Skeleton from 'react-loading-skeleton'; +import { css, cx } from '@emotion/css'; import { GrafanaTheme2 } from '@grafana/data'; -import { t } from '@grafana/i18n'; -import { IconButton, Input, Tooltip, useStyles2 } from '@grafana/ui'; +import { t, Trans } from '@grafana/i18n'; +import { getInputStyles, Icon, LinkButton, Spinner, Tooltip, useStyles2, Text, Stack } from '@grafana/ui'; +import { getFocusStyles } from '@grafana/ui/internal'; import { getPathOfNode } from './scopesTreeUtils'; import { NodesMap, ScopesMap, SelectedScope } from './types'; @@ -32,11 +31,9 @@ export function ScopesInput({ onInputClick, onRemoveAllClick, }: ScopesInputProps) { - const [tooltipVisible, setTooltipVisible] = useState(false); - const scopeNodeId = appliedScopes[0]?.scopeNodeId; const parentNodeIdFromUrl = appliedScopes[0]?.parentNodeId; - + const styles = useStyles2(getStyles); const { node: scopeNode, isLoading: scopeNodeLoading } = useScopeNode(scopeNodeId); // Get parent from scope node if available, otherwise use parentNodeId from URL (for backward compatibility) @@ -44,70 +41,61 @@ export function ScopesInput({ const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title - const displayTitle = scopeNode?.spec.subTitle ?? parentNode?.spec.title; + const displayTitle = + scopeNode?.spec.subTitle ?? parentNode?.spec.title ?? (appliedScopes.length > 0 ? 'Mimir' : undefined); const isLoadingTitle = scopeNodeLoading || parentNodeLoading; + const placeholderText = t('scopes.selector.input.placeholder', 'No scopes'); - useEffect(() => { - setTooltipVisible(false); - }, [appliedScopes]); - - const tooltipContent = - appliedScopes.length > 0 ? : <>; - - const scopesTitles = useMemo( - () => - appliedScopes - .map( - (s) => - // If we are still loading the scope data just show the id - scopes[s.scopeId]?.spec.title || s.scopeId - ) - .join(' + '), - [appliedScopes, scopes] + const tooltipContent = ( + ); - const parentNodePrefix = useMemo( - () => - isLoadingTitle ? : displayTitle ? {displayTitle}: : undefined, - [isLoadingTitle, displayTitle] - ); + const scopesTitles = appliedScopes + .map( + (s) => + // If we are still loading the scope data just show the id + scopes[s.scopeId]?.spec.title || s.scopeId + ) + .join(' + '); - const input = useMemo( - () => ( - 0 && !disabled ? ( - onRemoveAllClick()} - /> - ) : undefined - } - onMouseOver={() => setTooltipVisible(true)} - onMouseOut={() => setTooltipVisible(false)} - onClick={() => { - if (!disabled) { - onInputClick(); - } - }} - /> - ), - [disabled, loading, onInputClick, onRemoveAllClick, appliedScopes, scopesTitles, parentNodePrefix] - ); + const onClick = () => { + if (!disabled) { + onInputClick(); + } + }; return ( - - {input} + + ); } @@ -131,13 +119,16 @@ export interface ScopesTooltipProps { nodes: NodesMap; scopes: ScopesMap; appliedScopes: SelectedScope[]; + disabled?: boolean; + onRemoveAllClick?: () => void; } -function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) { - const styles = useStyles2(getStyles); +function ScopesTooltip({ nodes, scopes, appliedScopes, onRemoveAllClick, disabled }: ScopesTooltipProps) { + if (appliedScopes.length === 0) { + return t('scopes.selector.input.tooltip', 'Select scope'); + } const nicePath = getScopesPath(appliedScopes, nodes); - const scopeNames = appliedScopes.map((s) => { if (s.scopeNodeId) { return nodes[s.scopeNodeId]?.spec.title || s.scopeNodeId; @@ -146,21 +137,76 @@ function ScopesTooltip({ nodes, scopes, appliedScopes }: ScopesTooltipProps) { } }); + const parentPaths = nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : ''; + return ( - <> -

- {(nicePath && nicePath.length > 0 ? nicePath.join(' > ') + ' > ' : '') + scopeNames.join(', ')} -

- + + {parentPaths + scopeNames.join(', ')} + {!disabled && ( + + Remove all + + )} + ); } const getStyles = (theme: GrafanaTheme2) => { + const baseStyles = getInputStyles({ theme }); + return { - scopePath: css({ - color: theme.colors.text.primary, - fontSize: theme.typography.pxToRem(12), - margin: theme.spacing(0, 0), + prefix: baseStyles.prefix, + suffix: baseStyles.suffix, + fakeInput: css([ + baseStyles.input, + { + width: 'auto', + minWidth: 60, + height: theme.spacing(theme.components.height.md), + maxWidth: '40%', + position: 'relative', + display: 'flex', + alignItems: 'center', + textAlign: 'left', + overflow: 'hidden', + whiteSpace: 'nowrap', + paddingRight: 28, + flexGrow: 0, + + '&:disabled': cx( + baseStyles.inputDisabled, + css({ + cursor: 'not-allowed', + }) + ), + + // We want the focus styles to appear only when tabbing through, not when clicking the button + // (and when focus is restored after command palette closes) + '&:focus': { + outline: 'unset', + boxShadow: 'unset', + }, + + '&:focus-visible': getFocusStyles(theme), + }, + ]), + text: css({ + textOverflow: 'ellipsis', + overflow: 'hidden', + }), + parentNode: css({ + marginRight: theme.spacing(1), + paddingRight: theme.spacing(1), + borderRight: `1px solid ${theme.colors.border.weak}`, + color: theme.colors.text.secondary, }), }; }; diff --git a/public/app/features/scopes/selector/ScopesSelector.tsx b/public/app/features/scopes/selector/ScopesSelector.tsx index 061829d77b7..cdb28acddf8 100644 --- a/public/app/features/scopes/selector/ScopesSelector.tsx +++ b/public/app/features/scopes/selector/ScopesSelector.tsx @@ -7,7 +7,6 @@ import { GrafanaTheme2 } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; import { useScopes } from '@grafana/runtime'; import { Button, Drawer, ErrorBoundary, ErrorWithStack, IconButton, Spinner, Text, useStyles2 } from '@grafana/ui'; -import { useGrafana } from 'app/core/context/GrafanaContext'; import { getModKey } from 'app/core/utils/browser'; import { useScopesServices } from '../ScopesContextProvider'; @@ -17,10 +16,7 @@ import { ScopesSelectorServiceState } from './ScopesSelectorService'; import { ScopesTree } from './ScopesTree'; export const ScopesSelector = () => { - const { chrome } = useGrafana(); - const chromeState = chrome.useState(); - const menuDockedAndOpen = !chromeState.chromeless && chromeState.megaMenuDocked && chromeState.megaMenuOpen; - const styles = useStyles2(getStyles, menuDockedAndOpen); + const styles = useStyles2(getStyles); const scopes = useScopes(); const services = useScopesServices(); @@ -81,7 +77,7 @@ export const ScopesSelector = () => { : t('scopes.dashboards.toggle.expand', 'Expand suggested dashboards list'); return ( -
+ <> { )} -
+ ); }; -const getStyles = (theme: GrafanaTheme2, menuDockedAndOpen: boolean) => { +const getStyles = (theme: GrafanaTheme2) => { return { - container: css({ - display: 'flex', - flexDirection: 'row', - paddingLeft: menuDockedAndOpen ? theme.spacing(2) : 'unset', - }), dashboards: css({ color: theme.colors.text.secondary, - marginRight: theme.spacing(2), '&:hover': css({ color: theme.colors.text.primary, diff --git a/public/app/features/scopes/tests/selector.test.ts b/public/app/features/scopes/tests/selector.test.ts index 4c8f3b3deb0..b7083a7fc13 100644 --- a/public/app/features/scopes/tests/selector.test.ts +++ b/public/app/features/scopes/tests/selector.test.ts @@ -15,6 +15,7 @@ import { expandResultApplications, selectRecentScope, clearSelector, + hoverSelector, } from './utils/actions'; import { expectRecentScope, @@ -121,7 +122,8 @@ describe('Selector', () => { await selectResultApplicationsMimir(); await applyScopes(); - // recent scopes only show on top level, so we need to make sure the scopes tree is not exapnded. + // recent scopes only show on top level, so we need to make sure the scopes tree is not expanded. + await hoverSelector(); await clearSelector(); await openSelector(); diff --git a/public/app/features/scopes/tests/utils/actions.ts b/public/app/features/scopes/tests/utils/actions.ts index b018ab674be..fc5fde93193 100644 --- a/public/app/features/scopes/tests/utils/actions.ts +++ b/public/app/features/scopes/tests/utils/actions.ts @@ -47,6 +47,7 @@ const type = async (selector: () => HTMLInputElement, value: string) => { export const updateScopes = async (service: ScopesService, scopes: string[]) => act(async () => service.changeScopes(scopes)); export const openSelector = async () => click(getSelectorInput); +export const hoverSelector = async () => fireEvent.mouseOver(getSelectorInput()); export const clearSelector = async () => click(getSelectorClear); export const applyScopes = async () => { await click(getSelectorApply); diff --git a/public/app/features/scopes/tests/utils/assertions.ts b/public/app/features/scopes/tests/utils/assertions.ts index 92e98715231..f3e031de7b4 100644 --- a/public/app/features/scopes/tests/utils/assertions.ts +++ b/public/app/features/scopes/tests/utils/assertions.ts @@ -12,7 +12,7 @@ import { getResultApplicationsCloudSelect, getResultApplicationsGrafanaSelect, getResultApplicationsMimirSelect, - getSelectorInput, + getSelectorInput as getSelectorButton, getTreeHeadline, queryAllDashboard, queryDashboard, @@ -45,8 +45,8 @@ export const expectRecentScope = (scope: string) => expectInDocument(() => getRe export const expectRecentScopeNotPresentInDocument = () => expectNotInDocument(queryRecentScopesSection); export const expectRecentScopesSection = () => expectInDocument(getRecentScopesSection); export const expectScopesSelectorClosed = () => expectNotInDocument(querySelectorApply); -export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorInput); -export const expectScopesSelectorValue = (value: string) => expectValue(getSelectorInput, value); +export const expectScopesSelectorDisabled = () => expectDisabled(getSelectorButton); +export const expectScopesSelectorValue = (value: string) => expect(getSelectorButton().dataset.value).toBe(value); export const expectScopesHeadline = (value: string) => expectTextContent(getTreeHeadline, value); export const expectPersistedApplicationsGrafanaNotPresent = () => expectNotInDocument(queryPersistedApplicationsGrafanaSelect); diff --git a/public/app/features/scopes/tests/utils/selectors.ts b/public/app/features/scopes/tests/utils/selectors.ts index 4e3b7ed109a..173198f7e59 100644 --- a/public/app/features/scopes/tests/utils/selectors.ts +++ b/public/app/features/scopes/tests/utils/selectors.ts @@ -35,7 +35,7 @@ const selectors = { }, }; -export const getSelectorInput = () => screen.getByTestId(selectors.selector.input); +export const getSelectorInput = () => screen.getByTestId(selectors.selector.input); export const getSelectorClear = () => screen.getByTestId(selectors.selector.clear); export const querySelectorApply = () => screen.queryByTestId(selectors.selector.apply); export const getSelectorApply = () => screen.getByTestId(selectors.selector.apply); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index e2efd17b56c..305c35ceb3e 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12413,8 +12413,10 @@ "cancel": "Cancel", "error-title": "An unexpected error happened", "input": { - "placeholder": "Select scopes...", - "removeAll": "Remove all scopes" + "placeholder": "No scopes", + "remove-all": "Remove all", + "removeAll": "Remove all scopes", + "tooltip": "Select scope" }, "title": "Select scopes" }, From a1a73dde662223fe7c2e2eedd7adf3fec3039473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A1bor=20Farkas?= Date: Tue, 18 Nov 2025 10:33:59 +0100 Subject: [PATCH 022/125] datasources: querier: forward more headers (#113897) --- pkg/registry/apis/query/header_utils.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/pkg/registry/apis/query/header_utils.go b/pkg/registry/apis/query/header_utils.go index 1284f0c2648..4a2890c878e 100644 --- a/pkg/registry/apis/query/header_utils.go +++ b/pkg/registry/apis/query/header_utils.go @@ -22,6 +22,9 @@ import ( // // the headers X-Real-IP and X-Forwarded-For are used by the HostedGrafanaACHeaderMiddleware at // https://github.com/grafana/grafana/blob/f191acf8114ab79609fc631e0f01fe2b47371188/pkg/services/pluginsintegration/clientmiddleware/grafana_request_id_header_middleware.go#L107 +// +// the Cookie header is used by the CookiesMiddleware at +// https://github.com/grafana/grafana/blob/326d374266077fbacc28c740f276748c636774f5/pkg/services/pluginsintegration/clientmiddleware/cookies_middleware.go#L60 var expectedHeaders = map[string]string{ strings.ToLower(models.FromAlertHeaderName): models.FromAlertHeaderName, strings.ToLower(models.CacheSkipHeaderName): models.CacheSkipHeaderName, @@ -40,6 +43,9 @@ var expectedHeaders = map[string]string{ strings.ToLower(queryService.HeaderPanelPluginId): queryService.HeaderPanelPluginId, strings.ToLower(queryService.HeaderDashboardTitle): queryService.HeaderDashboardTitle, strings.ToLower(queryService.HeaderPanelTitle): queryService.HeaderPanelTitle, + strings.ToLower("X-Real-IP"): "X-Real-IP", + strings.ToLower("X-Forwarded-For"): "X-Forwarded-For", + strings.ToLower("Cookie"): "Cookie", } func ExtractKnownHeaders(header http.Header) map[string]string { From db0b35f6dfd6fd2396d5b85166e135314fe97f7f Mon Sep 17 00:00:00 2001 From: "renovate-sh-app[bot]" <219655108+renovate-sh-app[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 10:43:10 +0100 Subject: [PATCH 023/125] chore(deps): update dependency glob to v11.1.0 [security] (#114064) | datasource | package | from | to | | ---------- | ------- | ------ | ------ | | npm | glob | 11.0.3 | 11.1.0 | Signed-off-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> Co-authored-by: renovate-sh-app[bot] <219655108+renovate-sh-app[bot]@users.noreply.github.com> --- package.json | 2 +- packages/grafana-plugin-configs/package.json | 2 +- .../app/plugins/datasource/tempo/package.json | 2 +- yarn.lock | 72 +++++++++++++++---- 4 files changed, 63 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 3aac5dd892e..a41dee5cd15 100644 --- a/package.json +++ b/package.json @@ -206,7 +206,7 @@ "fishery": "^2.2.2", "fork-ts-checker-webpack-plugin": "9.1.0", "github-codeowners": "^0.2.1", - "glob": "11.0.3", + "glob": "11.1.0", "html-loader": "5.1.0", "html-webpack-plugin": "5.6.3", "http-server": "14.1.1", diff --git a/packages/grafana-plugin-configs/package.json b/packages/grafana-plugin-configs/package.json index 8caf2a63245..267e43bedee 100644 --- a/packages/grafana-plugin-configs/package.json +++ b/packages/grafana-plugin-configs/package.json @@ -21,7 +21,7 @@ "eslint": "9.32.0", "eslint-webpack-plugin": "5.0.2", "fork-ts-checker-webpack-plugin": "9.1.0", - "glob": "11.0.3", + "glob": "11.1.0", "identity-obj-proxy": "^3.0.0", "imports-loader": "^5.0.0", "jest-canvas-mock": "2.5.2", diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index 2295e54fb43..722abb37dd0 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -51,7 +51,7 @@ "@types/react-dom": "18.3.5", "@types/semver": "7.7.1", "@types/uuid": "10.0.0", - "glob": "11.0.3", + "glob": "11.1.0", "jest": "29.7.0", "react-select-event": "5.5.1", "ts-node": "10.9.2", diff --git a/yarn.lock b/yarn.lock index 51e1ff9acdc..f4346919b99 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2938,7 +2938,7 @@ __metadata: "@types/uuid": "npm:10.0.0" buffer: "npm:6.0.3" events: "npm:3.3.0" - glob: "npm:11.0.3" + glob: "npm:11.1.0" i18next: "npm:^25.0.0" jest: "npm:29.7.0" lodash: "npm:4.17.21" @@ -3421,7 +3421,7 @@ __metadata: eslint: "npm:9.32.0" eslint-webpack-plugin: "npm:5.0.2" fork-ts-checker-webpack-plugin: "npm:9.1.0" - glob: "npm:11.0.3" + glob: "npm:11.1.0" identity-obj-proxy: "npm:^3.0.0" imports-loader: "npm:^5.0.0" jest-canvas-mock: "npm:2.5.2" @@ -18604,7 +18604,38 @@ __metadata: languageName: node linkType: hard -"glob@npm:*, glob@npm:11.0.3, glob@npm:^11.0.0": +"glob@npm:*": + version: 12.0.0 + resolution: "glob@npm:12.0.0" + dependencies: + foreground-child: "npm:^3.3.1" + jackspeak: "npm:^4.1.1" + minimatch: "npm:^10.1.1" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10/6e21b3f1f1fa635836d45e54bbe50704884cc3e310e0cc011cfb5429db65a030e12936d99b07e66236370efe45dc8c8b26fa5334dbf555d6f8709e0315c77c30 + languageName: node + linkType: hard + +"glob@npm:10.4.1": + version: 10.4.1 + resolution: "glob@npm:10.4.1" + dependencies: + foreground-child: "npm:^3.1.0" + jackspeak: "npm:^3.1.2" + minimatch: "npm:^9.0.4" + minipass: "npm:^7.1.2" + path-scurry: "npm:^1.11.1" + bin: + glob: dist/esm/bin.mjs + checksum: 10/d7bb49d2b413f77bdd59fea4ca86dcc12450deee221af0ca93e09534b81b9ef68fe341345751d8ff0c5b54bad422307e0e44266ff8ad7fbbd0c200e8ec258b16 + languageName: node + linkType: hard + +"glob@npm:11.0.3": version: 11.0.3 resolution: "glob@npm:11.0.3" dependencies: @@ -18620,18 +18651,35 @@ __metadata: languageName: node linkType: hard -"glob@npm:10.4.1, glob@npm:^10.2.2, glob@npm:^10.3.10": - version: 10.4.1 - resolution: "glob@npm:10.4.1" +"glob@npm:11.1.0, glob@npm:^11.0.0": + version: 11.1.0 + resolution: "glob@npm:11.1.0" + dependencies: + foreground-child: "npm:^3.3.1" + jackspeak: "npm:^4.1.1" + minimatch: "npm:^10.1.1" + minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" + path-scurry: "npm:^2.0.0" + bin: + glob: dist/esm/bin.mjs + checksum: 10/da4501819633daff8822c007bb3f93d5c4d2cbc7b15a8e886660f4497dd251a1fb4f53a85fba1e760b31704eff7164aeb2c7a82db10f9f2c362d12c02fe52cf3 + languageName: node + linkType: hard + +"glob@npm:^10.2.2, glob@npm:^10.3.10": + version: 10.5.0 + resolution: "glob@npm:10.5.0" dependencies: foreground-child: "npm:^3.1.0" jackspeak: "npm:^3.1.2" minimatch: "npm:^9.0.4" minipass: "npm:^7.1.2" + package-json-from-dist: "npm:^1.0.0" path-scurry: "npm:^1.11.1" bin: glob: dist/esm/bin.mjs - checksum: 10/d7bb49d2b413f77bdd59fea4ca86dcc12450deee221af0ca93e09534b81b9ef68fe341345751d8ff0c5b54bad422307e0e44266ff8ad7fbbd0c200e8ec258b16 + checksum: 10/ab3bccfefcc0afaedbd1f480cd0c4a2c0e322eb3f0aa7ceaa31b3f00b825069f17cf0f1fc8b6f256795074b903f37c0ade37ddda6a176aa57f1c2bbfe7240653 languageName: node linkType: hard @@ -19044,7 +19092,7 @@ __metadata: fishery: "npm:^2.2.2" fork-ts-checker-webpack-plugin: "npm:9.1.0" github-codeowners: "npm:^0.2.1" - glob: "npm:11.0.3" + glob: "npm:11.1.0" history: "npm:4.10.1" html-loader: "npm:5.1.0" html-webpack-plugin: "npm:5.6.3" @@ -23739,12 +23787,12 @@ __metadata: languageName: node linkType: hard -"minimatch@npm:^10.0.3": - version: 10.0.3 - resolution: "minimatch@npm:10.0.3" +"minimatch@npm:^10.0.3, minimatch@npm:^10.1.1": + version: 10.1.1 + resolution: "minimatch@npm:10.1.1" dependencies: "@isaacs/brace-expansion": "npm:^5.0.0" - checksum: 10/d5b8b2538b367f2cfd4aeef27539fddeee58d1efb692102b848e4a968a09780a302c530eb5aacfa8c57f7299155fb4b4e85219ad82664dcef5c66f657111d9b8 + checksum: 10/110f38921ea527022e90f7a5f43721838ac740d0a0c26881c03b57c261354fb9a0430e40b2c56dfcea2ef3c773768f27210d1106f1f2be19cde3eea93f26f45e languageName: node linkType: hard From 9321ca2019acdfd125a882e30c210cf3835706aa Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Tue, 18 Nov 2025 09:49:26 +0000 Subject: [PATCH 024/125] Config: Set skip migrations in defaults.ini + override when running frontend service locally (#114007) fs: skip migrations locally + add to defaults.ini --- conf/defaults.ini | 6 ++++++ devenv/frontend-service/docker-compose.yaml | 2 ++ 2 files changed, 8 insertions(+) diff --git a/conf/defaults.ini b/conf/defaults.ini index bdde8c7d254..620e009fe92 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -218,6 +218,12 @@ delete_auto_gen_ids = false # Improves startup performance for instances with large numbers of annotations who do not plan to downgrade Grafana. skip_dashboard_uid_migration_on_startup = false +# Set to true to skip all database migrations on startup. Should be used in conjunction with "ensure_default_org_and_user". +skip_migrations = false + +# Set to false to skip ensuring the default organization and user on startup. +ensure_default_org_and_user = true + #################################### Cache server ############################# [remote_cache] # Either "redis", "memcached" or "database" default is "database" diff --git a/devenv/frontend-service/docker-compose.yaml b/devenv/frontend-service/docker-compose.yaml index e46f54fd8a0..606067f9629 100644 --- a/devenv/frontend-service/docker-compose.yaml +++ b/devenv/frontend-service/docker-compose.yaml @@ -65,6 +65,8 @@ services: - 'alloy.logs=true' environment: OTEL_BSP_SCHEDULE_DELAY: 500 + GF_DATABASE_SKIP_MIGRATIONS: true + GF_DATABASE_ENSURE_DEFAULT_ORG_AND_USER: false GF_DEFAULT_APP_MODE: development GF_DEFAULT_TARGET: frontend-server GF_SECURITY_CONTENT_SECURITY_POLICY: false From 374cb1b163f8228969bc8cd679fee6346d43bf7d Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 18 Nov 2025 10:42:58 +0000 Subject: [PATCH 025/125] Plugins: Bump plugin SDK version to v0.282.0 (#114079) bump plugin SDK version to v0.282.0 --- apps/advisor/go.mod | 8 ++++---- apps/advisor/go.sum | 16 ++++++++-------- apps/alerting/notifications/go.mod | 5 +++-- apps/alerting/notifications/go.sum | 12 ++++++------ apps/alerting/rules/go.mod | 4 ++-- apps/alerting/rules/go.sum | 8 ++++---- apps/annotation/go.mod | 4 ++-- apps/annotation/go.sum | 8 ++++---- apps/correlations/go.mod | 4 ++-- apps/correlations/go.sum | 8 ++++---- apps/dashboard/go.mod | 8 ++++---- apps/dashboard/go.sum | 16 ++++++++-------- apps/example/go.mod | 4 ++-- apps/example/go.sum | 8 ++++---- apps/folder/go.mod | 4 ++-- apps/folder/go.sum | 8 ++++---- apps/investigations/go.mod | 4 ++-- apps/investigations/go.sum | 8 ++++---- apps/logsdrilldown/go.mod | 4 ++-- apps/logsdrilldown/go.sum | 8 ++++---- apps/playlist/go.mod | 4 ++-- apps/playlist/go.sum | 8 ++++---- apps/plugins/go.mod | 4 ++-- apps/plugins/go.sum | 8 ++++---- apps/preferences/go.mod | 4 ++-- apps/preferences/go.sum | 8 ++++---- apps/provisioning/go.mod | 4 ++-- apps/provisioning/go.sum | 8 ++++---- apps/secret/go.mod | 4 ++-- apps/secret/go.sum | 8 ++++---- apps/shorturl/go.mod | 4 ++-- apps/shorturl/go.sum | 8 ++++---- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- go.work.sum | 5 +++++ pkg/aggregator/go.mod | 8 ++++---- pkg/aggregator/go.sum | 16 ++++++++-------- pkg/apiserver/go.mod | 6 +++--- pkg/apiserver/go.sum | 12 ++++++------ pkg/promlib/go.mod | 7 ++++--- pkg/promlib/go.sum | 18 ++++++++++-------- 41 files changed, 163 insertions(+), 154 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 57d4647c7cf..70c3e695347 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -10,7 +10,7 @@ require ( github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 - github.com/grafana/grafana-plugin-sdk-go v0.281.0 + github.com/grafana/grafana-plugin-sdk-go v0.282.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0 github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 @@ -160,7 +160,7 @@ require ( github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -232,7 +232,7 @@ require ( github.com/prometheus/alertmanager v0.28.0 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -279,7 +279,7 @@ require ( golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index a7c25c01ab7..ca0ee360b74 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -667,8 +667,8 @@ github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp github.com/grafana/grafana-aws-sdk v1.3.0/go.mod h1:VGycF0JkCGKND2O5je1ucOqPJ0ZNhZYzV3c2bNBAaGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 h1:FFcEA01tW+SmuJIuDbHOdgUBL+d7DPrZ2N4zwzPhfGk= github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1/go.mod h1:Oi4anANlCuTCc66jCyqIzfVbgLXFll8Wja+Y4vfANlc= -github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana-plugin-sdk-go v0.282.0 h1:9JT9E6T0h8HsK9emTZ5s01uCKaf0Ok4PjYbb7M/iOjM= +github.com/grafana/grafana-plugin-sdk-go v0.282.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= github.com/grafana/grafana/pkg/promlib v0.0.8/go.mod h1:U1ezG/MGaEPoThqsr3lymMPN5yIPdVTJnDZ+wcXT+ao= github.com/grafana/grafana/pkg/semconv v0.0.0-20250804150913-990f1c69ecc2 h1:A65jWgLk4Re28gIuZcpC0aTh71JZ0ey89hKGE9h543s= @@ -687,8 +687,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= @@ -1046,8 +1046,8 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.29.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= @@ -1420,8 +1420,8 @@ golang.org/x/oauth2 v0.0.0-20211104180415-d3ed0bb246c8/go.mod h1:KelEdhl1UZF7XfJ golang.org/x/oauth2 v0.0.0-20220223155221-ee480838109b/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 70f4fbec82d..521bb0b586f 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -38,6 +38,7 @@ require ( github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect @@ -59,7 +60,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -85,7 +86,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 2ec24921942..7a53f8de14c 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -77,8 +77,8 @@ github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndb github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= @@ -140,8 +140,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1: github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= @@ -239,8 +239,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/alerting/rules/go.mod b/apps/alerting/rules/go.mod index f40848c1688..c262035d973 100644 --- a/apps/alerting/rules/go.mod +++ b/apps/alerting/rules/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 - github.com/prometheus/common v0.67.1 + github.com/prometheus/common v0.67.2 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -68,7 +68,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/alerting/rules/go.sum b/apps/alerting/rules/go.sum index b15ac884e9a..26bd55d3b5d 100644 --- a/apps/alerting/rules/go.sum +++ b/apps/alerting/rules/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -165,8 +165,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/annotation/go.mod b/apps/annotation/go.mod index 90ed8b74a24..19ac2c8b88a 100644 --- a/apps/annotation/go.mod +++ b/apps/annotation/go.mod @@ -49,7 +49,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -68,7 +68,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/annotation/go.sum b/apps/annotation/go.sum index b15ac884e9a..26bd55d3b5d 100644 --- a/apps/annotation/go.sum +++ b/apps/annotation/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -165,8 +165,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/correlations/go.mod b/apps/correlations/go.mod index 3875fe92ed7..898b631516d 100644 --- a/apps/correlations/go.mod +++ b/apps/correlations/go.mod @@ -49,7 +49,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -68,7 +68,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/correlations/go.sum b/apps/correlations/go.sum index b15ac884e9a..26bd55d3b5d 100644 --- a/apps/correlations/go.sum +++ b/apps/correlations/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -165,8 +165,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 15ed02b9266..c8315d44434 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -7,7 +7,7 @@ require ( github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 - github.com/grafana/grafana-plugin-sdk-go v0.281.0 + github.com/grafana/grafana-plugin-sdk-go v0.282.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 @@ -51,7 +51,7 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -84,7 +84,7 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -110,7 +110,7 @@ require ( golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 0927fd84bc0..5c2d4405a26 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -89,8 +89,8 @@ github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJH github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana-plugin-sdk-go v0.282.0 h1:9JT9E6T0h8HsK9emTZ5s01uCKaf0Ok4PjYbb7M/iOjM= +github.com/grafana/grafana-plugin-sdk-go v0.282.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= @@ -99,8 +99,8 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasn github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= @@ -202,8 +202,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq7lB31IeJL8iy7jkUmU/PG1Sr8jVGhS749dbUA= @@ -294,8 +294,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/example/go.mod b/apps/example/go.mod index 7b5d1783cf7..143b915615a 100644 --- a/apps/example/go.mod +++ b/apps/example/go.mod @@ -56,7 +56,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -75,7 +75,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/example/go.sum b/apps/example/go.sum index 837151cf605..2fe30a1cd49 100644 --- a/apps/example/go.sum +++ b/apps/example/go.sum @@ -116,8 +116,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/folder/go.mod b/apps/folder/go.mod index 29948f31b83..659e4e8a019 100644 --- a/apps/folder/go.mod +++ b/apps/folder/go.mod @@ -40,7 +40,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect @@ -51,7 +51,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/apps/folder/go.sum b/apps/folder/go.sum index f795f67e514..56c5afd751f 100644 --- a/apps/folder/go.sum +++ b/apps/folder/go.sum @@ -85,8 +85,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -128,8 +128,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index 11acc87bbc2..51e8ea0c47b 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -50,7 +50,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -69,7 +69,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index b15ac884e9a..26bd55d3b5d 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -165,8 +165,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/logsdrilldown/go.mod b/apps/logsdrilldown/go.mod index 014a0c5bd95..2056bb1f8f5 100644 --- a/apps/logsdrilldown/go.mod +++ b/apps/logsdrilldown/go.mod @@ -51,7 +51,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -70,7 +70,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/logsdrilldown/go.sum b/apps/logsdrilldown/go.sum index b15ac884e9a..26bd55d3b5d 100644 --- a/apps/logsdrilldown/go.sum +++ b/apps/logsdrilldown/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -165,8 +165,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index be65bd498a1..246be5289c2 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -51,7 +51,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect @@ -70,7 +70,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index b15ac884e9a..26bd55d3b5d 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -165,8 +165,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index ece0c3eaeaf..ca743f0f56a 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -58,7 +58,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -77,7 +77,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 2e25d0f885f..96d8dce605d 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -116,8 +116,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index adc0ba32b44..51e94063e22 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -41,7 +41,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect @@ -51,7 +51,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/apps/preferences/go.sum b/apps/preferences/go.sum index c09ddffd51b..02827e1769d 100644 --- a/apps/preferences/go.sum +++ b/apps/preferences/go.sum @@ -85,8 +85,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -128,8 +128,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index 848f82951c4..5a00cb7031b 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -13,7 +13,7 @@ require ( github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b github.com/migueleliasweb/go-github-mock v1.1.0 github.com/stretchr/testify v1.11.1 - golang.org/x/oauth2 v0.32.0 + golang.org/x/oauth2 v0.33.0 k8s.io/apimachinery v0.34.2 k8s.io/apiserver v0.34.2 k8s.io/client-go v0.34.2 @@ -57,7 +57,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index 822b6aa7330..6a63c2cd85e 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -172,8 +172,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/secret/go.mod b/apps/secret/go.mod index b939cbdc85c..c64f6ad2a2e 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -45,7 +45,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect @@ -56,7 +56,7 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect golang.org/x/text v0.31.0 // indirect diff --git a/apps/secret/go.sum b/apps/secret/go.sum index c7babeffcb8..29563e7984d 100644 --- a/apps/secret/go.sum +++ b/apps/secret/go.sum @@ -89,8 +89,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -140,8 +140,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/apps/shorturl/go.mod b/apps/shorturl/go.mod index 2c4ccd17852..7f853adb219 100644 --- a/apps/shorturl/go.mod +++ b/apps/shorturl/go.mod @@ -56,7 +56,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.10 // indirect @@ -75,7 +75,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/apps/shorturl/go.sum b/apps/shorturl/go.sum index 82606eb4015..a19635cc757 100644 --- a/apps/shorturl/go.sum +++ b/apps/shorturl/go.sum @@ -116,8 +116,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= @@ -179,8 +179,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/go.mod b/go.mod index f1a43644cf9..bbc68d42913 100644 --- a/go.mod +++ b/go.mod @@ -103,7 +103,7 @@ require ( github.com/grafana/grafana-cloud-migration-snapshot v1.9.0 // @grafana/grafana-operator-experience-squad github.com/grafana/grafana-google-sdk-go v0.4.2 // @grafana/partner-datasources github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group - github.com/grafana/grafana-plugin-sdk-go v0.281.0 // @grafana/plugins-platform-backend + github.com/grafana/grafana-plugin-sdk-go v0.282.0 // @grafana/plugins-platform-backend github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 // @grafana/alerting-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b // indirect; @grafana/grafana-git-ui-sync-team @@ -112,7 +112,7 @@ require ( github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // @grafana/grafana-search-and-storage github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // @grafana/plugins-platform-backend - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // @grafana/grafana-backend-group + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // @grafana/grafana-backend-group github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // @grafana/identity-access-team github.com/hashicorp/go-hclog v1.6.3 // @grafana/plugins-platform-backend github.com/hashicorp/go-multierror v1.1.1 // @grafana/alerting-squad @@ -157,7 +157,7 @@ require ( github.com/prometheus/alertmanager v0.28.0 // @grafana/alerting-backend github.com/prometheus/client_golang v1.23.2 // @grafana/alerting-backend github.com/prometheus/client_model v0.6.2 // @grafana/grafana-backend-group - github.com/prometheus/common v0.67.1 // @grafana/alerting-backend + github.com/prometheus/common v0.67.2 // @grafana/alerting-backend github.com/prometheus/prometheus v0.303.1 // @grafana/alerting-backend github.com/prometheus/sigv4 v0.1.2 // @grafana/alerting-backend github.com/puzpuzpuz/xsync/v4 v4.2.0 // @grafana/grafana-backend-group @@ -203,7 +203,7 @@ require ( golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // @grafana/alerting-backend golang.org/x/mod v0.30.0 // indirect; @grafana/grafana-backend-group golang.org/x/net v0.47.0 // @grafana/oss-big-tent @grafana/partner-datasources - golang.org/x/oauth2 v0.32.0 // @grafana/identity-access-team + golang.org/x/oauth2 v0.33.0 // @grafana/identity-access-team golang.org/x/sync v0.18.0 // @grafana/alerting-backend golang.org/x/text v0.31.0 // @grafana/grafana-backend-group golang.org/x/time v0.14.0 // @grafana/grafana-backend-group diff --git a/go.sum b/go.sum index a464fc25ed0..42d0ecd1703 100644 --- a/go.sum +++ b/go.sum @@ -1647,8 +1647,8 @@ github.com/grafana/grafana-google-sdk-go v0.4.2 h1:F44hQF1y6UVJhlJPi+Mz+GCJsioVg github.com/grafana/grafana-google-sdk-go v0.4.2/go.mod h1:U73+w9DlbEtUonhQUzERwlXnzWTtfRoyrtKH8d3VY40= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 h1:r+mU5bGMzcXCRVAuOrTn54S80qbfVkvTdUJZfSfTNbs= github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= -github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana-plugin-sdk-go v0.282.0 h1:9JT9E6T0h8HsK9emTZ5s01uCKaf0Ok4PjYbb7M/iOjM= +github.com/grafana/grafana-plugin-sdk-go v0.282.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b h1:6Bo65etvjQ4tStkaA5+N3A3ENbO4UAWj53TxF6g2Hdk= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b/go.mod h1:6+wASOCN8LWt6FJ8dc0oODUBIEY5XHaE6ABi8g0mR+k= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= @@ -1683,8 +1683,8 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDa github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= @@ -2295,8 +2295,8 @@ github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGy github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/common/assets v0.2.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI= github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= @@ -2974,8 +2974,8 @@ golang.org/x/oauth2 v0.12.0/go.mod h1:A74bZ3aGXgCY0qaIC9Ahg6Lglin4AMAco8cIv9baba golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/oauth2 v0.23.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/go.work.sum b/go.work.sum index 8113680659b..8b3ec04e07b 100644 --- a/go.work.sum +++ b/go.work.sum @@ -924,6 +924,7 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= @@ -1309,6 +1310,7 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= github.com/prometheus/statsd_exporter v0.21.0/go.mod h1:rbT83sZq2V+p73lHhPZfMc3MLCHmSHelCh9hSGYNLTQ= @@ -1904,6 +1906,7 @@ golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= @@ -1999,6 +2002,7 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250303144028-a0af3efb3deb/go. google.golang.org/genproto/googleapis/api v0.0.0-20250505200425-f936aa4a68b2/go.mod h1:pKLAc5OolXC3ViWGI62vvC0n10CpwAtRcTNCFwTKBEw= google.golang.org/genproto/googleapis/api v0.0.0-20250512202823-5a2f75b736a9/go.mod h1:W3S/3np0/dPWsWLi1h/UymYctGXaGBM2StwzD0y140U= google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto= +google.golang.org/genproto/googleapis/api v0.0.0-20250528174236-200df99c418a/go.mod h1:a77HrdMjoeKbnd2jmgcWdaS++ZLZAEq3orIOAEIKiVw= google.golang.org/genproto/googleapis/api v0.0.0-20250603155806-513f23925822/go.mod h1:h3c4v36UTKzUiuaOKQ6gr3S+0hovBtUrXzTG/i3+XEc= google.golang.org/genproto/googleapis/api v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:kXqgZtrWaf6qS3jZOCnCH7WYfrvFjkC51bM8fz3RsCA= google.golang.org/genproto/googleapis/api v0.0.0-20250715232539-7130f93afb79/go.mod h1:HKJDgKsFUnv5VAGeQjz8kxcgDP0HoE0iZNp0OdZNlhE= @@ -2019,6 +2023,7 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20241202173237-19429a94021a/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= google.golang.org/genproto/googleapis/rpc v0.0.0-20250303144028-a0af3efb3deb/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I= google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250528174236-200df99c418a/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250603155806-513f23925822/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= google.golang.org/genproto/googleapis/rpc v0.0.0-20250715232539-7130f93afb79/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index d7d805aad0b..b2ae6dc4df5 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -4,7 +4,7 @@ go 1.25.3 require ( github.com/emicklei/go-restful/v3 v3.13.0 - github.com/grafana/grafana-plugin-sdk-go v0.281.0 + github.com/grafana/grafana-plugin-sdk-go v0.282.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/grafana/grafana/pkg/semconv v0.0.0-20250514132646-acbc7b54ed9e github.com/mattbaird/jsonpatch v0.0.0-20240118010651-0ba75a80ca38 @@ -59,7 +59,7 @@ require ( github.com/grafana/otel-profiling-go v0.5.1 // indirect github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect @@ -90,7 +90,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cobra v1.10.1 // indirect @@ -122,7 +122,7 @@ require ( golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect golang.org/x/mod v0.30.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index caebd853fa9..404c3743c70 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -101,8 +101,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana-plugin-sdk-go v0.282.0 h1:9JT9E6T0h8HsK9emTZ5s01uCKaf0Ok4PjYbb7M/iOjM= +github.com/grafana/grafana-plugin-sdk-go v0.282.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h1:BTKk7LHuG1kmAkucwTA7DuMbKpKvJTKrGdBmUNO4dfQ= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grafana/grafana/pkg/semconv v0.0.0-20250514132646-acbc7b54ed9e h1:vheR6iPO1np+G/ARjcWx9yiWd7BnTDgyTgsnMhOvx70= @@ -113,8 +113,8 @@ github.com/grafana/pyroscope-go/godeltaprof v0.1.9 h1:c1Us8i6eSmkW+Ez05d3co8kasn github.com/grafana/pyroscope-go/godeltaprof v0.1.9/go.mod h1:2+l7K7twW49Ct4wFluZD3tZ6e0SjanjcUUBPVD/UuGU= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= @@ -208,8 +208,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1: github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= @@ -333,8 +333,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index 9c296694617..b1dba553ed5 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -48,7 +48,7 @@ require ( github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/josharian/intern v1.0.0 // indirect @@ -64,7 +64,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect @@ -86,7 +86,7 @@ require ( go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sync v0.18.0 // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/term v0.37.0 // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 927155e141c..4070cf8e5f8 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -77,8 +77,8 @@ github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e h github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:IA4SOwun8QyST9c5UNs/fN37XL6boXXDvRYFcFwbipg= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= @@ -129,8 +129,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1: github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= @@ -222,8 +222,8 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index 77129d981d2..e472abd1ad3 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -4,11 +4,11 @@ go 1.25.3 require ( github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 - github.com/grafana/grafana-plugin-sdk-go v0.281.0 + github.com/grafana/grafana-plugin-sdk-go v0.282.0 github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134 github.com/json-iterator/go v1.1.12 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.67.1 + github.com/prometheus/common v0.67.2 github.com/prometheus/prometheus v0.303.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.38.0 @@ -54,7 +54,7 @@ require ( github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // indirect github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/go-hclog v1.6.3 // indirect github.com/hashicorp/go-plugin v1.7.0 // indirect @@ -76,6 +76,7 @@ require ( github.com/oklog/run v1.1.0 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/olekukonko/tablewriter v0.0.5 // indirect + github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index 14c55a0ec52..04556e32c24 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -130,8 +130,8 @@ github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81 github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= -github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= -github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana-plugin-sdk-go v0.282.0 h1:9JT9E6T0h8HsK9emTZ5s01uCKaf0Ok4PjYbb7M/iOjM= +github.com/grafana/grafana-plugin-sdk-go v0.282.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134 h1:xly75v5lFNR37q+wXnwA5yU/fPW9IOSYbhFpt4tQyt8= github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134/go.mod h1:zijsUNa1zi476JJIR2Lcm/Paz1nRCno9XCp6hbS6G9o= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20251007081214-26e147d01f0a h1:L7xgV9mP6MRF3L2/vDOjNR7heaBPbXPMGTDN9/jXSFQ= @@ -144,8 +144,8 @@ github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc h1:GN2Lv3MGO7AS6PrR github.com/grafana/regexp v0.0.0-20240518133315-a468a5bfb3bc/go.mod h1:+JKpmjMGhpgPL+rXZ5nsZieVzvarn86asRlBg4uNGnk= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs/O40yoNK9vmy4rhUGBVyMf1lISBGtXRpsu/Qu/o= github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 h1:8Tjv8EJ+pM1xP8mK6egEbD1OgnVTyacbefKhmbLhIhU= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -223,6 +223,8 @@ github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= +github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -237,8 +239,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= -github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/prometheus v0.303.1 h1:He/2jRE6sB23Ew38AIoR1WRR3fCMgPlJA2E0obD2WSY= @@ -333,8 +335,8 @@ golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLL golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= -golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= -golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= From 82b283959bab28ebf5968699ba7b72b1748f31c7 Mon Sep 17 00:00:00 2001 From: Tobias Skarhed <1438972+tskarhed@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:05:27 +0100 Subject: [PATCH 026/125] Scopes: Remove fallback title for scope node display (#114080) * Remove fallback title for scope node display * Remove newline --- public/app/features/scopes/selector/ScopesInput.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/public/app/features/scopes/selector/ScopesInput.tsx b/public/app/features/scopes/selector/ScopesInput.tsx index aaf54adb8d0..ce22820b32d 100644 --- a/public/app/features/scopes/selector/ScopesInput.tsx +++ b/public/app/features/scopes/selector/ScopesInput.tsx @@ -41,8 +41,7 @@ export function ScopesInput({ const { node: parentNode, isLoading: parentNodeLoading } = useScopeNode(parentNodeId); // Prioritize scope node subtitle over parent node title - const displayTitle = - scopeNode?.spec.subTitle ?? parentNode?.spec.title ?? (appliedScopes.length > 0 ? 'Mimir' : undefined); + const displayTitle = scopeNode?.spec.subTitle ?? parentNode?.spec.title; const isLoadingTitle = scopeNodeLoading || parentNodeLoading; const placeholderText = t('scopes.selector.input.placeholder', 'No scopes'); From e00eb854f54c972286a39d8ed101f1a423084f06 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Tue, 18 Nov 2025 11:17:23 +0000 Subject: [PATCH 027/125] Plugins: Add `ttlPluginInstanceManager` feature toggle (#114084) add ttlPluginInstanceManager feature toggle --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ pkg/services/featuremgmt/registry.go | 7 +++++++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++++ pkg/services/featuremgmt/toggles_gen.json | 13 +++++++++++++ 5 files changed, 29 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 65858e37221..3aaaa4aba42 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1194,4 +1194,8 @@ export interface FeatureToggles { * Show transformation quick-start cards in empty transformations state */ transformationsEmptyPlaceholder?: boolean; + /** + * Enable TTL plugin instance manager + */ + ttlPluginInstanceManager?: boolean; } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 86c5c400381..69893cb94eb 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2072,6 +2072,13 @@ var ( FrontendOnly: true, Owner: grafanaDataProSquad, }, + { + Name: "ttlPluginInstanceManager", + Description: "Enable TTL plugin instance manager", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaPluginsPlatformSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index cb48c884e7d..a6f6bb4a480 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -266,3 +266,4 @@ dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true +ttlPluginInstanceManager,experimental,@grafana/plugins-platform-backend,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index d96539b7a03..ba6d0d3a415 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1073,4 +1073,8 @@ const ( // FlagTransformationsEmptyPlaceholder // Show transformation quick-start cards in empty transformations state FlagTransformationsEmptyPlaceholder = "transformationsEmptyPlaceholder" + + // FlagTtlPluginInstanceManager + // Enable TTL plugin instance manager + FlagTtlPluginInstanceManager = "ttlPluginInstanceManager" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index a5bc3b342a1..9fc363536d4 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4140,6 +4140,19 @@ "expression": "true" } }, + { + "metadata": { + "name": "ttlPluginInstanceManager", + "resourceVersion": "1763462850634", + "creationTimestamp": "2025-11-18T10:47:30Z" + }, + "spec": { + "description": "Enable TTL plugin instance manager", + "stage": "experimental", + "codeowner": "@grafana/plugins-platform-backend", + "frontend": true + } + }, { "metadata": { "name": "unifiedHistory", From a5638b5c8b9a4d85dc70c60ed7f34df28ebb80c7 Mon Sep 17 00:00:00 2001 From: Taha Derouiche <2433681+tahaderouiche@users.noreply.github.com> Date: Tue, 18 Nov 2025 12:39:06 +0100 Subject: [PATCH 028/125] Docs: Fix SCIM EntraId broken link (#114078) --- .../configure-scim-provisioning/manage-users-teams/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/manage-users-teams/_index.md b/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/manage-users-teams/_index.md index c1d9249c5f2..e7eed28fc16 100644 --- a/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/manage-users-teams/_index.md +++ b/docs/sources/setup-grafana/configure-access/configure-scim-provisioning/manage-users-teams/_index.md @@ -282,5 +282,5 @@ Team membership maintenance: ## Next steps - [Troubleshoot SCIM provisioning](../troubleshooting/) -- [Configure SCIM with Entra ID](../configure-scim-with-azuread/) +- [Configure SCIM with Entra ID](../configure-scim-with-entraid/) - [Configure SCIM with Okta](../configure-scim-with-okta/) From ad9f9fc4089d7e2cfc6a058db15bd886c48cb125 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Tue, 18 Nov 2025 12:41:08 +0100 Subject: [PATCH 029/125] Remove unneeded node options (#114081) * Remove unneeded node options * Remove experimentalWarning flag --- public/app/plugins/datasource/azuremonitor/package.json | 6 +++--- public/app/plugins/datasource/cloud-monitoring/package.json | 6 +++--- .../datasource/grafana-postgresql-datasource/package.json | 6 +++--- .../datasource/grafana-pyroscope-datasource/package.json | 6 +++--- .../datasource/grafana-testdata-datasource/package.json | 6 +++--- public/app/plugins/datasource/graphite/package.json | 6 +++--- public/app/plugins/datasource/jaeger/package.json | 6 +++--- public/app/plugins/datasource/loki/package.json | 6 +++--- public/app/plugins/datasource/mssql/package.json | 6 +++--- public/app/plugins/datasource/mysql/package.json | 6 +++--- public/app/plugins/datasource/opentsdb/package.json | 6 +++--- public/app/plugins/datasource/parca/package.json | 6 +++--- public/app/plugins/datasource/tempo/package.json | 6 +++--- public/app/plugins/datasource/zipkin/package.json | 6 +++--- 14 files changed, 42 insertions(+), 42 deletions(-) diff --git a/public/app/plugins/datasource/azuremonitor/package.json b/public/app/plugins/datasource/azuremonitor/package.json index fc4c0d7dd9f..8e3a153c85f 100644 --- a/public/app/plugins/datasource/azuremonitor/package.json +++ b/public/app/plugins/datasource/azuremonitor/package.json @@ -49,9 +49,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "i18n-extract": "i18next-cli extract --sync-primary", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" diff --git a/public/app/plugins/datasource/cloud-monitoring/package.json b/public/app/plugins/datasource/cloud-monitoring/package.json index 7098f71fcc9..5a080a13d2f 100644 --- a/public/app/plugins/datasource/cloud-monitoring/package.json +++ b/public/app/plugins/datasource/cloud-monitoring/package.json @@ -49,9 +49,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json index 48129b2f2ed..e73347574c3 100644 --- a/public/app/plugins/datasource/grafana-postgresql-datasource/package.json +++ b/public/app/plugins/datasource/grafana-postgresql-datasource/package.json @@ -34,9 +34,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json index c9841f68f5c..bc9131a3087 100644 --- a/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json +++ b/public/app/plugins/datasource/grafana-pyroscope-datasource/package.json @@ -43,9 +43,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/grafana-testdata-datasource/package.json b/public/app/plugins/datasource/grafana-testdata-datasource/package.json index 372556165aa..4e291573daa 100644 --- a/public/app/plugins/datasource/grafana-testdata-datasource/package.json +++ b/public/app/plugins/datasource/grafana-testdata-datasource/package.json @@ -43,9 +43,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/graphite/package.json b/public/app/plugins/datasource/graphite/package.json index 325cf1bd1d5..5fbb1d59438 100644 --- a/public/app/plugins/datasource/graphite/package.json +++ b/public/app/plugins/datasource/graphite/package.json @@ -45,9 +45,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/jaeger/package.json b/public/app/plugins/datasource/jaeger/package.json index a3d7059d4ef..e66b5e53623 100644 --- a/public/app/plugins/datasource/jaeger/package.json +++ b/public/app/plugins/datasource/jaeger/package.json @@ -45,9 +45,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/loki/package.json b/public/app/plugins/datasource/loki/package.json index 029984b1d47..6659a1c6854 100644 --- a/public/app/plugins/datasource/loki/package.json +++ b/public/app/plugins/datasource/loki/package.json @@ -46,9 +46,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/mssql/package.json b/public/app/plugins/datasource/mssql/package.json index 632f0c93913..cb766af057f 100644 --- a/public/app/plugins/datasource/mssql/package.json +++ b/public/app/plugins/datasource/mssql/package.json @@ -35,9 +35,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "i18n-extract": "i18next-cli extract --sync-primary" }, "packageManager": "yarn@4.11.0" diff --git a/public/app/plugins/datasource/mysql/package.json b/public/app/plugins/datasource/mysql/package.json index 80ce62bca43..21d05ad703e 100644 --- a/public/app/plugins/datasource/mysql/package.json +++ b/public/app/plugins/datasource/mysql/package.json @@ -34,9 +34,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/opentsdb/package.json b/public/app/plugins/datasource/opentsdb/package.json index e80c50d9df5..449176efddd 100644 --- a/public/app/plugins/datasource/opentsdb/package.json +++ b/public/app/plugins/datasource/opentsdb/package.json @@ -42,9 +42,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/parca/package.json b/public/app/plugins/datasource/parca/package.json index e604e7933e7..3607993916e 100644 --- a/public/app/plugins/datasource/parca/package.json +++ b/public/app/plugins/datasource/parca/package.json @@ -35,9 +35,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/tempo/package.json b/public/app/plugins/datasource/tempo/package.json index 722abb37dd0..0b940183f40 100644 --- a/public/app/plugins/datasource/tempo/package.json +++ b/public/app/plugins/datasource/tempo/package.json @@ -62,10 +62,10 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", + "build": "webpack -c ./webpack.config.ts --env production", "build:stats": "yarn build --env stats --profile --json=compilation-stats.json", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, diff --git a/public/app/plugins/datasource/zipkin/package.json b/public/app/plugins/datasource/zipkin/package.json index 8489a35a270..5b374ab7044 100644 --- a/public/app/plugins/datasource/zipkin/package.json +++ b/public/app/plugins/datasource/zipkin/package.json @@ -38,9 +38,9 @@ "@grafana/runtime": "*" }, "scripts": { - "build": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production", - "build:commit": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", - "dev": "NODE_OPTIONS='--experimental-strip-types --no-warnings=ExperimentalWarning' webpack -w -c ./webpack.config.ts --env development", + "build": "webpack -c ./webpack.config.ts --env production", + "build:commit": "webpack -c ./webpack.config.ts --env production --env commit=$(git rev-parse --short HEAD)", + "dev": "webpack -w -c ./webpack.config.ts --env development", "test": "jest --watch --onlyChanged", "test:ci": "jest --maxWorkers 4" }, From e821578ab049fd76df39ac7a3d198ecdf1f2d5aa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Tue, 18 Nov 2025 12:58:45 +0100 Subject: [PATCH 030/125] Dynamic Dashboards: Disallow adding empty row and tab titles (#113941) --- .../dashboard-group-panels.spec.ts | 100 ++++++++++++++++++ .../src/selectors/components.ts | 5 + .../scene/layout-rows/RowItemEditor.tsx | 13 ++- .../scene/layout-tabs/TabItemEditor.tsx | 10 +- 4 files changed, 124 insertions(+), 4 deletions(-) diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts index d9cde06cc69..ffdd52e24f6 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-group-panels.spec.ts @@ -503,6 +503,60 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); }); + test('cannot add a row without a title', async ({ dashboardPage, selectors, page }) => { + await importTestDashboard(page, selectors, 'Cannot add row without title'); + + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + + await groupIntoRow(page, dashboardPage, selectors); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) + ).toBeVisible(); + + // edit row title to a non-default and click away to trigger onBlur + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) + .fill('Test row 1'); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // clear the title input to simulate no title and click away to trigger onBlur + await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 1')).click(); + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) + .fill(''); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // title should be set to a default name + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row')) + ).toBeVisible(); + + // add another row + await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow).click(); + + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) + ).toBeVisible(); + + // edit row title to a non-default and click away to trigger onBlur + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) + .fill('Test row 2'); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // clear the title input to simulate no title and click away to trigger onBlur + await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 2')).click(); + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput) + .fill(''); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // title should be set to a default name + 1 to avoid duplicates + await expect( + dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('New row 1')) + ).toBeVisible(); + }); /* * Tabs @@ -835,5 +889,51 @@ test.describe( dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel')) ).toHaveCount(3); }); + + test('cannot add a tab without a title', async ({ dashboardPage, selectors, page }) => { + await importTestDashboard(page, selectors, 'Cannot add tab without title'); + + await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); + + await groupIntoTab(page, dashboardPage, selectors); + + await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab'))).toBeVisible(); + + // edit tab title to a non-default and click away to trigger onBlur + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) + .fill('Test tab 1'); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // clear the title input to simulate no title and click away to trigger onBlur + await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 1')).click(); + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) + .fill(''); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // title should be set to a default name + await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab'))).toBeVisible(); + + // add another tab + await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addTab).click(); + await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 1'))).toBeVisible(); + + // edit tab title to a non-default and click away to trigger onBlur + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) + .fill('Test tab 2'); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // clear the title input to simulate no title and click away to trigger onBlur + await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 2')).click(); + await dashboardPage + .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput) + .fill(''); + await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click(); + + // title should be set to a default name + 1 to avoid duplicates + await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 1'))).toBeVisible(); + }); } ); diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index c31b474588f..c8cfb1d47cb 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -683,6 +683,11 @@ export const versionedComponents = { '12.2.0': 'data-testid tab title input', }, }, + RowsLayout: { + titleInput: { + '12.3.0': 'data-testid row title input', + }, + }, }, }, PanelInspector: { diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx index 15a8bf1c089..2df803da9fd 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItemEditor.tsx @@ -13,7 +13,7 @@ import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks import { dashboardEditActions } from '../../edit-pane/shared'; import { getQueryRunnerFor, useDashboard } from '../../utils/utils'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; -import { useEditPaneInputAutoFocus } from '../layouts-shared/utils'; +import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils'; import { RowItem } from './RowItem'; @@ -108,6 +108,7 @@ function RowTitleInput({ row, isNewElement }: { row: RowItem; isNewElement: bool onFocus={() => (prevTitle.current = title || '')} onBlur={() => editRowTitleAction(row, title || '', prevTitle.current || '')} onChange={(e) => row.onChangeTitle(e.currentTarget.value)} + data-testid={selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput} /> ); @@ -175,10 +176,18 @@ function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) { } function editRowTitleAction(row: RowItem, title: string, prevTitle: string) { - if (title === prevTitle) { + if (title !== '' && title === prevTitle) { return; } + if (title === '') { + const rowsLayout = row.getParentLayout(); + const existingNames = new Set( + rowsLayout.state.rows.map((row) => row.state.title).filter((title) => title !== undefined) + ); + title = generateUniqueTitle('New row', existingNames); + } + dashboardEditActions.edit({ description: t('dashboard.edit-actions.row-title', 'Change row title'), source: row, diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 3c4466e8866..745f612a8cc 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -13,7 +13,7 @@ import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks import { dashboardEditActions } from '../../edit-pane/shared'; import { getQueryRunnerFor, useDashboard } from '../../utils/utils'; import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector'; -import { useEditPaneInputAutoFocus } from '../layouts-shared/utils'; +import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils'; import { TabItem } from './TabItem'; @@ -147,10 +147,16 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { } function editTabTitleAction(tab: TabItem, title: string, prevTitle: string) { - if (title === prevTitle) { + if (title !== '' && title === prevTitle) { return; } + if (title === '') { + const tabs = tab.getParentLayout().getTabsIncludingRepeats(); + const existingNames = new Set(tabs.map((tab) => tab.state.title).filter((title) => title !== undefined)); + title = generateUniqueTitle('New tab', existingNames); + } + dashboardEditActions.edit({ description: t('dashboard.edit-actions.tab-title', 'Change tab title'), source: tab, From ec74a721ea360c07356cc1d743b6118831a26cce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20=C5=A0tibran=C3=BD?= Date: Tue, 18 Nov 2025 14:01:57 +0100 Subject: [PATCH 031/125] Be more tolerant to invalid JSON when parsing dashboards for indexing (#114040) * Tags can only be string array. * Be more lenient when parsing dashboard. Parse what we can, don't error out easily. * Check element before parsing, log unexpected types. --- .../store/kind/dashboard/dashboard.go | 317 +++++++++++++----- .../store/kind/dashboard/dashboard_test.go | 2 + pkg/services/store/kind/dashboard/targets.go | 24 +- .../testdata/absolute-garbage-info.json | 58 ++++ .../dashboard/testdata/absolute-garbage.json | 84 +++++ .../k8s-wrapper-tags-string-info.json | 74 ++++ .../testdata/k8s-wrapper-tags-string.json | 122 +++++++ pkg/storage/unified/search/dashboard.go | 5 +- 8 files changed, 586 insertions(+), 100 deletions(-) create mode 100644 pkg/services/store/kind/dashboard/testdata/absolute-garbage-info.json create mode 100644 pkg/services/store/kind/dashboard/testdata/absolute-garbage.json create mode 100644 pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string-info.json create mode 100644 pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string.json diff --git a/pkg/services/store/kind/dashboard/dashboard.go b/pkg/services/store/kind/dashboard/dashboard.go index 848acb620e5..42a2e1e5067 100644 --- a/pkg/services/store/kind/dashboard/dashboard.go +++ b/pkg/services/store/kind/dashboard/dashboard.go @@ -1,16 +1,16 @@ package dashboard import ( + "errors" + "fmt" "io" "strconv" "strings" jsoniter "github.com/json-iterator/go" -) -func logf(format string, a ...any) { - //fmt.Printf(format, a...) -} + "github.com/grafana/grafana/pkg/infra/log" +) type templateVariable struct { current struct { @@ -48,7 +48,7 @@ func (d *datasourceVariableLookup) getDsRefsByTemplateVariableValue(value string case "No data sources found": return []DataSourceRef{} default: - // some variables use `ds.name` rather `ds.uid` + // some variables use `ds.name` rather than `ds.uid` if ref := d.dsLookup.ByRef(&DataSourceRef{ UID: value, }); ref != nil { @@ -114,45 +114,63 @@ func newDatasourceVariableLookup(dsLookup DatasourceLookup) *datasourceVariableL // ReadDashboard will take a byte stream and return dashboard info func ReadDashboard(stream io.Reader, lookup DatasourceLookup) (*DashboardSummaryInfo, error) { + return ReadDashboardWithLogContext(stream, lookup, nil) +} + +func ReadDashboardWithLogContext(stream io.Reader, lookup DatasourceLookup, logContext map[string]any) (*DashboardSummaryInfo, error) { iter := jsoniter.Parse(jsoniter.ConfigDefault, stream, 1024) - return readDashboardIter(iter, lookup) + return readDashboardIter("$", iter, lookup, logContext) } // nolint:gocyclo -func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*DashboardSummaryInfo, error) { +func readDashboardIter(jsonPath string, iter *jsoniter.Iterator, lookup DatasourceLookup, lc map[string]any) (*DashboardSummaryInfo, error) { dash := &DashboardSummaryInfo{} + if !checkAndSkipUnexpectedElement(iter, jsonPath, lc, jsoniter.ObjectValue) { + return dash, errors.New("expected JSON object at " + jsonPath) + } + datasourceVariablesLookup := newDatasourceVariableLookup(lookup) - for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + for field := iter.ReadObject(); field != ""; field = iter.ReadObject() { // Skip null values so we don't need special int handling if iter.WhatIsNext() == jsoniter.NilValue { iter.Skip() continue } - switch l1Field { + switch field { // k8s metadata wrappers (skip) case "metadata", "kind", "apiVersion": - _ = iter.Read() + iter.Skip() // recursively read the spec as dashboard json case "spec": - return readDashboardIter(iter, lookup) + return readDashboardIter(jsonPath+".spec", iter, lookup, lc) case "id": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".id", lc, jsoniter.NumberValue) { + continue + } dash.ID = iter.ReadInt64() - case "uid": - iter.ReadString() - case "title": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".title", lc, jsoniter.StringValue) { + continue + } dash.Title = iter.ReadString() case "description": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".description", lc, jsoniter.StringValue) { + continue + } dash.Description = iter.ReadString() case "schemaVersion": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".schemaVersion", lc, jsoniter.NumberValue, jsoniter.StringValue) { + continue + } + switch iter.WhatIsNext() { case jsoniter.NumberValue: dash.SchemaVersion = iter.ReadInt64() @@ -164,10 +182,18 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb default: iter.Skip() } + case "timezone": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".timezone", lc, jsoniter.StringValue) { + continue + } dash.TimeZone = iter.ReadString() case "editable": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".editable", lc, jsoniter.StringValue, jsoniter.BoolValue) { + continue + } + switch iter.WhatIsNext() { case jsoniter.BoolValue: dash.ReadOnly = !iter.ReadBool() @@ -178,25 +204,42 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb } case "refresh": - nxt := iter.WhatIsNext() - if nxt == jsoniter.StringValue { - dash.Refresh = iter.ReadString() - } else { - iter.Skip() + if !checkAndSkipUnexpectedElement(iter, jsonPath+".refresh", lc, jsoniter.StringValue) { + continue } + dash.Refresh = iter.ReadString() case "tags": - for iter.ReadArray() { + tagsPath := jsonPath + ".tags" + + // Only support string array tags. Ignore everything else. + if !checkAndSkipUnexpectedElement(iter, tagsPath, lc, jsoniter.ArrayValue) { + continue + } + + for ix := 0; iter.ReadArray(); ix++ { + if !checkAndSkipUnexpectedElement(iter, fmt.Sprintf("%s[%d]", tagsPath, ix), lc, jsoniter.StringValue) { + continue + } + dash.Tags = append(dash.Tags, iter.ReadString()) } case "links": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".links", lc, jsoniter.ArrayValue) { + continue + } + for iter.ReadArray() { iter.Skip() dash.LinkCount++ } case "time": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".time", lc, jsoniter.ObjectValue) { + continue + } + obj, ok := iter.Read().(map[string]any) if ok { if timeFrom, ok := obj["from"].(string); ok { @@ -206,61 +249,66 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb dash.TimeTo = timeTo } } + case "panels": - for iter.ReadArray() { - dash.Panels = append(dash.Panels, readpanelInfo(iter, lookup)) + panelsPath := jsonPath + ".panels" + if !checkAndSkipUnexpectedElement(iter, panelsPath, lc, jsoniter.ArrayValue) { + continue } - case "rows": - for iter.ReadArray() { - v := iter.Read() - logf("[DASHBOARD.ROW???] id=%s // %v\n", dash.ID, v) - } - - case "annotations": - switch iter.WhatIsNext() { - case jsoniter.ArrayValue: - // dashboards v2 is an array - for iter.ReadArray() { - v := iter.Read() - logf("[dash.anno] %v\n", v) + for ix := 0; iter.ReadArray(); ix++ { + p, ok := readpanelInfo(iter, lookup, fmt.Sprintf("%s[%d]", panelsPath, ix), lc) + if ok { + dash.Panels = append(dash.Panels, p) } - case jsoniter.ObjectValue: - // dashboards v0/v1 are an object - for sub := iter.ReadObject(); sub != ""; sub = iter.ReadObject() { - if sub == "list" { - for iter.ReadArray() { - v := iter.Read() - logf("[dash.anno] %v\n", v) - } - } else { - iter.Skip() - } - } - default: - iter.Skip() } case "templating": + templatingPath := jsonPath + ".templating" + if !checkAndSkipUnexpectedElement(iter, templatingPath, lc, jsoniter.ObjectValue) { + continue + } + for sub := iter.ReadObject(); sub != ""; sub = iter.ReadObject() { if sub == "list" { - for iter.ReadArray() { - templateVariable := templateVariable{} + templatingListPath := templatingPath + ".list" + if !checkAndSkipUnexpectedElement(iter, templatingListPath, lc, jsoniter.ArrayValue) { + continue + } + + for ix := 0; iter.ReadArray(); ix++ { + tv := templateVariable{} + + templatingListElementPath := fmt.Sprintf("%s[%d]", templatingListPath, ix) + if !checkAndSkipUnexpectedElement(iter, templatingListElementPath, lc, jsoniter.ObjectValue) { + continue + } for k := iter.ReadObject(); k != ""; k = iter.ReadObject() { switch k { case "name": + if !checkAndSkipUnexpectedElement(iter, templatingListElementPath+".name", lc, jsoniter.StringValue) { + continue + } + name := iter.ReadString() dash.TemplateVars = append(dash.TemplateVars, name) - templateVariable.name = name + tv.name = name case "type": - templateVariable.variableType = iter.ReadString() + if !checkAndSkipUnexpectedElement(iter, templatingListElementPath+".type", lc, jsoniter.StringValue) { + continue + } + tv.variableType = iter.ReadString() case "query": - templateVariable.query = iter.Read() + tv.query = iter.Read() case "current": + if !checkAndSkipUnexpectedElement(iter, templatingListElementPath+".current", lc, jsoniter.ObjectValue) { + continue + } + for c := iter.ReadObject(); c != ""; c = iter.ReadObject() { if c == "value" { - templateVariable.current.value = iter.Read() + tv.current.value = iter.Read() } else { iter.Skip() } @@ -270,8 +318,8 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb } } - if templateVariable.variableType == "datasource" { - datasourceVariablesLookup.add(templateVariable) + if tv.variableType == "datasource" { + datasourceVariablesLookup.add(tv) } } } else { @@ -279,17 +327,9 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb } } - // Ignore these properties - case "timepicker": - fallthrough - case "version": - fallthrough - case "iteration": - iter.Skip() - + // Ignore everything else default: - v := iter.Read() - logf("[DASHBOARD] support key: %s / %v\n", l1Field, v) + iter.Skip() } } @@ -311,6 +351,64 @@ func readDashboardIter(iter *jsoniter.Iterator, lookup DatasourceLookup) (*Dashb return dash, iter.Error } +var logger = log.New("services.store.kind.dashboard") + +// checkAndSkipUnexpectedElement verifies if the next JSON element matches any allowed value types for the specified JSON path. +// If the type matches, it returns true, otherwise it skips the element, logs an error, and returns false. +func checkAndSkipUnexpectedElement(iter *jsoniter.Iterator, jsonPath string, logContext map[string]any, allowedValues ...jsoniter.ValueType) bool { + next := iter.WhatIsNext() + for _, a := range allowedValues { + if next == a { + return true + } + } + + // Skip unexpected element. + iter.Skip() + + // Prepare log message. + params := []any{ + "jsonPath", jsonPath, + "got", valueTypesToString(next), + "expected", valueTypesToString(allowedValues...), + } + + // Map iteration is random, so a log message may look different each time if there are multiple entries in the map. That's fine. + for k, v := range logContext { + params = append(params, k, v) + } + + logger.Error("Unexpected element in Dashboard JSON", params...) + return false +} + +func valueTypesToString(allowedValues ...jsoniter.ValueType) string { + expected := strings.Builder{} + for ix, a := range allowedValues { + if ix > 0 { + expected.WriteString(", ") + } + + switch a { + case jsoniter.NilValue: + expected.WriteString("null") + case jsoniter.StringValue: + expected.WriteString("string") + case jsoniter.NumberValue: + expected.WriteString("number") + case jsoniter.BoolValue: + expected.WriteString("bool") + case jsoniter.ArrayValue: + expected.WriteString("array") + case jsoniter.ObjectValue: + expected.WriteString("object") + default: + expected.WriteString(fmt.Sprintf("unknown: %d", a)) + } + } + return expected.String() +} + func panelRequiresDatasource(panel PanelSummaryInfo) bool { return panel.Type != "row" } @@ -399,16 +497,20 @@ func findDatasourceRefsForVariables(dsVariableRefs []DataSourceRef, datasourceVa return referencedDs } -// will always return strings for now -func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup) PanelSummaryInfo { +// nolint:gocyclo +func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup, jsonPath string, lc map[string]any) (PanelSummaryInfo, bool) { panel := PanelSummaryInfo{} + if !checkAndSkipUnexpectedElement(iter, jsonPath, lc, jsoniter.ObjectValue) { + return panel, false + } + targets := newTargetInfo(lookup) - for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { + for field := iter.ReadObject(); field != ""; field = iter.ReadObject() { if iter.WhatIsNext() == jsoniter.NilValue { - if l1Field == "datasource" { - targets.addDatasource(iter) + if field == "datasource" { + targets.addDatasource(iter, jsonPath+".datasource", lc) continue } @@ -417,23 +519,42 @@ func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup) PanelSummar continue } - switch l1Field { + switch field { case "id": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".id", lc, jsoniter.NumberValue) { + continue + } panel.ID = iter.ReadInt64() case "type": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".type", lc, jsoniter.StringValue) { + continue + } panel.Type = iter.ReadString() case "title": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".title", lc, jsoniter.StringValue) { + continue + } panel.Title = iter.ReadString() case "description": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".description", lc, jsoniter.StringValue) { + continue + } panel.Description = iter.ReadString() case "pluginVersion": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".pluginVersion", lc, jsoniter.StringValue) { + continue + } panel.PluginVersion = iter.ReadString() // since 7x (the saved version for the plugin model) case "libraryPanel": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".libraryPanel", lc, jsoniter.ObjectValue) { + continue + } + var v map[string]interface{} iter.ReadVal(&v) if uid, ok := v["uid"]; ok { @@ -443,26 +564,42 @@ func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup) PanelSummar } case "datasource": - targets.addDatasource(iter) + targets.addDatasource(iter, jsonPath+".datasource", lc) case "targets": + if !checkAndSkipUnexpectedElement(iter, jsonPath+".targets", lc, jsoniter.ArrayValue, jsoniter.ObjectValue) { + continue + } + switch iter.WhatIsNext() { case jsoniter.ArrayValue: - for iter.ReadArray() { - targets.addTarget(iter) + for ix := 0; iter.ReadArray(); ix++ { + targets.addTarget(iter, fmt.Sprintf("%s.targets[%d]", jsonPath, ix), lc) } case jsoniter.ObjectValue: - for f := iter.ReadObject(); f != ""; f = iter.ReadObject() { - targets.addTarget(iter) + for fn := iter.ReadObject(); fn != ""; fn = iter.ReadObject() { + targets.addTarget(iter, jsonPath+".targets."+fn, lc) } default: iter.Skip() } case "transformations": - for iter.ReadArray() { + if !checkAndSkipUnexpectedElement(iter, jsonPath+".transformations", lc, jsoniter.ArrayValue) { + continue + } + + for ix := 0; iter.ReadArray(); ix++ { + if !checkAndSkipUnexpectedElement(iter, fmt.Sprintf("%s.transformations[%d]", jsonPath, ix), lc, jsoniter.ObjectValue) { + continue + } + for sub := iter.ReadObject(); sub != ""; sub = iter.ReadObject() { if sub == "id" { + if !checkAndSkipUnexpectedElement(iter, fmt.Sprintf("%s.transformations[%d].id", jsonPath, ix), lc, jsoniter.StringValue) { + continue + } + panel.Transformer = append(panel.Transformer, iter.ReadString()) } else { iter.Skip() @@ -472,26 +609,26 @@ func readpanelInfo(iter *jsoniter.Iterator, lookup DatasourceLookup) PanelSummar // Rows have nested panels case "panels": - for iter.ReadArray() { - panel.Collapsed = append(panel.Collapsed, readpanelInfo(iter, lookup)) + if !checkAndSkipUnexpectedElement(iter, jsonPath+".panels", lc, jsoniter.ArrayValue) { + continue } - case "options": - fallthrough + for ix := 0; iter.ReadArray(); ix++ { + p, ok := readpanelInfo(iter, lookup, fmt.Sprintf("%s.panels[%d]", jsonPath, ix), lc) + if ok { + panel.Collapsed = append(panel.Collapsed, p) + } + } - case "gridPos": - fallthrough - - case "fieldConfig": + case "options", "gridPos", "fieldConfig": iter.Skip() default: - v := iter.Read() - logf("[PANEL] support key: %s / %v\n", l1Field, v) + iter.Skip() } } panel.Datasource = targets.GetDatasourceInfo() - return panel + return panel, true } diff --git a/pkg/services/store/kind/dashboard/dashboard_test.go b/pkg/services/store/kind/dashboard/dashboard_test.go index cdc336b55c1..5e31a86f8bd 100644 --- a/pkg/services/store/kind/dashboard/dashboard_test.go +++ b/pkg/services/store/kind/dashboard/dashboard_test.go @@ -55,6 +55,7 @@ func dsLookupForTests() DatasourceLookup { func TestReadDashboard(t *testing.T) { inputs := []string{ + "absolute-garbage", "check-string-datasource-id", "all-panels", "panel-graph/graph-shared-tooltips", @@ -73,6 +74,7 @@ func TestReadDashboard(t *testing.T) { "panel-with-library-panel-field", "k8s-wrapper", "k8s-wrapper-editable-string", + "k8s-wrapper-tags-string", } devdash := "../../../../../devenv/dev-dashboards/" diff --git a/pkg/services/store/kind/dashboard/targets.go b/pkg/services/store/kind/dashboard/targets.go index 603fec0b0d6..ef35329e424 100644 --- a/pkg/services/store/kind/dashboard/targets.go +++ b/pkg/services/store/kind/dashboard/targets.go @@ -27,7 +27,11 @@ func (s *targetInfo) GetDatasourceInfo() []DataSourceRef { } // the node will either be string (name|uid) OR ref -func (s *targetInfo) addDatasource(iter *jsoniter.Iterator) { +func (s *targetInfo) addDatasource(iter *jsoniter.Iterator, jsonPath string, lc map[string]any) { + if !checkAndSkipUnexpectedElement(iter, jsonPath, lc, jsoniter.StringValue, jsoniter.NilValue, jsoniter.ObjectValue) { + return + } + switch iter.WhatIsNext() { case jsoniter.StringValue: key := iter.ReadString() @@ -55,8 +59,7 @@ func (s *targetInfo) addDatasource(iter *jsoniter.Iterator) { } default: - v := iter.Read() - logf("[Panel.datasource.unknown] %v\n", v) + iter.Skip() } } @@ -66,18 +69,21 @@ func (s *targetInfo) addRef(ref *DataSourceRef) { } } -func (s *targetInfo) addTarget(iter *jsoniter.Iterator) { - for l1Field := iter.ReadObject(); l1Field != ""; l1Field = iter.ReadObject() { - switch l1Field { +func (s *targetInfo) addTarget(iter *jsoniter.Iterator, jsonPath string, lc map[string]any) { + if !checkAndSkipUnexpectedElement(iter, jsonPath, lc, jsoniter.ObjectValue) { + return + } + + for f := iter.ReadObject(); f != ""; f = iter.ReadObject() { + switch f { case "datasource": - s.addDatasource(iter) + s.addDatasource(iter, jsonPath+".datasource", lc) case "refId": iter.Skip() default: - v := iter.Read() - logf("[Panel.TARGET] %s=%v\n", l1Field, v) + iter.Skip() } } } diff --git a/pkg/services/store/kind/dashboard/testdata/absolute-garbage-info.json b/pkg/services/store/kind/dashboard/testdata/absolute-garbage-info.json new file mode 100644 index 00000000000..8d74369dadf --- /dev/null +++ b/pkg/services/store/kind/dashboard/testdata/absolute-garbage-info.json @@ -0,0 +1,58 @@ +{ + "title": "adfbg6f", + "tags": null, + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ], + "panels": [ + { + "id": 1, + "title": "green pie", + "libraryPanel": "a7975b7a-fb53-4ab7-951d-15810953b54f", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 0, + "title": "", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 7, + "title": "", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 8, + "title": "", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + } + ], + "schemaVersion": 0, + "linkCount": 4, + "timeFrom": "", + "timeTo": "", + "timezone": "" +} \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/absolute-garbage.json b/pkg/services/store/kind/dashboard/testdata/absolute-garbage.json new file mode 100644 index 00000000000..3af33590cc9 --- /dev/null +++ b/pkg/services/store/kind/dashboard/testdata/absolute-garbage.json @@ -0,0 +1,84 @@ +{ + "metadata": 123, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": "true", + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": "not a number", + "links": [ + "http://12345", + { + "object": "goes, here" + }, + null, + true + ], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "libraryPanel": { + "name": "green pie", + "uid": "a7975b7a-fb53-4ab7-951d-15810953b54f" + }, + "title": "green pie" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": "not a number", + "title": true + }, + { + "id": 7, + "type": null + }, + { + "id": 8, + "type": { + "a": 123 + } + } + ], + "refresh": "", + "schemaVersion": null, + "tags": 1234, + "templating": { + "list": [] + }, + "time": false, + "timepicker": {}, + "timezone": 3.141592653589793, + "title": "adfbg6f", + "uid": ["aaa", "bbb", "cc"], + "description": [null, true], + "version": [], + "weekStart": false + } +} diff --git a/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string-info.json b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string-info.json new file mode 100644 index 00000000000..6451c3d805b --- /dev/null +++ b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string-info.json @@ -0,0 +1,74 @@ +{ + "id": 141, + "title": "pppp", + "tags": null, + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ], + "panels": [ + { + "id": 1, + "title": "green pie", + "libraryPanel": "a7975b7a-fb53-4ab7-951d-15810953b54f", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 2, + "title": "green pie", + "libraryPanel": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 7, + "title": "", + "type": "barchart", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 8, + "title": "", + "type": "graph", + "datasource": [ + { + "uid": "default.uid", + "type": "default.type" + } + ] + }, + { + "id": 3, + "title": "collapsed row", + "type": "row", + "collapsed": [ + { + "id": 42, + "title": "blue pie", + "libraryPanel": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + } + ] + } + ], + "schemaVersion": 38, + "linkCount": 0, + "timeFrom": "now-6h", + "timeTo": "now", + "timezone": "" +} \ No newline at end of file diff --git a/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string.json b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string.json new file mode 100644 index 00000000000..28bca5c0079 --- /dev/null +++ b/pkg/services/store/kind/dashboard/testdata/k8s-wrapper-tags-string.json @@ -0,0 +1,122 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "adfbg6f", + "namespace": "default", + "uid": "b396894e-56bf-4a01-837b-64157912ca00", + "creationTimestamp": "2024-10-30T18:30:54Z", + "annotations": { + "grafana.app/createdBy": "user:be2g71ke8yoe8b", + "grafana.app/originHash": "Grafana v9.2.0 (NA)", + "grafana.app/originName": "UI", + "grafana.app/originPath": "/dashboard/new" + } + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": 141, + "links": [], + "liveNow": false, + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "libraryPanel": { + "name": "green pie", + "uid": "a7975b7a-fb53-4ab7-951d-15810953b54f" + }, + "title": "green pie" + }, + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "libraryPanel": { + "name": "red pie", + "uid": "e1d5f519-dabd-47c6-9ad7-83d181ce1cee" + }, + "title": "green pie" + }, + { + "id": 7, + "type": "barchart" + }, + { + "id": 8, + "type": "graph" + }, + { + "collapsed": true, + "gridPos": { + "h": 1, + "w": 24, + "x": 0, + "y": 9 + }, + "id": 3, + "panels": [ + { + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 42, + "libraryPanel": { + "name": "blue pie", + "uid": "l3d2s634-fdgf-75u4-3fg3-67j966ii7jur" + }, + "title": "blue pie" + } + ], + "title": "collapsed row", + "type": "row" + } + ], + "refresh": "", + "schemaVersion": 38, + "tags": "tag1,tag2, tag3,,", + "templating": { + "list": [] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "", + "title": "pppp", + "uid": "adfbg6f", + "version": 3, + "weekStart": "" + } +} diff --git a/pkg/storage/unified/search/dashboard.go b/pkg/storage/unified/search/dashboard.go index 7e2284fb118..44a822a1b4c 100644 --- a/pkg/storage/unified/search/dashboard.go +++ b/pkg/storage/unified/search/dashboard.go @@ -261,7 +261,10 @@ func (s *DashboardDocumentBuilder) BuildDocument(ctx context.Context, key *resou value = rsp.Value } - summary, err := dashboard.ReadDashboard(bytes.NewReader(value), s.DatasourceLookup) + summary, err := dashboard.ReadDashboardWithLogContext(bytes.NewReader(value), s.DatasourceLookup, map[string]any{ + "document": fmt.Sprintf("%s/%s/%s/%s", key.GetNamespace(), key.GetGroup(), key.GetResource(), key.GetName()), + "rv": rv, + }) if err != nil { return nil, err } From 083c7f31f7c80e0e70fac30de4614889d2ef9746 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Tue, 18 Nov 2025 14:12:23 +0100 Subject: [PATCH 032/125] PluginExtensions: Prevent unneccesary re-renders (#113436) * wip * wip * wip * wip * wip * wip * removed array.from. * using concat * added more tests. * renamed according to feedback. * Update the getPluginExtensions to use the slice. * fixed expect statements. * Reverted the test app from the allow list. * cleanded up tests. * added generic tests for base functionality. * removed unused iport. --- eslint-suppressions.json | 10 - .../plugins/extensions/getPluginExtensions.ts | 18 +- .../registry/AddedComponentsRegistry.test.ts | 236 +++++++++- .../registry/AddedComponentsRegistry.ts | 8 +- .../registry/AddedFunctionsRegistry.test.ts | 244 ++++++++++- .../registry/AddedFunctionsRegistry.ts | 12 +- .../registry/AddedLinksRegistry.test.ts | 196 ++++++++- .../extensions/registry/AddedLinksRegistry.ts | 9 +- .../ExposedComponentsRegistry.test.ts | 104 ++++- .../extensions/registry/Registry.test.ts | 89 ++++ .../plugins/extensions/registry/Registry.ts | 22 +- .../registry/useRegistrySlice.test.tsx | 410 ++++++++++++++++++ .../extensions/registry/useRegistrySlice.tsx | 53 +++ .../plugins/extensions/usePluginComponent.tsx | 11 +- .../extensions/usePluginComponents.tsx | 15 +- .../plugins/extensions/usePluginFunctions.tsx | 12 +- .../plugins/extensions/usePluginLinks.tsx | 10 +- 17 files changed, 1385 insertions(+), 74 deletions(-) create mode 100644 public/app/features/plugins/extensions/registry/Registry.test.ts create mode 100644 public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx create mode 100644 public/app/features/plugins/extensions/registry/useRegistrySlice.tsx diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 87e831494aa..5db3b71773e 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -3154,16 +3154,6 @@ "count": 1 } }, - "public/app/features/plugins/extensions/usePluginComponents.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, - "public/app/features/plugins/extensions/usePluginFunctions.tsx": { - "@typescript-eslint/consistent-type-assertions": { - "count": 1 - } - }, "public/app/features/plugins/sandbox/distortions.ts": { "@typescript-eslint/consistent-type-assertions": { "count": 1 diff --git a/public/app/features/plugins/extensions/getPluginExtensions.ts b/public/app/features/plugins/extensions/getPluginExtensions.ts index 5968bb6fc42..c81fd7c26f4 100644 --- a/public/app/features/plugins/extensions/getPluginExtensions.ts +++ b/public/app/features/plugins/extensions/getPluginExtensions.ts @@ -1,5 +1,5 @@ import { isString } from 'lodash'; -import { combineLatest, filter, map, Observable } from 'rxjs'; +import { combineLatest, map, Observable } from 'rxjs'; import { PluginExtensionTypes, @@ -37,16 +37,22 @@ import { export const getObservablePluginExtensions = ( options: Omit ): Observable> => { + const { extensionPointId } = options; + const { addedComponentsRegistry, addedLinksRegistry } = pluginExtensionRegistries; + return combineLatest([ - pluginExtensionRegistries.addedComponentsRegistry.asObservable(), - pluginExtensionRegistries.addedLinksRegistry.asObservable(), + addedComponentsRegistry.asObservableSlice((state) => state[extensionPointId]), + addedLinksRegistry.asObservableSlice((state) => state[extensionPointId]), ]).pipe( - filter(([components, links]) => Boolean(components) && Boolean(links)), // filter out uninitialized registries map(([components, links]) => getPluginExtensions({ ...options, - addedComponentsRegistry: components, - addedLinksRegistry: links, + addedComponentsRegistry: { + [extensionPointId]: components, + }, + addedLinksRegistry: { + [extensionPointId]: links, + }, }) ) ); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts index 5a18e222431..98c0fb1781b 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.test.ts @@ -1,5 +1,5 @@ import React from 'react'; -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, take } from 'rxjs'; import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -537,4 +537,238 @@ describe('AddedComponentsRegistry', () => { expect(Object.keys(currentState)).toHaveLength(1); expect(log.warning).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected slice from the registry', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Component'); + }); + }); + + it('should return undefined when the selected key does not exist', async () => { + const registry = new AddedComponentsRegistry(); + const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + expect(slice).toBeUndefined(); + }); + }); + + it('should only emit when the selected slice changes', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial empty state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first component + registry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Component 1', + description: 'Description 1', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Component 1'), + }, + ], + }); + + // Should emit because the slice changed + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + + // Register another component to the same extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Component 2', + description: 'Description 2', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Component 2'), + }, + ], + }); + + // Should emit because the slice changed (array reference changed) + expect(subscribeCallback).toHaveBeenCalledTimes(3); + expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2); + + // Register a component to a different extension point + registry.register({ + pluginId: 'test-plugin-3', + configs: [ + { + title: 'Component 3', + description: 'Description 3', + targets: ['grafana/other/point'], + component: () => React.createElement('div', null, 'Component 3'), + }, + ], + }); + + // Should NOT emit because the selected slice (for extensionPointId) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(3); + }); + + it('should deep freeze the selected slice', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + // @ts-expect-error - Testing that frozen objects cannot be modified + expect(() => slice.push({})).toThrow(); + expect(() => (slice[0].title = 'Modified')).toThrow(); + }); + }); + + it('should work with read-only registries', async () => { + const registry = new AddedComponentsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Component'); + }); + }); + + it('should emit immediately to new subscribers with the current slice value', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + // Subscribe after registration + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + const subscribeCallback = jest.fn(); + observable.subscribe(subscribeCallback); + + // Should have been called immediately with the current value + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1); + expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Component'); + }); + + it('should not emit when Object.is returns true for the same value', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + // Register a component + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + // Should emit once more + expect(subscribeCallback).toHaveBeenCalledTimes(2); + const firstValue = subscribeCallback.mock.calls[1][0]; + + // Register another component to a different extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Other Component', + description: 'Other description', + targets: ['grafana/other/point'], + component: () => React.createElement('div', null, 'Other'), + }, + ], + }); + + // Should NOT emit because the selected slice (same reference) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(2); + const secondValue = subscribeCallback.mock.calls[1][0]; + expect(Object.is(firstValue, secondValue)).toBe(true); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts index de732ce8c6d..b63f6405bc3 100644 --- a/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedComponentsRegistry.ts @@ -74,11 +74,9 @@ export class AddedComponentsRegistry extends Registry< pointIdLog.debug('Added component extension successfully registered'); - if (!(extensionPointId in registry)) { - registry[extensionPointId] = [result]; - } else { - registry[extensionPointId].push(result); - } + // Creating a new array instead of pushing to get a new reference + const slice = registry[extensionPointId] ?? []; + registry[extensionPointId] = slice.concat(result); } } diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts index ae0addc52c7..4c68a3a3eac 100644 --- a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.test.ts @@ -1,4 +1,4 @@ -import { firstValueFrom } from 'rxjs'; +import { firstValueFrom, take } from 'rxjs'; import { PluginLoadingStrategy } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -120,6 +120,61 @@ describe('addedFunctionsRegistry', () => { ], }); }); + + it('should not emit when registering with empty configs', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + const observable = addedFunctionsRegistry.asObservable(); + const subscribeCallback = jest.fn(); + + observable.subscribe(subscribeCallback); + + // Initial subscription should be called once with empty registry + expect(subscribeCallback).toHaveBeenCalledTimes(1); + + // Register with empty configs - should not emit + addedFunctionsRegistry.register({ + pluginId, + configs: [], + }); + + // Should still only be called once (no new emission) + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toEqual({}); + + // Verify registry state is still empty + const registry = await addedFunctionsRegistry.getState(); + expect(registry).toEqual({}); + }); + + it('should not change registry state when registering with empty configs after previous registrations', async () => { + const addedFunctionsRegistry = new AddedFunctionsRegistry(); + + // First register some extensions + addedFunctionsRegistry.register({ + pluginId, + configs: [ + { + title: 'Function 1', + description: 'Function 1 description', + targets: 'grafana/dashboard/panel/menu', + fn: jest.fn(), + }, + ], + }); + + const registryBefore = await addedFunctionsRegistry.getState(); + expect(Object.keys(registryBefore)).toHaveLength(1); + + // Register with empty configs - should not change state + addedFunctionsRegistry.register({ + pluginId, + configs: [], + }); + + const registryAfter = await addedFunctionsRegistry.getState(); + expect(registryAfter).toEqual(registryBefore); + }); + it('should be possible to asynchronously register function extensions for the same placement (different plugins)', async () => { const pluginId1 = 'grafana-basic-app'; const pluginId2 = 'grafana-basic-app2'; @@ -674,4 +729,191 @@ describe('addedFunctionsRegistry', () => { expect(Object.keys(currentState)).toHaveLength(1); expect(log.error).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected slice from the registry', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Function'); + }); + }); + + it('should return undefined when the selected key does not exist', async () => { + const registry = new AddedFunctionsRegistry(); + const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + expect(slice).toBeUndefined(); + }); + }); + + it('should only emit when the selected slice changes (distinctUntilChanged)', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial empty state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first function + registry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Function 1', + description: 'Description 1', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + // Should emit because the slice changed + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + + // Register another function to the same extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Function 2', + description: 'Description 2', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + // Should emit because the slice changed (array reference changed) + expect(subscribeCallback).toHaveBeenCalledTimes(3); + expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2); + + // Register a function to a different extension point + registry.register({ + pluginId: 'test-plugin-3', + configs: [ + { + title: 'Function 3', + description: 'Description 3', + targets: 'grafana/other/point', + fn: jest.fn(), + }, + ], + }); + + // Should NOT emit because the selected slice (for extensionPointId) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(3); + }); + + it('should deep freeze the selected slice', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + // @ts-expect-error - Testing that frozen objects cannot be modified + expect(() => slice.push({})).toThrow(); + expect(() => (slice[0].title = 'Modified')).toThrow(); + }); + }); + + it('should work with read-only registries', async () => { + const registry = new AddedFunctionsRegistry(); + const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Function'); + }); + }); + + it('should emit immediately to new subscribers with the current slice value', async () => { + const registry = new AddedFunctionsRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + description: 'Test description', + targets: extensionPointId, + fn: jest.fn(), + }, + ], + }); + + // Subscribe after registration + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + const subscribeCallback = jest.fn(); + observable.subscribe(subscribeCallback); + + // Should have been called immediately with the current value + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1); + expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Function'); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts index d23fe5e78b2..a57d4a83d3e 100644 --- a/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedFunctionsRegistry.ts @@ -11,10 +11,10 @@ import { PluginExtensionConfigs, Registry, RegistryType } from './Registry'; const logPrefix = 'Could not register function extension. Reason:'; -export type AddedFunctionsRegistryItem = { +export type AddedFunctionsRegistryItem = { pluginId: string; title: string; - fn: unknown; + fn: Signature; description?: string; }; @@ -67,11 +67,9 @@ export class AddedFunctionsRegistry extends Registry { expect(Object.keys(currentState)).toHaveLength(1); expect(log.error).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected slice from the registry', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Link'); + }); + }); + + it('should return undefined when the selected key does not exist', async () => { + const registry = new AddedLinksRegistry(); + const observable = registry.asObservableSlice((state) => state['non-existent-key']).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + expect(slice).toBeUndefined(); + }); + }); + + it('should only emit when the selected slice changes (distinctUntilChanged)', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initial empty state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first link + registry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Link 1', + description: 'Description 1', + path: '/path1', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Should emit because the slice changed + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + + // Register another link to the same extension point + registry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Link 2', + description: 'Description 2', + path: '/path2', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Should emit because the slice changed (array reference changed) + expect(subscribeCallback).toHaveBeenCalledTimes(3); + expect(subscribeCallback.mock.calls[2][0]?.length).toBe(2); + + // Register a link to a different extension point + registry.register({ + pluginId: 'test-plugin-3', + configs: [ + { + title: 'Link 3', + description: 'Description 3', + path: '/path3', + targets: 'grafana/other/point', + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Should NOT emit because the selected slice (for extensionPointId) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(3); + }); + + it('should deep freeze the selected slice', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + // @ts-expect-error - Testing that frozen objects cannot be modified + expect(() => slice.push({})).toThrow(); + expect(() => (slice[0].title = 'Modified')).toThrow(); + }); + }); + + it('should work with read-only registries', async () => { + const registry = new AddedLinksRegistry(); + const readOnlyRegistry = registry.readOnly(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + const observable = readOnlyRegistry.asObservableSlice((state) => state[extensionPointId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(Array.isArray(slice)).toBe(true); + expect(slice?.length).toBe(1); + expect(slice?.[0].title).toBe('Test Link'); + }); + }); + + it('should emit immediately to new subscribers with the current slice value', async () => { + const registry = new AddedLinksRegistry(); + const extensionPointId = 'grafana/dashboard/panel/menu'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + description: 'Test description', + path: '/test-path', + targets: extensionPointId, + configure: jest.fn().mockReturnValue({}), + }, + ], + }); + + // Subscribe after registration + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + const subscribeCallback = jest.fn(); + observable.subscribe(subscribeCallback); + + // Should have been called immediately with the current value + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]?.length).toBe(1); + expect(subscribeCallback.mock.calls[0][0]?.[0].title).toBe('Test Link'); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts index 5d6e8be7d72..b3f293675ce 100644 --- a/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts +++ b/public/app/features/plugins/extensions/registry/AddedLinksRegistry.ts @@ -74,13 +74,12 @@ export class AddedLinksRegistry extends Registry { expect(Object.keys(currentState)).toHaveLength(1); expect(log.error).not.toHaveBeenCalled(); }); + + describe('asObservableSlice', () => { + it('should return the selected exposed component from the registry', async () => { + const registry = new ExposedComponentsRegistry(); + const componentId = 'test-plugin/exposed-component/v1'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId, + title: 'Exposed Component', + description: 'Test description', + component: () => React.createElement('div', null, 'Exposed'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[componentId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(slice?.title).toBe('Exposed Component'); + expect(slice?.pluginId).toBe('test-plugin'); + }); + }); + + it('should deep freeze exposed components', async () => { + const registry = new ExposedComponentsRegistry(); + const componentId = 'test-plugin/exposed-component/v1'; + + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId, + title: 'Exposed Component', + description: 'Test description', + component: () => React.createElement('div', null, 'Exposed'), + }, + ], + }); + + const observable = registry.asObservableSlice((state) => state[componentId]).pipe(take(1)); + + await expect(observable).toEmitValuesWith((received) => { + const [slice] = received; + + expect(slice).toBeDefined(); + expect(() => (slice.title = 'Modified')).toThrow(); + }); + }); + + it('should only emit when the selected exposed component changes', async () => { + const registry = new ExposedComponentsRegistry(); + const componentId1 = 'test-plugin/component1/v1'; + const componentId2 = 'test-plugin/component2/v1'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[componentId1]); + observable.subscribe(subscribeCallback); + + // Initial state + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register first component + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId1, + title: 'Component 1', + description: 'Description 1', + component: () => React.createElement('div', null, 'Component 1'), + }, + ], + }); + + // Should emit + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.title).toBe('Component 1'); + + // Register second component (different id) + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: componentId2, + title: 'Component 2', + description: 'Description 2', + component: () => React.createElement('div', null, 'Component 2'), + }, + ], + }); + + // Should NOT emit because the selected slice (componentId1) didn't change + expect(subscribeCallback).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/public/app/features/plugins/extensions/registry/Registry.test.ts b/public/app/features/plugins/extensions/registry/Registry.test.ts new file mode 100644 index 00000000000..34e84b36886 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/Registry.test.ts @@ -0,0 +1,89 @@ +import React from 'react'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; + +describe('Registry.asObservableSlice ', () => { + describe('Shared behaviour', () => { + it('should handle selecting a non-existent key that later gets added', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId = 'grafana/alerting/home'; + const subscribeCallback = jest.fn(); + + const observable = registry.asObservableSlice((state) => state[extensionPointId]); + observable.subscribe(subscribeCallback); + + // Initially undefined + expect(subscribeCallback).toHaveBeenCalledTimes(1); + expect(subscribeCallback.mock.calls[0][0]).toBeUndefined(); + + // Register component + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + description: 'Test description', + targets: [extensionPointId], + component: () => React.createElement('div', null, 'Test'), + }, + ], + }); + + // Should emit the new value + expect(subscribeCallback).toHaveBeenCalledTimes(2); + expect(subscribeCallback.mock.calls[1][0]?.length).toBe(1); + }); + + it('should handle multiple subscribers independently', async () => { + const registry = new AddedComponentsRegistry(); + const extensionPointId1 = 'grafana/alerting/home'; + const extensionPointId2 = 'grafana/other/point'; + const callback1 = jest.fn(); + const callback2 = jest.fn(); + + const observable1 = registry.asObservableSlice((state) => state[extensionPointId1]); + const observable2 = registry.asObservableSlice((state) => state[extensionPointId2]); + + observable1.subscribe(callback1); + observable2.subscribe(callback2); + + // Both should receive initial undefined + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + + // Register component for extensionPointId1 + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Component 1', + description: 'Description 1', + targets: [extensionPointId1], + component: () => React.createElement('div', null, 'Component 1'), + }, + ], + }); + + // Only callback1 should be called + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(1); + + // Register component for extensionPointId2 + registry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Component 2', + description: 'Description 2', + targets: [extensionPointId2], + component: () => React.createElement('div', null, 'Component 2'), + }, + ], + }); + + // Only callback2 should be called + expect(callback1).toHaveBeenCalledTimes(2); + expect(callback2).toHaveBeenCalledTimes(2); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/Registry.ts b/public/app/features/plugins/extensions/registry/Registry.ts index b2fbfbc09d8..d37e5276bc6 100644 --- a/public/app/features/plugins/extensions/registry/Registry.ts +++ b/public/app/features/plugins/extensions/registry/Registry.ts @@ -1,4 +1,4 @@ -import { Observable, ReplaySubject, Subject, firstValueFrom, map, scan, startWith } from 'rxjs'; +import { Observable, ReplaySubject, Subject, distinctUntilChanged, firstValueFrom, map, scan, startWith } from 'rxjs'; import { ExtensionsLog, log } from '../logs/log'; import { deepFreeze } from '../utils'; @@ -13,7 +13,7 @@ export type PluginExtensionConfigs = { export type RegistryType = Record; // This is the base-class used by the separate specific registries. -export abstract class Registry { +export abstract class Registry, TMapType> { // Used in cases when we would like to pass a read-only registry to plugin. // In these cases we are passing in the `registrySubject` to the constructor. // (If TRUE `initialState` is ignored.) @@ -38,7 +38,6 @@ export abstract class Registry { if (options.registrySubject) { this.registrySubject = options.registrySubject; this.isReadOnly = true; - return; } @@ -47,8 +46,7 @@ export abstract class Registry { .pipe( scan(this.mapToRegistry.bind(this), options.initialState ?? {}), // Emit an empty registry to start the stream (it is only going to do it once during construction, and then just passes down the values) - startWith(options.initialState ?? {}), - map((registry) => deepFreeze(registry)) + startWith(options.initialState ?? {}) ) // Emitting the new registry to `this.registrySubject` .subscribe(this.registrySubject); @@ -63,12 +61,22 @@ export abstract class Registry { if (this.isReadOnly) { throw new Error(MSG_CANNOT_REGISTER_READ_ONLY); } - + if (result.configs.length === 0) { + return; + } this.resultSubject.next(result); } asObservable(): Observable> { - return this.registrySubject.asObservable(); + return this.registrySubject.asObservable().pipe(map((state) => deepFreeze(state))); + } + + asObservableSlice(selector: (state: RegistryType) => TRegistryValue): Observable { + return this.registrySubject.asObservable().pipe( + map((state) => selector(state)), + distinctUntilChanged((prev, curr) => Object.is(prev, curr)), + map((slice) => deepFreeze(slice)) + ); } getState(): Promise> { diff --git a/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx b/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx new file mode 100644 index 00000000000..c31797ea2dd --- /dev/null +++ b/public/app/features/plugins/extensions/registry/useRegistrySlice.test.tsx @@ -0,0 +1,410 @@ +import { renderHook } from '@testing-library/react'; +import React from 'react'; + +import { ExtensionRegistriesProvider } from '../ExtensionRegistriesContext'; + +import { AddedComponentsRegistry } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistry } from './AddedFunctionsRegistry'; +import { AddedLinksRegistry } from './AddedLinksRegistry'; +import { ExposedComponentsRegistry } from './ExposedComponentsRegistry'; +import { PluginExtensionRegistries } from './types'; +import { + useAddedComponentsRegistrySlice, + useAddedFunctionsRegistrySlice, + useAddedLinksRegistrySlice, + useExposedComponentRegistrySlice, +} from './useRegistrySlice'; + +describe('useRegistrySlice', () => { + let registries: PluginExtensionRegistries; + let wrapper: ({ children }: { children: React.ReactNode }) => JSX.Element; + + beforeEach(() => { + registries = { + addedComponentsRegistry: new AddedComponentsRegistry(), + exposedComponentsRegistry: new ExposedComponentsRegistry(), + addedLinksRegistry: new AddedLinksRegistry(), + addedFunctionsRegistry: new AddedFunctionsRegistry(), + }; + + wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} + ); + }); + + describe('useAddedComponentsRegistrySlice', () => { + it('should return undefined when no components are registered for the extension point', () => { + const { result } = renderHook(() => useAddedComponentsRegistrySlice('test/extension-point'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return an array of components when components are registered for the extension point', () => { + const extensionPointId = 'test/extension-point'; + const TestComponent = () =>
Test Component
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + component: TestComponent, + targets: extensionPointId, + description: 'Test description', + }, + ], + }); + + const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Component'); + expect(result.current?.[0].pluginId).toBe('test-plugin'); + expect(result.current?.[0].description).toBe('Test description'); + }); + + it('should return multiple components when multiple components are registered for the same extension point', () => { + const extensionPointId = 'test/extension-point'; + const TestComponent1 = () =>
Test Component 1
; + const TestComponent2 = () =>
Test Component 2
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Test Component 1', + component: TestComponent1, + targets: extensionPointId, + description: 'Test description 1', + }, + ], + }); + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Test Component 2', + component: TestComponent2, + targets: extensionPointId, + description: 'Test description 2', + }, + ], + }); + + const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(2); + expect(result.current?.[0].title).toBe('Test Component 1'); + expect(result.current?.[0].description).toBe('Test description 1'); + expect(result.current?.[1].title).toBe('Test Component 2'); + expect(result.current?.[1].description).toBe('Test description 2'); + }); + + it('should only return components for the specified extension point', () => { + const extensionPointId1 = 'test/extension-point-1'; + const extensionPointId2 = 'test/extension-point-2'; + const TestComponent1 = () =>
Test Component 1
; + const TestComponent2 = () =>
Test Component 2
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component 1', + component: TestComponent1, + targets: extensionPointId1, + description: 'Test description 1', + }, + { + title: 'Test Component 2', + component: TestComponent2, + targets: extensionPointId2, + description: 'Test description 2', + }, + ], + }); + + const { result } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId1), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Component 1'); + expect(result.current?.[0].description).toBe('Test description 1'); + }); + }); + + describe('useExposedComponentsRegistrySlice', () => { + it('should return undefined when no component is exposed for the extension point', () => { + const { result } = renderHook(() => useExposedComponentRegistrySlice('test/exposed-component'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return a single exposed component when registered', () => { + const exposedComponentId = 'test-plugin/exposed-component'; + const TestComponent = () =>
Exposed Component
; + + registries.exposedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: exposedComponentId, + title: 'Exposed Component', + component: TestComponent, + }, + ], + }); + + const { result } = renderHook(() => useExposedComponentRegistrySlice(exposedComponentId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.title).toBe('Exposed Component'); + expect(result.current?.pluginId).toBe('test-plugin'); + expect(Array.isArray(result.current)).toBe(false); + }); + + it('should return undefined for a different extension point id', () => { + const exposedComponentId = 'test-plugin/exposed-component'; + const otherId = 'test-plugin/other-component'; + const TestComponent = () =>
Exposed Component
; + + registries.exposedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + id: exposedComponentId, + title: 'Exposed Component', + component: TestComponent, + }, + ], + }); + + const { result } = renderHook(() => useExposedComponentRegistrySlice(otherId), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + }); + + describe('useAddedLinksRegistrySlice', () => { + it('should return undefined when no links are registered for the extension point', () => { + const { result } = renderHook(() => useAddedLinksRegistrySlice('test/extension-point'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return an array of links when links are registered for the extension point', () => { + const extensionPointId = 'test/extension-point'; + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link', + path: '/test-path', + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Link'); + expect(result.current?.[0].path).toBe('/test-path'); + expect(result.current?.[0].pluginId).toBe('test-plugin'); + }); + + it('should return multiple links when multiple links are registered for the same extension point', () => { + const extensionPointId = 'test/extension-point'; + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Test Link 1', + path: '/test-path-1', + targets: extensionPointId, + }, + ], + }); + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Test Link 2', + path: '/test-path-2', + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(2); + expect(result.current?.[0].title).toBe('Test Link 1'); + expect(result.current?.[1].title).toBe('Test Link 2'); + }); + + it('should only return links for the specified extension point', () => { + const extensionPointId1 = 'test/extension-point-1'; + const extensionPointId2 = 'test/extension-point-2'; + + registries.addedLinksRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Link 1', + path: '/test-path-1', + targets: extensionPointId1, + }, + { + title: 'Test Link 2', + path: '/test-path-2', + targets: extensionPointId2, + }, + ], + }); + + const { result } = renderHook(() => useAddedLinksRegistrySlice(extensionPointId1), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Link 1'); + }); + }); + + describe('useAddedFunctionsRegistrySlice', () => { + it('should return undefined when no functions are registered for the extension point', () => { + const { result } = renderHook(() => useAddedFunctionsRegistrySlice('test/extension-point'), { wrapper }); + + expect(result.current).toBeUndefined(); + }); + + it('should return an array of functions when functions are registered for the extension point', () => { + const extensionPointId = 'test/extension-point'; + const testFunction = jest.fn(); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function', + fn: testFunction, + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(Array.isArray(result.current)).toBe(true); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Function'); + expect(result.current?.[0].fn).toBe(testFunction); + expect(result.current?.[0].pluginId).toBe('test-plugin'); + }); + + it('should return multiple functions when multiple functions are registered for the same extension point', () => { + const extensionPointId = 'test/extension-point'; + const testFunction1 = jest.fn(); + const testFunction2 = jest.fn(); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin-1', + configs: [ + { + title: 'Test Function 1', + fn: testFunction1, + targets: extensionPointId, + }, + ], + }); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin-2', + configs: [ + { + title: 'Test Function 2', + fn: testFunction2, + targets: extensionPointId, + }, + ], + }); + + const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(2); + expect(result.current?.[0].title).toBe('Test Function 1'); + expect(result.current?.[0].fn).toBe(testFunction1); + expect(result.current?.[1].title).toBe('Test Function 2'); + expect(result.current?.[1].fn).toBe(testFunction2); + }); + + it('should only return functions for the specified extension point', () => { + const extensionPointId1 = 'test/extension-point-1'; + const extensionPointId2 = 'test/extension-point-2'; + const testFunction1 = jest.fn(); + const testFunction2 = jest.fn(); + + registries.addedFunctionsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Function 1', + fn: testFunction1, + targets: extensionPointId1, + }, + { + title: 'Test Function 2', + fn: testFunction2, + targets: extensionPointId2, + }, + ], + }); + + const { result } = renderHook(() => useAddedFunctionsRegistrySlice(extensionPointId1), { wrapper }); + + expect(result.current).toBeDefined(); + expect(result.current?.length).toBe(1); + expect(result.current?.[0].title).toBe('Test Function 1'); + expect(result.current?.[0].fn).toBe(testFunction1); + }); + }); + + describe('observable memoization', () => { + it('should memoize the observable when extensionPointId and registry do not change', () => { + const extensionPointId = 'test/extension-point'; + const TestComponent = () =>
Test Component
; + + registries.addedComponentsRegistry.register({ + pluginId: 'test-plugin', + configs: [ + { + title: 'Test Component', + component: TestComponent, + targets: extensionPointId, + description: 'Test description', + }, + ], + }); + + const { result, rerender } = renderHook(() => useAddedComponentsRegistrySlice(extensionPointId), { wrapper }); + + const firstResult = result.current; + + rerender(); + + expect(result.current).toBe(firstResult); + }); + }); +}); diff --git a/public/app/features/plugins/extensions/registry/useRegistrySlice.tsx b/public/app/features/plugins/extensions/registry/useRegistrySlice.tsx new file mode 100644 index 00000000000..e5bb5234f58 --- /dev/null +++ b/public/app/features/plugins/extensions/registry/useRegistrySlice.tsx @@ -0,0 +1,53 @@ +import { useMemo } from 'react'; +import { useObservable } from 'react-use'; + +import { + useAddedComponentsRegistry, + useAddedFunctionsRegistry, + useAddedLinksRegistry, + useExposedComponentsRegistry, +} from '../ExtensionRegistriesContext'; + +import { AddedComponentRegistryItem } from './AddedComponentsRegistry'; +import { AddedFunctionsRegistryItem } from './AddedFunctionsRegistry'; +import { AddedLinkRegistryItem } from './AddedLinksRegistry'; +import { ExposedComponentRegistryItem } from './ExposedComponentsRegistry'; +import { Registry } from './Registry'; + +export function useAddedComponentsRegistrySlice( + extensionPointId: string +): Array> | undefined { + const registry = useAddedComponentsRegistry(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return useRegistrySlice(extensionPointId, registry) as Array> | undefined; +} + +export function useExposedComponentRegistrySlice(id: string): ExposedComponentRegistryItem | undefined { + const registry = useExposedComponentsRegistry(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return useRegistrySlice(id, registry) as ExposedComponentRegistryItem | undefined; +} + +export function useAddedLinksRegistrySlice(extensionPointId: string): AddedLinkRegistryItem[] | undefined { + const registry = useAddedLinksRegistry(); + return useRegistrySlice(extensionPointId, registry); +} + +export function useAddedFunctionsRegistrySlice( + extensionPointId: string +): Array> | undefined { + const registry = useAddedFunctionsRegistry(); + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + return useRegistrySlice(extensionPointId, registry) as Array> | undefined; +} + +function useRegistrySlice, TMapType>( + extensionPointId: string, + registry: Registry +): TRegistryValue | undefined { + const observable = useMemo(() => { + return registry.asObservableSlice((state) => state[extensionPointId]); + }, [extensionPointId, registry]); + + return useObservable(observable); +} diff --git a/public/app/features/plugins/extensions/usePluginComponent.tsx b/public/app/features/plugins/extensions/usePluginComponent.tsx index 7a2d3669b24..c85eabb0441 100644 --- a/public/app/features/plugins/extensions/usePluginComponent.tsx +++ b/public/app/features/plugins/extensions/usePluginComponent.tsx @@ -1,12 +1,11 @@ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { usePluginContext } from '@grafana/data'; import { UsePluginComponentResult } from '@grafana/runtime'; -import { useExposedComponentsRegistry } from './ExtensionRegistriesContext'; import * as errors from './errors'; import { log } from './logs/log'; +import { useExposedComponentRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { getExposedComponentPluginDependencies, isGrafanaDevMode, wrapWithPluginContext } from './utils'; import { isExposedComponentDependencyMissing } from './validators'; @@ -14,8 +13,7 @@ import { isExposedComponentDependencyMissing } from './validators'; // Returns a component exposed by a plugin. // (Exposed components can be defined in plugins by calling .exposeComponent() on the AppPlugin instance.) export function usePluginComponent(id: string): UsePluginComponentResult { - const registry = useExposedComponentsRegistry(); - const registryState = useObservable(registry.asObservable()); + const registryItem = useExposedComponentRegistrySlice(id); const pluginContext = usePluginContext(); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExposedComponentPluginDependencies(id)); @@ -30,14 +28,13 @@ export function usePluginComponent(id: string): UsePl }; } - if (!registryState?.[id]) { + if (!registryItem) { return { isLoading: false, component: null, }; } - const registryItem = registryState[id]; const componentLog = log.child({ title: registryItem.title, description: registryItem.description ?? '', @@ -61,5 +58,5 @@ export function usePluginComponent(id: string): UsePl log: componentLog, }), }; - }, [id, pluginContext, registryState, isLoadingAppPlugins]); + }, [id, pluginContext, registryItem, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginComponents.tsx b/public/app/features/plugins/extensions/usePluginComponents.tsx index 8126838f87a..e02fa956bbb 100644 --- a/public/app/features/plugins/extensions/usePluginComponents.tsx +++ b/public/app/features/plugins/extensions/usePluginComponents.tsx @@ -1,5 +1,4 @@ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { type ComponentTypeWithExtensionMeta, @@ -9,8 +8,8 @@ import { } from '@grafana/data'; import { UsePluginComponentsOptions, UsePluginComponentsResult } from '@grafana/runtime'; -import { useAddedComponentsRegistry } from './ExtensionRegistriesContext'; import { AddedComponentRegistryItem } from './registry/AddedComponentsRegistry'; +import { useAddedComponentsRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, getExtensionPointPluginDependencies } from './utils'; import { validateExtensionPoint } from './validateExtensionPoint'; @@ -20,8 +19,7 @@ export function usePluginComponents({ limitPerPlugin, extensionPointId, }: UsePluginComponentsOptions): UsePluginComponentsResult { - const registry = useAddedComponentsRegistry(); - const registryState = useObservable(registry.asObservable()); + const registryItems = useAddedComponentsRegistrySlice(extensionPointId); const pluginContext = usePluginContext(); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); @@ -38,7 +36,7 @@ export function usePluginComponents({ const components: Array> = []; const extensionsByPlugin: Record = {}; - for (const registryItem of registryState?.[extensionPointId] ?? []) { + for (const registryItem of registryItems ?? []) { const { pluginId } = registryItem; // Only limit if the `limitPerPlugin` is set @@ -50,10 +48,7 @@ export function usePluginComponents({ extensionsByPlugin[pluginId] = 0; } - const component = createComponentWithMeta( - registryItem as AddedComponentRegistryItem, - extensionPointId - ); + const component = createComponentWithMeta(registryItem, extensionPointId); components.push(component); extensionsByPlugin[pluginId] += 1; @@ -63,7 +58,7 @@ export function usePluginComponents({ isLoading: false, components, }; - }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]); } export function createComponentWithMeta( diff --git a/public/app/features/plugins/extensions/usePluginFunctions.tsx b/public/app/features/plugins/extensions/usePluginFunctions.tsx index df4f95678a7..b0c065d7aaa 100644 --- a/public/app/features/plugins/extensions/usePluginFunctions.tsx +++ b/public/app/features/plugins/extensions/usePluginFunctions.tsx @@ -1,10 +1,9 @@ import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { usePluginContext, PluginExtensionFunction, PluginExtensionTypes } from '@grafana/data'; import { UsePluginFunctionsOptions, UsePluginFunctionsResult } from '@grafana/runtime'; -import { useAddedFunctionsRegistry } from './ExtensionRegistriesContext'; +import { useAddedFunctionsRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, getExtensionPointPluginDependencies } from './utils'; import { validateExtensionPoint } from './validateExtensionPoint'; @@ -14,8 +13,7 @@ export function usePluginFunctions({ limitPerPlugin, extensionPointId, }: UsePluginFunctionsOptions): UsePluginFunctionsResult { - const registry = useAddedFunctionsRegistry(); - const registryState = useObservable(registry.asObservable()); + const registryItems = useAddedFunctionsRegistrySlice(extensionPointId); const pluginContext = usePluginContext(); const deps = getExtensionPointPluginDependencies(extensionPointId); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(deps); @@ -33,7 +31,7 @@ export function usePluginFunctions({ const results: Array> = []; const extensionsByPlugin: Record = {}; - for (const registryItem of registryState?.[extensionPointId] ?? []) { + for (const registryItem of registryItems ?? []) { const { pluginId } = registryItem; // Only limit if the `limitPerPlugin` is set @@ -51,7 +49,7 @@ export function usePluginFunctions({ title: registryItem.title, description: registryItem.description ?? '', pluginId: pluginId, - fn: registryItem.fn as Signature, + fn: registryItem.fn, }); extensionsByPlugin[pluginId] += 1; } @@ -60,5 +58,5 @@ export function usePluginFunctions({ isLoading: false, functions: results, }; - }, [extensionPointId, limitPerPlugin, pluginContext, registryState, isLoadingAppPlugins]); + }, [extensionPointId, limitPerPlugin, pluginContext, registryItems, isLoadingAppPlugins]); } diff --git a/public/app/features/plugins/extensions/usePluginLinks.tsx b/public/app/features/plugins/extensions/usePluginLinks.tsx index be25ec5105a..70518ab9450 100644 --- a/public/app/features/plugins/extensions/usePluginLinks.tsx +++ b/public/app/features/plugins/extensions/usePluginLinks.tsx @@ -1,11 +1,10 @@ import { isString } from 'lodash'; import { useMemo } from 'react'; -import { useObservable } from 'react-use'; import { PluginExtensionLink, PluginExtensionTypes, usePluginContext } from '@grafana/data'; import { UsePluginLinksOptions, UsePluginLinksResult } from '@grafana/runtime'; -import { useAddedLinksRegistry } from './ExtensionRegistriesContext'; +import { useAddedLinksRegistrySlice } from './registry/useRegistrySlice'; import { useLoadAppPlugins } from './useLoadAppPlugins'; import { generateExtensionId, @@ -23,9 +22,8 @@ export function usePluginLinks({ extensionPointId, context, }: UsePluginLinksOptions): UsePluginLinksResult { - const registry = useAddedLinksRegistry(); + const registryItems = useAddedLinksRegistrySlice(extensionPointId); const pluginContext = usePluginContext(); - const registryState = useObservable(registry.asObservable()); const { isLoading: isLoadingAppPlugins } = useLoadAppPlugins(getExtensionPointPluginDependencies(extensionPointId)); return useMemo(() => { @@ -46,7 +44,7 @@ export function usePluginLinks({ const extensions: PluginExtensionLink[] = []; const extensionsByPlugin: Record = {}; - for (const addedLink of registryState?.[extensionPointId] ?? []) { + for (const addedLink of registryItems ?? []) { const { pluginId } = addedLink; const linkLog = pointLog.child({ path: addedLink.path ?? '', @@ -96,5 +94,5 @@ export function usePluginLinks({ isLoading: false, links: extensions, }; - }, [context, extensionPointId, limitPerPlugin, registryState, pluginContext, isLoadingAppPlugins]); + }, [context, extensionPointId, limitPerPlugin, registryItems, pluginContext, isLoadingAppPlugins]); } From a6952b554f70fea9daa6892fb7e09281a5fca1c2 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Tue, 18 Nov 2025 14:21:55 +0100 Subject: [PATCH 033/125] Alerting: Rules filtering improvements (#114073) * Reorganize filtering code structure * Remove unused imports * Simplify naming --- .../rule-list/hooks/datasourceFilter.test.ts | 325 +++++++++++++++ .../rule-list/hooks/datasourceFilter.ts | 56 +++ .../hooks/filterNormalization.test.ts | 95 +++++ .../rule-list/hooks/filterNormalization.ts | 43 ++ .../rule-list/hooks/filterPredicates.ts | 267 ++++++++++++ ...{filters.test.ts => grafanaFilter.test.ts} | 382 ++++-------------- .../unified/rule-list/hooks/grafanaFilter.ts | 117 ++++++ .../hooks/useFilteredRulesIterator.test.ts | 21 - 8 files changed, 981 insertions(+), 325 deletions(-) create mode 100644 public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts create mode 100644 public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts rename public/app/features/alerting/unified/rule-list/hooks/{filters.test.ts => grafanaFilter.test.ts} (64%) create mode 100644 public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts new file mode 100644 index 00000000000..f15318642e8 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.test.ts @@ -0,0 +1,325 @@ +import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; +import { RuleHealth } from '../../search/rulesSearchParser'; +import { Annotation } from '../../utils/constants'; +import { getDatasourceAPIUid } from '../../utils/datasource'; +import { getFilter } from '../../utils/search'; + +import { getDatasourceFilter } from './datasourceFilter'; + +jest.mock('../../utils/datasource'); + +const getDatasourceAPIUidMock = jest.mocked(getDatasourceAPIUid); + +getDatasourceAPIUidMock.mockImplementation((ruleSourceName) => { + if (ruleSourceName === 'prometheus') { + return 'datasource-uid-1'; + } + if (ruleSourceName === 'loki') { + return 'datasource-uid-3'; + } + throw new Error(`Unknown datasource name: ${ruleSourceName}`); +}); + +describe('datasource-managed rules', () => { + describe('groupFilter', () => { + it('should filter by namespace (file path)', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { groupMatches } = getDatasourceFilter(getFilter({ namespace: 'production' })); + expect(groupMatches(group)).toBe(true); + + const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ namespace: 'staging' })); + expect(groupMatches2(group)).toBe(false); + }); + + it('should filter by group name', () => { + const group: PromRuleGroupDTO = { + name: 'CPU Usage Alerts', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { groupMatches } = getDatasourceFilter(getFilter({ groupName: 'cpu' })); + expect(groupMatches(group)).toBe(true); + + const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ groupName: 'memory' })); + expect(groupMatches2(group)).toBe(false); + }); + + it('should return true when no filters are applied', () => { + const group: PromRuleGroupDTO = { + name: 'Test Group', + file: 'production/alerts', + rules: [], + interval: 60, + }; + + const { groupMatches } = getDatasourceFilter(getFilter({})); + expect(groupMatches(group)).toBe(true); + }); + }); + + describe('ruleFilter', () => { + it('should filter by free form words in rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ freeFormWords: ['cpu'] })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ freeFormWords: ['memory'] })); + expect(ruleMatches2(rule)).toBe(false); + }); + + it('should filter by rule name', () => { + const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleName: 'cpu' })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleName: 'memory' })); + expect(ruleMatches2(rule)).toBe(false); + }); + + it('should filter by labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical', team: 'ops' }, + alerts: [], + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['severity=critical'] })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['severity=warning'] })); + expect(ruleMatches2(rule)).toBe(false); + + const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['team=ops'] })); + expect(ruleMatches3(rule)).toBe(true); + }); + + it('should filter by alert instance labels', () => { + const rule = mockPromAlertingRule({ + labels: { severity: 'critical' }, + alerts: [ + { + labels: { instance: 'server-1', env: 'production' }, + state: PromAlertingRuleState.Firing, + value: '100', + activeAt: '', + annotations: {}, + }, + ], + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['instance=server-1'] })); + expect(ruleMatches(rule)).toBe(true); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['env=production'] })); + expect(ruleMatches2(rule)).toBe(true); + + const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['instance=server-2'] })); + expect(ruleMatches3(rule)).toBe(false); + }); + + it('should filter by rule type', () => { + const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); + const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Alerting })); + expect(ruleMatches(alertingRule)).toBe(true); + expect(ruleMatches(recordingRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Recording })); + expect(ruleMatches2(alertingRule)).toBe(false); + expect(ruleMatches2(recordingRule)).toBe(true); + }); + + it('should filter by rule state', () => { + const firingRule = mockPromAlertingRule({ + name: 'Firing Alert', + state: PromAlertingRuleState.Firing, + }); + + const pendingRule = mockPromAlertingRule({ + name: 'Pending Alert', + state: PromAlertingRuleState.Pending, + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); + expect(ruleMatches(firingRule)).toBe(true); + expect(ruleMatches(pendingRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter( + getFilter({ ruleState: PromAlertingRuleState.Pending }) + ); + expect(ruleMatches2(firingRule)).toBe(false); + expect(ruleMatches2(pendingRule)).toBe(true); + }); + + it('should filter out recording rules when filtering by rule state', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + }); + + // Recording rules should always be filtered out when any rule state filter is applied as they don't have a state + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); + expect(ruleMatches(recordingRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter( + getFilter({ ruleState: PromAlertingRuleState.Pending }) + ); + expect(ruleMatches2(recordingRule)).toBe(false); + + const { ruleMatches: ruleMatches3 } = getDatasourceFilter( + getFilter({ ruleState: PromAlertingRuleState.Inactive }) + ); + expect(ruleMatches3(recordingRule)).toBe(false); + }); + + it('should filter by rule health', () => { + const healthyRule = mockPromAlertingRule({ + name: 'Healthy Rule', + health: RuleHealth.Ok, + }); + + const errorRule = mockPromAlertingRule({ + name: 'Error Rule', + health: RuleHealth.Error, + }); + + const prometheusErrorRule = mockPromAlertingRule({ + name: 'Error Rule', + health: 'err', + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Ok })); + expect(ruleMatches(healthyRule)).toBe(true); + expect(ruleMatches(errorRule)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error })); + expect(ruleMatches2(healthyRule)).toBe(false); + expect(ruleMatches2(errorRule)).toBe(true); + expect(ruleMatches2(prometheusErrorRule)).toBe(true); + }); + + it('should normalize health values when filtering', () => { + // Legacy Prometheus health value 'err' should be normalized to 'error' + const legacyErrorRule = mockPromAlertingRule({ + name: 'Legacy Error Rule', + health: 'err', + }); + + // When filtering for 'error', it should match rules with health 'err' (legacy) or 'error' + const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error })); + expect(ruleMatches(legacyErrorRule)).toBe(true); + }); + + it('should filter by dashboard UID', () => { + const ruleDashboardA = mockPromAlertingRule({ + name: 'Dashboard A Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, + }); + + const ruleDashboardB = mockPromAlertingRule({ + name: 'Dashboard B Rule', + annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-a' })); + expect(ruleMatches(ruleDashboardA)).toBe(true); + expect(ruleMatches(ruleDashboardB)).toBe(false); + + const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-b' })); + expect(ruleMatches2(ruleDashboardA)).toBe(false); + expect(ruleMatches2(ruleDashboardB)).toBe(true); + }); + + it('should filter out recording rules when filtering by dashboard UID', () => { + const recordingRule = mockPromRecordingRule({ + name: 'Recording Rule', + // Recording rules cannot have dashboard UIDs because they don't have annotations + }); + + // Dashboard UID filter should filter out recording rules + const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'any-dashboard' })); + expect(ruleMatches(recordingRule)).toBe(false); + }); + + describe('dataSourceNames filter', () => { + it('should match rules that use the filtered datasource', () => { + // Create a Grafana rule with matching datasource + const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1'], + }); + + // 'prometheus' resolves to 'datasource-uid-1' which is in the rule + const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(ruleMatches(ruleWithMatchingDatasource)).toBe(true); + }); + + it("should filter out rules that don't use the filtered datasource", () => { + // Create a Grafana rule without the target datasource + const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ + queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], + }); + + // 'loki' resolves to 'datasource-uid-3' which is not in the rule + const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['loki'] })); + expect(ruleMatches(ruleWithoutMatchingDatasource)).toBe(false); + }); + + it('should return false when there is an error parsing the query', () => { + const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ + query: 'not-valid-json', + }); + + const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] })); + expect(ruleMatches(ruleWithInvalidQuery)).toBe(false); + }); + }); + + it('should combine multiple filters with AND logic', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=critical', 'environment=production'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + const { ruleMatches } = getDatasourceFilter(filter); + expect(ruleMatches(rule)).toBe(true); + }); + + it('should return false if any filter does not match', () => { + const rule = mockPromAlertingRule({ + name: 'High CPU Usage Production', + labels: { severity: 'critical', environment: 'production' }, + state: PromAlertingRuleState.Firing, + health: RuleHealth.Ok, + alerts: [], + }); + + const filter = getFilter({ + ruleName: 'cpu', + labels: ['severity=warning'], + ruleState: PromAlertingRuleState.Firing, + ruleHealth: RuleHealth.Ok, + }); + const { ruleMatches } = getDatasourceFilter(filter); + expect(ruleMatches(rule)).toBe(false); + }); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts new file mode 100644 index 00000000000..e6aa3089e55 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/datasourceFilter.ts @@ -0,0 +1,56 @@ +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { RulesFilter } from '../../search/rulesSearchParser'; + +import { normalizeFilterState } from './filterNormalization'; +import { + GroupFilterConfig, + RuleFilterConfig, + contactPointFilter, + dashboardUidFilter, + dataSourceNamesFilter, + freeFormFilter, + groupMatches, + groupNameFilter, + labelsFilter, + namespaceFilter, + pluginsFilter, + ruleHealthFilter, + ruleMatches, + ruleNameFilter, + ruleStateFilter, + ruleTypeFilter, +} from './filterPredicates'; + +/** + * Builds filter configurations for data source-managed alert rules. + * + * Constructs filter objects for both rules and groups that are managed by data sources. + * All filters are applied on the client-side for data source rules. + */ +export function getDatasourceFilter(filterState: RulesFilter) { + const normalizedFilterState = normalizeFilterState(filterState); + + const dsRuleFilterConfig: RuleFilterConfig = { + freeFormWords: freeFormFilter, + ruleName: ruleNameFilter, + ruleState: ruleStateFilter, + ruleType: ruleTypeFilter, + dataSourceNames: dataSourceNamesFilter, + labels: labelsFilter, + ruleHealth: ruleHealthFilter, + dashboardUid: dashboardUidFilter, + plugins: pluginsFilter, + contactPoint: contactPointFilter, + }; + + const dsGroupFilterConfig: GroupFilterConfig = { + namespace: namespaceFilter, + groupName: groupNameFilter, + }; + + return { + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, dsGroupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, dsRuleFilterConfig), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts new file mode 100644 index 00000000000..83a308c4563 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.test.ts @@ -0,0 +1,95 @@ +import { PromRuleType } from 'app/types/unified-alerting-dto'; + +import { getFilter } from '../../utils/search'; + +import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; + +describe('buildTitleSearch', () => { + it('should return undefined when no title filters are provided', () => { + expect(buildTitleSearch(getFilter({}))).toBeUndefined(); + }); + + it('should return ruleName when only ruleName is provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'high cpu' }))).toBe('high cpu'); + }); + + it('should return freeFormWords when only freeFormWords are provided', () => { + expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', 'usage'] }))).toBe('cpu usage'); + }); + + it('should combine ruleName and freeFormWords', () => { + expect(buildTitleSearch(getFilter({ ruleName: 'alert', freeFormWords: ['cpu'] }))).toBe('alert cpu'); + }); + + it('should trim whitespace from inputs', () => { + expect(buildTitleSearch(getFilter({ ruleName: ' alert ', freeFormWords: [' cpu ', ' usage '] }))).toBe( + 'alert cpu usage' + ); + }); + + it('should filter out empty strings from freeFormWords', () => { + expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', '', ' ', 'usage'] }))).toBe('cpu usage'); + }); + + it('should return undefined when only empty strings are provided', () => { + expect(buildTitleSearch(getFilter({ ruleName: ' ', freeFormWords: ['', ' '] }))).toBeUndefined(); + }); +}); + +describe('normalizeFilterState', () => { + it('should lowercase freeFormWords', () => { + const result = normalizeFilterState(getFilter({ freeFormWords: ['CPU', 'Usage'] })); + expect(result.freeFormWords).toEqual(['cpu', 'usage']); + }); + + it('should lowercase ruleName', () => { + const result = normalizeFilterState(getFilter({ ruleName: 'High CPU' })); + expect(result.ruleName).toBe('high cpu'); + }); + + it('should lowercase groupName', () => { + const result = normalizeFilterState(getFilter({ groupName: 'Production Alerts' })); + expect(result.groupName).toBe('production alerts'); + }); + + it('should lowercase namespace', () => { + const result = normalizeFilterState(getFilter({ namespace: 'Production/Alerts' })); + expect(result.namespace).toBe('production/alerts'); + }); + + it('should handle undefined values', () => { + const result = normalizeFilterState(getFilter({})); + expect(result.ruleName).toBeUndefined(); + expect(result.groupName).toBeUndefined(); + expect(result.namespace).toBeUndefined(); + }); + + it('should preserve other filter properties', () => { + const result = normalizeFilterState( + getFilter({ + ruleName: 'Alert', + ruleType: PromRuleType.Alerting, + labels: ['severity=critical'], + dataSourceNames: ['prometheus'], + }) + ); + expect(result.ruleType).toBe(PromRuleType.Alerting); + expect(result.labels).toEqual(['severity=critical']); + expect(result.dataSourceNames).toEqual(['prometheus']); + }); + + it('should lowercase all relevant fields in a complex filter', () => { + const result = normalizeFilterState( + getFilter({ + freeFormWords: ['CPU', 'Memory'], + ruleName: 'High Usage', + groupName: 'System Alerts', + namespace: 'Production/Critical', + }) + ); + expect(result.freeFormWords).toEqual(['cpu', 'memory']); + expect(result.ruleName).toBe('high usage'); + expect(result.groupName).toBe('system alerts'); + expect(result.namespace).toBe('production/critical'); + }); +}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts new file mode 100644 index 00000000000..d8594135463 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filterNormalization.ts @@ -0,0 +1,43 @@ +import { RulesFilter } from '../../search/rulesSearchParser'; + +/** + * Build title search parameter for backend filtering + * Combines ruleName and freeFormWords into a single search string + */ +export function buildTitleSearch(filterState: RulesFilter): string | undefined { + const titleParts: string[] = []; + + const ruleName = filterState.ruleName?.trim(); + if (ruleName) { + titleParts.push(ruleName); + } + + const freeFormSegment = filterState.freeFormWords + .map((word) => word.trim()) + .filter(Boolean) + .join(' '); + + if (freeFormSegment) { + titleParts.push(freeFormSegment); + } + + if (titleParts.length === 0) { + return undefined; + } + + return titleParts.join(' '); +} + +/** + * Normalize filter state for case-insensitive matching + * Lowercase free form words, rule name, group name and namespace + */ +export function normalizeFilterState(filterState: RulesFilter): RulesFilter { + return { + ...filterState, + freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), + ruleName: filterState.ruleName?.toLowerCase(), + groupName: filterState.groupName?.toLowerCase(), + namespace: filterState.namespace?.toLowerCase(), + }; +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts b/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts new file mode 100644 index 00000000000..d268a336f08 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/filterPredicates.ts @@ -0,0 +1,267 @@ +import { attempt, compact, isString } from 'lodash'; +import memoize from 'micro-memoize'; + +import { Matcher } from 'app/plugins/datasource/alertmanager/types'; +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { RulesFilter } from '../../search/rulesSearchParser'; +import { labelsMatchMatchers } from '../../utils/alertmanager'; +import { Annotation } from '../../utils/constants'; +import { getDatasourceAPIUid } from '../../utils/datasource'; +import { fuzzyMatches } from '../../utils/fuzzySearch'; +import { parseMatcher } from '../../utils/matchers'; +import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; +import { normalizeHealth } from '../components/util'; + +export type RuleFilterHandler = (rule: PromRuleDTO, filterState: RulesFilter) => boolean; +export type GroupFilterHandler = ( + group: PromRuleGroupDTO, + filterState: Pick +) => boolean; + +export type RuleFilterConfig = Record< + Exclude, + RuleFilterHandler | null +>; + +export type GroupFilterConfig = Record, GroupFilterHandler | null>; + +/** + * @returns True if the group matches the filter, false otherwise. Keeps rules intact + */ +export function groupMatches( + group: PromRuleGroupDTO, + filterState: Pick, + filterConfig: GroupFilterConfig +): boolean { + if (filterConfig.namespace && filterConfig.namespace(group, filterState) === false) { + return false; + } + + if (filterConfig.groupName && filterConfig.groupName(group, filterState) === false) { + return false; + } + + return true; +} + +/** + * @returns True if the rule matches the filter, false otherwise + */ +export function ruleMatches(rule: PromRuleDTO, filterState: RulesFilter, filterConfig: RuleFilterConfig) { + if (filterConfig.freeFormWords && filterConfig.freeFormWords(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleName && filterConfig.ruleName(rule, filterState) === false) { + return false; + } + + if (filterConfig.labels && filterConfig.labels(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleType && filterConfig.ruleType(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleState && filterConfig.ruleState(rule, filterState) === false) { + return false; + } + + if (filterConfig.ruleHealth && filterConfig.ruleHealth(rule, filterState) === false) { + return false; + } + + if (filterConfig.contactPoint && filterConfig.contactPoint(rule, filterState) === false) { + return false; + } + + if (filterConfig.dashboardUid && filterConfig.dashboardUid(rule, filterState) === false) { + return false; + } + + if (filterConfig.plugins && filterConfig.plugins(rule, filterState) === false) { + return false; + } + + if (filterConfig.dataSourceNames && filterConfig.dataSourceNames(rule, filterState) === false) { + return false; + } + + return true; +} + +export function namespaceFilter( + group: PromRuleGroupDTO, + filterState: Pick +): boolean { + if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) { + return false; + } + + return true; +} + +export function groupNameFilter( + group: PromRuleGroupDTO, + filterState: Pick +): boolean { + if (filterState.groupName && !fuzzyMatches(group.name, filterState.groupName)) { + return false; + } + + return true; +} + +export function freeFormFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.freeFormWords.length > 0) { + const nameMatches = fuzzyMatches(rule.name, filterState.freeFormWords.join(' ')); + if (!nameMatches) { + return false; + } + } + + return true; +} + +export function ruleNameFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleName && !fuzzyMatches(rule.name, filterState.ruleName)) { + return false; + } + + return true; +} + +export function labelsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.labels.length > 0) { + const matchers = compact(filterState.labels.map(looseParseMatcher)); + const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels || {}, matchers); + + // Also check alerts if they exist + const doAlertsContainMatchingLabels = + matchers.length > 0 && + prometheusRuleType.alertingRule(rule) && + rule.alerts && + rule.alerts.some((alert) => labelsMatchMatchers(alert.labels || {}, matchers)); + + if (!doRuleLabelsMatchQuery && !doAlertsContainMatchingLabels) { + return false; + } + } + + return true; +} + +export function ruleTypeFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleType && rule.type !== filterState.ruleType) { + return false; + } + + return true; +} + +export function ruleStateFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleState) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + if (rule.state !== filterState.ruleState) { + return false; + } + } + + return true; +} + +export function ruleHealthFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.ruleHealth && normalizeHealth(rule.health) !== filterState.ruleHealth) { + return false; + } + + return true; +} + +export function contactPointFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.contactPoint) { + if (!prometheusRuleType.grafana.alertingRule(rule)) { + return false; + } + + if (!rule.notificationSettings) { + return false; + } + + if (filterState.contactPoint !== rule.notificationSettings.receiver) { + return false; + } + } + + return true; +} + +export function dashboardUidFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + if (filterState.dashboardUid) { + if (!prometheusRuleType.alertingRule(rule)) { + return false; + } + + const dashboardAnnotation = rule.annotations?.[Annotation.dashboardUID]; + if (dashboardAnnotation !== filterState.dashboardUid) { + return false; + } + } + + return true; +} + +export function pluginsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + // Plugins filter - hide plugin-provided rules when set to 'hide' + if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) { + return false; + } + + return true; +} + +export function dataSourceNamesFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { + // Note: We can't implement these filters from reduceGroups because they rely on rulerRule property + // which is not available in PromRuleDTO: + // - contactPoint filter + // - dataSourceNames filter + if (filterState.dataSourceNames.length > 0) { + const isGrafanaRule = prometheusRuleType.grafana.rule(rule); + if (isGrafanaRule) { + try { + const filterDatasourceUids = mapDataSourceNamesToUids(filterState.dataSourceNames); + const queriedDatasourceUids = rule.queriedDatasourceUIDs || []; + + const queryIncludesDataSource = queriedDatasourceUids.some((uid) => filterDatasourceUids.includes(uid)); + if (!queryIncludesDataSource) { + return false; + } + } catch (error) { + return false; + } + } + } + + return true; +} + +function looseParseMatcher(matcherQuery: string): Matcher | undefined { + try { + return parseMatcher(matcherQuery); + } catch { + // Try to createa a matcher than matches all values for a given key + return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; + } +} + +// Memoize the function to avoid calling getDatasourceAPIUid for the filter values multiple times +const mapDataSourceNamesToUids = memoize( + (names: string[]): string[] => { + return names.map((name) => attempt(getDatasourceAPIUid, name)).filter(isString); + }, + { maxSize: 1 } +); diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts similarity index 64% rename from public/app/features/alerting/unified/rule-list/hooks/filters.test.ts rename to public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts index af9d9011f89..3825c905a52 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.test.ts @@ -2,13 +2,13 @@ import { testWithFeatureToggles } from 'test/test-utils'; import { PromAlertingRuleState, PromRuleGroupDTO, PromRuleType } from 'app/types/unified-alerting-dto'; -import { mockGrafanaPromAlertingRule, mockPromAlertingRule, mockPromRecordingRule } from '../../mocks'; +import { mockGrafanaPromAlertingRule, mockPromRecordingRule } from '../../mocks'; import { RuleHealth } from '../../search/rulesSearchParser'; import { Annotation } from '../../utils/constants'; import { getDatasourceAPIUid } from '../../utils/datasource'; import { getFilter } from '../../utils/search'; -import { getDatasourceFilter, getGrafanaFilter } from './filters'; +import { getGrafanaFilter, hasClientSideFilters } from './grafanaFilter'; jest.mock('../../utils/datasource'); @@ -24,308 +24,6 @@ getDatasourceAPIUidMock.mockImplementation((ruleSourceName) => { throw new Error(`Unknown datasource name: ${ruleSourceName}`); }); -describe('datasource-managed rules', () => { - describe('groupFilter', () => { - it('should filter by namespace (file path)', () => { - const group: PromRuleGroupDTO = { - name: 'Test Group', - file: 'production/alerts', - rules: [], - interval: 60, - }; - - const { groupMatches } = getDatasourceFilter(getFilter({ namespace: 'production' })); - expect(groupMatches(group)).toBe(true); - - const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ namespace: 'staging' })); - expect(groupMatches2(group)).toBe(false); - }); - - it('should filter by group name', () => { - const group: PromRuleGroupDTO = { - name: 'CPU Usage Alerts', - file: 'production/alerts', - rules: [], - interval: 60, - }; - - const { groupMatches } = getDatasourceFilter(getFilter({ groupName: 'cpu' })); - expect(groupMatches(group)).toBe(true); - - const { groupMatches: groupMatches2 } = getDatasourceFilter(getFilter({ groupName: 'memory' })); - expect(groupMatches2(group)).toBe(false); - }); - - it('should return true when no filters are applied', () => { - const group: PromRuleGroupDTO = { - name: 'Test Group', - file: 'production/alerts', - rules: [], - interval: 60, - }; - - const { groupMatches } = getDatasourceFilter(getFilter({})); - expect(groupMatches(group)).toBe(true); - }); - }); - - describe('ruleFilter', () => { - it('should filter by free form words in rule name', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ freeFormWords: ['cpu'] })); - expect(ruleMatches(rule)).toBe(true); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ freeFormWords: ['memory'] })); - expect(ruleMatches2(rule)).toBe(false); - }); - - it('should filter by rule name', () => { - const rule = mockPromAlertingRule({ name: 'High CPU Usage' }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ ruleName: 'cpu' })); - expect(ruleMatches(rule)).toBe(true); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleName: 'memory' })); - expect(ruleMatches2(rule)).toBe(false); - }); - - it('should filter by labels', () => { - const rule = mockPromAlertingRule({ - labels: { severity: 'critical', team: 'ops' }, - alerts: [], - }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['severity=critical'] })); - expect(ruleMatches(rule)).toBe(true); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['severity=warning'] })); - expect(ruleMatches2(rule)).toBe(false); - - const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['team=ops'] })); - expect(ruleMatches3(rule)).toBe(true); - }); - - it('should filter by alert instance labels', () => { - const rule = mockPromAlertingRule({ - labels: { severity: 'critical' }, - alerts: [ - { - labels: { instance: 'server-1', env: 'production' }, - state: PromAlertingRuleState.Firing, - value: '100', - activeAt: '', - annotations: {}, - }, - ], - }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ labels: ['instance=server-1'] })); - expect(ruleMatches(rule)).toBe(true); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ labels: ['env=production'] })); - expect(ruleMatches2(rule)).toBe(true); - - const { ruleMatches: ruleMatches3 } = getDatasourceFilter(getFilter({ labels: ['instance=server-2'] })); - expect(ruleMatches3(rule)).toBe(false); - }); - - it('should filter by rule type', () => { - const alertingRule = mockPromAlertingRule({ name: 'Test Alert' }); - const recordingRule = mockPromRecordingRule({ name: 'Test Recording' }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Alerting })); - expect(ruleMatches(alertingRule)).toBe(true); - expect(ruleMatches(recordingRule)).toBe(false); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleType: PromRuleType.Recording })); - expect(ruleMatches2(alertingRule)).toBe(false); - expect(ruleMatches2(recordingRule)).toBe(true); - }); - - it('should filter by rule state', () => { - const firingRule = mockPromAlertingRule({ - name: 'Firing Alert', - state: PromAlertingRuleState.Firing, - }); - - const pendingRule = mockPromAlertingRule({ - name: 'Pending Alert', - state: PromAlertingRuleState.Pending, - }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); - expect(ruleMatches(firingRule)).toBe(true); - expect(ruleMatches(pendingRule)).toBe(false); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter( - getFilter({ ruleState: PromAlertingRuleState.Pending }) - ); - expect(ruleMatches2(firingRule)).toBe(false); - expect(ruleMatches2(pendingRule)).toBe(true); - }); - - it('should filter out recording rules when filtering by rule state', () => { - const recordingRule = mockPromRecordingRule({ - name: 'Recording Rule', - }); - - // Recording rules should always be filtered out when any rule state filter is applied as they don't have a state - const { ruleMatches } = getDatasourceFilter(getFilter({ ruleState: PromAlertingRuleState.Firing })); - expect(ruleMatches(recordingRule)).toBe(false); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter( - getFilter({ ruleState: PromAlertingRuleState.Pending }) - ); - expect(ruleMatches2(recordingRule)).toBe(false); - - const { ruleMatches: ruleMatches3 } = getDatasourceFilter( - getFilter({ ruleState: PromAlertingRuleState.Inactive }) - ); - expect(ruleMatches3(recordingRule)).toBe(false); - }); - - it('should filter by rule health', () => { - const healthyRule = mockPromAlertingRule({ - name: 'Healthy Rule', - health: RuleHealth.Ok, - }); - - const errorRule = mockPromAlertingRule({ - name: 'Error Rule', - health: RuleHealth.Error, - }); - - const prometheusErrorRule = mockPromAlertingRule({ - name: 'Error Rule', - health: 'err', - }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Ok })); - expect(ruleMatches(healthyRule)).toBe(true); - expect(ruleMatches(errorRule)).toBe(false); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error })); - expect(ruleMatches2(healthyRule)).toBe(false); - expect(ruleMatches2(errorRule)).toBe(true); - expect(ruleMatches2(prometheusErrorRule)).toBe(true); - }); - - it('should normalize health values when filtering', () => { - // Legacy Prometheus health value 'err' should be normalized to 'error' - const legacyErrorRule = mockPromAlertingRule({ - name: 'Legacy Error Rule', - health: 'err', - }); - - // When filtering for 'error', it should match rules with health 'err' (legacy) or 'error' - const { ruleMatches } = getDatasourceFilter(getFilter({ ruleHealth: RuleHealth.Error })); - expect(ruleMatches(legacyErrorRule)).toBe(true); - }); - - it('should filter by dashboard UID', () => { - const ruleDashboardA = mockPromAlertingRule({ - name: 'Dashboard A Rule', - annotations: { [Annotation.dashboardUID]: 'dashboard-a' }, - }); - - const ruleDashboardB = mockPromAlertingRule({ - name: 'Dashboard B Rule', - annotations: { [Annotation.dashboardUID]: 'dashboard-b' }, - }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-a' })); - expect(ruleMatches(ruleDashboardA)).toBe(true); - expect(ruleMatches(ruleDashboardB)).toBe(false); - - const { ruleMatches: ruleMatches2 } = getDatasourceFilter(getFilter({ dashboardUid: 'dashboard-b' })); - expect(ruleMatches2(ruleDashboardA)).toBe(false); - expect(ruleMatches2(ruleDashboardB)).toBe(true); - }); - - it('should filter out recording rules when filtering by dashboard UID', () => { - const recordingRule = mockPromRecordingRule({ - name: 'Recording Rule', - // Recording rules cannot have dashboard UIDs because they don't have annotations - }); - - // Dashboard UID filter should filter out recording rules - const { ruleMatches } = getDatasourceFilter(getFilter({ dashboardUid: 'any-dashboard' })); - expect(ruleMatches(recordingRule)).toBe(false); - }); - - describe('dataSourceNames filter', () => { - it('should match rules that use the filtered datasource', () => { - // Create a Grafana rule with matching datasource - const ruleWithMatchingDatasource = mockGrafanaPromAlertingRule({ - queriedDatasourceUIDs: ['datasource-uid-1'], - }); - - // 'prometheus' resolves to 'datasource-uid-1' which is in the rule - const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] })); - expect(ruleMatches(ruleWithMatchingDatasource)).toBe(true); - }); - - it("should filter out rules that don't use the filtered datasource", () => { - // Create a Grafana rule without the target datasource - const ruleWithoutMatchingDatasource = mockGrafanaPromAlertingRule({ - queriedDatasourceUIDs: ['datasource-uid-1', 'datasource-uid-2'], - }); - - // 'loki' resolves to 'datasource-uid-3' which is not in the rule - const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['loki'] })); - expect(ruleMatches(ruleWithoutMatchingDatasource)).toBe(false); - }); - - it('should return false when there is an error parsing the query', () => { - const ruleWithInvalidQuery = mockGrafanaPromAlertingRule({ - query: 'not-valid-json', - }); - - const { ruleMatches } = getDatasourceFilter(getFilter({ dataSourceNames: ['prometheus'] })); - expect(ruleMatches(ruleWithInvalidQuery)).toBe(false); - }); - }); - - it('should combine multiple filters with AND logic', () => { - const rule = mockPromAlertingRule({ - name: 'High CPU Usage Production', - labels: { severity: 'critical', environment: 'production' }, - state: PromAlertingRuleState.Firing, - health: RuleHealth.Ok, - }); - - const filter = getFilter({ - ruleName: 'cpu', - labels: ['severity=critical', 'environment=production'], - ruleState: PromAlertingRuleState.Firing, - ruleHealth: RuleHealth.Ok, - }); - const { ruleMatches } = getDatasourceFilter(filter); - expect(ruleMatches(rule)).toBe(true); - }); - - it('should return false if any filter does not match', () => { - const rule = mockPromAlertingRule({ - name: 'High CPU Usage Production', - labels: { severity: 'critical', environment: 'production' }, - state: PromAlertingRuleState.Firing, - health: RuleHealth.Ok, - alerts: [], - }); - - const filter = getFilter({ - ruleName: 'cpu', - labels: ['severity=warning'], - ruleState: PromAlertingRuleState.Firing, - ruleHealth: RuleHealth.Ok, - }); - const { ruleMatches } = getDatasourceFilter(filter); - expect(ruleMatches(rule)).toBe(false); - }); - }); -}); - describe('grafana-managed rules', () => { describe('groupFilter', () => { it('should filter by namespace (file path)', () => { @@ -787,4 +485,80 @@ describe('grafana-managed rules', () => { }); }); }); + + describe('hasClientSideFilters', () => { + describe('when alertingUIUseBackendFilters is disabled', () => { + testWithFeatureToggles({ disable: ['alertingUIUseBackendFilters'] }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + + it('should return true for title-related filters (freeFormWords, ruleName)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(true); + }); + + it('should return true for ruleType filter', () => { + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); + }); + + it('should return true for dashboardUid filter', () => { + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); + }); + + it('should return true for groupName filter', () => { + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(true); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + + it('should return false for backend-only filters (state, health, contactPoint)', () => { + expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + }); + }); + + describe('when alertingUIUseBackendFilters is enabled', () => { + testWithFeatureToggles({ enable: ['alertingUIUseBackendFilters'] }); + + it('should return false when no filters are applied', () => { + expect(hasClientSideFilters(getFilter({}))).toBe(false); + }); + + it('should return false for title-related filters (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleName: 'alert' }))).toBe(false); + }); + + it('should return false for ruleType filter (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); + }); + + it('should return false for dashboardUid filter (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); + }); + + it('should return false for groupName filter (handled by backend)', () => { + expect(hasClientSideFilters(getFilter({ groupName: 'test-group' }))).toBe(false); + }); + + it('should return true for client-side only filters', () => { + expect(hasClientSideFilters(getFilter({ namespace: 'production' }))).toBe(true); + expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); + expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); + }); + + it('should return false for backend-only filters (state, health, contactPoint)', () => { + expect(hasClientSideFilters(getFilter({ ruleState: PromAlertingRuleState.Firing }))).toBe(false); + expect(hasClientSideFilters(getFilter({ ruleHealth: RuleHealth.Ok }))).toBe(false); + expect(hasClientSideFilters(getFilter({ contactPoint: 'my-contact-point' }))).toBe(false); + }); + }); + }); }); diff --git a/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts new file mode 100644 index 00000000000..d7d224047d7 --- /dev/null +++ b/public/app/features/alerting/unified/rule-list/hooks/grafanaFilter.ts @@ -0,0 +1,117 @@ +import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; + +import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; +import { shouldUseBackendFilters } from '../../featureToggles'; +import { RulesFilter } from '../../search/rulesSearchParser'; + +import { buildTitleSearch, normalizeFilterState } from './filterNormalization'; +import { + GroupFilterConfig, + RuleFilterConfig, + dashboardUidFilter, + dataSourceNamesFilter, + freeFormFilter, + groupMatches, + groupNameFilter, + labelsFilter, + namespaceFilter, + pluginsFilter, + ruleMatches, + ruleNameFilter, + ruleTypeFilter, +} from './filterPredicates'; + +/** + * Determines if client-side filtering is needed for Grafana-managed rules. + */ +export function hasClientSideFilters(filterState: RulesFilter): boolean { + const { ruleFilterConfig, groupFilterConfig } = buildGrafanaFilterConfigs(); + + // Check each rule filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering + const hasActiveRuleFilters = + (ruleFilterConfig.freeFormWords !== null && filterState.freeFormWords.length > 0) || + (ruleFilterConfig.ruleName !== null && Boolean(filterState.ruleName)) || + (ruleFilterConfig.ruleState !== null && Boolean(filterState.ruleState)) || + (ruleFilterConfig.ruleType !== null && Boolean(filterState.ruleType)) || + (ruleFilterConfig.dataSourceNames !== null && filterState.dataSourceNames.length > 0) || + (ruleFilterConfig.labels !== null && filterState.labels.length > 0) || + (ruleFilterConfig.ruleHealth !== null && Boolean(filterState.ruleHealth)) || + (ruleFilterConfig.dashboardUid !== null && Boolean(filterState.dashboardUid)) || + (ruleFilterConfig.plugins !== null && Boolean(filterState.plugins)) || + (ruleFilterConfig.contactPoint !== null && Boolean(filterState.contactPoint)); + + // Check each group filter: if the config has a non-null handler AND the filter state has a value, we need client-side filtering + const hasActiveGroupFilters = + (groupFilterConfig.namespace !== null && Boolean(filterState.namespace)) || + (groupFilterConfig.groupName !== null && Boolean(filterState.groupName)); + + return hasActiveRuleFilters || hasActiveGroupFilters; +} + +/** + * Builds a combined filter configuration for Grafana-managed alert rules. + * + * Constructs both backend and frontend filter objects based on the provided filter state. + * The backend filter is used for server-side filtering when `shouldUseBackendFilters()` is enabled, + * while the frontend filter provides client-side matching functions for rules and groups. + */ +export function getGrafanaFilter(filterState: RulesFilter) { + const normalizedFilterState = normalizeFilterState(filterState); + const useBackendFilters = shouldUseBackendFilters(); + + // Build title search for backend filtering + const titleSearch = buildTitleSearch(normalizedFilterState); + + const backendFilter: GrafanaPromRulesOptions = { + state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [], + health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [], + contactPoint: normalizedFilterState.contactPoint ?? undefined, + title: useBackendFilters ? titleSearch : undefined, + type: useBackendFilters ? normalizedFilterState.ruleType : undefined, + dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined, + searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, + }; + + const { ruleFilterConfig: grafanaFilterProcessingConfig, groupFilterConfig: grafanaGroupFilterConfig } = + buildGrafanaFilterConfigs(); + + return { + backendFilter, + frontendFilter: { + groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig), + ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig), + }, + }; +} + +/** + * Builds filter configurations for Grafana rules and groups. + * + * Determines which filters are applied on the backend vs. client-side based on + * the `shouldUseBackendFilters()` flag. When backend filtering is enabled, certain + * filters are set to null to prevent duplicate filtering. + */ +function buildGrafanaFilterConfigs() { + const useBackendFilters = shouldUseBackendFilters(); + + const ruleFilterConfig: RuleFilterConfig = { + // When backend filtering is enabled, these filters are handled by the backend + freeFormWords: useBackendFilters ? null : freeFormFilter, + ruleName: useBackendFilters ? null : ruleNameFilter, + ruleState: null, + ruleType: useBackendFilters ? null : ruleTypeFilter, + dataSourceNames: dataSourceNamesFilter, + labels: labelsFilter, + ruleHealth: null, + dashboardUid: useBackendFilters ? null : dashboardUidFilter, + plugins: pluginsFilter, + contactPoint: null, + }; + + const groupFilterConfig: GroupFilterConfig = { + namespace: namespaceFilter, + groupName: useBackendFilters ? null : groupNameFilter, + }; + + return { ruleFilterConfig, groupFilterConfig }; +} diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts index 592485e211c..1b226d50c5b 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts @@ -4,7 +4,6 @@ import { PromRuleType } from 'app/types/unified-alerting-dto'; import { RuleSource } from '../../search/rulesSearchParser'; import { getFilter } from '../../utils/search'; -import { buildTitleSearch } from './filters'; import { hasClientSideFilters } from './useFilteredRulesIterator'; describe('hasClientSideFilters', () => { @@ -84,23 +83,3 @@ describe('hasClientSideFilters', () => { }); }); }); - -describe('buildTitleSearch', () => { - it('returns undefined when no search terms are provided', () => { - expect(buildTitleSearch(getFilter({}))).toBeUndefined(); - }); - - it('returns the rule name when only a rule search is provided', () => { - expect(buildTitleSearch(getFilter({ ruleName: 'cpu' }))).toBe('cpu'); - }); - - it('returns joined free-form words when only text search is provided', () => { - expect(buildTitleSearch(getFilter({ freeFormWords: ['cpu', 'memory'] }))).toBe('cpu memory'); - }); - - it('concatenates rule search and free-form text when both are provided', () => { - expect(buildTitleSearch(getFilter({ ruleName: 'cpu', freeFormWords: ['memory', 'latency'] }))).toBe( - 'cpu memory latency' - ); - }); -}); From 66259bdede3c4a743cd5471b755f34a2877fbc8e Mon Sep 17 00:00:00 2001 From: "David Kim (DK)" <54411318+idk-92@users.noreply.github.com> Date: Tue, 18 Nov 2025 06:30:46 -0800 Subject: [PATCH 034/125] SLO: remove is new badge for SLO reports (#113784) --- pkg/services/navtree/navtreeimpl/applinks.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/pkg/services/navtree/navtreeimpl/applinks.go b/pkg/services/navtree/navtreeimpl/applinks.go index d6650c85ee1..4f8015f5687 100644 --- a/pkg/services/navtree/navtreeimpl/applinks.go +++ b/pkg/services/navtree/navtreeimpl/applinks.go @@ -223,11 +223,6 @@ func (s *ServiceImpl) processAppPlugin(plugin pluginstore.Plugin, c *contextmode IsNew: true, } alertsSection.Children = append(alertsSection.Children, serviceLink) - - reportsNavLink := navtree.FindByURL(alertsSection.Children, "/a/grafana-slo-app/reports") - if reportsNavLink != nil { - reportsNavLink.IsNew = true - } } } From 633c9a9cb02e105b584ad8ee18dfbc5ca2ec7f65 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 18 Nov 2025 15:56:40 +0100 Subject: [PATCH 035/125] Alerting: Add rule_limit parameter to the list rules API (#114055) --- .../ngalert/api/api_prometheus_test.go | 148 +++++++++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 17 +- pkg/services/ngalert/models/alert_rule.go | 1 + pkg/services/ngalert/store/alert_rule.go | 7 + pkg/services/ngalert/tests/fakes/rules.go | 8 +- pkg/tests/api/alerting/api_prometheus_test.go | 155 ++++++++++++++++++ 6 files changed, 333 insertions(+), 3 deletions(-) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 9755a28505c..7f1e9c8e4a2 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -1069,6 +1069,154 @@ func TestRouteGetRuleStatuses(t *testing.T) { }) }) + t.Run("when requesting rules with rule_limit pagination", func(t *testing.T) { + const namespaceUID = "namespace_0" + + ruleStore := fakes.NewRuleStore(t) + fakeAIM := NewFakeAlertInstanceManager(t) + + // Generate 3 rule groups with 10 rules each + allRules := make([]*ngmodels.AlertRule, 0, 30) + for i := range 3 { + rules := gen.With(gen.WithGroupKey(ngmodels.AlertRuleGroupKey{ + RuleGroup: fmt.Sprintf("rule_group_%d", i), + NamespaceUID: namespaceUID, + OrgID: orgID, + })).GenerateManyRef(10) + + allRules = append(allRules, rules...) + ruleStore.PutRule(context.Background(), rules...) + } + + api := NewPrometheusSrv( + log.NewNopLogger(), + fakeAIM, + newFakeSchedulerReader(t).setupStates(fakeAIM), + ruleStore, + accesscontrol.NewRuleService(acimpl.ProvideAccessControl(featuremgmt.WithFeatures())), + fakes.NewFakeProvisioningStore(), + ) + + permissions := createPermissionsForRules(allRules, orgID) + user := &user.SignedInUser{ + OrgID: orgID, + Permissions: permissions, + } + c := &contextmodel.ReqContext{ + SignedInUser: user, + } + + t.Run("should return complete groups until rule_limit is met", func(t *testing.T) { + // With rule_limit=15, should return group-0 (10) + group-1 (10) + // Even though 20 > 15, we never return partial groups + r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=15", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups") + require.Len(t, result.Data.Totals, 0) + require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name) + require.Equal(t, "rule_group_1", result.Data.RuleGroups[1].Name) + require.Len(t, result.Data.RuleGroups[0].Rules, 10) + require.Len(t, result.Data.RuleGroups[1].Rules, 10) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_1", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should return two groups when the number of alerts == limit", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=20", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups") + require.Len(t, result.Data.Totals, 0) + require.Equal(t, "rule_group_0", result.Data.RuleGroups[0].Name) + require.Equal(t, "rule_group_1", result.Data.RuleGroups[1].Name) + require.Len(t, result.Data.RuleGroups[0].Rules, 10) + require.Len(t, result.Data.RuleGroups[1].Rules, 10) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_1", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should respect group_limit when it is reached first", func(t *testing.T) { + // group_limit=1 with rule_limit=100: group limit reached first + r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=1&rule_limit=100", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 1, "should return 1 group (group_limit=1)") + require.Len(t, result.Data.Totals, 0) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_0", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should respect rule_limit when it is reached first", func(t *testing.T) { + // rule_limit=15 with group_limit=5: rule limit reached first (returns 2 groups, 20 rules) + r, err := http.NewRequest("GET", "/api/v1/rules?group_limit=5&rule_limit=15", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2, "should return 2 groups (20 rules exceeds rule_limit=15)") + require.Len(t, result.Data.Totals, 0) + + expectedToken := ngmodels.EncodeGroupCursor(ngmodels.GroupCursor{ + NamespaceUID: namespaceUID, + RuleGroup: "rule_group_1", + }) + require.Equal(t, expectedToken, result.Data.NextToken) + }) + + t.Run("should return nothing when using rule_limit=0", func(t *testing.T) { + r, err := http.NewRequest("GET", "/api/v1/rules?rule_limit=0", nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 0) + }) + }) + t.Run("when fine-grained access is enabled", func(t *testing.T) { t.Run("should return only rules if the user can query all data sources", func(t *testing.T) { ruleStore := fakes.NewRuleStore(t) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index e7a6b7f57a4..99cb0d95530 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -492,10 +492,22 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleType = ngmodels.RuleTypeFilterAll } + // Pagination limits + // + // group_limit: Maximum number of rule groups to return + // - Returns exactly this many groups or fewer if not enough exist + // + // rule_limit: Maximum number of rules to return across all groups + // - Returns complete groups until total rules meets or exceeds this limit + // - May exceed the rule limit if needed to include the final complete group + // - Example: rule_limit=15 with groups of [10, 10, 10] rules returns first 2 groups, 20 rules total + // + // When both limits are specified, whichever limit is reached first takes precedence. maxGroups := getInt64WithDefault(opts.Query, "group_limit", -1) + maxRules := getInt64WithDefault(opts.Query, "rule_limit", -1) nextToken := opts.Query.Get("group_next_token") - if maxGroups == 0 { + if maxGroups == 0 || maxRules == 0 { return ruleResponse } @@ -512,6 +524,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt }, RuleType: ruleType, Limit: maxGroups, + RuleLimit: maxRules, ContinueToken: nextToken, } ruleList, continueToken, err := store.ListAlertRulesByGroup(opts.Ctx, &byGroupQuery) @@ -557,7 +570,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ruleResponse.Data.NextToken = continueToken // Only return Totals if there is no pagination - if maxGroups == -1 { + if maxGroups == -1 && maxRules == -1 { ruleResponse.Data.Totals = rulesTotals } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 4823cd977fe..9f899bc2af7 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -994,6 +994,7 @@ type ListAlertRulesExtendedQuery struct { RuleType RuleTypeFilter Limit int64 + RuleLimit int64 ContinueToken string } diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 393bfdabc91..9044bb369c5 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -618,6 +618,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis // Process rules and implement per-group pagination var groupsFetched int64 + var rulesFetched int64 for rows.Next() { rule := new(alertRule) err = rows.Scan(rule) @@ -644,6 +645,11 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis nextToken = ngmodels.EncodeGroupCursor(cursor) break } + // Check if we've reached the rule limit + if query.RuleLimit > 0 && rulesFetched >= query.RuleLimit { + nextToken = ngmodels.EncodeGroupCursor(cursor) + break + } // Reset for new group cursor = key @@ -656,6 +662,7 @@ func (st DBstore) ListAlertRulesByGroup(ctx context.Context, query *ngmodels.Lis } alertRules = append(alertRules, &converted) + rulesFetched++ } result = alertRules diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index 287a788399e..a0dd60e32e5 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -234,12 +234,13 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert } } - if q.Limit < 0 { + if q.Limit < 0 && q.RuleLimit < 0 { return ruleList, "", nil } outputRules := make([]*models.AlertRule, 0, len(ruleList)) var groupsFetched int64 + var rulesFetched int64 initialCursor := cursor for _, r := range ruleList { // skip rules before the initial cursor @@ -258,11 +259,16 @@ func (f *RuleStore) ListAlertRulesByGroup(_ context.Context, q *models.ListAlert nextToken = models.EncodeGroupCursor(cursor) break } + if q.RuleLimit > 0 && rulesFetched >= q.RuleLimit { + nextToken = models.EncodeGroupCursor(cursor) + break + } cursor = key groupsFetched++ } outputRules = append(outputRules, r) + rulesFetched++ } return outputRules, nextToken, nil diff --git a/pkg/tests/api/alerting/api_prometheus_test.go b/pkg/tests/api/alerting/api_prometheus_test.go index c7b0a1c8c5f..23681381fc9 100644 --- a/pkg/tests/api/alerting/api_prometheus_test.go +++ b/pkg/tests/api/alerting/api_prometheus_test.go @@ -361,6 +361,161 @@ func TestIntegrationPrometheusRules(t *testing.T) { } } +func TestIntegrationPrometheusRulesPagination(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + testinfra.SQLiteIntegrationTest(t) + + dir, path := testinfra.CreateGrafDir(t, testinfra.GrafanaOpts{ + DisableLegacyAlerting: true, + EnableUnifiedAlerting: true, + DisableAnonymous: true, + AppModeProduction: true, + }) + + grafanaListedAddr, env := testinfra.StartGrafanaEnv(t, dir, path) + + createUser(t, env.SQLStore, env.Cfg, user.CreateUserCommand{ + DefaultOrgRole: string(org.RoleEditor), + Password: "password", + Login: "grafana", + }) + + apiClient := newAlertingApiClient(grafanaListedAddr, "grafana", "password") + apiClient.CreateFolder(t, "default", "default") + + interval, err := model.ParseDuration("10s") + require.NoError(t, err) + + // Create 3 rule groups with different numbers of rules + // Group 1: 5 rules, Group 2: 3 rules, Group 3: 2 rules (total: 10 rules) + for groupIdx := 1; groupIdx <= 3; groupIdx++ { + var rulesCount int + switch groupIdx { + case 1: + rulesCount = 5 + case 2: + rulesCount = 3 + case 3: + rulesCount = 2 + } + + rules := make([]apimodels.PostableExtendedRuleNode, rulesCount) + for i := 0; i < rulesCount; i++ { + rules[i] = apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: &interval, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: fmt.Sprintf("rule-%d-%d", groupIdx, i+1), + Condition: "A", + Data: []apimodels.AlertQuery{ + { + RefID: "A", + RelativeTimeRange: apimodels.RelativeTimeRange{ + From: apimodels.Duration(time.Duration(5) * time.Hour), + To: apimodels.Duration(time.Duration(3) * time.Hour), + }, + DatasourceUID: expr.DatasourceUID, + Model: json.RawMessage(`{ + "type": "math", + "expression": "0 > 1" + }`), + }, + }, + }, + } + } + + ruleGroup := apimodels.PostableRuleGroupConfig{ + Name: fmt.Sprintf("group-%d", groupIdx), + Rules: rules, + } + + apiClient.PostRulesGroup(t, "default", &ruleGroup, false) + } + + t.Run("with group_limit should return only 2 groups", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?group_limit=2", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 2) + require.NotEmpty(t, result.Data.NextToken) + }) + + // Test rule_limit: with limit of 7, should return groups 1 and 2 (5+3=8 rules in total expected) + t.Run("with rule_limit should return full groups with rules limit", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_limit=7", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 2) + + totalRules := 0 + for _, group := range result.Data.RuleGroups { + totalRules += len(group.Rules) + } + require.Equal(t, 8, totalRules) + require.NotEmpty(t, result.Data.NextToken) + }) + + // With both group_limit and rule_limit set, the API should return + // data with respect to whichever limit is reached first. + t.Run("both limits respect whichever is reached first", func(t *testing.T) { + // group_limit=1 with rule_limit=100: group limit reached first + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?group_limit=1&rule_limit=100", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 1) + }) + + t.Run("rule_limit=0 returns empty results", func(t *testing.T) { + promRulesURL := fmt.Sprintf("http://grafana:password@%s/api/prometheus/grafana/api/v1/rules?rule_limit=0", grafanaListedAddr) + // nolint:gosec + resp, err := http.Get(promRulesURL) + require.NoError(t, err) + t.Cleanup(func() { + err := resp.Body.Close() + require.NoError(t, err) + }) + + var result apimodels.RuleResponse + err = json.NewDecoder(resp.Body).Decode(&result) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + require.Len(t, result.Data.RuleGroups, 0, "should return no groups") + }) +} + func TestIntegrationPrometheusRulesFilterByDashboard(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) From bbd0fbe9d0c692d2f3dd4459790dcce6468f4b49 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 18 Nov 2025 15:56:54 +0100 Subject: [PATCH 036/125] Alerting: Update documentation for Prometheus list rules API (#114057) --- pkg/services/ngalert/api/tooling/api.json | 89 +++++++- .../ngalert/api/tooling/definitions/prom.go | 83 ++++++- pkg/services/ngalert/api/tooling/post.json | 206 +++++++++++++++++- pkg/services/ngalert/api/tooling/spec.json | 206 +++++++++++++++++- public/api-merged.json | 89 +++++++- public/openapi3.json | 89 +++++++- 6 files changed, 751 insertions(+), 11 deletions(-) diff --git a/pkg/services/ngalert/api/tooling/api.json b/pkg/services/ngalert/api/tooling/api.json index 7eae1a55974..1a003d00365 100644 --- a/pkg/services/ngalert/api/tooling/api.json +++ b/pkg/services/ngalert/api/tooling/api.json @@ -1530,6 +1530,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -2114,6 +2120,32 @@ "title": "InspectType is a type for the Inspect property of a Notice.", "type": "integer" }, + "IntegrationStatus": { + "properties": { + "lastNotifyAttempt": { + "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", + "format": "date-time", + "type": "string" + }, + "lastNotifyAttemptDuration": { + "description": "Duration of the last attempt to deliver a notification in humanized format (`1s` or `15ms`, etc).", + "type": "string" + }, + "lastNotifyAttemptError": { + "description": "Error string for the last attempt to deliver a notification. Empty if the last attempt was successful.", + "type": "string" + }, + "name": { + "description": "Name of the integration.", + "type": "string" + }, + "sendResolved": { + "description": "Whether the integration is configured to send resolved notifications.", + "type": "boolean" + } + }, + "type": "object" + }, "InternalDataLink": { "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", "properties": { @@ -2263,6 +2295,29 @@ }, "type": "object" }, + "MSTeamsV2Config": { + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "send_resolved": { + "type": "boolean" + }, + "text": { + "type": "string" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" + } + }, + "type": "object" + }, "MatchRegexps": { "additionalProperties": { "type": "string" @@ -2803,6 +2858,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -3557,6 +3618,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -3643,6 +3710,26 @@ "title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.", "type": "object" }, + "ReceiverStatus": { + "properties": { + "active": { + "description": "Whether the receiver is used in a route or not.", + "type": "boolean" + }, + "integrations": { + "description": "Integrations configured for this receiver.", + "items": { + "$ref": "#/definitions/IntegrationStatus" + }, + "type": "array" + }, + "name": { + "description": "Name of the receiver.", + "type": "string" + } + }, + "type": "object" + }, "Record": { "properties": { "from": { @@ -7314,7 +7401,7 @@ "description": "", "schema": { "items": { - "$ref": "#/definitions/Receiver" + "$ref": "#/definitions/ReceiverStatus" }, "type": "array" } diff --git a/pkg/services/ngalert/api/tooling/definitions/prom.go b/pkg/services/ngalert/api/tooling/definitions/prom.go index b11ef6fc62b..128c69787c0 100644 --- a/pkg/services/ngalert/api/tooling/definitions/prom.go +++ b/pkg/services/ngalert/api/tooling/definitions/prom.go @@ -377,10 +377,89 @@ type GetGrafanaRuleStatusesParams struct { // Filter the list of rules to those that belong to the specified dashboard UID. // in: query // required: false - DashboardUID string + DashboardUID string `json:"dashboard_uid"` // Filter the list of rules to those that belong to the specified panel ID. Dashboard UID must be specified. // in: query // required: false - PanelID int64 + PanelID int64 `json:"panel_id"` + + // Filter the list of rules to those that belong to the specified folder UID. + // in: query + // required: false + FolderUID string `json:"folder_uid"` + + // Filter the list of rules to those that belong to the specified rule group(s). Can be specified multiple times. + // in: query + // required: false + RuleGroup []string `json:"rule_group"` + + // Filter the list of rules to those that reference the specified receiver/contact point. + // in: query + // required: false + ReceiverName string `json:"receiver_name"` + + // Search rules by title (case insensitive substring match). + // in: query + // required: false + SearchRuleName string `json:"search.rule_name"` + + // Search rules by rule group name (case insensitive substring match). + // in: query + // required: false + SearchRuleGroup string `json:"search.rule_group"` + + // Filter the list of rules to those whose titles exactly match the specified values. Can be specified multiple times. + // in: query + // required: false + RuleName []string `json:"rule_name"` + + // Filter by rule type: "alerting" or "recording". + // in: query + // required: false + RuleType string `json:"rule_type"` + + // Filter by alert state: "normal" (or "inactive"), "pending", "alerting" (or "firing"), "nodata", "error", "recovering". + // in: query + // required: false + State []string `json:"state"` + + // Filter by rule health: "ok", "error", "nodata". + // in: query + // required: false + Health []string `json:"health"` + + // Limit the number of alert instances per rule. + // in: query + // required: false + // default: -1 + LimitAlerts int64 `json:"limit_alerts"` + + // Limit the number of rules per group. + // in: query + // required: false + // default: -1 + LimitRules int64 `json:"limit_rules"` + + // Limit the number of rule groups returned. + // in: query + // required: false + // default: -1 + GroupLimit int64 `json:"group_limit"` + + // Limit the total number of rules returned across all groups. Returns complete groups until the limit is met or exceeded. + // in: query + // required: false + // default: -1 + RuleLimit int64 `json:"rule_limit"` + + // Continuation token for pagination. Use the value returned in the previous response's "groupNextToken" field. + // in: query + // required: false + GroupNextToken string `json:"group_next_token"` + + // Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {"type":0,"name":"severity","value":"critical"}). Provide one matcher per query string value. + // in: query + // required: false + Matchers []string `json:"matcher"` } diff --git a/pkg/services/ngalert/api/tooling/post.json b/pkg/services/ngalert/api/tooling/post.json index 84e4a4826d5..a73d76c2151 100644 --- a/pkg/services/ngalert/api/tooling/post.json +++ b/pkg/services/ngalert/api/tooling/post.json @@ -1530,6 +1530,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -2114,6 +2120,32 @@ "title": "InspectType is a type for the Inspect property of a Notice.", "type": "integer" }, + "IntegrationStatus": { + "properties": { + "lastNotifyAttempt": { + "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", + "format": "date-time", + "type": "string" + }, + "lastNotifyAttemptDuration": { + "description": "Duration of the last attempt to deliver a notification in humanized format (`1s` or `15ms`, etc).", + "type": "string" + }, + "lastNotifyAttemptError": { + "description": "Error string for the last attempt to deliver a notification. Empty if the last attempt was successful.", + "type": "string" + }, + "name": { + "description": "Name of the integration.", + "type": "string" + }, + "sendResolved": { + "description": "Whether the integration is configured to send resolved notifications.", + "type": "boolean" + } + }, + "type": "object" + }, "InternalDataLink": { "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", "properties": { @@ -2263,6 +2295,29 @@ }, "type": "object" }, + "MSTeamsV2Config": { + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "send_resolved": { + "type": "boolean" + }, + "text": { + "type": "string" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" + } + }, + "type": "object" + }, "MatchRegexps": { "additionalProperties": { "type": "string" @@ -2803,6 +2858,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -3557,6 +3618,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -3643,6 +3710,26 @@ "title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.", "type": "object" }, + "ReceiverStatus": { + "properties": { + "active": { + "description": "Whether the receiver is used in a route or not.", + "type": "boolean" + }, + "integrations": { + "description": "Integrations configured for this receiver.", + "items": { + "$ref": "#/definitions/IntegrationStatus" + }, + "type": "array" + }, + "name": { + "description": "Name of the receiver.", + "type": "string" + } + }, + "type": "object" + }, "Record": { "properties": { "from": { @@ -7314,15 +7401,128 @@ { "description": "Filter the list of rules to those that belong to the specified dashboard UID.", "in": "query", - "name": "DashboardUID", + "name": "dashboard_uid", "type": "string" }, { "description": "Filter the list of rules to those that belong to the specified panel ID. Dashboard UID must be specified.", "format": "int64", "in": "query", - "name": "PanelID", + "name": "panel_id", "type": "integer" + }, + { + "description": "Filter the list of rules to those that belong to the specified folder UID.", + "in": "query", + "name": "folder_uid", + "type": "string" + }, + { + "description": "Filter the list of rules to those that belong to the specified rule group(s). Can be specified multiple times.", + "in": "query", + "items": { + "type": "string" + }, + "name": "rule_group", + "type": "array" + }, + { + "description": "Filter the list of rules to those that reference the specified receiver/contact point.", + "in": "query", + "name": "receiver_name", + "type": "string" + }, + { + "description": "Search rules by title (case insensitive substring match).", + "in": "query", + "name": "search.rule_name", + "type": "string" + }, + { + "description": "Search rules by rule group name (case insensitive substring match).", + "in": "query", + "name": "search.rule_group", + "type": "string" + }, + { + "description": "Filter the list of rules to those whose titles exactly match the specified values. Can be specified multiple times.", + "in": "query", + "items": { + "type": "string" + }, + "name": "rule_name", + "type": "array" + }, + { + "description": "Filter by rule type: \"alerting\" or \"recording\".", + "in": "query", + "name": "rule_type", + "type": "string" + }, + { + "description": "Filter by alert state: \"normal\" (or \"inactive\"), \"pending\", \"alerting\" (or \"firing\"), \"nodata\", \"error\", \"recovering\".", + "in": "query", + "items": { + "type": "string" + }, + "name": "state", + "type": "array" + }, + { + "description": "Filter by rule health: \"ok\", \"error\", \"nodata\".", + "in": "query", + "items": { + "type": "string" + }, + "name": "health", + "type": "array" + }, + { + "default": -1, + "description": "Limit the number of alert instances per rule.", + "format": "int64", + "in": "query", + "name": "limit_alerts", + "type": "integer" + }, + { + "default": -1, + "description": "Limit the number of rules per group.", + "format": "int64", + "in": "query", + "name": "limit_rules", + "type": "integer" + }, + { + "default": -1, + "description": "Limit the number of rule groups returned.", + "format": "int64", + "in": "query", + "name": "group_limit", + "type": "integer" + }, + { + "default": -1, + "description": "Limit the total number of rules returned across all groups. Returns complete groups until the limit is met or exceeded.", + "format": "int64", + "in": "query", + "name": "rule_limit", + "type": "integer" + }, + { + "description": "Continuation token for pagination. Use the value returned in the previous response's \"groupNextToken\" field.", + "in": "query", + "name": "group_next_token", + "type": "string" + }, + { + "description": "Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}). Provide one matcher per query string value.", + "in": "query", + "items": { + "type": "string" + }, + "name": "matcher", + "type": "array" } ], "responses": { @@ -9798,7 +9998,7 @@ "description": "", "schema": { "items": { - "$ref": "#/definitions/Receiver" + "$ref": "#/definitions/ReceiverStatus" }, "type": "array" } diff --git a/pkg/services/ngalert/api/tooling/spec.json b/pkg/services/ngalert/api/tooling/spec.json index 0617865b8d2..b590bb3edd7 100644 --- a/pkg/services/ngalert/api/tooling/spec.json +++ b/pkg/services/ngalert/api/tooling/spec.json @@ -1825,14 +1825,127 @@ { "type": "string", "description": "Filter the list of rules to those that belong to the specified dashboard UID.", - "name": "DashboardUID", + "name": "dashboard_uid", "in": "query" }, { "type": "integer", "format": "int64", "description": "Filter the list of rules to those that belong to the specified panel ID. Dashboard UID must be specified.", - "name": "PanelID", + "name": "panel_id", + "in": "query" + }, + { + "type": "string", + "description": "Filter the list of rules to those that belong to the specified folder UID.", + "name": "folder_uid", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter the list of rules to those that belong to the specified rule group(s). Can be specified multiple times.", + "name": "rule_group", + "in": "query" + }, + { + "type": "string", + "description": "Filter the list of rules to those that reference the specified receiver/contact point.", + "name": "receiver_name", + "in": "query" + }, + { + "type": "string", + "description": "Search rules by title (case insensitive substring match).", + "name": "search.rule_name", + "in": "query" + }, + { + "type": "string", + "description": "Search rules by rule group name (case insensitive substring match).", + "name": "search.rule_group", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter the list of rules to those whose titles exactly match the specified values. Can be specified multiple times.", + "name": "rule_name", + "in": "query" + }, + { + "type": "string", + "description": "Filter by rule type: \"alerting\" or \"recording\".", + "name": "rule_type", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter by alert state: \"normal\" (or \"inactive\"), \"pending\", \"alerting\" (or \"firing\"), \"nodata\", \"error\", \"recovering\".", + "name": "state", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter by rule health: \"ok\", \"error\", \"nodata\".", + "name": "health", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": -1, + "description": "Limit the number of alert instances per rule.", + "name": "limit_alerts", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": -1, + "description": "Limit the number of rules per group.", + "name": "limit_rules", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": -1, + "description": "Limit the number of rule groups returned.", + "name": "group_limit", + "in": "query" + }, + { + "type": "integer", + "format": "int64", + "default": -1, + "description": "Limit the total number of rules returned across all groups. Returns complete groups until the limit is met or exceeded.", + "name": "rule_limit", + "in": "query" + }, + { + "type": "string", + "description": "Continuation token for pagination. Use the value returned in the previous response's \"groupNextToken\" field.", + "name": "group_next_token", + "in": "query" + }, + { + "type": "array", + "items": { + "type": "string" + }, + "description": "Filter by label matchers encoded as JSON representations of Prometheus matchers (for example, {\"type\":0,\"name\":\"severity\",\"value\":\"critical\"}). Provide one matcher per query string value.", + "name": "matcher", "in": "query" } ], @@ -5834,6 +5947,12 @@ "$ref": "#/definitions/MSTeamsConfig" } }, + "msteamsv2_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + } + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -6417,6 +6536,32 @@ "format": "int64", "title": "InspectType is a type for the Inspect property of a Notice." }, + "IntegrationStatus": { + "type": "object", + "properties": { + "lastNotifyAttempt": { + "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", + "type": "string", + "format": "date-time" + }, + "lastNotifyAttemptDuration": { + "description": "Duration of the last attempt to deliver a notification in humanized format (`1s` or `15ms`, etc).", + "type": "string" + }, + "lastNotifyAttemptError": { + "description": "Error string for the last attempt to deliver a notification. Empty if the last attempt was successful.", + "type": "string" + }, + "name": { + "description": "Name of the integration.", + "type": "string" + }, + "sendResolved": { + "description": "Whether the integration is configured to send resolved notifications.", + "type": "boolean" + } + } + }, "InternalDataLink": { "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", "type": "object", @@ -6566,6 +6711,29 @@ } } }, + "MSTeamsV2Config": { + "type": "object", + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "send_resolved": { + "type": "boolean" + }, + "text": { + "type": "string" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" + } + } + }, "MatchRegexps": { "type": "object", "title": "MatchRegexps represents a map of Regexp.", @@ -7108,6 +7276,12 @@ "$ref": "#/definitions/MSTeamsConfig" } }, + "msteamsv2_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + } + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -7863,6 +8037,12 @@ "$ref": "#/definitions/MSTeamsConfig" } }, + "msteamsv2_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + } + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -7947,6 +8127,26 @@ } } }, + "ReceiverStatus": { + "type": "object", + "properties": { + "active": { + "description": "Whether the receiver is used in a route or not.", + "type": "boolean" + }, + "integrations": { + "description": "Integrations configured for this receiver.", + "type": "array", + "items": { + "$ref": "#/definitions/IntegrationStatus" + } + }, + "name": { + "description": "Name of the receiver.", + "type": "string" + } + } + }, "Record": { "type": "object", "required": [ @@ -9851,7 +10051,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/Receiver" + "$ref": "#/definitions/ReceiverStatus" } } } diff --git a/public/api-merged.json b/public/api-merged.json index a168fed65c0..589d1f2a222 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -16543,6 +16543,12 @@ "$ref": "#/definitions/MSTeamsConfig" } }, + "msteamsv2_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + } + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -17352,6 +17358,32 @@ "format": "int64", "title": "InspectType is a type for the Inspect property of a Notice." }, + "IntegrationStatus": { + "type": "object", + "properties": { + "lastNotifyAttempt": { + "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", + "type": "string", + "format": "date-time" + }, + "lastNotifyAttemptDuration": { + "description": "Duration of the last attempt to deliver a notification in humanized format (`1s` or `15ms`, etc).", + "type": "string" + }, + "lastNotifyAttemptError": { + "description": "Error string for the last attempt to deliver a notification. Empty if the last attempt was successful.", + "type": "string" + }, + "name": { + "description": "Name of the integration.", + "type": "string" + }, + "sendResolved": { + "description": "Whether the integration is configured to send resolved notifications.", + "type": "boolean" + } + } + }, "InternalDataLink": { "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", "type": "object", @@ -17749,6 +17781,29 @@ } } }, + "MSTeamsV2Config": { + "type": "object", + "properties": { + "http_config": { + "$ref": "#/definitions/HTTPClientConfig" + }, + "send_resolved": { + "type": "boolean" + }, + "text": { + "type": "string" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/definitions/SecretURL" + }, + "webhook_url_file": { + "type": "string" + } + } + }, "ManagerKind": { "description": "It can be a user or a tool or a generic API client.\n+enum", "type": "string", @@ -18972,6 +19027,12 @@ "$ref": "#/definitions/MSTeamsConfig" } }, + "msteamsv2_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + } + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -20055,6 +20116,12 @@ "$ref": "#/definitions/MSTeamsConfig" } }, + "msteamsv2_configs": { + "type": "array", + "items": { + "$ref": "#/definitions/MSTeamsV2Config" + } + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -20139,6 +20206,26 @@ } } }, + "ReceiverStatus": { + "type": "object", + "properties": { + "active": { + "description": "Whether the receiver is used in a route or not.", + "type": "boolean" + }, + "integrations": { + "description": "Integrations configured for this receiver.", + "type": "array", + "items": { + "$ref": "#/definitions/IntegrationStatus" + } + }, + "name": { + "description": "Name of the receiver.", + "type": "string" + } + } + }, "Record": { "type": "object", "required": [ @@ -25508,7 +25595,7 @@ "schema": { "type": "array", "items": { - "$ref": "#/definitions/Receiver" + "$ref": "#/definitions/ReceiverStatus" } } }, diff --git a/public/openapi3.json b/public/openapi3.json index 1a3473953d5..706b89abb32 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -1757,7 +1757,7 @@ "application/json": { "schema": { "items": { - "$ref": "#/components/schemas/Receiver" + "$ref": "#/components/schemas/ReceiverStatus" }, "type": "array" } @@ -6030,6 +6030,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/components/schemas/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -6840,6 +6846,32 @@ "title": "InspectType is a type for the Inspect property of a Notice.", "type": "integer" }, + "IntegrationStatus": { + "properties": { + "lastNotifyAttempt": { + "description": "A timestamp indicating the last attempt to deliver a notification regardless of the outcome.\nFormat: date-time", + "format": "date-time", + "type": "string" + }, + "lastNotifyAttemptDuration": { + "description": "Duration of the last attempt to deliver a notification in humanized format (`1s` or `15ms`, etc).", + "type": "string" + }, + "lastNotifyAttemptError": { + "description": "Error string for the last attempt to deliver a notification. Empty if the last attempt was successful.", + "type": "string" + }, + "name": { + "description": "Name of the integration.", + "type": "string" + }, + "sendResolved": { + "description": "Whether the integration is configured to send resolved notifications.", + "type": "boolean" + } + }, + "type": "object" + }, "InternalDataLink": { "description": "InternalDataLink definition to allow Explore links to be constructed in the backend", "properties": { @@ -7237,6 +7269,29 @@ }, "type": "object" }, + "MSTeamsV2Config": { + "properties": { + "http_config": { + "$ref": "#/components/schemas/HTTPClientConfig" + }, + "send_resolved": { + "type": "boolean" + }, + "text": { + "type": "string" + }, + "title": { + "type": "string" + }, + "webhook_url": { + "$ref": "#/components/schemas/SecretURL" + }, + "webhook_url_file": { + "type": "string" + } + }, + "type": "object" + }, "ManagerKind": { "description": "It can be a user or a tool or a generic API client.\n+enum", "title": "ManagerKind is the type of manager, which is responsible for managing the resource.", @@ -8459,6 +8514,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/components/schemas/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -9541,6 +9602,12 @@ }, "type": "array" }, + "msteamsv2_configs": { + "items": { + "$ref": "#/components/schemas/MSTeamsV2Config" + }, + "type": "array" + }, "name": { "description": "A unique identifier for this receiver.", "type": "string" @@ -9627,6 +9694,26 @@ "title": "ReceiverExport is the provisioned file export of alerting.ReceiverV1.", "type": "object" }, + "ReceiverStatus": { + "properties": { + "active": { + "description": "Whether the receiver is used in a route or not.", + "type": "boolean" + }, + "integrations": { + "description": "Integrations configured for this receiver.", + "items": { + "$ref": "#/components/schemas/IntegrationStatus" + }, + "type": "array" + }, + "name": { + "description": "Name of the receiver.", + "type": "string" + } + }, + "type": "object" + }, "Record": { "properties": { "from": { From e6f0107923d3609d1a4ef11c4599782f263dfe3d Mon Sep 17 00:00:00 2001 From: Sebastian Gaiser Date: Tue, 18 Nov 2025 16:41:13 +0100 Subject: [PATCH 037/125] Prometheus: Add option to configure "use series endpoint" provisioned data source (#109852) * feat(datasources,prometheus): add option to configure 'Use series endpoint' from datasource config Signed-off-by: Sebastian Gaiser # Conflicts: # docs/sources/datasources/prometheus/configure/_index.md * Update public/app/plugins/datasource/tempo/_importedDependencies/datasources/prometheus/types.ts --- docs/sources/administration/provisioning/index.md | 1 + docs/sources/datasources/prometheus/configure/_index.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/sources/administration/provisioning/index.md b/docs/sources/administration/provisioning/index.md index fadb936e5a0..15215275b53 100644 --- a/docs/sources/administration/provisioning/index.md +++ b/docs/sources/administration/provisioning/index.md @@ -254,6 +254,7 @@ Common settings in the [built-in core data sources](../../datasources/#built-in- | `incrementalQuerying` | string | Prometheus | Experimental: Turn on incremental querying to enhance dashboard reload performance with slow data sources | | `incrementalQueryOverlapWindow` | string | Prometheus | Experimental: Configure incremental query overlap window. Requires a valid duration string, for example, `180s` or `15m` Default value is `10m` (10 minutes). | | `disableRecordingRules` | boolean | Prometheus | Experimental: Turn off Prometheus recording rules | +| `seriesEndpoint` | boolean | Prometheus | Using Prometheus `/api/v1/series` endpoint | | `implementation` | string | Alertmanager | The implementation of the Alertmanager data source, such as `prometheus`, `cortex` or `mimir` | | `handleGrafanaManagedAlerts` | boolean | Alertmanager | When enabled, Grafana-managed alerts are sent to this Alertmanager | diff --git a/docs/sources/datasources/prometheus/configure/_index.md b/docs/sources/datasources/prometheus/configure/_index.md index 6e2c0d974f6..0911c7a1cc5 100644 --- a/docs/sources/datasources/prometheus/configure/_index.md +++ b/docs/sources/datasources/prometheus/configure/_index.md @@ -273,6 +273,7 @@ datasources: prometheusVersion: 3.3.0 cacheLevel: 'High' disableRecordingRules: false + seriesEndpoint: false timeInterval: 10s # Prometheus scrape interval incrementalQueryOverlapWindow: 10m exemplarTraceIdDestinations: From 11901cd2425a28f88980637c6ad85c97db06c2f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 18 Nov 2025 17:05:30 +0100 Subject: [PATCH 038/125] PanelChrome: Hover header fixes (#113908) * PanelChrome: Hover header fixes * More fixes * fixes * Fix test * restore hoverHeaderOffset feature * Fixes --- .../components/PanelChrome/HoverWidget.tsx | 16 +++--- .../components/PanelChrome/PanelChrome.tsx | 53 ++++++++++--------- .../src/components/PanelChrome/TitleItem.tsx | 2 +- .../containers/PublicDashboardPage.test.tsx | 1 + yarn.lock | 16 +++--- 5 files changed, 47 insertions(+), 41 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx index 74cf3cc108c..eb5c54d7b3b 100644 --- a/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/HoverWidget.tsx @@ -37,7 +37,11 @@ export function HoverWidget({ menu, title, dragClass, children, offset = -32, on } return ( -
+
{dragClass && (
)} -
- - {titleItems} -
+ {(titleItems || description) && ( +
+ + {titleItems} +
+ )} + {loadingState === LoadingState.Streaming && ( )} -
- {actions &&
{itemsRenderer(actions, (item) => item)}
} -
+ {!hoverHeader &&
} + {actions && itemsRenderer(actions, (item) => item)} ); + // Ignores streaming and loading (cancel query) states for simplicity + // If you need to cancel streaming / loading panels set a title + const hasHeaderContent = title || description || titleItems || menu || dragClass || actions; + return ( // tabIndex={0} is needed for keyboard accessibility in the plot area
- - {headerContent} - + {hasHeaderContent && ( + + {headerContent} + + )} {statusMessage && (
@@ -607,16 +615,6 @@ const getStyles = (theme: GrafanaTheme2) => { top: 0, zIndex: 1, }), - rightActions: css({ - display: 'flex', - gap: theme.spacing(1), - }), - rightAligned: css({ - label: 'right-aligned-container', - marginLeft: 'auto', - display: 'flex', - alignItems: 'center', - }), titleItems: css({ display: 'flex', height: '100%', @@ -631,5 +629,8 @@ const getStyles = (theme: GrafanaTheme2) => { padding: 0, maxWidth: '100%', }), + flexGrow: css({ + flexGrow: 1, + }), }; }; diff --git a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx index 008ae1d6d48..baf035a52ed 100644 --- a/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/TitleItem.tsx @@ -81,7 +81,7 @@ const getStyles = (theme: GrafanaTheme2) => { '&:hover ': { boxShadow: `${theme.shadows.z1}`, - background: `${theme.colors.background.secondary}`, + background: theme.colors.secondary.shade, color: `${theme.colors.text.primary}`, }, }); diff --git a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx index d9038bf1f2b..4500526802a 100644 --- a/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx +++ b/public/app/features/dashboard/containers/PublicDashboardPage.test.tsx @@ -202,6 +202,7 @@ describe('PublicDashboardPage', () => { repeatDirection: 'h', transformations: [], transparent: false, + description: 'hello', }, ]; diff --git a/yarn.lock b/yarn.lock index f4346919b99..248e94efef1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3597,10 +3597,10 @@ __metadata: linkType: soft "@grafana/scenes-react@npm:^6.46.0": - version: 6.46.0 - resolution: "@grafana/scenes-react@npm:6.46.0" + version: 6.46.1 + resolution: "@grafana/scenes-react@npm:6.46.1" dependencies: - "@grafana/scenes": "npm:6.46.0" + "@grafana/scenes": "npm:6.46.1" lru-cache: "npm:^10.2.2" react-use: "npm:^17.4.0" peerDependencies: @@ -3612,7 +3612,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/be082f31c14e636efe6c60f98771a03b94606c6210a1f90d64a1c4ee8429ccb5aaa9e401092f4f6d728befeec13051d9811ec894bdee9b3c39865a5ebc2bba41 + checksum: 10/47007ae998dd52c774f1b53cc1cbd62cbff6c077886ba36381f371454918bd7fbe9ea9373e021a38ba0144e507c8c51a435a2be1aa8250a36c37d7bbfa72fb9d languageName: node linkType: hard @@ -3642,9 +3642,9 @@ __metadata: languageName: node linkType: hard -"@grafana/scenes@npm:6.46.0, @grafana/scenes@npm:^6.46.0": - version: 6.46.0 - resolution: "@grafana/scenes@npm:6.46.0" +"@grafana/scenes@npm:6.46.1, @grafana/scenes@npm:^6.46.0": + version: 6.46.1 + resolution: "@grafana/scenes@npm:6.46.1" dependencies: "@floating-ui/react": "npm:^0.26.16" "@leeoniya/ufuzzy": "npm:^1.0.16" @@ -3664,7 +3664,7 @@ __metadata: react: ^18.0.0 react-dom: ^18.0.0 react-router-dom: ^6.28.0 - checksum: 10/c4b2b3113da0ea9b5745b4d560e73ad8877934138cb2862ed0fecf0954a36dc7aee2b15e12b802e0884ff8f56756935acc545019753f99a2900ba19a620a4e96 + checksum: 10/e72a90833a94307c6a4633d9f637e407f681d2be9fa27938158b0bdbb9520e31a3ff804eaab3f5e146673af5a21822b6a8279b4b8fd2ead79b94e2593e366144 languageName: node linkType: hard From 0fc0fb8db567101f579de7d28f42a1486db2630f Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Tue, 18 Nov 2025 17:32:25 +0100 Subject: [PATCH 039/125] chore: move enable search to US configuration (#114070) --- e2e/dashboards-search-suite/mode0.ini | 6 +- e2e/dashboards-search-suite/mode1.ini | 6 +- .../mode2-legacy-search-api.ini | 6 +- e2e/dashboards-search-suite/mode2.ini | 6 +- e2e/dashboards-search-suite/mode3.ini | 6 +- e2e/dashboards-search-suite/mode4.ini | 6 +- e2e/dashboards-search-suite/mode5.ini | 6 +- pkg/api/folder_test.go | 64 +++++++++---------- pkg/server/search_server_distributor_test.go | 8 ++- pkg/setting/setting.go | 1 + pkg/setting/setting_unified_storage.go | 2 + pkg/storage/unified/README.md | 24 ++++--- pkg/storage/unified/search/options.go | 2 +- pkg/tests/apis/dashboard/dashboards_test.go | 8 +-- .../integration/api_validation_test.go | 24 +++---- .../library_panels_api_validation_test.go | 6 +- pkg/tests/apis/folder/folder_tree_test.go | 7 +- pkg/tests/apis/folder/folders_test.go | 26 +++----- pkg/tests/apis/iam/user_integration_test.go | 2 +- pkg/tests/testinfra/testinfra.go | 7 ++ 20 files changed, 115 insertions(+), 108 deletions(-) diff --git a/e2e/dashboards-search-suite/mode0.ini b/e2e/dashboards-search-suite/mode0.ini index 5bb45c0fceb..7248a2f81a2 100644 --- a/e2e/dashboards-search-suite/mode0.ini +++ b/e2e/dashboards-search-suite/mode0.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 0 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 0 \ No newline at end of file +dualWriterMode = 0 diff --git a/e2e/dashboards-search-suite/mode1.ini b/e2e/dashboards-search-suite/mode1.ini index 0a3a6463c03..9875afbec80 100644 --- a/e2e/dashboards-search-suite/mode1.ini +++ b/e2e/dashboards-search-suite/mode1.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 1 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 1 \ No newline at end of file +dualWriterMode = 1 diff --git a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini index b034996f8cb..18bd29127a0 100644 --- a/e2e/dashboards-search-suite/mode2-legacy-search-api.ini +++ b/e2e/dashboards-search-suite/mode2-legacy-search-api.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = false grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 2 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 2 \ No newline at end of file +dualWriterMode = 2 diff --git a/e2e/dashboards-search-suite/mode2.ini b/e2e/dashboards-search-suite/mode2.ini index 83c15652063..d255663299e 100644 --- a/e2e/dashboards-search-suite/mode2.ini +++ b/e2e/dashboards-search-suite/mode2.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 2 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 2 \ No newline at end of file +dualWriterMode = 2 diff --git a/e2e/dashboards-search-suite/mode3.ini b/e2e/dashboards-search-suite/mode3.ini index b9c02681dd8..dfc75a310b0 100644 --- a/e2e/dashboards-search-suite/mode3.ini +++ b/e2e/dashboards-search-suite/mode3.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 3 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 3 \ No newline at end of file +dualWriterMode = 3 diff --git a/e2e/dashboards-search-suite/mode4.ini b/e2e/dashboards-search-suite/mode4.ini index db8796c572f..a73a0502353 100644 --- a/e2e/dashboards-search-suite/mode4.ini +++ b/e2e/dashboards-search-suite/mode4.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 4 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 4 \ No newline at end of file +dualWriterMode = 4 diff --git a/e2e/dashboards-search-suite/mode5.ini b/e2e/dashboards-search-suite/mode5.ini index 596fe7cb80c..a79ab97e792 100644 --- a/e2e/dashboards-search-suite/mode5.ini +++ b/e2e/dashboards-search-suite/mode5.ini @@ -1,13 +1,15 @@ [server] [feature_toggles] -unifiedStorageSearch = true unifiedStorageSearchUI = true grafanaAPIServerWithExperimentalAPIs = true unifiedStorageSearchSprinkles = true +[unified_storage] +enable_search = true + [unified_storage.folders.folder.grafana.app] dualWriterMode = 5 [unified_storage.dashboards.dashboard.grafana.app] -dualWriterMode = 5 \ No newline at end of file +dualWriterMode = 5 diff --git a/pkg/api/folder_test.go b/pkg/api/folder_test.go index 12d65a06d29..6f3838d075b 100644 --- a/pkg/api/folder_test.go +++ b/pkg/api/folder_test.go @@ -534,55 +534,49 @@ func TestGetFolderLegacyAndUnifiedStorage(t *testing.T) { legacyFolder folder.Folder expectedFolder dtos.Folder expectedFolderServiceError error - unifiedStorageEnabled bool unifiedStorageMode grafanarest.DualWriterMode expectedCode int } tcs := []testCase{ { - description: "General folder - Legacy", - expectedCode: http.StatusOK, - legacyFolder: legacyFolder, - folderUID: legacyFolder.UID, - expectedFolder: expectedFolder, - unifiedStorageEnabled: false, + description: "General folder - Legacy", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, }, { - description: "General folder - Unified storage, mode 1", - expectedCode: http.StatusOK, - legacyFolder: legacyFolder, - folderUID: legacyFolder.UID, - expectedFolder: expectedFolder, - unifiedStorageEnabled: true, - unifiedStorageMode: grafanarest.Mode1, + description: "General folder - Unified storage, mode 1", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageMode: grafanarest.Mode1, }, { - description: "General folder - Unified storage, mode 2", - expectedCode: http.StatusOK, - legacyFolder: legacyFolder, - folderUID: legacyFolder.UID, - expectedFolder: expectedFolder, - unifiedStorageEnabled: true, - unifiedStorageMode: grafanarest.Mode2, + description: "General folder - Unified storage, mode 2", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageMode: grafanarest.Mode2, }, { - description: "General folder - Unified storage, mode 3", - expectedCode: http.StatusOK, - legacyFolder: legacyFolder, - folderUID: legacyFolder.UID, - expectedFolder: expectedFolder, - unifiedStorageEnabled: true, - unifiedStorageMode: grafanarest.Mode3, + description: "General folder - Unified storage, mode 3", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageMode: grafanarest.Mode3, }, { - description: "General folder - Unified storage, mode 4", - expectedCode: http.StatusOK, - legacyFolder: legacyFolder, - folderUID: legacyFolder.UID, - expectedFolder: expectedFolder, - unifiedStorageEnabled: true, - unifiedStorageMode: grafanarest.Mode4, + description: "General folder - Unified storage, mode 4", + expectedCode: http.StatusOK, + legacyFolder: legacyFolder, + folderUID: legacyFolder.UID, + expectedFolder: expectedFolder, + unifiedStorageMode: grafanarest.Mode4, }, } diff --git a/pkg/server/search_server_distributor_test.go b/pkg/server/search_server_distributor_test.go index d9e2f5e5048..92182586105 100644 --- a/pkg/server/search_server_distributor_test.go +++ b/pkg/server/search_server_distributor_test.go @@ -279,6 +279,7 @@ func initDistributorServerForTest(t *testing.T, memberlistPort int) testModuleSe cfg.SearchRingReplicationFactor = 1 cfg.Target = []string{modules.SearchServerDistributor} cfg.InstanceID = "distributor" // does nothing for the distributor but may be useful to debug tests + cfg.EnableSearch = true conn, err := grpc.NewClient(cfg.GRPCServer.Address, grpc.WithTransportCredentials(insecure.NewCredentials()), @@ -321,6 +322,7 @@ func createStorageServerApi(t *testing.T, instanceId int, dbType, dbConnStr stri // otherwise the tests will be flaky, // also, tests are going to timeout after 300 seconds anyway cfg.ResourceServerJoinRingTimeout = 300 * time.Second + cfg.EnableSearch = true return initModuleServerForTest(t, cfg, Options{}, api.ServerOptions{}) } @@ -334,8 +336,7 @@ func initModuleServerForTest( tracer := tracing.InitializeTracerForTest() hooksService := hooks.ProvideService() license := &licensing.OSSLicensingService{} - - ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, license, ProvideNoopModuleRegisterer(), nil, hooksService) + ms, err := NewModule(opts, apiOpts, featuremgmt.WithFeatures(), cfg, nil, nil, prometheus.NewRegistry(), prometheus.DefaultGatherer, tracer, license, ProvideNoopModuleRegisterer(), nil, hooksService) require.NoError(t, err) conn, err := grpc.NewClient(cfg.GRPCServer.Address, @@ -359,7 +360,8 @@ func createBaselineServer(t *testing.T, dbType, dbConnStr string, testNamespaces require.NoError(t, err) cfg.IndexPath = t.TempDir() cfg.IndexFileThreshold = testIndexFileThreshold - features := featuremgmt.WithFeatures(featuremgmt.FlagUnifiedStorageSearch) + cfg.EnableSearch = true + features := featuremgmt.WithFeatures() docBuilders, err := InitializeDocumentBuilders(cfg) require.NoError(t, err) tracer := noop.NewTracerProvider().Tracer("test-tracer") diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index c53feab2c76..885ea5703ec 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -604,6 +604,7 @@ type Cfg struct { CACertPath string HttpsSkipVerify bool ResourceServerJoinRingTimeout time.Duration + EnableSearch bool // Secrets Management SecretsManagement SecretsManagerSettings diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index a3702dc0a3b..15bed66b6bb 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -51,6 +51,8 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // Set indexer config for unified storage section := cfg.Raw.Section("unified_storage") + + cfg.EnableSearch = section.Key("enable_search").MustBool(false) cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0) cfg.IndexPath = section.Key("index_path").String() cfg.IndexWorkers = section.Key("index_workers").MustInt(10) diff --git a/pkg/storage/unified/README.md b/pkg/storage/unified/README.md index b33d2aa3660..034147d84d9 100644 --- a/pkg/storage/unified/README.md +++ b/pkg/storage/unified/README.md @@ -253,16 +253,18 @@ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest - to compile all protobuf files in the repository run `make protobuf` at its top level ## Setting up search -To enable it, add the following to your `custom.ini` under the `[feature_toggles]` section: +To enable it, add the following to your `custom.ini` under the `[feature_toggles]` and `[unified_storage]` sections: ```ini [feature_toggles] ; Used by the Grafana instance unifiedStorageSearchUI = true -; Used by unified storage -unifiedStorageSearch = true ; (optional) Allows you to sort dashboards by usage insights fields when using enterprise ; unifiedStorageSearchSprinkles = true + +[unified_storage] +; Used by unified storage server +enable_search = true ``` The dashboard search page has been set up to search unified storage. Additionally, all legacy search calls (e.g. `/api/search`) will go to @@ -371,10 +373,10 @@ mode = "on-prem" [feature_toggles] unifiedStorage = true -unifiedStorageSearch = true [unified_storage] enable_sharding = true +enable_search = true instance_id = node-0 memberlist_bind_addr = "127.0.0.2" memberlist_advertise_addr = "127.0.0.2" @@ -873,20 +875,22 @@ Unified Search requires several feature flags to be enabled depending on the des | Feature Flag | Purpose | Stage | Required For | |--------------|---------|-------|--------------| -| `unifiedStorageSearch` | Core search functionality | Experimental | Search API servers, indexing | | `unifiedStorageSearchUI` | Frontend search interface | Experimental | Grafana UI search | | `unifiedStorageSearchSprinkles` | Usage insights integration | Experimental | Dashboard usage sorting (Enterprise) | | `unifiedStorageSearchDualReaderEnabled` | Shadow traffic to unified search | Experimental | Shadow traffic during migration | +#### Unified Search Specific Configuration + +| Configuration | Purpose | Stage | Required For | +|-----------------|---------|-------|--------------| +| `enable_search` | Core search functionality | Experimental | Search API servers, indexing | + #### Basic Configuration ```ini [feature_toggles] ; Prerequisites for unified storage (required) grafanaAPIServerWithExperimentalAPIs = true -; Core search functionality (required) -unifiedStorageSearch = true - ; Enable search UI (required for frontend) unifiedStorageSearchUI = true @@ -895,6 +899,10 @@ unifiedStorageSearchDualReaderEnabled = true ; Enable usage insights sorting (Enterprise only) unifiedStorageSearchSprinkles = true + +[unified_storage] +; Enable core search functionality (required) +enable_search = true ``` ### Request Flow Diagrams diff --git a/pkg/storage/unified/search/options.go b/pkg/storage/unified/search/options.go index ca61b684061..0550a32ead4 100644 --- a/pkg/storage/unified/search/options.go +++ b/pkg/storage/unified/search/options.go @@ -21,7 +21,7 @@ func NewSearchOptions( ownsIndexFn func(key resource.NamespacedResource) (bool, error), ) (resource.SearchOptions, error) { //nolint:staticcheck // not yet migrated to OpenFeature - if features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { + if cfg.EnableSearch || features.IsEnabledGlobally(featuremgmt.FlagUnifiedStorageSearch) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) { root := cfg.IndexPath if root == "" { root = filepath.Join(cfg.DataPath, "unified-search", "bleve") diff --git a/pkg/tests/apis/dashboard/dashboards_test.go b/pkg/tests/apis/dashboard/dashboards_test.go index 09567f506aa..541398ae620 100644 --- a/pkg/tests/apis/dashboard/dashboards_test.go +++ b/pkg/tests/apis/dashboard/dashboards_test.go @@ -23,7 +23,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/slugify" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" @@ -307,20 +306,15 @@ func runDashboardSearchTest(t *testing.T, mode rest.DualWriterMode) { t.Run(fmt.Sprintf("search types with dual writer mode %d", mode), func(t *testing.T) { ctx := context.Background() - flags := []string{} - if mode >= rest.Mode3 { - flags = append(flags, featuremgmt.FlagUnifiedStorageSearch) - } - helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "unified", - EnableFeatureToggles: flags, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": {DualWriterMode: mode}, "folders.folder.grafana.app": {DualWriterMode: mode}, }, + UnifiedStorageEnableSearch: mode >= rest.Mode3, }) defer helper.Shutdown() diff --git a/pkg/tests/apis/dashboard/integration/api_validation_test.go b/pkg/tests/apis/dashboard/integration/api_validation_test.go index e71825c6463..9e087cb76a6 100644 --- a/pkg/tests/apis/dashboard/integration/api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/api_validation_test.go @@ -70,14 +70,15 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesDashboards, // Enable FE-only dashboard feature flag }, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: dualWriterMode, }, - }}) + }, + UnifiedStorageEnableSearch: true, + }) t.Cleanup(func() { helper.Shutdown() @@ -105,9 +106,6 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { // Create a K8sTestHelper which will set up a real API server helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, DisableFeatureToggles: []string{ featuremgmt.FlagKubernetesDashboards, }, @@ -115,7 +113,9 @@ func TestIntegrationDashboardAPIValidation(t *testing.T) { "dashboards.dashboard.grafana.app": { DualWriterMode: dualWriterMode, }, - }}) + }, + UnifiedStorageEnableSearch: true, + }) t.Cleanup(func() { helper.Shutdown() @@ -139,9 +139,6 @@ func TestIntegrationDashboardAPIAuthorization(t *testing.T) { t.Run(fmt.Sprintf("DualWriterMode %d", dualWriterMode), func(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: dualWriterMode, @@ -149,7 +146,9 @@ func TestIntegrationDashboardAPIAuthorization(t *testing.T) { "folders.folder.grafana.app": { DualWriterMode: dualWriterMode, }, - }}) + }, + UnifiedStorageEnableSearch: true, + }) t.Cleanup(func() { helper.Shutdown() @@ -188,7 +187,6 @@ func TestIntegrationDashboardAPI(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesDashboards, }, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ @@ -198,7 +196,9 @@ func TestIntegrationDashboardAPI(t *testing.T) { "folders.folder.grafana.app": { DualWriterMode: dualWriterMode, }, - }}) + }, + UnifiedStorageEnableSearch: true, + }) t.Cleanup(func() { helper.Shutdown() diff --git a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go index 5726acd85a4..dd85fdb2400 100644 --- a/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go +++ b/pkg/tests/apis/dashboard/integration/library_panels_api_validation_test.go @@ -31,9 +31,9 @@ func TestIntegrationLibraryPanelConnections(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - "unifiedStorageSearch", "kubernetesLibraryPanels", }, + UnifiedStorageEnableSearch: true, }) ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) adminClient := getResourceClient(t, ctx.Helper, ctx.AdminUser, getDashboardGVR()) @@ -95,10 +95,10 @@ func TestIntegrationLibraryElementPermissions(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - "unifiedStorageSearch", "kubernetesLibraryPanels", "grafanaAPIServerWithExperimentalAPIs", // needed until we move it to v0beta1 at least (currently v0alpha1) }, + UnifiedStorageEnableSearch: true, }) ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) @@ -298,9 +298,9 @@ func TestIntegrationLibraryPanelConnectionsWithFolderAccess(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ DisableAnonymous: true, EnableFeatureToggles: []string{ - "unifiedStorageSearch", "kubernetesLibraryPanels", }, + UnifiedStorageEnableSearch: true, }) ctx := createTestContext(t, helper, helper.Org1, dualWriterMode) diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index ac4e27422e4..b1d38ab3918 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -23,7 +23,6 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/folder" "github.com/grafana/grafana/pkg/services/search/model" "github.com/grafana/grafana/pkg/setting" @@ -48,15 +47,10 @@ func TestIntegrationFolderTree(t *testing.T) { } for _, mode := range modes { t.Run(fmt.Sprintf("mode %d", mode), func(t *testing.T) { - flags := []string{} - if mode >= grafanarest.Mode3 { // make sure modes 0-3 work without it - flags = append(flags, featuremgmt.FlagUnifiedStorageSearch) - } helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, DisableAnonymous: true, APIServerStorageType: "unified", - EnableFeatureToggles: flags, UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ "dashboards.dashboard.grafana.app": { DualWriterMode: mode, @@ -65,6 +59,7 @@ func TestIntegrationFolderTree(t *testing.T) { DualWriterMode: mode, }, }, + UnifiedStorageEnableSearch: mode >= grafanarest.Mode3, // make sure modes 0-3 work without search enabled }) defer helper.Shutdown() diff --git a/pkg/tests/apis/folder/folders_test.go b/pkg/tests/apis/folder/folders_test.go index 7740bda7393..ee958bcc7ec 100644 --- a/pkg/tests/apis/folder/folders_test.go +++ b/pkg/tests/apis/folder/folders_test.go @@ -249,9 +249,7 @@ func TestIntegrationFolderDeletionBlockedByAlertRules(t *testing.T) { UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ folders.RESOURCEGROUP: {DualWriterMode: grafanarest.Mode5}, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1265,9 +1263,7 @@ func TestIntegrationFoldersGetAPIEndpointK8S(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, }) // Run all test cases within the same server instance @@ -1380,9 +1376,9 @@ func TestIntegrationFolderDeletionBlockedByLibraryElements(t *testing.T) { }, }, EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesLibraryPanels, }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1460,9 +1456,9 @@ func TestIntegrationRootFolderDeletionBlockedByLibraryElementsInSubfolder(t *tes }, }, EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, featuremgmt.FlagKubernetesLibraryPanels, }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1558,9 +1554,7 @@ func TestIntegrationFolderDeletionBlockedByConnectedLibraryPanels(t *testing.T) DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1635,9 +1629,7 @@ func TestIntegrationFolderDeletionWithDanglingLibraryPanels(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1826,13 +1818,13 @@ func TestIntegrationMoveNestedFolderToRootK8S(t *testing.T) { helper := apis.NewK8sTestHelper(t, testinfra.GrafanaOpts{ AppModeProduction: true, DisableAnonymous: true, - EnableFeatureToggles: []string{featuremgmt.FlagUnifiedStorageSearch}, APIServerStorageType: "unified", UnifiedStorageConfig: map[string]setting.UnifiedStorageConfig{ folders.RESOURCEGROUP: { DualWriterMode: grafanarest.Mode5, }, }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ @@ -1910,9 +1902,7 @@ func TestIntegrationDeleteNestedFoldersPostorder(t *testing.T) { DualWriterMode: modeDw, }, }, - EnableFeatureToggles: []string{ - featuremgmt.FlagUnifiedStorageSearch, - }, + UnifiedStorageEnableSearch: true, }) client := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, diff --git a/pkg/tests/apis/iam/user_integration_test.go b/pkg/tests/apis/iam/user_integration_test.go index d98b183654c..6d5da51112d 100644 --- a/pkg/tests/apis/iam/user_integration_test.go +++ b/pkg/tests/apis/iam/user_integration_test.go @@ -36,8 +36,8 @@ func TestIntegrationUsers(t *testing.T) { EnableFeatureToggles: []string{ featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs, featuremgmt.FlagKubernetesAuthnMutation, - featuremgmt.FlagUnifiedStorageSearch, }, + UnifiedStorageEnableSearch: true, }) t.Cleanup(func() { diff --git a/pkg/tests/testinfra/testinfra.go b/pkg/tests/testinfra/testinfra.go index 9b0baf4f0ba..d95f108a231 100644 --- a/pkg/tests/testinfra/testinfra.go +++ b/pkg/tests/testinfra/testinfra.go @@ -526,6 +526,12 @@ func CreateGrafDir(t *testing.T, opts GrafanaOpts) (string, string) { require.NoError(t, err) } } + if opts.UnifiedStorageEnableSearch { + section, err := getOrCreateSection("unified_storage") + require.NoError(t, err) + _, err = section.NewKey("enable_search", "true") + require.NoError(t, err) + } if opts.UnifiedStorageMaxPageSizeBytes > 0 { section, err := getOrCreateSection("unified_storage") require.NoError(t, err) @@ -622,6 +628,7 @@ type GrafanaOpts struct { QueryRetries int64 GrafanaComAPIURL string UnifiedStorageConfig map[string]setting.UnifiedStorageConfig + UnifiedStorageEnableSearch bool UnifiedStorageMaxPageSizeBytes int PermittedProvisioningPaths string GrafanaComSSOAPIToken string From a636e1dc8e736ce963757556f30c5530f1548e80 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 18 Nov 2025 18:19:54 +0100 Subject: [PATCH 040/125] Docs: Add secrets management beta API docs (#114020) * Docs: Add secrets management beta API docs * Apply suggestions from code review Co-authored-by: Anna Urbiztondo * backtick params * all caps JSON * Apply suggestions from code review Co-authored-by: Jacob Valdez * fix tab * run yarn prettier * fix anchor and permission for update * example delete request * Apply suggestions from code review --------- Co-authored-by: Anna Urbiztondo Co-authored-by: Jacob Valdez --- .../custom-role-actions-scopes/index.md | 4 + .../api-reference/http-api/_index.md | 1 + .../http-api/secrets_management.md | 401 ++++++++++++++++++ 3 files changed, 406 insertions(+) create mode 100644 docs/sources/developer-resources/api-reference/http-api/secrets_management.md diff --git a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md index 466e0aa99b7..92f6c6fe5e2 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/custom-role-actions-scopes/index.md @@ -138,6 +138,10 @@ The following list contains role-based access control actions. | `roles:read` |
  • `roles:*`
  • `roles:uid:*`
| List roles and read a specific role with its permissions. | | `roles:write` |
  • `permissions:type:delegate`
    • | Create or update a custom role. | | `roles:write` |
      • `permissions:type:escalate`
        • | Reset basic roles to their default permissions. | +| `secret.securevalues:create` |
          • `secret.securevalues:*`
          • | Create secure values. | +| `secret.securevalues:read` |
            • `secret.securevalues:*`
            • | Read and list secure values. | +| `secret.securevalues:write` |
              • `secret.securevalues:*`
              • | Update secure values. | +| `secret.securevalues:delete` |
                • `secret.securevalues:*`
                • | Delete secure values. | | `server.stats:read` | None | Read Grafana instance statistics. | | `server.usagestats.report:read` | None | View usage statistics report. | | `serviceaccounts:write` |
                  • `serviceaccounts:*`
                    • | Create Grafana service accounts. | diff --git a/docs/sources/developer-resources/api-reference/http-api/_index.md b/docs/sources/developer-resources/api-reference/http-api/_index.md index 7d45c435832..39818bd4185 100644 --- a/docs/sources/developer-resources/api-reference/http-api/_index.md +++ b/docs/sources/developer-resources/api-reference/http-api/_index.md @@ -98,6 +98,7 @@ Grafana Enterprise includes all of the Grafana OSS APIs as well as the following - [Query and resource caching API](query_and_resource_caching/) - [Reporting API](reporting/) - [Role-based access control API](access_control/) +- [Secrets Management API](secrets_management/) - [Team sync API](team_sync/) ## Deprecated HTTP APIs diff --git a/docs/sources/developer-resources/api-reference/http-api/secrets_management.md b/docs/sources/developer-resources/api-reference/http-api/secrets_management.md new file mode 100644 index 00000000000..7dc7edcec3c --- /dev/null +++ b/docs/sources/developer-resources/api-reference/http-api/secrets_management.md @@ -0,0 +1,401 @@ +--- +aliases: + - ../../../http_api/secrets_management/ # /docs/grafana/next/http_api/secrets_management/ + - ../../../developers/http_api/secrets_management/ # /docs/grafana/next/developers/http_api/secrets_management/ +canonical: https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/secrets_management/ +description: Grafana Secrets Management HTTP API +keywords: + - grafana + - http + - documentation + - api + - secrets + - enterprise +labels: + products: + - enterprise + - cloud +title: Secrets Management HTTP API +refs: + api-overview: + - pattern: /docs/grafana/ + destination: /docs/grafana//developers/http_api/apis/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/developer-resources/api-reference/http-api/apis/ + rbac-permissions: + - pattern: /docs/grafana/ + destination: /docs/grafana//administration/roles-and-permissions/access-control/custom-role-actions-scopes/ + - pattern: /docs/grafana-cloud/ + destination: /docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/custom-role-actions-scopes/ +--- + +# Secrets Management API + +> If you are running Grafana Enterprise, you need to have specific permissions for some endpoints . Refer to [Role-based access control permissions](ref:rbac-permissions) for more information. + +> To view more about the new API structure, refer to [API overview](ref:api-overview). + +{{< admonition type="caution" >}} +The API is currently in [public preview](https://grafana.com/docs/release-life-cycle/#public-preview) and might be subject to changes. +{{< /admonition >}} + +The Grafana Secrets Management API allows you to manage secrets that are used by other services and applications within your Grafana instance. + +### Decrypters + +The decrypters field is an allowlist that lets the secure value know which services and apps can decrypt the secret value. + +Currently available decrypters: + +- `synthetic-monitoring` (for Synthetic Monitoring checks) +- `provisioning.grafana.app` (for GitSync/Provisioning) + +## Create a secure value + +`POST /apis/secret.grafana.app/v1beta1/namespaces/:namespace/securevalues` + +Creates a new secure value. + +**URL parameters** + +- `namespace`: To read more about which namespace to use, see the [API overview](ref:api-overview). + +**Request body** + +- `metadata.name`: The Grafana unique identifier. If you do not want to provide this, set `metadata.generateName` instead to the prefix you would like for the randomly generated uid (can't be an empty string). +- `spec.description`: Short description that explains the purpose of this secure value. Required. Up to 25 characters long. +- `spec.value`: The secret value to store. Required. Up to 24576 bytes long. +- `spec.decrypters`: List of services allowed to decrypt this secure value. Up to 64 items, see note in [decrypters](#decrypters) for a list of supported values. + +**Required permissions** + +See note in the [introduction](#secrets-management-api) for an explanation. + + +| Action | Scope | +| ---------------------------- | ----------------------------------------- | +| `secret.securevalues:create` |
                      • `secret.securevalues:*`
                      | +{ .no-spacing-list } + + +**Example create request**: + +```http +POST /apis/secret.grafana.app/v1beta1/namespaces/default/securevalues HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "metadata": { + "name": "api-key" + }, + "spec": { + "description": "External API Key", + "value": "secret-api-key-12345", + "decrypters": ["synthetic-monitoring"] + } +} +``` + +**Example response**: + +```http +HTTP/1.1 201 Created +Content-Type: application/json; charset=UTF-8 +Content-Length: 343 + +{ + "apiVersion": "secret.grafana.app/v1beta1", + "kind": "SecureValue", + "metadata": { + "name": "api-key", + "namespace": "default", + "uid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "creationTimestamp": "2024-01-15T10:35:00Z" + }, + "spec": { + "description": "External API Key", + "decrypters": ["synthetic-monitoring"] + }, + "status": {} +} +``` + +Status Codes: + +- **201** – Created +- **400** – Errors (invalid JSON, missing or invalid fields, etc) +- **401** – Unauthorized +- **403** – Access denied +- **409** – Conflict (secure value with the same name already exists) + +{{< admonition type="note">}} +The `spec.value` field is never returned by API endpoints. Users cannot not decrypt secrets. +{{< /admonition >}} + +## List secure values + +`GET /apis/secret.grafana.app/v1beta1/namespaces/:namespace/securevalues` + +List all secure values in a namespace. + +**URL parameters** + +- `namespace`: To read more about which namespace to use, see the [API overview](ref:api-overview). + +**Query parameters** + +- `labelSelector`: Filter secure values by labels. + +**Required permissions** + +See note in the [introduction](#secrets-management-api) for an explanation. + + +| Action | Scope | +| ---------------------------- | ----------------------------------------- | +| `secret.securevalues:read` |
                      • `secret.securevalues:*`
                      | +{ .no-spacing-list } + + +**Example list request**: + +```http +GET /apis/secret.grafana.app/v1beta1/namespaces/default/securevalues HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response**: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 383 + +{ + "apiVersion": "secret.grafana.app/v1beta1", + "kind": "SecureValueList", + "metadata": { + "resourceVersion": "12345" + }, + "items": [ + { + "apiVersion": "secret.grafana.app/v1beta1", + "kind": "SecureValue", + "metadata": { + "name": "database-password", + "namespace": "default", + "creationTimestamp": "2024-01-15T10:30:00Z" + }, + "spec": { + "description": "Production DB Password", + "decrypters": ["synthetic-monitoring"] + }, + "status": {} + } + ] +} +``` + +Status Codes: + +- **200** – OK +- **401** – Unauthorized +- **403** – Access denied + +## Get a secure value + +`GET /apis/secret.grafana.app/v1beta1/namespaces/:namespace/securevalues/:name` + +Get the details of a specific secure value. It will not return the secret value. + +**URL parameters** + +- `namespace`: To read more about which namespace to use, see the [API overview](ref:api-overview). +- `name`: The name of the secure value. + +**Required permissions** + +See note in the [introduction](#secrets-management-api) for an explanation. + + +| Action | Scope | +| ---------------------------- | ----------------------------------------- | +| `secret.securevalues:read` |
                      • `secret.securevalues:*`
                      | +{ .no-spacing-list } + + +**Example get request**: + +```http +GET /apis/secret.grafana.app/v1beta1/namespaces/default/securevalues/api-key HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response**: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 294 + +{ + "apiVersion": "secret.grafana.app/v1beta1", + "kind": "SecureValue", + "metadata": { + "name": "api-key", + "namespace": "default", + "uid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "creationTimestamp": "2024-01-15T10:35:00Z" + }, + "spec": { + "description": "External API Key", + "decrypters": ["synthetic-monitoring"] + }, + "status": {} +} +``` + +Status Codes: + +- **200** – OK +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found + +## Update a secure value + +`PUT /apis/secret.grafana.app/v1beta1/namespaces/:namespace/securevalues/:name` + +Replace an existing secure value with a new specification. + +**URL parameters** + +- `namespace`: To read more about which namespace to use, see the [API overview](ref:api-overview). +- `name`: The name of the secure value. + +**Request body** + +- `spec.description`: Short description that explains the purpose of this secure value. Required. Up to 25 characters long. +- `spec.value`: The secret value to store. Required. Up to 24576 bytes long. +- `spec.decrypters`: List of services allowed to decrypt this secure value. Up to 64 items, see note in [decrypters](#decrypters) for a list of supported values. + +**Required permissions** + +See note in the [introduction](#secrets-management-api) for an explanation. + + +| Action | Scope | +| --------------------------- | ----------------------------------------- | +| `secret.securevalues:write` |
                      • `secret.securevalues:*`
                      | +{ .no-spacing-list } + + +**Example update request**: + +```http +PUT /apis/secret.grafana.app/v1beta1/namespaces/default/securevalues/api-key HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk + +{ + "metadata": { + "name": "api-key" + }, + "spec": { + "description": "External API Key", + "value": "new-value-12345", + "decrypters": ["synthetic-monitoring"] + } +} +``` + +**Example response**: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 282 + +{ + "apiVersion": "secret.grafana.app/v1beta1", + "kind": "SecureValue", + "metadata": { + "name": "api-key", + "namespace": "default", + "uid": "f1e2d3c4-b5a6-7890-abcd-ef1234567890", + "creationTimestamp": "2024-01-15T10:35:00Z" + }, + "spec": { + "description": "External API Key", + "decrypters": ["synthetic-monitoring"] + } +} +``` + +Status Codes: + +- **200** – OK +- **400** – Errors (invalid JSON, missing or invalid fields, etc) +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found + +## Delete a secure value + +`DELETE /apis/secret.grafana.app/v1beta1/namespaces/:namespace/securevalues/:name` + +Permanently delete a secure value. This also deletes the underlying stored secret value. + +**URL parameters** + +- `namespace`: To read more about the namespace to use, see the [API overview](ref:api-overview). +- `name`: The name of the secure value. + +**Required permissions** + +See note in the [introduction](#secrets-management-api) for an explanation. + + +| Action | Scope | +| ---------------------------- | ----------------------------------------- | +| `secret.securevalues:delete` |
                      • `secret.securevalues:*`
                      | +{ .no-spacing-list } + + +**Example delete request**: + +```http +DELETE /apis/secret.grafana.app/v1beta1/namespaces/default/securevalues/api-key HTTP/1.1 +Accept: application/json +Content-Type: application/json +Authorization: Bearer eyJrIjoiT0tTcG1pUlY2RnVKZTFVaDFsNFZXdE9ZWmNrMkZYbk +``` + +**Example response**: + +```http +HTTP/1.1 200 OK +Content-Type: application/json; charset=UTF-8 +Content-Length: 65 + +{ + "apiVersion": "v1", + "kind": "Status", + "status": "Success", + "code": 200 +} +``` + +Status Codes: + +- **200** – OK +- **401** – Unauthorized +- **403** – Access denied +- **404** – Not found From 92ac33736b66ff1cd1f17dbba4c778804e9fde48 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 18 Nov 2025 12:49:29 -0500 Subject: [PATCH 041/125] Suggestions: Proposed PanelPlugin API (#113688) * Suggestions: Update ownership of core files and improve some types * Suggestions: Proposed PanelPlugin API * get rid of .useSuggestionsConfig * update i18n * iterate on TypeScript types, add example in radialbar * tweak implementation, add commentary * actually, suggestions really does not need panel defaults * split suggestions handler into its own file and add tests for radialbar * small comment revision * fix test * add ds.hasData check back for state-timeline * restore a handful of comments that got lost in the merge shuffle * more updated commnets * remove pluginId from VisualizationSuggestion, whoops * fix getAllSuggestions test * update i18n * might as well restore description here * move fieldconfig back on radialbar * call them suppliers, remove boolean return type in favor of internal util * Update packages/grafana-data/src/panel/PanelPlugin.ts * Update packages/grafana-data/src/panel/PanelPlugin.ts * Update packages/grafana-data/src/panel/PanelPlugin.ts * tweak return type for setSuggestionSupplier to be this --- packages/grafana-data/src/index.ts | 2 + .../grafana-data/src/panel/PanelPlugin.ts | 41 ++++- .../grafana-data/src/types/suggestions.ts | 74 +++++--- .../src/components/PanelDataErrorView.tsx | 4 +- .../panel/components/PanelDataErrorView.tsx | 10 +- .../VisualizationSuggestionCard.tsx | 4 +- .../VisualizationSuggestions.tsx | 6 +- .../suggestions/getAllSuggestions.test.ts | 72 ++++---- .../panel/suggestions/getAllSuggestions.ts | 7 +- .../features/panel/suggestions/utils.test.ts | 33 ++++ .../app/features/panel/suggestions/utils.ts | 11 ++ public/app/plugins/panel/radialbar/module.tsx | 4 +- .../panel/radialbar/suggestions.test.ts | 151 +++++++++++++++++ .../plugins/panel/radialbar/suggestions.ts | 158 ++++++++---------- .../plugins/panel/state-timeline/module.tsx | 30 +++- .../panel/state-timeline/suggestions.ts | 43 ----- .../plugins/panel/timeseries/suggestions.ts | 4 +- public/locales/en-US/grafana.json | 8 + 18 files changed, 456 insertions(+), 206 deletions(-) create mode 100644 public/app/features/panel/suggestions/utils.test.ts create mode 100644 public/app/features/panel/suggestions/utils.ts create mode 100644 public/app/plugins/panel/radialbar/suggestions.test.ts delete mode 100644 public/app/plugins/panel/state-timeline/suggestions.ts diff --git a/packages/grafana-data/src/index.ts b/packages/grafana-data/src/index.ts index 06b7f19444e..ab6a55fb5ff 100644 --- a/packages/grafana-data/src/index.ts +++ b/packages/grafana-data/src/index.ts @@ -715,6 +715,8 @@ export { export { type VisualizationSuggestion, type VisualizationSuggestionsSupplier, + type VisualizationSuggestionsSupplierFn, + type PanelPluginVisualizationSuggestion, VisualizationSuggestionScore, VisualizationSuggestionsBuilder, VisualizationSuggestionsListAppender, diff --git a/packages/grafana-data/src/panel/PanelPlugin.ts b/packages/grafana-data/src/panel/PanelPlugin.ts index e9119a37b52..fdae270fd48 100644 --- a/packages/grafana-data/src/panel/PanelPlugin.ts +++ b/packages/grafana-data/src/panel/PanelPlugin.ts @@ -14,7 +14,7 @@ import { PanelPluginDataSupport, } from '../types/panel'; import { GrafanaPlugin } from '../types/plugin'; -import { VisualizationSuggestionsSupplier } from '../types/suggestions'; +import { VisualizationSuggestionsSupplierFn, VisualizationSuggestionsSupplier } from '../types/suggestions'; import { FieldConfigEditorBuilder, PanelOptionsEditorBuilder } from '../utils/OptionsUIBuilders'; import { deprecationWarning } from '../utils/deprecationWarning'; @@ -363,11 +363,34 @@ export class PanelPlugin< } /** - * Sets function that can return visualization examples and suggestions. - * @alpha + * @deprecated use VisualizationSuggestionsSupplierFn */ - setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier) { - this.suggestionsSupplier = supplier; + setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplier): this; + /** + * @alpha + * sets function that can return visualization examples and suggestions. + */ + setSuggestionsSupplier(supplier: VisualizationSuggestionsSupplierFn): this; + setSuggestionsSupplier( + supplier: VisualizationSuggestionsSupplier | VisualizationSuggestionsSupplierFn + ): this { + this.suggestionsSupplier = + typeof supplier === 'function' + ? { + getSuggestionsForData: (builder) => { + const appender = builder.getListAppender({ + pluginId: this.meta.id, + name: this.meta.name, + }); + + const result = supplier(builder.dataSummary); + + if (Array.isArray(result)) { + appender.appendAll(result); + } + }, + } + : supplier; return this; } @@ -379,6 +402,14 @@ export class PanelPlugin< return this.suggestionsSupplier; } + /** + * @alpha + * returns whether the plugin has configured suggestions + */ + hasSuggestions(): boolean { + return this.suggestionsSupplier !== undefined; + } + hasPluginId(pluginId: string) { return this.meta.id === pluginId; } diff --git a/packages/grafana-data/src/types/suggestions.ts b/packages/grafana-data/src/types/suggestions.ts index a7b2611a990..1765e5406f2 100644 --- a/packages/grafana-data/src/types/suggestions.ts +++ b/packages/grafana-data/src/types/suggestions.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ import { defaultsDeep } from 'lodash'; import { DataTransformerConfig } from '@grafana/schema'; @@ -11,29 +10,45 @@ import { PanelData } from './panel'; /** * @alpha + * A suggestion for a visualization given some data. This represents the shape of the panel (including options and field config) + * that will be used to show a small preview in the Grafana UI when suggesting visualizations in the Panel Editor. */ -export interface VisualizationSuggestion { +export interface VisualizationSuggestion { /** Name of suggestion */ - name: string; + name?: string; /** Description */ description?: string; - /** Panel plugin id */ - pluginId: string; /** Panel plugin options */ options?: Partial; /** Panel plugin field options */ fieldConfig?: FieldConfigSource>; /** Data transformations */ transformations?: DataTransformerConfig[]; + /** A value between 0-100 how suitable suggestion is */ + score?: VisualizationSuggestionScore; /** Options for how to render suggestion card */ cardOptions?: { - /** Tweak for small preview */ - previewModifier?: (suggestion: VisualizationSuggestion) => void; + /** + * Given that the suggestion is being rendered as a small preview, you may want to modify certain options + * specifically for the smaller preview version of the visualization. In this method, you should directly + * mutate the suggestion object which is passed in as the first argument. + */ + previewModifier?: (suggestion: VisualizationSuggestion) => void; icon?: string; imgSrc?: string; }; - /** A value between 0-100 how suitable suggestion is */ - score?: VisualizationSuggestionScore; +} + +/** + * @internal + * the internal interface that the PanelPlugin transforms the supplied suggestions into. + */ +export interface PanelPluginVisualizationSuggestion + extends VisualizationSuggestion { + /** Name of suggestion */ + name: string; + /** Panel plugin id */ + pluginId: string; } /** @@ -49,12 +64,13 @@ export enum VisualizationSuggestionScore { } /** - * @alpha + * @internal + * TODO this will move into the grafana app code once suppliers are migrated. */ export class VisualizationSuggestionsBuilder { /** Summary stats for current data */ dataSummary: PanelDataSummary; - private list: VisualizationSuggestion[] = []; + private list: PanelPluginVisualizationSuggestion[] = []; constructor( /** Current data */ @@ -65,7 +81,9 @@ export class VisualizationSuggestionsBuilder { this.dataSummary = getPanelDataSummary(data?.series); } - getListAppender(defaults: VisualizationSuggestion) { + getListAppender( + defaults: PanelPluginVisualizationSuggestion + ) { return new VisualizationSuggestionsListAppender(this.list, defaults); } @@ -76,25 +94,43 @@ export class VisualizationSuggestionsBuilder { /** * @alpha + * TODO: this name is temporary; it will become just "VisualizationSuggestionsSupplier" when the other interface is deleted. + * + * executed while rendering suggestions each time the DataFrame changes, this method + * determines which suggestions can be shown for this PanelPlugin given the PanelDataSummary. + * + * - returns an array of VisualizationSuggestions + * - boolean return equates to "show a single suggestion card for this panel plugin with the default options" (true = show, false or void = hide) + */ +export type VisualizationSuggestionsSupplierFn = ( + panelDataSummary: PanelDataSummary +) => Array> | void; + +/** + * @deprecated use VisualizationSuggestionsSupplierFn instead. */ export type VisualizationSuggestionsSupplier = { /** - * Adds good suitable suggestions for the current data + * Adds suitable suggestions for the current data */ getSuggestionsForData: (builder: VisualizationSuggestionsBuilder) => void; }; /** - * Helps with typings and defaults - * @alpha + * @internal + * TODO this will move into the grafana app code once suppliers are migrated. */ -export class VisualizationSuggestionsListAppender { +export class VisualizationSuggestionsListAppender { constructor( private list: VisualizationSuggestion[], - private defaults: VisualizationSuggestion + private defaults: Partial> = {} ) {} - append(overrides: Partial>) { - this.list.push(defaultsDeep(overrides, this.defaults)); + append(suggestion: VisualizationSuggestion) { + this.list.push(defaultsDeep(suggestion, this.defaults)); + } + + appendAll(suggestions: Array>) { + this.list.push(...suggestions.map((o) => defaultsDeep(o, this.defaults))); } } diff --git a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx index 53a716a4d8f..3636918bf86 100644 --- a/packages/grafana-runtime/src/components/PanelDataErrorView.tsx +++ b/packages/grafana-runtime/src/components/PanelDataErrorView.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; -import { FieldConfigSource, PanelData, VisualizationSuggestion } from '@grafana/data'; +import { FieldConfigSource, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; /** * Describes the properties that can be passed to the PanelDataErrorView. @@ -15,7 +15,7 @@ export interface PanelDataErrorViewProps { needsTimeField?: boolean; needsNumberField?: boolean; needsStringField?: boolean; - suggestions?: VisualizationSuggestion[]; + suggestions?: PanelPluginVisualizationSuggestion[]; } /** diff --git a/public/app/features/panel/components/PanelDataErrorView.tsx b/public/app/features/panel/components/PanelDataErrorView.tsx index 96b9299b3d8..4c72a3c879f 100644 --- a/public/app/features/panel/components/PanelDataErrorView.tsx +++ b/public/app/features/panel/components/PanelDataErrorView.tsx @@ -1,6 +1,12 @@ import { css } from '@emotion/css'; -import { CoreApp, getPanelDataSummary, GrafanaTheme2, PanelDataSummary, VisualizationSuggestion } from '@grafana/data'; +import { + CoreApp, + getPanelDataSummary, + GrafanaTheme2, + PanelDataSummary, + PanelPluginVisualizationSuggestion, +} from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { PanelDataErrorViewProps, locationService } from '@grafana/runtime'; @@ -63,7 +69,7 @@ export function PanelDataErrorView(props: PanelDataErrorViewProps) { ); }; - const loadSuggestion = (s: VisualizationSuggestion) => { + const loadSuggestion = (s: PanelPluginVisualizationSuggestion) => { if (!panel) { return; } diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 5bf2e56e23f..32eae1afc87 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx @@ -2,7 +2,7 @@ import { css, cx } from '@emotion/css'; import { cloneDeep } from 'lodash'; import { CSSProperties } from 'react'; -import { GrafanaTheme2, PanelData, VisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Tooltip, useStyles2 } from '@grafana/ui'; @@ -13,7 +13,7 @@ import { VizTypeChangeDetails } from './types'; export interface Props { data: PanelData; width: number; - suggestion: VisualizationSuggestion; + suggestion: PanelPluginVisualizationSuggestion; onChange: (details: VizTypeChangeDetails) => void; } diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index d78b56f89a5..46e5bdbe012 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -3,7 +3,7 @@ import { useMemo } from 'react'; import { useAsync } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { GrafanaTheme2, PanelData, PanelModel, VisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelModel, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { Trans } from '@grafana/i18n'; import { useStyles2 } from '@grafana/ui'; @@ -80,8 +80,8 @@ export function VisualizationSuggestions({ searchQuery, onChange, data, panel, t function filterSuggestionsBySearch( searchQuery: string, - suggestions?: VisualizationSuggestion[] -): VisualizationSuggestion[] { + suggestions?: PanelPluginVisualizationSuggestion[] +): PanelPluginVisualizationSuggestion[] { if (!searchQuery || !suggestions) { return suggestions || []; } diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e0b194e1901..e4a0c72c5d9 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -6,7 +6,7 @@ import { PanelData, PanelPluginMeta, toDataFrame, - VisualizationSuggestion, + PanelPluginVisualizationSuggestion, } from '@grafana/data'; import { GraphFieldConfig, ReduceDataOptions } from '@grafana/schema'; import { config } from 'app/core/config'; @@ -33,7 +33,7 @@ config.panels['text'] = { class ScenarioContext { data: DataFrame[] = []; - suggestions: Array> = []; + suggestions: Array> = []; setData(scenarioData: DataFrame[]) { this.data = scenarioData; @@ -99,22 +99,22 @@ scenario('Single frame with time and number field', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([ - SuggestionName.LineChart, - SuggestionName.LineChartSmooth, - SuggestionName.AreaChart, - SuggestionName.LineChartGradientColorScheme, - SuggestionName.BarChart, - SuggestionName.BarChartGradientColorScheme, - SuggestionName.Gauge, - SuggestionName.GaugeNoThresholds, - SuggestionName.Stat, - SuggestionName.StatColoredBackground, - SuggestionName.BarGaugeBasic, - SuggestionName.BarGaugeLCD, - SuggestionName.Table, - SuggestionName.StateTimeline, - SuggestionName.StatusHistory, + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ name: SuggestionName.LineChart }), + expect.objectContaining({ name: SuggestionName.LineChartSmooth }), + expect.objectContaining({ name: SuggestionName.AreaChart }), + expect.objectContaining({ name: SuggestionName.LineChartGradientColorScheme }), + expect.objectContaining({ name: SuggestionName.BarChart }), + expect.objectContaining({ name: SuggestionName.BarChartGradientColorScheme }), + expect.objectContaining({ name: SuggestionName.Gauge }), + expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), + expect.objectContaining({ name: SuggestionName.Stat }), + expect.objectContaining({ name: SuggestionName.StatColoredBackground }), + expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), + expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), + expect.objectContaining({ name: SuggestionName.Table }), + expect.objectContaining({ pluginId: 'state-timeline' }), + expect.objectContaining({ name: SuggestionName.StatusHistory }), ]); }); @@ -143,24 +143,24 @@ scenario('Single frame with time 2 number fields', (ctx) => { ]); it('should return correct suggestions', () => { - expect(ctx.names()).toEqual([ - SuggestionName.LineChart, - SuggestionName.LineChartSmooth, - SuggestionName.AreaChartStacked, - SuggestionName.AreaChartStackedPercent, - SuggestionName.BarChartStacked, - SuggestionName.BarChartStackedPercent, - SuggestionName.Gauge, - SuggestionName.GaugeNoThresholds, - SuggestionName.Stat, - SuggestionName.StatColoredBackground, - SuggestionName.PieChart, - SuggestionName.PieChartDonut, - SuggestionName.BarGaugeBasic, - SuggestionName.BarGaugeLCD, - SuggestionName.Table, - SuggestionName.StateTimeline, - SuggestionName.StatusHistory, + expect(ctx.suggestions).toEqual([ + expect.objectContaining({ name: SuggestionName.LineChart }), + expect.objectContaining({ name: SuggestionName.LineChartSmooth }), + expect.objectContaining({ name: SuggestionName.AreaChartStacked }), + expect.objectContaining({ name: SuggestionName.AreaChartStackedPercent }), + expect.objectContaining({ name: SuggestionName.BarChartStacked }), + expect.objectContaining({ name: SuggestionName.BarChartStackedPercent }), + expect.objectContaining({ name: SuggestionName.Gauge }), + expect.objectContaining({ name: SuggestionName.GaugeNoThresholds }), + expect.objectContaining({ name: SuggestionName.Stat }), + expect.objectContaining({ name: SuggestionName.StatColoredBackground }), + expect.objectContaining({ name: SuggestionName.PieChart }), + expect.objectContaining({ name: SuggestionName.PieChartDonut }), + expect.objectContaining({ name: SuggestionName.BarGaugeBasic }), + expect.objectContaining({ name: SuggestionName.BarGaugeLCD }), + expect.objectContaining({ name: SuggestionName.Table }), + expect.objectContaining({ pluginId: 'state-timeline' }), + expect.objectContaining({ name: SuggestionName.StatusHistory }), ]); }); diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index 6b53f98c341..86fb0e17bde 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -1,6 +1,6 @@ import { PanelData, - VisualizationSuggestion, + PanelPluginVisualizationSuggestion, VisualizationSuggestionsBuilder, PanelModel, VisualizationSuggestionScore, @@ -25,7 +25,10 @@ export const panelsToCheckFirst = [ 'nodeGraph', ]; -export async function getAllSuggestions(data?: PanelData, panel?: PanelModel): Promise { +export async function getAllSuggestions( + data?: PanelData, + panel?: PanelModel +): Promise { const builder = new VisualizationSuggestionsBuilder(data, panel); for (const pluginId of panelsToCheckFirst) { diff --git a/public/app/features/panel/suggestions/utils.test.ts b/public/app/features/panel/suggestions/utils.test.ts new file mode 100644 index 00000000000..905b59930a5 --- /dev/null +++ b/public/app/features/panel/suggestions/utils.test.ts @@ -0,0 +1,33 @@ +import { createDataFrame, FieldType, getPanelDataSummary, PanelDataSummary } from '@grafana/data'; + +import { showDefaultSuggestion } from './utils'; + +describe('Suggestions utils', () => { + describe('showDefaultSuggestion', () => { + it('should return [{}] when fn returns true', () => { + const fn = (panelDataSummary: PanelDataSummary) => panelDataSummary.hasFieldType(FieldType.string); + const wrapped = showDefaultSuggestion(fn); + const result = wrapped( + getPanelDataSummary([ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.string }], + }), + ]) + ); + expect(result).toEqual([{}]); + }); + + it('should return undefined when fn returns false', () => { + const fn = (panelDataSummary: PanelDataSummary) => panelDataSummary.hasFieldType(FieldType.string); + const wrapped = showDefaultSuggestion(fn); + const result = wrapped( + getPanelDataSummary([ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.number }], + }), + ]) + ); + expect(result).toBeUndefined(); + }); + }); +}); diff --git a/public/app/features/panel/suggestions/utils.ts b/public/app/features/panel/suggestions/utils.ts new file mode 100644 index 00000000000..8668ec2f654 --- /dev/null +++ b/public/app/features/panel/suggestions/utils.ts @@ -0,0 +1,11 @@ +import { PanelDataSummary } from '@grafana/data'; + +/** + * @internal + * for panel plugins which want to simply indicate that they want to show or hide their default suggestion, + * this helper would wrap a method which can return a boolean instead of `[{}]` or `undefined` as a bit of + * syntactic sugar. + */ +export function showDefaultSuggestion(fn: (panelDataSummary: PanelDataSummary) => boolean | void) { + return (panelDataSummary: PanelDataSummary) => (fn(panelDataSummary) ? [{}] : undefined); +} diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index 9667f6fb714..dfbdb875865 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -8,7 +8,7 @@ import { EffectsEditor } from './EffectsEditor'; import { gaugePanelChangedHandler, gaugePanelMigrationHandler, shouldMigrateGauge } from './GaugeMigrations'; import { RadialBarPanel } from './RadialBarPanel'; import { defaultGaugePanelEffects, defaultOptions, Options } from './panelcfg.gen'; -import { GaugeSuggestionsSupplier } from './suggestions'; +import { radialBarSuggestionsHandler } from './suggestions'; export const plugin = new PanelPlugin(RadialBarPanel) .useFieldConfig({}) @@ -112,6 +112,6 @@ export const plugin = new PanelPlugin(RadialBarPanel) defaultValue: defaultGaugePanelEffects, }); }) - .setSuggestionsSupplier(new GaugeSuggestionsSupplier()) + .setSuggestionsSupplier(radialBarSuggestionsHandler) .setMigrationHandler(gaugePanelMigrationHandler, shouldMigrateGauge) .setPanelChangeHandler(gaugePanelChangedHandler); diff --git a/public/app/plugins/panel/radialbar/suggestions.test.ts b/public/app/plugins/panel/radialbar/suggestions.test.ts new file mode 100644 index 00000000000..21a59267bbb --- /dev/null +++ b/public/app/plugins/panel/radialbar/suggestions.test.ts @@ -0,0 +1,151 @@ +import { createDataFrame, Field, FieldType, getPanelDataSummary } from '@grafana/data'; + +import { radialBarSuggestionsHandler } from './suggestions'; + +describe('RadialBarPanel Suggestions', () => { + it('does not suggest gauge if no data is present', () => { + expect(radialBarSuggestionsHandler(getPanelDataSummary([]))).toBeFalsy(); + expect(radialBarSuggestionsHandler(getPanelDataSummary(undefined))).toBeFalsy(); + expect( + radialBarSuggestionsHandler( + getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [] }, + { name: 'value', type: FieldType.number, values: [] }, + ], + }), + ]) + ) + ).toBeFalsy(); + }); + + it('does not suggest gauge if there are no numeric fields', () => { + const df = createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time }, + { name: 'status', type: FieldType.string }, + ], + }); + expect(radialBarSuggestionsHandler(getPanelDataSummary([df]))).toBeFalsy(); + }); + + it('does not suggest gauge if there are too many numeric fields', () => { + const fields: Field[] = []; + for (let i = 0; i < 20; i++) { + fields.push({ name: `numeric-${i}`, type: FieldType.number, values: [0, 100, 200, 300, 400, 500], config: {} }); + } + expect(radialBarSuggestionsHandler(getPanelDataSummary([createDataFrame({ fields })]))).toBeFalsy(); + }); + + it('suggests gauge for a single numeric field', () => { + expect( + radialBarSuggestionsHandler( + getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + ], + }), + ]) + ) + ).toEqual([ + expect.objectContaining({ name: 'Gauge' }), + expect.objectContaining({ name: 'Circular gauge', options: expect.objectContaining({ shape: 'circle' }) }), + ]); + }); + + it('suggests gauge for a few numeric fields, with other fields mixed in', () => { + expect( + radialBarSuggestionsHandler( + getPanelDataSummary([ + createDataFrame({ + fields: [ + { name: 'time', type: FieldType.time, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value2', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + { name: 'value3', type: FieldType.number, values: [0, 100, 200, 300, 400, 500] }, + { name: 'string', type: FieldType.string, values: ['foo', 'bar', null, 'bax', 'bop', 'bim'] }, + { name: 'boolean', type: FieldType.boolean, values: [true, false, true, false, true, false] }, + ], + }), + ]) + ) + ).toEqual([ + expect.objectContaining({ name: 'Gauge' }), + expect.objectContaining({ name: 'Circular gauge', options: expect.objectContaining({ shape: 'circle' }) }), + ]); + }); + + describe('aggregation', () => { + it.each([ + { + description: 'tabular data with few rows', + aggregated: false, + dataframes: [ + createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [100, 200, 300] }, + ], + }), + ], + }, + { + description: 'tabular data with too many datapoints', + aggregated: true, + dataframes: [ + createDataFrame({ + fields: [ + { + name: 'string', + type: FieldType.string, + values: ['A', 'B', 'A', 'B', 'A', 'B', 'A', 'B', 'A', 'B', 'A'], + }, + { name: 'value', type: FieldType.number, values: [10, 20, 30, 40, 50, 60, 50, 40, 30, 20, 10] }, + ], + }), + ], + }, + { + description: 'only numeric data', + aggregated: true, + dataframes: [ + createDataFrame({ + fields: [{ name: 'value', type: FieldType.number, values: [10, 20, 30, 40, 50] }], + }), + ], + }, + { + description: 'multiple frames with tabular data', + aggregated: true, + dataframes: [ + createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['A', 'B', 'C'] }, + { name: 'value', type: FieldType.number, values: [100, 200, 300] }, + ], + }), + createDataFrame({ + fields: [ + { name: 'name', type: FieldType.string, values: ['D', 'E', 'F'] }, + { name: 'value', type: FieldType.number, values: [600, 700, 800] }, + ], + }), + ], + }, + ])('$description suggests aggregated=$aggregated', ({ dataframes, aggregated }) => { + const suggestions = radialBarSuggestionsHandler(getPanelDataSummary(dataframes)); + const expected = aggregated ? { values: false, calcs: ['lastNotNull'] } : { values: true, calcs: [] }; + if (Array.isArray(suggestions)) { + for (const suggestion of suggestions) { + expect(suggestion.options?.reduceOptions).toEqual(expected); + } + } else { + // this will fail if we're in this else case. + expect(suggestions).toBeInstanceOf(Array); + } + }); + }); +}); diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 8471dc27cbe..35cde48ec0f 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -1,101 +1,87 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { FieldColorModeId } from '@grafana/schema'; +import { defaultsDeep } from 'lodash'; + +import { + FieldColorModeId, + FieldType, + VisualizationSuggestion, + VisualizationSuggestionsSupplierFn, +} from '@grafana/data'; +import { t } from '@grafana/i18n'; import { GraphFieldConfig } from '@grafana/ui'; -import { SuggestionName } from 'app/types/suggestions'; import { Options } from './panelcfg.gen'; -export class GaugeSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary } = builder; +export const radialBarSuggestionsHandler: VisualizationSuggestionsSupplierFn = ( + dataSummary +) => { + if (!dataSummary.hasData || !dataSummary.hasFieldType(FieldType.number)) { + return; + } - if (!dataSummary.hasData || !dataSummary.hasNumberField) { - return; - } + // for many fields / series this is probably not a good fit + if (dataSummary.fieldCountByType(FieldType.number) >= 10) { + return; + } - // for many fields / series this is probably not a good fit - if (dataSummary.numberFieldCount >= 10) { - return; - } - - const list = builder.getListAppender({ - name: SuggestionName.Gauge, - pluginId: 'gauge', - options: {}, + const withDefaults = ( + suggestion: VisualizationSuggestion + ): VisualizationSuggestion => { + // if there is a string field and there are few enough rows, we assume it's tabular data and not numeric series data, + // and the de-aggregated version of the viz probably makes more sense + const isTabularData = + dataSummary.hasFieldType(FieldType.string) && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10; + return defaultsDeep(suggestion, { + options: { + reduceOptions: isTabularData + ? { + values: true, + calcs: [], + } + : { + values: false, + calcs: ['lastNotNull'], + }, + }, fieldConfig: { - defaults: {}, + defaults: isTabularData + ? { + color: { mode: FieldColorModeId.PaletteClassic }, + } + : {}, overrides: [], }, cardOptions: { previewModifier: (s) => { - if (s.options?.reduceOptions?.values) { - s.options.reduceOptions.limit = 2; + if (s.options?.reduceOptions) { + s.options.reduceOptions.limit = 4; } }, }, - }); + // styles: [{ + // name: t('gauge.suggestions.style.circular', 'Glowing'), + // options: { + // effects: { + // rounded: true, + // barGlow: true, + // centerGlow: true, + // spotlight: true, + // }, + // }, + // }, { + // name: t('gauge.suggestions.style.simple', 'Simple'), + // }] + } satisfies VisualizationSuggestion); + }; - if (dataSummary.hasStringField && dataSummary.frameCount === 1 && dataSummary.rowCountTotal < 10) { - list.append({ - name: SuggestionName.Gauge, - options: { - reduceOptions: { - values: true, - calcs: [], - }, - }, - }); - list.append({ - name: SuggestionName.GaugeCircular, - options: { - shape: 'circle', - showThresholdMarkers: false, - reduceOptions: { - values: true, - calcs: [], - }, - }, - fieldConfig: { - defaults: { - color: { mode: FieldColorModeId.PaletteClassic }, - }, - overrides: [], - }, - }); - } else { - list.append({ - name: SuggestionName.Gauge, - options: { - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - }); - list.append({ - name: SuggestionName.GaugeCircular, - options: { - shape: 'circle', - showThresholdMarkers: false, - barWidthFactor: 0.3, - effects: { - rounded: true, - barGlow: true, - centerGlow: true, - spotlight: true, - }, - reduceOptions: { - values: false, - calcs: ['lastNotNull'], - }, - }, - fieldConfig: { - defaults: { - color: { mode: FieldColorModeId.PaletteClassic }, - }, - overrides: [], - }, - }); - } - } -} + return [ + withDefaults({ name: t('gauge.suggestions.arc', 'Gauge') }), + withDefaults({ + name: t('gauge.suggestions.circular', 'Circular gauge'), + options: { + shape: 'circle', + showThresholdMarkers: false, + barWidthFactor: 0.3, + }, + }), + ]; +}; diff --git a/public/app/plugins/panel/state-timeline/module.tsx b/public/app/plugins/panel/state-timeline/module.tsx index 61053b28090..55fd557eea0 100644 --- a/public/app/plugins/panel/state-timeline/module.tsx +++ b/public/app/plugins/panel/state-timeline/module.tsx @@ -8,6 +8,7 @@ import { import { t } from '@grafana/i18n'; import { AxisPlacement, VisibilityMode } from '@grafana/schema'; import { commonOptionsBuilder } from '@grafana/ui'; +import { showDefaultSuggestion } from 'app/features/panel/suggestions/utils'; import { InsertNullsEditor } from '../timeseries/InsertNullsEditor'; import { SpanNullsEditor } from '../timeseries/SpanNullsEditor'; @@ -16,7 +17,6 @@ import { NullEditorSettings } from '../timeseries/config'; import { StateTimelinePanel } from './StateTimelinePanel'; import { timelinePanelChangedHandler } from './migrations'; import { defaultFieldConfig, defaultOptions, FieldConfig, Options } from './panelcfg.gen'; -import { StatTimelineSuggestionsSupplier } from './suggestions'; export const plugin = new PanelPlugin(StateTimelinePanel) .setPanelChangeHandler(timelinePanelChangedHandler) @@ -157,5 +157,31 @@ export const plugin = new PanelPlugin(StateTimelinePanel) commonOptionsBuilder.addLegendOptions(builder, false); commonOptionsBuilder.addTooltipOptions(builder); }) - .setSuggestionsSupplier(new StatTimelineSuggestionsSupplier()) + .setSuggestionsSupplier( + showDefaultSuggestion((ds) => { + if (!ds.hasData) { + return; + } + + // This panel needs a time field and a string or number field + if ( + !ds.hasFieldType(FieldType.time) || + (!ds.hasFieldType(FieldType.string) && !ds.hasFieldType(FieldType.number)) + ) { + return; + } + + // If there are many series then they won't fit on y-axis so this panel is not good fit + if (ds.fieldCountByType(FieldType.number) >= 30) { + return; + } + + // Probably better ways to filter out this by inspecting the types of string values so view this as temporary + if (ds.preferredVisualisationType === 'logs') { + return; + } + + return true; + }) + ) .setDataSupport({ annotations: true }); diff --git a/public/app/plugins/panel/state-timeline/suggestions.ts b/public/app/plugins/panel/state-timeline/suggestions.ts deleted file mode 100644 index f98c1ea7273..00000000000 --- a/public/app/plugins/panel/state-timeline/suggestions.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { VisualizationSuggestionsBuilder } from '@grafana/data'; -import { SuggestionName } from 'app/types/suggestions'; - -import { FieldConfig, Options } from './panelcfg.gen'; - -export class StatTimelineSuggestionsSupplier { - getSuggestionsForData(builder: VisualizationSuggestionsBuilder) { - const { dataSummary: ds } = builder; - - if (!ds.hasData) { - return; - } - - // This panel needs a time field and a string or number field - if (!ds.hasTimeField || (!ds.hasStringField && !ds.hasNumberField)) { - return; - } - - // If there are many series then they won't fit on y-axis so this panel is not good fit - if (ds.numberFieldCount >= 30) { - return; - } - - // Probably better ways to filter out this by inspecting the types of string values so view this as temporary - if (ds.preferredVisualisationType === 'logs') { - return; - } - - const list = builder.getListAppender({ - name: '', - pluginId: 'state-timeline', - options: {}, - fieldConfig: { - defaults: { - custom: {}, - }, - overrides: [], - }, - }); - - list.append({ name: SuggestionName.StateTimeline }); - } -} diff --git a/public/app/plugins/panel/timeseries/suggestions.ts b/public/app/plugins/panel/timeseries/suggestions.ts index 08c2dfc41f7..8ca7d6148ed 100644 --- a/public/app/plugins/panel/timeseries/suggestions.ts +++ b/public/app/plugins/panel/timeseries/suggestions.ts @@ -1,8 +1,8 @@ import { FieldColorModeId, VisualizationSuggestionsBuilder, - VisualizationSuggestion, DataTransformerID, + PanelPluginVisualizationSuggestion, } from '@grafana/data'; import { GraphDrawStyle, @@ -218,7 +218,7 @@ export class TimeSeriesSuggestionsSupplier { } // This will try to get a suggestion that will add a long to wide conversion -export function getPrepareTimeseriesSuggestion(panelId: number): VisualizationSuggestion | undefined { +export function getPrepareTimeseriesSuggestion(panelId: number): PanelPluginVisualizationSuggestion | undefined { const panel = getDashboardSrv().getCurrent()?.getPanelById(panelId); if (panel) { const transformations = panel.transformations ? [...panel.transformations] : []; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 305c35ceb3e..a01604136df 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Show threshold labels", "name-show-threshold-markers": "Show threshold markers", "placeholder-neutral": "auto", + "suggestions": { + "arc": "Gauge", + "circular": "Circular gauge", + "style": { + "circular": "Glowing", + "simple": "Simple" + } + }, "threshold": "Threshold {{value}}" }, "gen-ai": { From 1458a28eee97d458ebe85f493278f1b17c7e7e09 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Tue, 18 Nov 2025 15:43:25 -0300 Subject: [PATCH 042/125] DashboardLibrary: Fix 404 not found error when browsing the page (#114094) * 404 fix * any enabled added --- pkg/api/api.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pkg/api/api.go b/pkg/api/api.go index bcfc188770d..6a838206ba9 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -164,6 +164,9 @@ func (hs *HTTPServer) registerRoutes() { r.Get("/d/:uid", reqSignedIn, redirectFromLegacyPanelEditURL, hs.Index) r.Get("/dashboard/script/*", reqSignedIn, hs.Index) r.Get("/dashboard/new", reqSignedIn, hs.Index) + if featuremgmt.AnyEnabled(hs.Features, featuremgmt.FlagDashboardLibrary, featuremgmt.FlagSuggestedDashboards, featuremgmt.FlagDashboardTemplates) { + r.Get("/dashboard/template", reqSignedIn, hs.Index) + } r.Get("/dashboard-solo/snapshot/*", hs.Index) r.Get("/dashboard/provisioning/*", reqSignedIn, hs.Index) r.Get("/d-solo/:uid/:slug", reqSignedIn, hs.Index) From bf3de0714724615acae02059b19dc2ffe37618a4 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Tue, 18 Nov 2025 13:58:55 -0500 Subject: [PATCH 043/125] QueryEditor: datasource getQueryDisplayText error (#114106) Fixes an issue where `getQueryDisplayText` throes an error and breaks the query editor. --- .../features/query/components/QueryEditorRow.tsx | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/public/app/features/query/components/QueryEditorRow.tsx b/public/app/features/query/components/QueryEditorRow.tsx index c13c6c97c93..d0e6369ced9 100644 --- a/public/app/features/query/components/QueryEditorRow.tsx +++ b/public/app/features/query/components/QueryEditorRow.tsx @@ -310,11 +310,18 @@ export class QueryEditorRow extends PureComponent { From 8dddff3ce4271cea5b536dd066aefdfbc44ec799 Mon Sep 17 00:00:00 2001 From: owensmallwood Date: Tue, 18 Nov 2025 13:05:21 -0600 Subject: [PATCH 044/125] Unified Storage: Pass ns, group, resource to GetResourceStats instead of just namespace (#114050) * passes nsr to GetResourceStats instead of just namespace * removes ns check * fixes failing tests * make update-workspace * pass group and resource from rebuild request when getting resource stats --- go.mod | 2 +- pkg/extensions/enterprise_imports.go | 6 ++-- .../apis/dashboard/legacy/sql_dashboards.go | 2 +- .../apis/iam/noopstorage/storage_backend.go | 2 +- .../iam/resourcepermission/storage_backend.go | 2 +- pkg/storage/unified/resource/search.go | 29 +++++++++++++++---- pkg/storage/unified/resource/search_test.go | 2 +- pkg/storage/unified/resource/server.go | 2 +- .../unified/resource/storage_backend.go | 4 +-- .../unified/resource/storage_backend_test.go | 6 ++-- pkg/storage/unified/sql/backend.go | 6 ++-- .../unified/testing/storage_backend.go | 20 ++++++++++--- 12 files changed, 57 insertions(+), 26 deletions(-) diff --git a/go.mod b/go.mod index bbc68d42913..cd27554f792 100644 --- a/go.mod +++ b/go.mod @@ -403,7 +403,7 @@ require ( github.com/diegoholiveira/jsonlogic/v3 v3.7.4 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/docker v28.4.0+incompatible // indirect - github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad + github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad github.com/docker/go-units v0.5.0 // indirect github.com/dolthub/flatbuffers/v23 v23.3.3-dh.2 // indirect github.com/dolthub/jsonpath v0.0.2-0.20240227200619-19675ab05c71 // indirect diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 472652cc103..113c2f8e4bb 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -15,7 +15,6 @@ import ( _ "github.com/blugelabs/bluge" _ "github.com/blugelabs/bluge_segment_api" _ "github.com/crewjam/saml" - _ "github.com/docker/go-connections/nat" _ "github.com/go-jose/go-jose/v4" _ "github.com/gobwas/glob" _ "github.com/googleapis/gax-go/v2" @@ -31,7 +30,6 @@ import ( _ "github.com/spf13/cobra" // used by the standalone apiserver cli _ "github.com/spyzhov/ajson" _ "github.com/stretchr/testify/require" - _ "github.com/testcontainers/testcontainers-go" _ "gocloud.dev/secrets/awskms" _ "gocloud.dev/secrets/azurekeyvault" _ "gocloud.dev/secrets/gcpkms" @@ -56,7 +54,9 @@ import ( _ "github.com/grafana/e2e" _ "github.com/grafana/gofpdf" _ "github.com/grafana/gomemcache/memcache" + _ "github.com/grafana/tempo/pkg/traceql" + _ "github.com/grafana/grafana/apps/alerting/alertenrichment/pkg/apis/alertenrichment/v1beta1" _ "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" - _ "github.com/grafana/tempo/pkg/traceql" + _ "github.com/testcontainers/testcontainers-go" ) diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 0fca282fa82..3d157e09489 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -165,7 +165,7 @@ type rowsWrapper struct { rejected []dashboardRow } -func (a *dashboardSqlAccess) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) { +func (a *dashboardSqlAccess) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) { return nil, fmt.Errorf("not implemented") } diff --git a/pkg/registry/apis/iam/noopstorage/storage_backend.go b/pkg/registry/apis/iam/noopstorage/storage_backend.go index ad0ebbe2335..107ac4e087c 100644 --- a/pkg/registry/apis/iam/noopstorage/storage_backend.go +++ b/pkg/registry/apis/iam/noopstorage/storage_backend.go @@ -23,7 +23,7 @@ func ProvideStorageBackend() *StorageBackendImpl { } // GetResourceStats implements resource.StorageBackend. -func (c *StorageBackendImpl) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) { +func (c *StorageBackendImpl) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) { return []resource.ResourceStats{}, errNoopStorage } diff --git a/pkg/registry/apis/iam/resourcepermission/storage_backend.go b/pkg/registry/apis/iam/resourcepermission/storage_backend.go index 8e2d323ff5c..84015bcf171 100644 --- a/pkg/registry/apis/iam/resourcepermission/storage_backend.go +++ b/pkg/registry/apis/iam/resourcepermission/storage_backend.go @@ -60,7 +60,7 @@ func ProvideStorageBackend(dbProvider legacysql.LegacyDatabaseProvider) *Resourc } } -func (s *ResourcePermSqlBackend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) { +func (s *ResourcePermSqlBackend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) { return []resource.ResourceStats{}, errNotImplemented } diff --git a/pkg/storage/unified/resource/search.go b/pkg/storage/unified/resource/search.go index 0f3e9f95912..7b5a6544e5b 100644 --- a/pkg/storage/unified/resource/search.go +++ b/pkg/storage/unified/resource/search.go @@ -245,7 +245,10 @@ func (s *searchSupport) ListManagedObjects(ctx context.Context, req *resourcepb. } rsp := &resourcepb.ListManagedObjectsResponse{} - stats, err := s.storage.GetResourceStats(ctx, req.Namespace, 0) + nsr := NamespacedResource{ + Namespace: req.Namespace, + } + stats, err := s.storage.GetResourceStats(ctx, nsr, 0) if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -286,7 +289,10 @@ func (s *searchSupport) ListManagedObjects(ctx context.Context, req *resourcepb. func (s *searchSupport) CountManagedObjects(ctx context.Context, req *resourcepb.CountManagedObjectsRequest) (*resourcepb.CountManagedObjectsResponse, error) { rsp := &resourcepb.CountManagedObjectsResponse{} - stats, err := s.storage.GetResourceStats(ctx, req.Namespace, 0) + nsr := NamespacedResource{ + Namespace: req.Namespace, + } + stats, err := s.storage.GetResourceStats(ctx, nsr, 0) if err != nil { rsp.Error = AsErrorResult(err) return rsp, nil @@ -408,7 +414,10 @@ func (s *searchSupport) GetStats(ctx context.Context, req *resourcepb.ResourceSt return rsp, nil } - stats, err := s.storage.GetResourceStats(ctx, req.Namespace, 0) + nsr := NamespacedResource{ + Namespace: req.Namespace, + } + stats, err := s.storage.GetResourceStats(ctx, nsr, 0) if err != nil { return &resourcepb.ResourceStatsResponse{ Error: AsErrorResult(err), @@ -457,7 +466,7 @@ func (s *searchSupport) buildIndexes(ctx context.Context) (int, error) { group := errgroup.Group{} group.SetLimit(s.initWorkers) - stats, err := s.storage.GetResourceStats(ctx, "", s.initMinSize) + stats, err := s.storage.GetResourceStats(ctx, NamespacedResource{}, s.initMinSize) if err != nil { return 0, err } @@ -657,7 +666,12 @@ func (s *searchSupport) rebuildIndex(ctx context.Context, req rebuildRequest) { // Get the correct value of size + RV for building the index. This is important for our Bleve // backend to decide whether to build index in-memory or as file-based. - stats, err := s.storage.GetResourceStats(ctx, req.Namespace, 0) + nsr := NamespacedResource{ + Namespace: req.Namespace, + Group: req.Group, + Resource: req.Resource, + } + stats, err := s.storage.GetResourceStats(ctx, nsr, 0) if err != nil { span.RecordError(fmt.Errorf("failed to get resource stats: %w", err)) l.Error("failed to get resource stats", "error", err) @@ -750,7 +764,10 @@ func (s *searchSupport) getOrCreateIndex(ctx context.Context, key NamespacedReso // Get correct value of size + RV for building the index. This is important for our Bleve // backend to decide whether to build index in-memory or as file-based. - stats, err := s.storage.GetResourceStats(ctx, key.Namespace, 0) + nsr := NamespacedResource{ + Namespace: key.Namespace, + } + stats, err := s.storage.GetResourceStats(ctx, nsr, 0) if err != nil { return nil, fmt.Errorf("failed to get resource stats: %w", err) } diff --git a/pkg/storage/unified/resource/search_test.go b/pkg/storage/unified/resource/search_test.go index 2c3cfe81097..c3f852c837e 100644 --- a/pkg/storage/unified/resource/search_test.go +++ b/pkg/storage/unified/resource/search_test.go @@ -88,7 +88,7 @@ type mockStorageBackend struct { resourceStats []ResourceStats } -func (m *mockStorageBackend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]ResourceStats, error) { +func (m *mockStorageBackend) GetResourceStats(ctx context.Context, nsr NamespacedResource, minCount int) ([]ResourceStats, error) { var result []ResourceStats for _, stat := range m.resourceStats { // Apply the minCount filter like the real implementation does diff --git a/pkg/storage/unified/resource/server.go b/pkg/storage/unified/resource/server.go index 13bac8a2f6e..bf805cc0a6a 100644 --- a/pkg/storage/unified/resource/server.go +++ b/pkg/storage/unified/resource/server.go @@ -123,7 +123,7 @@ type StorageBackend interface { WatchWriteEvents(ctx context.Context) (<-chan *WrittenEvent, error) // Get resource stats within the storage backend. When namespace is empty, it will apply to all - GetResourceStats(ctx context.Context, namespace string, minCount int) ([]ResourceStats, error) + GetResourceStats(ctx context.Context, nsr NamespacedResource, minCount int) ([]ResourceStats, error) // GetResourceLastImportTimes returns import times for all namespaced resources in the backend. GetResourceLastImportTimes(ctx context.Context) iter.Seq2[ResourceLastImportTime, error] diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 3764147508d..838c0814750 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -1223,8 +1223,8 @@ func (k *kvStorageBackend) WatchWriteEvents(ctx context.Context) (<-chan *Writte } // GetResourceStats returns resource stats within the storage backend. -func (k *kvStorageBackend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]ResourceStats, error) { - return k.dataStore.GetResourceStats(ctx, namespace, minCount) +func (k *kvStorageBackend) GetResourceStats(ctx context.Context, nsr NamespacedResource, minCount int) ([]ResourceStats, error) { + return k.dataStore.GetResourceStats(ctx, nsr.Namespace, minCount) } func (k *kvStorageBackend) GetResourceLastImportTimes(ctx context.Context) iter.Seq2[ResourceLastImportTime, error] { diff --git a/pkg/storage/unified/resource/storage_backend_test.go b/pkg/storage/unified/resource/storage_backend_test.go index 81189fb4545..f8ea2717a1e 100644 --- a/pkg/storage/unified/resource/storage_backend_test.go +++ b/pkg/storage/unified/resource/storage_backend_test.go @@ -1397,7 +1397,7 @@ func TestKvStorageBackend_GetResourceStats_Success(t *testing.T) { } // Get stats for default namespace - stats, err := backend.GetResourceStats(ctx, "default", 0) + stats, err := backend.GetResourceStats(ctx, NamespacedResource{Namespace: "default"}, 0) require.NoError(t, err) require.Len(t, stats, 2) // Should have stats for 2 resource types in default namespace @@ -1413,12 +1413,12 @@ func TestKvStorageBackend_GetResourceStats_Success(t *testing.T) { require.Equal(t, int64(1), resourceTypes["default/core/services"]) // Get stats for all namespaces (empty string) - allStats, err := backend.GetResourceStats(ctx, "", 0) + allStats, err := backend.GetResourceStats(ctx, NamespacedResource{Namespace: ""}, 0) require.NoError(t, err) require.Len(t, allStats, 4) // Should have stats for all 4 resource types across namespaces // Get stats with minCount filter - filteredStats, err := backend.GetResourceStats(ctx, "", 1) + filteredStats, err := backend.GetResourceStats(ctx, NamespacedResource{Namespace: ""}, 1) require.NoError(t, err) require.Len(t, filteredStats, 1) // Only resources in default namespace has count > 1 diff --git a/pkg/storage/unified/sql/backend.go b/pkg/storage/unified/sql/backend.go index 181782eb606..d3dae729fd4 100644 --- a/pkg/storage/unified/sql/backend.go +++ b/pkg/storage/unified/sql/backend.go @@ -262,13 +262,15 @@ func (b *backend) Stop(_ context.Context) error { } // GetResourceStats implements Backend. -func (b *backend) GetResourceStats(ctx context.Context, namespace string, minCount int) ([]resource.ResourceStats, error) { +func (b *backend) GetResourceStats(ctx context.Context, nsr resource.NamespacedResource, minCount int) ([]resource.ResourceStats, error) { ctx, span := b.tracer.Start(ctx, tracePrefix+"GetResourceStats") defer span.End() req := &sqlStatsRequest{ SQLTemplate: sqltemplate.New(b.dialect), - Namespace: namespace, + Namespace: nsr.Namespace, + Group: nsr.Group, + Resource: nsr.Resource, MinCount: minCount, // not used in query... yet? } diff --git a/pkg/storage/unified/testing/storage_backend.go b/pkg/storage/unified/testing/storage_backend.go index 3f8b4b9ed55..b3cc08b57c0 100644 --- a/pkg/storage/unified/testing/storage_backend.go +++ b/pkg/storage/unified/testing/storage_backend.go @@ -262,7 +262,10 @@ func runTestIntegrationBackendGetResourceStats(t *testing.T, backend resource.St require.NoError(t, err) t.Run("Get stats for ns1", func(t *testing.T) { - stats, err := backend.GetResourceStats(ctx, nsPrefix+"-stats-ns1", 0) + nsr := resource.NamespacedResource{ + Namespace: nsPrefix + "-stats-ns1", + } + stats, err := backend.GetResourceStats(ctx, nsr, 0) require.NoError(t, err) require.Len(t, stats, 2) @@ -285,7 +288,10 @@ func runTestIntegrationBackendGetResourceStats(t *testing.T, backend resource.St }) t.Run("Get stats for ns2", func(t *testing.T) { - stats, err := backend.GetResourceStats(ctx, nsPrefix+"-stats-ns2", 0) + nsr := resource.NamespacedResource{ + Namespace: nsPrefix + "-stats-ns2", + } + stats, err := backend.GetResourceStats(ctx, nsr, 0) require.NoError(t, err) require.Len(t, stats, 1) @@ -297,7 +303,10 @@ func runTestIntegrationBackendGetResourceStats(t *testing.T, backend resource.St }) t.Run("Get stats with minimum count", func(t *testing.T) { - stats, err := backend.GetResourceStats(ctx, nsPrefix+"-stats-ns1", 1) + nsr := resource.NamespacedResource{ + Namespace: nsPrefix + "-stats-ns1", + } + stats, err := backend.GetResourceStats(ctx, nsr, 1) require.NoError(t, err) require.Len(t, stats, 1) @@ -308,7 +317,10 @@ func runTestIntegrationBackendGetResourceStats(t *testing.T, backend resource.St }) t.Run("Get stats for non-existent namespace", func(t *testing.T) { - stats, err := backend.GetResourceStats(ctx, "non-existent", 0) + nsr := resource.NamespacedResource{ + Namespace: "non-existent", + } + stats, err := backend.GetResourceStats(ctx, nsr, 0) require.NoError(t, err) require.Empty(t, stats) }) From 2f591aaaebfc3b4bf422dd021d5cfb62334e5479 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Tue, 18 Nov 2025 20:10:46 +0100 Subject: [PATCH 045/125] GitSync: Fix documentation link (#114119) --- .../app/features/provisioning/GettingStarted/FeaturesList.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx index ca25afa803c..9676baf2e49 100644 --- a/public/app/features/provisioning/GettingStarted/FeaturesList.tsx +++ b/public/app/features/provisioning/GettingStarted/FeaturesList.tsx @@ -48,9 +48,7 @@ export const FeaturesList = ({ hasRequiredFeatures, onSetupFeatures }: FeaturesL Want to learn more? See our{' '} documentation From 03ed5a372de9d02ce7c8155d534a383c978b7af2 Mon Sep 17 00:00:00 2001 From: Jacob Valdez Date: Tue, 18 Nov 2025 14:24:25 -0600 Subject: [PATCH 046/125] Docs: What's new and Upgrade guide v12.3 (#113372) --- .../upgrade-guide/upgrade-v12.3/index.md | 20 +++++++ docs/sources/whatsnew/_index.md | 1 + docs/sources/whatsnew/whats-new-in-v12-3.md | 59 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 docs/sources/upgrade-guide/upgrade-v12.3/index.md create mode 100644 docs/sources/whatsnew/whats-new-in-v12-3.md diff --git a/docs/sources/upgrade-guide/upgrade-v12.3/index.md b/docs/sources/upgrade-guide/upgrade-v12.3/index.md new file mode 100644 index 00000000000..8d3dacbf396 --- /dev/null +++ b/docs/sources/upgrade-guide/upgrade-v12.3/index.md @@ -0,0 +1,20 @@ +--- +description: Guide for upgrading to Grafana v12.3 +keywords: + - grafana + - configuration + - documentation + - upgrade + - '12.3' +title: Upgrade to Grafana v12.3 +menuTitle: Upgrade to v12.3 +weight: 497 +--- + +# Upgrade to Grafana v12.3 + +{{< docs/shared lookup="upgrade/intro_2.md" source="grafana" version="" >}} + +{{< docs/shared lookup="back-up/back-up-grafana.md" source="grafana" version="" leveloffset="+1" >}} + +{{< docs/shared lookup="upgrade/upgrade-common-tasks.md" source="grafana" version="" >}} diff --git a/docs/sources/whatsnew/_index.md b/docs/sources/whatsnew/_index.md index 6779856cdeb..2a320749e66 100644 --- a/docs/sources/whatsnew/_index.md +++ b/docs/sources/whatsnew/_index.md @@ -193,6 +193,7 @@ For a complete list of every change, with links to pull requests and related iss ## Grafana 12 +- [What's new in 12.3](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v12-3) - [What's new in 12.2](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v12-2) - [What's new in 12.1](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v12-1) - [What's new in 12.0](https://grafana.com/docs/grafana//whatsnew/whats-new-in-v12-0) diff --git a/docs/sources/whatsnew/whats-new-in-v12-3.md b/docs/sources/whatsnew/whats-new-in-v12-3.md new file mode 100644 index 00000000000..bcbb2201f65 --- /dev/null +++ b/docs/sources/whatsnew/whats-new-in-v12-3.md @@ -0,0 +1,59 @@ +--- +description: Feature and improvement highlights for Grafana v12.3 +keywords: + - grafana + - new + - documentation + - '12.3' + - release notes +labels: + products: + - cloud + - enterprise + - oss +title: What's new in Grafana v12.3 +posts: + - title: Interactive learning + items: + - whats-new/2025-10-22-interactive-learning-in-grafana.md + - title: Dashboards and visualizations + items: + - whats-new/2025-08-28-compare-time-series-data-across-time-ranges-in-the-same-panel.md + - whats-new/2025-10-27-switch-variable-type.md + - whats-new/2025-10-28-export-dashboards-as-images.md + - whats-new/2025-10-28-style-table-cells-with-css-properties.md + - whats-new/2025-10-31-save-reuse-and-share-dashboard-annotation-queries.md + - title: Logs + items: + - whats-new/2025-09-09-new-logs-panel.md + - whats-new/2025-10-20-new-logs-context.md + - whats-new/2025-10-22-new-field-selector-component-for-logs.md + - title: Data sources + items: + - whats-new/2025-10-16-unity-catalog-support-in-grafana-databricks-plugin.md + - whats-new/2025-10-01-raw-query-support-in-honeycomb-data-source.md + - whats-new/2025-11-04-cloudwatch-logs-anomaly-detection-and-pattern-analysis.md + - whats-new/2025-11-04-opensearch-ppl-language-support-and-sample-queries.md + - whats-new/2025-10-22-solarwinds-enterprise-data-source-for-grafana/ + - title: Breaking changes + items: + - whats-new/2025-10-03-removal-of-api-key-associated-permissions.md + - whats-new/2025-10-16-change-to-creator-permissions.md + - whats-new/2025-10-16-changes-to-provisioned-permissions.md +whats_new_grafana_version: 12.3 +weight: -52 +--- + +# What’s new in Grafana v12.3 + +Welcome to Grafana 12.3! + +This release reimagines how you explore logs, delivers more ways to connect your data, and enables more intuitive sharing, so you can spend less time navigating tools and more time turning signals into action. It starts with a completely redesigned logs panel that brings faster pattern recognition, clearer context, and a smoother exploration experience. We’ve also expanded what you can do with new data sources and query improvements, from SolarWinds Enterprise to advanced Honeycomb and OpenSearch capabilities. + +Sharing insights is easier too, with streamlined dashboard image exports and consolidated panel time controls that make comparisons and overrides more intuitive. And to help you learn as you go, a new interactive learning experience now brings contextual guidance directly into Grafana, giving you tips, tutorials, and documentation right when you need them. All of this comes together to make open observability easier, more accessible, and more powerful for every team. + +{{< youtube id=VidGeu4PRZw >}} + +For even more detail about all the changes in this release, refer to the [changelog](https://github.com/grafana/grafana/blob/main/CHANGELOG.md). For the specific steps we recommend when you upgrade to v12.3, check out our [Upgrade Guide](https://grafana.com/docs/grafana//upgrade-guide/upgrade-v12.3/). + +{{< docs/whats-new >}} From e558c9af5db3d066a4f80b5d182ac5d3c481d959 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 00:40:18 +0000 Subject: [PATCH 047/125] I18n: Download translations from Crowdin (#114130) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 21 +++++++++++++++++++-- public/locales/de-DE/grafana.json | 21 +++++++++++++++++++-- public/locales/es-ES/grafana.json | 21 +++++++++++++++++++-- public/locales/fr-FR/grafana.json | 21 +++++++++++++++++++-- public/locales/hu-HU/grafana.json | 21 +++++++++++++++++++-- public/locales/id-ID/grafana.json | 21 +++++++++++++++++++-- public/locales/it-IT/grafana.json | 21 +++++++++++++++++++-- public/locales/ja-JP/grafana.json | 21 +++++++++++++++++++-- public/locales/ko-KR/grafana.json | 21 +++++++++++++++++++-- public/locales/nl-NL/grafana.json | 21 +++++++++++++++++++-- public/locales/pl-PL/grafana.json | 21 +++++++++++++++++++-- public/locales/pt-BR/grafana.json | 21 +++++++++++++++++++-- public/locales/pt-PT/grafana.json | 21 +++++++++++++++++++-- public/locales/ru-RU/grafana.json | 21 +++++++++++++++++++-- public/locales/sv-SE/grafana.json | 21 +++++++++++++++++++-- public/locales/tr-TR/grafana.json | 21 +++++++++++++++++++-- public/locales/zh-Hans/grafana.json | 21 +++++++++++++++++++-- public/locales/zh-Hant/grafana.json | 21 +++++++++++++++++++-- 18 files changed, 342 insertions(+), 36 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 7968b04c1e2..d446bbc5710 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -7844,6 +7844,14 @@ "name-show-threshold-labels": "Zobrazit štítky prahových hodnot", "name-show-threshold-markers": "Zobrazit značky prahových hodnot", "placeholder-neutral": "auto.", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9074,6 +9082,13 @@ "copy": "Kopírovat do schránky", "csv-counts": "", "csv-placeholder": "Zadej CSV tady…", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filtrovat hodnoty", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Rozlišovat malá a velká písmena", @@ -12516,8 +12531,10 @@ "cancel": "Zrušit", "error-title": "", "input": { - "placeholder": "Vyberte rozsahy…", - "removeAll": "Odebrat všechny rozsahy" + "placeholder": "", + "remove-all": "", + "removeAll": "Odebrat všechny rozsahy", + "tooltip": "" }, "title": "Vyberte rozsahy" }, diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index a809649f203..7a59b2181a7 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Schwellenwert-Labels anzeigen", "name-show-threshold-markers": "Schwellenwert-Markierungen anzeigen", "placeholder-neutral": "auto", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "In Zwischenablage kopieren", "csv-counts": "", "csv-placeholder": "CSV hier eingeben …", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filterwerte", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Groß-/Kleinschreibung beachten", @@ -12406,8 +12421,10 @@ "cancel": "Abbrechen", "error-title": "", "input": { - "placeholder": "Bereiche auswählen …", - "removeAll": "Alle Bereiche entfernen" + "placeholder": "", + "remove-all": "", + "removeAll": "Alle Bereiche entfernen", + "tooltip": "" }, "title": "Bereiche auswählen" }, diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 678be0da90c..c9bf819dcc7 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Mostrar etiquetas de umbral", "name-show-threshold-markers": "Mostrar marcadores de umbral", "placeholder-neutral": "auto", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Copiar al portapapeles", "csv-counts": "", "csv-placeholder": "Introducir CSV aquí...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filtrar valores", "filter-popup-apply": "Aceptar", "filter-popup-aria-label-match-case": "Hacer coincidir mayúsculas y minúsculas", @@ -12406,8 +12421,10 @@ "cancel": "Cancelar", "error-title": "", "input": { - "placeholder": "Seleccionar ámbitos...", - "removeAll": "Eliminar todos los ámbitos" + "placeholder": "", + "remove-all": "", + "removeAll": "Eliminar todos los ámbitos", + "tooltip": "" }, "title": "Seleccionar ámbitos" }, diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index bb06a7ded11..909effb75e1 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Afficher les étiquettes de seuil", "name-show-threshold-markers": "Afficher les marqueurs de seuil", "placeholder-neutral": "auto", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Copier dans le presse-papier", "csv-counts": "", "csv-placeholder": "Saisir le CSV ici…", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filtrer les valeurs", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Respecter la casse", @@ -12406,8 +12421,10 @@ "cancel": "Annuler", "error-title": "", "input": { - "placeholder": "Sélectionner les portées...", - "removeAll": "Supprimer toutes les portées" + "placeholder": "", + "remove-all": "", + "removeAll": "Supprimer toutes les portées", + "tooltip": "" }, "title": "Sélectionner les portées" }, diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 2e42c81b7a7..faddf11c310 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Küszöbértékcímkék megjelenítése", "name-show-threshold-markers": "Küszöbértékjelölők megjelenítése", "placeholder-neutral": "automatikus", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Másolás a vágólapra", "csv-counts": "", "csv-placeholder": "Itt adja meg a CSV-t...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Értékek szűrése", "filter-popup-apply": "OK", "filter-popup-aria-label-match-case": "Nagybetűérzékeny", @@ -12406,8 +12421,10 @@ "cancel": "Mégse", "error-title": "", "input": { - "placeholder": "Hatókörök kijelölése...", - "removeAll": "Összes hatókör eltávolítása" + "placeholder": "", + "remove-all": "", + "removeAll": "Összes hatókör eltávolítása", + "tooltip": "" }, "title": "Hatókörök kijelölése" }, diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 710d5cb1002..ff85c103ce3 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -7766,6 +7766,14 @@ "name-show-threshold-labels": "Tampilkan label ambang batas", "name-show-threshold-markers": "Tampilkan penanda ambang batas", "placeholder-neutral": "otomatis", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -8966,6 +8974,13 @@ "copy": "Salin ke Papan Klip", "csv-counts": "", "csv-placeholder": "Masukkan CSV di sini...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filter nilai", "filter-popup-apply": "Oke", "filter-popup-aria-label-match-case": "Cocok huruf besar kecil", @@ -12351,8 +12366,10 @@ "cancel": "Batalkan", "error-title": "", "input": { - "placeholder": "Pilih cakupan...", - "removeAll": "Hapus semua cakupan" + "placeholder": "", + "remove-all": "", + "removeAll": "Hapus semua cakupan", + "tooltip": "" }, "title": "Pilih cakupan" }, diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 7ab4900c16b..2032908b4e0 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Mostra etichette di soglia", "name-show-threshold-markers": "Mostra indicatori di soglia", "placeholder-neutral": "automatico", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Copia negli appunti", "csv-counts": "", "csv-placeholder": "Inserisci qui il CSV...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Valori filtro", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Corrispondenza", @@ -12406,8 +12421,10 @@ "cancel": "Annulla", "error-title": "", "input": { - "placeholder": "Seleziona gli ambiti...", - "removeAll": "Rimuovi tutti gli ambiti" + "placeholder": "", + "remove-all": "", + "removeAll": "Rimuovi tutti gli ambiti", + "tooltip": "" }, "title": "Seleziona gli ambiti" }, diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index b0da2ed08de..d0219ba1ecc 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -7766,6 +7766,14 @@ "name-show-threshold-labels": "しきい値ラベルを表示", "name-show-threshold-markers": "しきい値マーカーを表示", "placeholder-neutral": "自動", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -8966,6 +8974,13 @@ "copy": "クリップボードにコピー", "csv-counts": "", "csv-placeholder": "ここにCSVを入力...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "値をフィルタリング", "filter-popup-apply": "OK", "filter-popup-aria-label-match-case": "大文字/小文字を区別", @@ -12351,8 +12366,10 @@ "cancel": "キャンセル", "error-title": "", "input": { - "placeholder": "スコープを選択...", - "removeAll": "すべてのスコープを削除" + "placeholder": "", + "remove-all": "", + "removeAll": "すべてのスコープを削除", + "tooltip": "" }, "title": "スコープを選択" }, diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 8da03a382ad..faea085221a 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -7766,6 +7766,14 @@ "name-show-threshold-labels": "임계값 레이블 표시", "name-show-threshold-markers": "임계값 마커 표시", "placeholder-neutral": "자동", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -8966,6 +8974,13 @@ "copy": "클립보드로 복사", "csv-counts": "", "csv-placeholder": "여기에 CSV를 입력하세요...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "값 필터링", "filter-popup-apply": "확인", "filter-popup-aria-label-match-case": "대소문자 일치", @@ -12351,8 +12366,10 @@ "cancel": "취소", "error-title": "", "input": { - "placeholder": "범위 선택...", - "removeAll": "모든 범위 제거" + "placeholder": "", + "remove-all": "", + "removeAll": "모든 범위 제거", + "tooltip": "" }, "title": "범위 선택" }, diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 2a517bd97c4..ff870d106ed 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Drempellabels weergeven", "name-show-threshold-markers": "Drempelmarkeringen weergeven", "placeholder-neutral": "automatisch", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Naar klembord kopiëren", "csv-counts": "", "csv-placeholder": "Voer hier csv in ...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filter waarden", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Case matchen", @@ -12406,8 +12421,10 @@ "cancel": "Annuleren", "error-title": "", "input": { - "placeholder": "Scopes selecteren ...", - "removeAll": "Alle scopes verwijderen" + "placeholder": "", + "remove-all": "", + "removeAll": "Alle scopes verwijderen", + "tooltip": "" }, "title": "Scopes selecteren" }, diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index da122b8635f..9d14b81af74 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -7844,6 +7844,14 @@ "name-show-threshold-labels": "Pokaż etykiety progów", "name-show-threshold-markers": "Pokaż znaczniki progów", "placeholder-neutral": "automatycznie", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9074,6 +9082,13 @@ "copy": "Kopiuj do schowka", "csv-counts": "", "csv-placeholder": "Wprowadź plik CSV tutaj…", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Wartości filtra", "filter-popup-apply": "OK", "filter-popup-aria-label-match-case": "Dopasuj wielkość liter", @@ -12516,8 +12531,10 @@ "cancel": "Anuluj", "error-title": "", "input": { - "placeholder": "Wybierz zakresy…", - "removeAll": "Usuń wszystkie zakresy" + "placeholder": "", + "remove-all": "", + "removeAll": "Usuń wszystkie zakresy", + "tooltip": "" }, "title": "Wybierz zakresy" }, diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 24f080ff425..0e3a51255e4 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Mostrar rótulos de limite", "name-show-threshold-markers": "Mostrar marcadores de limite", "placeholder-neutral": "automático", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Copiar para a área de transferência", "csv-counts": "", "csv-placeholder": "Insira o CSV aqui...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filtrar valores", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Corresponder maiúsculas/minúsculas", @@ -12406,8 +12421,10 @@ "cancel": "Cancelar", "error-title": "", "input": { - "placeholder": "Selecione os escopos...", - "removeAll": "Remover todos os escopos" + "placeholder": "", + "remove-all": "", + "removeAll": "Remover todos os escopos", + "tooltip": "" }, "title": "Selecionar escopos" }, diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 58cd80cf939..a201eb08d24 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Mostrar as etiquetas de limite", "name-show-threshold-markers": "Mostrar os marcadores de limite", "placeholder-neutral": "auto", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Copiar para a área de transferência", "csv-counts": "", "csv-placeholder": "Introduza o CSV aqui...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filtrar valores", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Diferenciar maiúsculas de minúsculas", @@ -12406,8 +12421,10 @@ "cancel": "Cancelar", "error-title": "", "input": { - "placeholder": "Selecionar âmbitos...", - "removeAll": "Remover todos os âmbitos" + "placeholder": "", + "remove-all": "", + "removeAll": "Remover todos os âmbitos", + "tooltip": "" }, "title": "Selecionar âmbitos" }, diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index af03cde5fe8..1c80cf4d118 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -7844,6 +7844,14 @@ "name-show-threshold-labels": "Показывать метки пороговых значений", "name-show-threshold-markers": "Показывать маркеры пороговых значений", "placeholder-neutral": "авто", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9074,6 +9082,13 @@ "copy": "Копировать в буфер обмена", "csv-counts": "", "csv-placeholder": "Ввести CSV...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Значения фильтра", "filter-popup-apply": "Ок", "filter-popup-aria-label-match-case": "С учетом регистра", @@ -12516,8 +12531,10 @@ "cancel": "Отмена", "error-title": "", "input": { - "placeholder": "Выбрать области...", - "removeAll": "Удалить все области" + "placeholder": "", + "remove-all": "", + "removeAll": "Удалить все области", + "tooltip": "" }, "title": "Выбор областей" }, diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 28cacec2dc8..35b5c4b7cc7 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Visa tröskelvärdesetiketter", "name-show-threshold-markers": "Visa tröskelmarkörer", "placeholder-neutral": "auto", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Kopiera till klippbordet", "csv-counts": "", "csv-placeholder": "Ange CSV här…", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Filtrera värden", "filter-popup-apply": "Ok", "filter-popup-aria-label-match-case": "Matcha gemener/versaler", @@ -12406,8 +12421,10 @@ "cancel": "Avbryt", "error-title": "", "input": { - "placeholder": "Välj omfattningar …", - "removeAll": "Ta bort alla omfattningar" + "placeholder": "", + "remove-all": "", + "removeAll": "Ta bort alla omfattningar", + "tooltip": "" }, "title": "Välj omfattningar" }, diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 0f00a582988..2fd50dc5856 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -7792,6 +7792,14 @@ "name-show-threshold-labels": "Eşik etiketlerini göster", "name-show-threshold-markers": "Eşik işaretçilerini göster", "placeholder-neutral": "otomatik", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -9002,6 +9010,13 @@ "copy": "Panoya kopyala", "csv-counts": "", "csv-placeholder": "Buraya CSV girin...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "Değerleri filtrele", "filter-popup-apply": "Tamam", "filter-popup-aria-label-match-case": "", @@ -12406,8 +12421,10 @@ "cancel": "İptal", "error-title": "", "input": { - "placeholder": "Kapsam seçin...", - "removeAll": "Tüm kapsamları kaldır" + "placeholder": "", + "remove-all": "", + "removeAll": "Tüm kapsamları kaldır", + "tooltip": "" }, "title": "Kapsam seçin" }, diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index 18a7238b33d..e139cf56610 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -7766,6 +7766,14 @@ "name-show-threshold-labels": "显示阈值标签", "name-show-threshold-markers": "显示阈值标记", "placeholder-neutral": "自动", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -8966,6 +8974,13 @@ "copy": "复制到剪贴板", "csv-counts": "", "csv-placeholder": "在此处输入 CSV...", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "筛选值", "filter-popup-apply": "好", "filter-popup-aria-label-match-case": "区分大小写", @@ -12351,8 +12366,10 @@ "cancel": "取消", "error-title": "", "input": { - "placeholder": "选择范围…", - "removeAll": "移除所有范围" + "placeholder": "", + "remove-all": "", + "removeAll": "移除所有范围", + "tooltip": "" }, "title": "选择范围" }, diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 203c2d2f177..80360c56f8b 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -7766,6 +7766,14 @@ "name-show-threshold-labels": "顯示閾值標籤", "name-show-threshold-markers": "顯示閾值標記", "placeholder-neutral": "自動", + "suggestions": { + "arc": "", + "circular": "", + "style": { + "circular": "", + "simple": "" + } + }, "threshold": "" }, "gen-ai": { @@ -8966,6 +8974,13 @@ "copy": "複製到剪貼簿", "csv-counts": "", "csv-placeholder": "在此處輸入 CSV …", + "filter": { + "add-all": "", + "remove-all": "", + "select-all": "", + "selected": "", + "selected-some-hidden": "" + }, "filter-placeholder": "篩選值", "filter-popup-apply": "好的", "filter-popup-aria-label-match-case": "區分大小寫", @@ -12351,8 +12366,10 @@ "cancel": "取消", "error-title": "", "input": { - "placeholder": "選取範圍…", - "removeAll": "移除所有範圍" + "placeholder": "", + "remove-all": "", + "removeAll": "移除所有範圍", + "tooltip": "" }, "title": "選取範圍" }, From 00329cab1407b2ada3277d62bd3874edf8a44eaf Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 19 Nov 2025 08:28:39 +0300 Subject: [PATCH 048/125] Stars: Move stars from preferences apiserver to a new collections apiserver (#114006) --- .github/CODEOWNERS | 1 + Dockerfile | 2 +- apps/collections/Makefile | 10 + apps/collections/go.mod | 69 + apps/collections/go.sum | 187 ++ apps/collections/kinds/cue.mod/module.cue | 4 + apps/collections/kinds/manifest.cue | 17 + .../kinds/stars.cue | 0 .../apis/collections/v1alpha1/constants.go | 18 + .../pkg/apis/collections/v1alpha1/register.go | 58 + .../pkg/apis/collections}/v1alpha1/stars.go | 0 .../collections}/v1alpha1/stars_client_gen.go | 0 .../collections}/v1alpha1/stars_codec_gen.go | 0 .../v1alpha1/stars_metadata_gen.go | 0 .../collections}/v1alpha1/stars_object_gen.go | 0 .../collections}/v1alpha1/stars_schema_gen.go | 2 +- .../collections}/v1alpha1/stars_spec_gen.go | 0 .../apis/collections}/v1alpha1/stars_test.go | 0 .../v1alpha1/zz_generated.openapi.go | 187 ++ ...enerated.openapi_violation_exceptions.list | 2 + .../pkg/apis/collections_manifest.go | 132 ++ apps/preferences/go.mod | 2 +- apps/preferences/kinds/manifest.cue | 3 +- .../pkg/apis/preferences/v1alpha1/register.go | 24 - .../v1alpha1/zz_generated.openapi.go | 169 -- ...enerated.openapi_violation_exceptions.list | 2 - .../pkg/apis/preferences_manifest.go | 20 - go.mod | 2 + go.work | 1 + hack/update-codegen.sh | 1 + packages/grafana-api-clients/package.json | 4 + .../rtkq/collections/v1alpha1/baseAPI.ts | 16 + .../collections/v1alpha1/endpoints.gen.ts | 538 ++++++ .../rtkq/collections/v1alpha1/index.ts | 5 + .../src/clients/rtkq/index.ts | 5 +- .../preferences/v1alpha1/endpoints.gen.ts | 349 +--- .../src/scripts/generate-rtk-apis.ts | 1 + .../src/handlers/all-handlers.ts | 4 +- .../v1alpha1/handlers.ts | 6 +- .../grafana-test-utils/src/server/index.ts | 2 +- pkg/apimachinery/identity/context.go | 3 +- pkg/registry/apis/apis.go | 2 + .../{preferences => collections}/admission.go | 6 +- .../apis/collections/legacy/queries.go | 67 + .../apis/collections/legacy/queries_test.go | 52 + pkg/registry/apis/collections/legacy/sql.go | 116 ++ .../legacy/sql_dashboard_stars.sql | 0 .../legacy/sql_dashboard_stars_rv.sql | 0 .../legacy/stars.go | 80 +- .../mysql--sql_dashboard_stars-all.sql | 0 .../mysql--sql_dashboard_stars-org.sql | 0 .../mysql--sql_dashboard_stars-user.sql | 0 .../mysql--sql_dashboard_stars_rv-get.sql | 0 .../postgres--sql_dashboard_stars-all.sql | 0 .../postgres--sql_dashboard_stars-org.sql | 0 .../postgres--sql_dashboard_stars-user.sql | 0 .../postgres--sql_dashboard_stars_rv-get.sql | 0 .../sqlite--sql_dashboard_stars-all.sql | 0 .../sqlite--sql_dashboard_stars-org.sql | 0 .../sqlite--sql_dashboard_stars-user.sql | 0 .../sqlite--sql_dashboard_stars_rv-get.sql | 0 pkg/registry/apis/collections/register.go | 178 ++ .../{preferences => collections}/stars.go | 10 +- .../stars_update.go | 14 +- .../stars_update_test.go | 8 +- .../apis/preferences/legacy/queries.go | 47 +- .../apis/preferences/legacy/queries_test.go | 65 - pkg/registry/apis/preferences/legacy/sql.go | 181 -- .../preferences/legacy/sql_history_stars.sql | 9 - .../legacy/sql_history_stars_delete.sql | 6 - .../legacy/sql_history_stars_insert.sql | 4 - .../testdata/mysql--sql_history_stars-org.sql | 6 - ...l_history_stars_delete-remove all star.sql | 3 - ...sql--sql_history_stars_insert-add star.sql | 4 - .../postgres--sql_history_stars-org.sql | 6 - ...l_history_stars_delete-remove all star.sql | 3 - ...res--sql_history_stars_insert-add star.sql | 4 - .../sqlite--sql_history_stars-org.sql | 6 - ...l_history_stars_delete-remove all star.sql | 3 - ...ite--sql_history_stars_insert-add star.sql | 4 - pkg/registry/apis/preferences/register.go | 97 +- .../preferences/{ => utils}/authorizer.go | 31 +- .../{ => utils}/authorizer_test.go | 39 +- pkg/registry/apis/wireset.go | 2 + pkg/server/wire_gen.go | 11 +- pkg/services/queryhistory/api.go | 21 - pkg/services/queryhistory/client.go | 103 -- pkg/services/queryhistory/queryhistory.go | 9 - pkg/services/star/api/client.go | 8 +- .../stars_test.go | 53 +- .../collections.grafana.app-v1alpha1.json | 1615 +++++++++++++++++ .../preferences.grafana.app-v1alpha1.json | 1064 ----------- pkg/tests/apis/openapi_test.go | 3 + .../v1alpha1/index.ts | 6 +- public/app/features/search/service/unified.ts | 2 +- public/app/features/stars/hooks.ts | 2 +- 96 files changed, 3416 insertions(+), 2380 deletions(-) create mode 100644 apps/collections/Makefile create mode 100644 apps/collections/go.mod create mode 100644 apps/collections/go.sum create mode 100644 apps/collections/kinds/cue.mod/module.cue create mode 100644 apps/collections/kinds/manifest.cue rename apps/{preferences => collections}/kinds/stars.cue (100%) create mode 100644 apps/collections/pkg/apis/collections/v1alpha1/constants.go create mode 100644 apps/collections/pkg/apis/collections/v1alpha1/register.go rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars.go (100%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_client_gen.go (100%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_codec_gen.go (100%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_metadata_gen.go (100%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_object_gen.go (100%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_schema_gen.go (92%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_spec_gen.go (100%) rename apps/{preferences/pkg/apis/preferences => collections/pkg/apis/collections}/v1alpha1/stars_test.go (100%) create mode 100644 apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi.go create mode 100644 apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi_violation_exceptions.list create mode 100644 apps/collections/pkg/apis/collections_manifest.go create mode 100644 packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/baseAPI.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/endpoints.gen.ts create mode 100644 packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/index.ts rename packages/grafana-test-utils/src/handlers/apis/{preferences.grafana.app => collections.grafana.app}/v1alpha1/handlers.ts (90%) rename pkg/registry/apis/{preferences => collections}/admission.go (79%) create mode 100644 pkg/registry/apis/collections/legacy/queries.go create mode 100644 pkg/registry/apis/collections/legacy/queries_test.go create mode 100644 pkg/registry/apis/collections/legacy/sql.go rename pkg/registry/apis/{preferences => collections}/legacy/sql_dashboard_stars.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/sql_dashboard_stars_rv.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/stars.go (78%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/mysql--sql_dashboard_stars-all.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/mysql--sql_dashboard_stars-org.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/mysql--sql_dashboard_stars-user.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/postgres--sql_dashboard_stars-all.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/postgres--sql_dashboard_stars-org.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/postgres--sql_dashboard_stars-user.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/sqlite--sql_dashboard_stars-all.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/sqlite--sql_dashboard_stars-org.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/sqlite--sql_dashboard_stars-user.sql (100%) rename pkg/registry/apis/{preferences => collections}/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql (100%) create mode 100644 pkg/registry/apis/collections/register.go rename pkg/registry/apis/{preferences => collections}/stars.go (81%) rename pkg/registry/apis/{preferences => collections}/stars_update.go (90%) rename pkg/registry/apis/{preferences => collections}/stars_update_test.go (84%) delete mode 100644 pkg/registry/apis/preferences/legacy/sql_history_stars.sql delete mode 100644 pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql delete mode 100644 pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql delete mode 100755 pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql rename pkg/registry/apis/preferences/{ => utils}/authorizer.go (78%) rename pkg/registry/apis/preferences/{ => utils}/authorizer_test.go (90%) delete mode 100644 pkg/services/queryhistory/client.go rename pkg/tests/apis/{preferences => collections}/stars_test.go (78%) create mode 100644 pkg/tests/apis/openapi_snapshots/collections.grafana.app-v1alpha1.json rename public/app/api/clients/{preferences => collections}/v1alpha1/index.ts (86%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 39a480c8a85..b9a89176f61 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -89,6 +89,7 @@ /apps/folder/ @grafana/grafana-app-platform-squad /apps/playlist/ @grafana/grafana-app-platform-squad /apps/plugins/ @grafana/plugins-platform-backend +/apps/collections/ @grafana/grafana-app-platform-squad @grafana/grafana-frontend-platform /apps/preferences/ @grafana/grafana-app-platform-squad @grafana/grafana-frontend-platform /apps/shorturl/ @grafana/sharing-squad /apps/secret/ @grafana/grafana-operator-experience-squad diff --git a/Dockerfile b/Dockerfile index 7cb65f4a05f..c07eb0360e6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -98,6 +98,7 @@ COPY apps/shorturl apps/shorturl COPY apps/annotation apps/annotation COPY apps/correlations apps/correlations COPY apps/preferences apps/preferences +COPY apps/collections apps/collections COPY apps/provisioning apps/provisioning COPY apps/secret apps/secret COPY apps/scope apps/scope @@ -106,7 +107,6 @@ COPY apps/logsdrilldown apps/logsdrilldown COPY apps/advisor apps/advisor COPY apps/dashboard apps/dashboard COPY apps/folder apps/folder -COPY apps/preferences apps/preferences COPY apps/iam apps/iam COPY apps apps COPY kindsv2 kindsv2 diff --git a/apps/collections/Makefile b/apps/collections/Makefile new file mode 100644 index 00000000000..bc8d6d30cb5 --- /dev/null +++ b/apps/collections/Makefile @@ -0,0 +1,10 @@ +include ../sdk.mk + +.PHONY: generate # Run Grafana App SDK code generation +generate: install-app-sdk update-app-sdk + @$(APP_SDK_BIN) generate \ + --source=./kinds/ \ + --gogenpath=./pkg/apis \ + --grouping=group \ + --genoperatorstate=false \ + --defencoding=none \ No newline at end of file diff --git a/apps/collections/go.mod b/apps/collections/go.mod new file mode 100644 index 00000000000..a3b927ec893 --- /dev/null +++ b/apps/collections/go.mod @@ -0,0 +1,69 @@ +module github.com/grafana/grafana/apps/collections + +go 1.25.3 + +require ( + github.com/grafana/grafana-app-sdk v0.48.2 + github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 + github.com/stretchr/testify v1.11.1 + k8s.io/apimachinery v0.34.2 + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 +) + +require ( + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/getkin/kin-openapi v0.133.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.22.1 // indirect + github.com/go-openapi/jsonreference v0.21.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-openapi/swag/jsonname v0.25.1 // indirect + github.com/go-test/deep v1.1.1 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/grafana/grafana-app-sdk/logging v0.48.1 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.9.0 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/procfs v0.16.1 // indirect + github.com/stretchr/objx v0.5.2 // indirect + github.com/woodsbury/decimal128 v1.3.0 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.opentelemetry.io/otel v1.38.0 // indirect + go.opentelemetry.io/otel/trace v1.38.0 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.14.0 // indirect + google.golang.org/protobuf v1.36.10 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/client-go v0.34.2 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect + sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect +) diff --git a/apps/collections/go.sum b/apps/collections/go.sum new file mode 100644 index 00000000000..02827e1769d --- /dev/null +++ b/apps/collections/go.sum @@ -0,0 +1,187 @@ +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ= +github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.22.1 h1:sHYI1He3b9NqJ4wXLoJDKmUmHkWy/L7rtEo92JUxBNk= +github.com/go-openapi/jsonpointer v0.22.1/go.mod h1:pQT9OsLkfz1yWoMgYFy4x3U5GY5nUlsOn1qSBH5MkCM= +github.com/go-openapi/jsonreference v0.21.2 h1:Wxjda4M/BBQllegefXrY/9aq1fxBA8sI5M/lFU6tSWU= +github.com/go-openapi/jsonreference v0.21.2/go.mod h1:pp3PEjIsJ9CZDGCNOyXIQxsNuroxm8FAJ/+quA0yKzQ= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag/jsonname v0.25.1 h1:Sgx+qbwa4ej6AomWC6pEfXrA6uP2RkaNjA9BR8a1RJU= +github.com/go-openapi/swag/jsonname v0.25.1/go.mod h1:71Tekow6UOLBD3wS7XhdT98g5J5GR13NOTQ9/6Q11Zo= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= +github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= +github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= +github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= +github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= +github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= +github.com/woodsbury/decimal128 v1.3.0 h1:8pffMNWIlC0O5vbyHWFZAt5yWvWcrHA+3ovIIjVWss0= +github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4ZKOB5I6Zjb+ds= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= +golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= +google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= +gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.34.2 h1:fsSUNZhV+bnL6Aqrp6O7lMTy6o5x2C4XLjnh//8SLYY= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= +k8s.io/apimachinery v0.34.2 h1:zQ12Uk3eMHPxrsbUJgNF8bTauTVR2WgqJsTmwTE/NW4= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/client-go v0.34.2 h1:Co6XiknN+uUZqiddlfAjT68184/37PS4QAzYvQvDR8M= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8tmbZBHi4zVsl1Y= +k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 h1:gBQPwqORJ8d8/YNZWEjoZs7npUVDpVXUUOFfW6CgAqE= +sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/apps/collections/kinds/cue.mod/module.cue b/apps/collections/kinds/cue.mod/module.cue new file mode 100644 index 00000000000..6280fd8f227 --- /dev/null +++ b/apps/collections/kinds/cue.mod/module.cue @@ -0,0 +1,4 @@ +module: "github.com/grafana/grafana/apps/preferences/kinds" +language: { + version: "v0.9.0" +} diff --git a/apps/collections/kinds/manifest.cue b/apps/collections/kinds/manifest.cue new file mode 100644 index 00000000000..8052da29431 --- /dev/null +++ b/apps/collections/kinds/manifest.cue @@ -0,0 +1,17 @@ +package preferences + +manifest: { + appName: "collections" + groupOverride: "collections.grafana.app" + versions: { + "v1alpha1": { + codegen: { + ts: {enabled: false} + go: {enabled: true} + } + kinds: [ + starsV1alpha1, + ] + } + } +} \ No newline at end of file diff --git a/apps/preferences/kinds/stars.cue b/apps/collections/kinds/stars.cue similarity index 100% rename from apps/preferences/kinds/stars.cue rename to apps/collections/kinds/stars.cue diff --git a/apps/collections/pkg/apis/collections/v1alpha1/constants.go b/apps/collections/pkg/apis/collections/v1alpha1/constants.go new file mode 100644 index 00000000000..4eb592236df --- /dev/null +++ b/apps/collections/pkg/apis/collections/v1alpha1/constants.go @@ -0,0 +1,18 @@ +package v1alpha1 + +import "k8s.io/apimachinery/pkg/runtime/schema" + +const ( + // APIGroup is the API group used by all kinds in this package + APIGroup = "collections.grafana.app" + // APIVersion is the API version used by all kinds in this package + APIVersion = "v1alpha1" +) + +var ( + // GroupVersion is a schema.GroupVersion consisting of the Group and Version constants for this package + GroupVersion = schema.GroupVersion{ + Group: APIGroup, + Version: APIVersion, + } +) diff --git a/apps/collections/pkg/apis/collections/v1alpha1/register.go b/apps/collections/pkg/apis/collections/v1alpha1/register.go new file mode 100644 index 00000000000..6b72ab94146 --- /dev/null +++ b/apps/collections/pkg/apis/collections/v1alpha1/register.go @@ -0,0 +1,58 @@ +package v1alpha1 + +import ( + "fmt" + time "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/grafana/grafana/pkg/apimachinery/utils" +) + +var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion, + "stars", "stars", "Stars", + func() runtime.Object { return &Stars{} }, + func() runtime.Object { return &StarsList{} }, + utils.TableColumns{ + Definition: []metav1.TableColumnDefinition{ + {Name: "Name", Type: "string", Format: "name"}, + {Name: "Created At", Type: "date"}, + }, + Reader: func(obj any) ([]any, error) { + p, ok := obj.(*Stars) + if ok && p != nil { + return []any{ + p.Name, + p.CreationTimestamp.UTC().Format(time.RFC3339), + }, nil + } + return nil, fmt.Errorf("expected stars") + }, + }, +) + +var ( + SchemeBuilder runtime.SchemeBuilder + localSchemeBuilder = &SchemeBuilder + AddToScheme = localSchemeBuilder.AddToScheme + schemeGroupVersion = GroupVersion +) + +func init() { + localSchemeBuilder.Register(addKnownTypes, addDefaultingFuncs) +} + +// Adds the list of known types to the given scheme. +func addKnownTypes(scheme *runtime.Scheme) error { + scheme.AddKnownTypes(schemeGroupVersion, + &Stars{}, + &StarsList{}, + ) + metav1.AddToGroupVersion(scheme, schemeGroupVersion) + return nil +} + +func addDefaultingFuncs(scheme *runtime.Scheme) error { + return nil // return RegisterDefaults(scheme) +} diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars.go b/apps/collections/pkg/apis/collections/v1alpha1/stars.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars.go diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_client_gen.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_client_gen.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_client_gen.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_client_gen.go diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_codec_gen.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_codec_gen.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_codec_gen.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_codec_gen.go diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_metadata_gen.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_metadata_gen.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_metadata_gen.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_metadata_gen.go diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_object_gen.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_object_gen.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_object_gen.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_object_gen.go diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_schema_gen.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_schema_gen.go similarity index 92% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_schema_gen.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_schema_gen.go index 00e153b5ef2..2be9d56a29f 100644 --- a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_schema_gen.go +++ b/apps/collections/pkg/apis/collections/v1alpha1/stars_schema_gen.go @@ -10,7 +10,7 @@ import ( // schema is unexported to prevent accidental overwrites var ( - schemaStars = resource.NewSimpleSchema("preferences.grafana.app", "v1alpha1", &Stars{}, &StarsList{}, resource.WithKind("Stars"), + schemaStars = resource.NewSimpleSchema("collections.grafana.app", "v1alpha1", &Stars{}, &StarsList{}, resource.WithKind("Stars"), resource.WithPlural("stars"), resource.WithScope(resource.NamespacedScope)) kindStars = resource.Kind{ Schema: schemaStars, diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_spec_gen.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_spec_gen.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_spec_gen.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_spec_gen.go diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go b/apps/collections/pkg/apis/collections/v1alpha1/stars_test.go similarity index 100% rename from apps/preferences/pkg/apis/preferences/v1alpha1/stars_test.go rename to apps/collections/pkg/apis/collections/v1alpha1/stars_test.go diff --git a/apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi.go b/apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi.go new file mode 100644 index 00000000000..5b486b690c0 --- /dev/null +++ b/apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi.go @@ -0,0 +1,187 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +// SPDX-License-Identifier: AGPL-3.0-only + +// Code generated by openapi-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + common "k8s.io/kube-openapi/pkg/common" + spec "k8s.io/kube-openapi/pkg/validation/spec" +) + +func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenAPIDefinition { + return map[string]common.OpenAPIDefinition{ + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars": schema_pkg_apis_collections_v1alpha1_Stars(ref), + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsList": schema_pkg_apis_collections_v1alpha1_StarsList(ref), + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource": schema_pkg_apis_collections_v1alpha1_StarsResource(ref), + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec": schema_pkg_apis_collections_v1alpha1_StarsSpec(ref), + } +} + +func schema_pkg_apis_collections_v1alpha1_Stars(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), + }, + }, + "spec": { + SchemaProps: spec.SchemaProps{ + Description: "Spec is the spec of the Stars", + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec"), + }, + }, + }, + Required: []string{"metadata", "spec"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, + } +} + +func schema_pkg_apis_collections_v1alpha1_StarsList(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "kind": { + SchemaProps: spec.SchemaProps{ + Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + Type: []string{"string"}, + Format: "", + }, + }, + "apiVersion": { + SchemaProps: spec.SchemaProps{ + Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + Type: []string{"string"}, + Format: "", + }, + }, + "metadata": { + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), + }, + }, + "items": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars"), + }, + }, + }, + }, + }, + }, + Required: []string{"metadata", "items"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.Stars", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, + } +} + +func schema_pkg_apis_collections_v1alpha1_StarsResource(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "group": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "kind": { + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + "names": { + VendorExtensible: spec.VendorExtensible{ + Extensions: spec.Extensions{ + "x-kubernetes-list-type": "set", + }, + }, + SchemaProps: spec.SchemaProps{ + Description: "The set of resources", + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: "", + Type: []string{"string"}, + Format: "", + }, + }, + }, + }, + }, + }, + Required: []string{"group", "kind", "names"}, + }, + }, + } +} + +func schema_pkg_apis_collections_v1alpha1_StarsSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { + return common.OpenAPIDefinition{ + Schema: spec.Schema{ + SchemaProps: spec.SchemaProps{ + Type: []string{"object"}, + Properties: map[string]spec.Schema{ + "resource": { + SchemaProps: spec.SchemaProps{ + Type: []string{"array"}, + Items: &spec.SchemaOrArray{ + Schema: &spec.Schema{ + SchemaProps: spec.SchemaProps{ + Default: map[string]interface{}{}, + Ref: ref("github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource"), + }, + }, + }, + }, + }, + }, + Required: []string{"resource"}, + }, + }, + Dependencies: []string{ + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1.StarsResource"}, + } +} diff --git a/apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi_violation_exceptions.list b/apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi_violation_exceptions.list new file mode 100644 index 00000000000..efaa822c24f --- /dev/null +++ b/apps/collections/pkg/apis/collections/v1alpha1/zz_generated.openapi_violation_exceptions.list @@ -0,0 +1,2 @@ +API rule violation: list_type_missing,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsSpec,Resource +API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1,StarsList,ListMeta diff --git a/apps/collections/pkg/apis/collections_manifest.go b/apps/collections/pkg/apis/collections_manifest.go new file mode 100644 index 00000000000..6c3f3cfe930 --- /dev/null +++ b/apps/collections/pkg/apis/collections_manifest.go @@ -0,0 +1,132 @@ +// +// This file is generated by grafana-app-sdk +// DO NOT EDIT +// + +package apis + +import ( + "encoding/json" + "fmt" + "strings" + + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/grafana/grafana-app-sdk/app" + "github.com/grafana/grafana-app-sdk/resource" + "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" +) + +var ( + rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`) + versionSchemaStarsv1alpha1 app.VersionSchema + _ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1) +) + +var appManifestData = app.ManifestData{ + AppName: "collections", + Group: "collections.grafana.app", + PreferredVersion: "v1alpha1", + Versions: []app.ManifestVersion{ + { + Name: "v1alpha1", + Served: true, + Kinds: []app.ManifestVersionKind{ + { + Kind: "Stars", + Plural: "Stars", + Scope: "Namespaced", + Conversion: false, + Admission: &app.AdmissionCapabilities{ + Validation: &app.ValidationCapability{ + Operations: []app.AdmissionOperation{ + app.AdmissionOperationCreate, + app.AdmissionOperationUpdate, + }, + }, + }, + Schema: &versionSchemaStarsv1alpha1, + }, + }, + Routes: app.ManifestVersionRoutes{ + Namespaced: map[string]spec3.PathProps{}, + Cluster: map[string]spec3.PathProps{}, + Schemas: map[string]spec.Schema{}, + }, + }, + }, +} + +func LocalManifest() app.Manifest { + return app.NewEmbeddedManifest(appManifestData) +} + +func RemoteManifest() app.Manifest { + return app.NewAPIServerManifest("collections") +} + +var kindVersionToGoType = map[string]resource.Kind{ + "Stars/v1alpha1": v1alpha1.StarsKind(), +} + +// ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. +// If there is no association for the provided Kind and Version, exists will return false. +func ManifestGoTypeAssociator(kind, version string) (goType resource.Kind, exists bool) { + goType, exists = kindVersionToGoType[fmt.Sprintf("%s/%s", kind, version)] + return goType, exists +} + +var customRouteToGoResponseType = map[string]any{} + +// ManifestCustomRouteResponsesAssociator returns the associated response go type for a given kind, version, custom route path, and method, if one exists. +// kind may be empty for custom routes which are not kind subroutes. Leading slashes are removed from subroute paths. +// If there is no association for the provided kind, version, custom route path, and method, exists will return false. +// Resource routes (those without a kind) should prefix their route with "/" if the route is namespaced (otherwise the route is assumed to be cluster-scope) +func ManifestCustomRouteResponsesAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoResponseType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoParamsType = map[string]runtime.Object{} + +func ManifestCustomRouteQueryAssociator(kind, version, path, verb string) (goType runtime.Object, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoParamsType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +var customRouteToGoRequestBodyType = map[string]any{} + +func ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb string) (goType any, exists bool) { + if len(path) > 0 && path[0] == '/' { + path = path[1:] + } + goType, exists = customRouteToGoRequestBodyType[fmt.Sprintf("%s|%s|%s|%s", version, kind, path, strings.ToUpper(verb))] + return goType, exists +} + +type GoTypeAssociator struct{} + +func NewGoTypeAssociator() *GoTypeAssociator { + return &GoTypeAssociator{} +} + +func (g *GoTypeAssociator) KindToGoType(kind, version string) (goType resource.Kind, exists bool) { + return ManifestGoTypeAssociator(kind, version) +} +func (g *GoTypeAssociator) CustomRouteReturnGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteResponsesAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteQueryGoType(kind, version, path, verb string) (goType runtime.Object, exists bool) { + return ManifestCustomRouteQueryAssociator(kind, version, path, verb) +} +func (g *GoTypeAssociator) CustomRouteRequestBodyGoType(kind, version, path, verb string) (goType any, exists bool) { + return ManifestCustomRouteRequestBodyAssociator(kind, version, path, verb) +} diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index 51e94063e22..05292d2cdd5 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -5,7 +5,6 @@ go 1.25.3 require ( github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 - github.com/stretchr/testify v1.11.1 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) @@ -44,6 +43,7 @@ require ( github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect + github.com/stretchr/testify v1.11.1 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/apps/preferences/kinds/manifest.cue b/apps/preferences/kinds/manifest.cue index aa75aa2d0ed..f7d08b81494 100644 --- a/apps/preferences/kinds/manifest.cue +++ b/apps/preferences/kinds/manifest.cue @@ -10,8 +10,7 @@ manifest: { go: {enabled: true} } kinds: [ - preferencesV1alpha1, - starsV1alpha1, + preferencesV1alpha1 ] } } diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/register.go b/apps/preferences/pkg/apis/preferences/v1alpha1/register.go index 6bedc78a325..4de18c09ef6 100644 --- a/apps/preferences/pkg/apis/preferences/v1alpha1/register.go +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/register.go @@ -32,28 +32,6 @@ var PreferencesResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion, }, ) -var StarsResourceInfo = utils.NewResourceInfo(APIGroup, APIVersion, - "stars", "stars", "Stars", - func() runtime.Object { return &Stars{} }, - func() runtime.Object { return &StarsList{} }, - utils.TableColumns{ - Definition: []metav1.TableColumnDefinition{ - {Name: "Name", Type: "string", Format: "name"}, - {Name: "Created At", Type: "date"}, - }, - Reader: func(obj any) ([]any, error) { - p, ok := obj.(*Stars) - if ok && p != nil { - return []any{ - p.Name, - p.CreationTimestamp.UTC().Format(time.RFC3339), - }, nil - } - return nil, fmt.Errorf("expected stars") - }, - }, -) - var ( SchemeBuilder runtime.SchemeBuilder localSchemeBuilder = &SchemeBuilder @@ -70,8 +48,6 @@ func addKnownTypes(scheme *runtime.Scheme) error { scheme.AddKnownTypes(schemeGroupVersion, &Preferences{}, &PreferencesList{}, - &Stars{}, - &StarsList{}, ) metav1.AddToGroupVersion(scheme, schemeGroupVersion) return nil diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi.go b/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi.go index c8700a3dfa5..542d5918626 100644 --- a/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi.go +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi.go @@ -20,10 +20,6 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.PreferencesNavbarPreference": schema_pkg_apis_preferences_v1alpha1_PreferencesNavbarPreference(ref), "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.PreferencesQueryHistoryPreference": schema_pkg_apis_preferences_v1alpha1_PreferencesQueryHistoryPreference(ref), "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.PreferencesSpec": schema_pkg_apis_preferences_v1alpha1_PreferencesSpec(ref), - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.Stars": schema_pkg_apis_preferences_v1alpha1_Stars(ref), - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsList": schema_pkg_apis_preferences_v1alpha1_StarsList(ref), - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsResource": schema_pkg_apis_preferences_v1alpha1_StarsResource(ref), - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsSpec": schema_pkg_apis_preferences_v1alpha1_StarsSpec(ref), } } @@ -266,168 +262,3 @@ func schema_pkg_apis_preferences_v1alpha1_PreferencesSpec(ref common.ReferenceCa "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.PreferencesCookiePreferences", "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.PreferencesNavbarPreference", "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.PreferencesQueryHistoryPreference"}, } } - -func schema_pkg_apis_preferences_v1alpha1_Stars(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"), - }, - }, - "spec": { - SchemaProps: spec.SchemaProps{ - Description: "Spec is the spec of the Stars", - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsSpec"), - }, - }, - }, - Required: []string{"metadata", "spec"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsSpec", "k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"}, - } -} - -func schema_pkg_apis_preferences_v1alpha1_StarsList(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "kind": { - SchemaProps: spec.SchemaProps{ - Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - Type: []string{"string"}, - Format: "", - }, - }, - "apiVersion": { - SchemaProps: spec.SchemaProps{ - Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - Type: []string{"string"}, - Format: "", - }, - }, - "metadata": { - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"), - }, - }, - "items": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.Stars"), - }, - }, - }, - }, - }, - }, - Required: []string{"metadata", "items"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.Stars", "k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"}, - } -} - -func schema_pkg_apis_preferences_v1alpha1_StarsResource(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "group": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "kind": { - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - "names": { - VendorExtensible: spec.VendorExtensible{ - Extensions: spec.Extensions{ - "x-kubernetes-list-type": "set", - }, - }, - SchemaProps: spec.SchemaProps{ - Description: "The set of resources", - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: "", - Type: []string{"string"}, - Format: "", - }, - }, - }, - }, - }, - }, - Required: []string{"group", "kind", "names"}, - }, - }, - } -} - -func schema_pkg_apis_preferences_v1alpha1_StarsSpec(ref common.ReferenceCallback) common.OpenAPIDefinition { - return common.OpenAPIDefinition{ - Schema: spec.Schema{ - SchemaProps: spec.SchemaProps{ - Type: []string{"object"}, - Properties: map[string]spec.Schema{ - "resource": { - SchemaProps: spec.SchemaProps{ - Type: []string{"array"}, - Items: &spec.SchemaOrArray{ - Schema: &spec.Schema{ - SchemaProps: spec.SchemaProps{ - Default: map[string]interface{}{}, - Ref: ref("github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsResource"), - }, - }, - }, - }, - }, - }, - Required: []string{"resource"}, - }, - }, - Dependencies: []string{ - "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1.StarsResource"}, - } -} diff --git a/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi_violation_exceptions.list b/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi_violation_exceptions.list index 80e3e004de7..efec2bc2b3e 100644 --- a/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi_violation_exceptions.list +++ b/apps/preferences/pkg/apis/preferences/v1alpha1/zz_generated.openapi_violation_exceptions.list @@ -1,4 +1,2 @@ API rule violation: list_type_missing,github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1,PreferencesNavbarPreference,BookmarkUrls -API rule violation: list_type_missing,github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1,StarsSpec,Resource API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1,PreferencesList,ListMeta -API rule violation: streaming_list_type_json_tags,github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1,StarsList,ListMeta diff --git a/apps/preferences/pkg/apis/preferences_manifest.go b/apps/preferences/pkg/apis/preferences_manifest.go index dbbbc8d4ab0..d67141364d1 100644 --- a/apps/preferences/pkg/apis/preferences_manifest.go +++ b/apps/preferences/pkg/apis/preferences_manifest.go @@ -23,9 +23,6 @@ var ( rawSchemaPreferencesv1alpha1 = []byte(`{"CookiePreferences":{"additionalProperties":false,"properties":{"analytics":{"additionalProperties":{},"type":"object"},"functional":{"additionalProperties":{},"type":"object"},"performance":{"additionalProperties":{},"type":"object"}},"type":"object"},"NavbarPreference":{"additionalProperties":false,"properties":{"bookmarkUrls":{"items":{"type":"string"},"type":"array"}},"required":["bookmarkUrls"],"type":"object"},"Preferences":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"QueryHistoryPreference":{"additionalProperties":false,"properties":{"homeTab":{"description":"one of: '' | 'query' | 'starred';","type":"string"}},"type":"object"},"spec":{"additionalProperties":false,"properties":{"cookiePreferences":{"$ref":"#/components/schemas/CookiePreferences","description":"Cookie preferences"},"homeDashboardUID":{"description":"UID for the home dashboard","type":"string"},"language":{"description":"Selected language (beta)","type":"string"},"navbar":{"$ref":"#/components/schemas/NavbarPreference","description":"Navigation preferences"},"queryHistory":{"$ref":"#/components/schemas/QueryHistoryPreference","description":"Explore query history preferences"},"regionalFormat":{"description":"Selected locale (beta)","type":"string"},"theme":{"description":"light, dark, empty is default","type":"string"},"timezone":{"description":"The timezone selection\nTODO: this should use the timezone defined in common","type":"string"},"weekStart":{"description":"day of the week (sunday, monday, etc)","type":"string"}},"type":"object"}}`) versionSchemaPreferencesv1alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPreferencesv1alpha1, &versionSchemaPreferencesv1alpha1) - rawSchemaStarsv1alpha1 = []byte(`{"Resource":{"additionalProperties":false,"properties":{"group":{"type":"string"},"kind":{"type":"string"},"names":{"description":"The set of resources\n+listType=set","items":{"type":"string"},"type":"array"}},"required":["group","kind","names"],"type":"object"},"Stars":{"properties":{"spec":{"$ref":"#/components/schemas/spec"}},"required":["spec"]},"spec":{"additionalProperties":false,"properties":{"resource":{"items":{"$ref":"#/components/schemas/Resource"},"type":"array"}},"required":["resource"],"type":"object"}}`) - versionSchemaStarsv1alpha1 app.VersionSchema - _ = json.Unmarshal(rawSchemaStarsv1alpha1, &versionSchemaStarsv1alpha1) ) var appManifestData = app.ManifestData{ @@ -52,22 +49,6 @@ var appManifestData = app.ManifestData{ }, Schema: &versionSchemaPreferencesv1alpha1, }, - - { - Kind: "Stars", - Plural: "Stars", - Scope: "Namespaced", - Conversion: false, - Admission: &app.AdmissionCapabilities{ - Validation: &app.ValidationCapability{ - Operations: []app.AdmissionOperation{ - app.AdmissionOperationCreate, - app.AdmissionOperationUpdate, - }, - }, - }, - Schema: &versionSchemaStarsv1alpha1, - }, }, Routes: app.ManifestVersionRoutes{ Namespaced: map[string]spec3.PathProps{}, @@ -88,7 +69,6 @@ func RemoteManifest() app.Manifest { var kindVersionToGoType = map[string]resource.Kind{ "Preferences/v1alpha1": v1alpha1.PreferencesKind(), - "Stars/v1alpha1": v1alpha1.StarsKind(), } // ManifestGoTypeAssociator returns the associated resource.Kind instance for a given Kind and Version, if one exists. diff --git a/go.mod b/go.mod index cd27554f792..aa04c633a7c 100644 --- a/go.mod +++ b/go.mod @@ -238,6 +238,7 @@ require ( github.com/grafana/grafana/apps/alerting/notifications v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/alerting/rules v0.0.0 // @grafana/alerting-backend github.com/grafana/grafana/apps/annotation v0.0.0 // @grafana/grafana-backend-services-squad + github.com/grafana/grafana/apps/collections v0.0.0 // @grafana/grafana-app-platform-squad github.com/grafana/grafana/apps/correlations v0.0.0 // @grafana/datapro github.com/grafana/grafana/apps/dashboard v0.0.0 // @grafana/grafana-app-platform-squad @grafana/dashboards-squad github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b // @grafana/grafana-app-platform-squad @@ -270,6 +271,7 @@ replace ( github.com/grafana/grafana/apps/alerting/notifications => ./apps/alerting/notifications github.com/grafana/grafana/apps/alerting/rules => ./apps/alerting/rules github.com/grafana/grafana/apps/annotation => ./apps/annotation + github.com/grafana/grafana/apps/collections => ./apps/collections github.com/grafana/grafana/apps/correlations => ./apps/correlations github.com/grafana/grafana/apps/dashboard => ./apps/dashboard github.com/grafana/grafana/apps/folder => ./apps/folder diff --git a/go.work b/go.work index c1baa393967..f0f5d9acc19 100644 --- a/go.work +++ b/go.work @@ -10,6 +10,7 @@ use ( ./apps/alerting/notifications ./apps/alerting/rules ./apps/annotation + ./apps/collections ./apps/correlations ./apps/dashboard ./apps/example diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh index 7a6e5898490..59976c9bf3c 100755 --- a/hack/update-codegen.sh +++ b/hack/update-codegen.sh @@ -90,6 +90,7 @@ grafana::codegen:run apps/dashboard/pkg grafana::codegen:run apps/provisioning/pkg grafana::codegen:run apps/folder/pkg grafana::codegen:run apps/preferences/pkg +grafana::codegen:run apps/collections/pkg grafana::codegen:run apps/scope/pkg grafana::codegen:run apps/alerting/alertenrichment/pkg diff --git a/packages/grafana-api-clients/package.json b/packages/grafana-api-clients/package.json index 79809210045..37fdf558cf3 100644 --- a/packages/grafana-api-clients/package.json +++ b/packages/grafana-api-clients/package.json @@ -72,6 +72,10 @@ "import": "./src/clients/rtkq/preferences/v1alpha1/index.ts", "require": "./src/clients/rtkq/preferences/v1alpha1/index.ts" }, + "./rtkq/collections/v1alpha1": { + "import": "./src/clients/rtkq/collections/v1alpha1/index.ts", + "require": "./src/clients/rtkq/collections/v1alpha1/index.ts" + }, "./rtkq/provisioning/v0alpha1": { "import": "./src/clients/rtkq/provisioning/v0alpha1/index.ts", "require": "./src/clients/rtkq/provisioning/v0alpha1/index.ts" diff --git a/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/baseAPI.ts b/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/baseAPI.ts new file mode 100644 index 00000000000..9e637edb295 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/baseAPI.ts @@ -0,0 +1,16 @@ +import { createApi } from '@reduxjs/toolkit/query/react'; + +import { getAPIBaseURL } from '../../../../utils/utils'; +import { createBaseQuery } from '../../createBaseQuery'; + +export const API_GROUP = 'collections.grafana.app' as const; +export const API_VERSION = 'v1alpha1' as const; +export const BASE_URL = getAPIBaseURL(API_GROUP, API_VERSION); + +export const api = createApi({ + reducerPath: 'collectionsAPIv1alpha1', + baseQuery: createBaseQuery({ + baseURL: BASE_URL, + }), + endpoints: () => ({}), +}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/endpoints.gen.ts new file mode 100644 index 00000000000..9e2b5c0b534 --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/endpoints.gen.ts @@ -0,0 +1,538 @@ +import { api } from './baseAPI'; +export const addTagTypes = ['API Discovery', 'Stars'] as const; +const injectedRtkApi = api + .enhanceEndpoints({ + addTagTypes, + }) + .injectEndpoints({ + endpoints: (build) => ({ + getApiResources: build.query({ + query: () => ({ url: `/` }), + providesTags: ['API Discovery'], + }), + listStars: build.query({ + query: (queryArg) => ({ + url: `/stars`, + params: { + pretty: queryArg.pretty, + allowWatchBookmarks: queryArg.allowWatchBookmarks, + continue: queryArg['continue'], + fieldSelector: queryArg.fieldSelector, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + watch: queryArg.watch, + }, + }), + providesTags: ['Stars'], + }), + createStars: build.mutation({ + query: (queryArg) => ({ + url: `/stars`, + method: 'POST', + body: queryArg.stars, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Stars'], + }), + deletecollectionStars: build.mutation({ + query: (queryArg) => ({ + url: `/stars`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + continue: queryArg['continue'], + dryRun: queryArg.dryRun, + fieldSelector: queryArg.fieldSelector, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + labelSelector: queryArg.labelSelector, + limit: queryArg.limit, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + resourceVersion: queryArg.resourceVersion, + resourceVersionMatch: queryArg.resourceVersionMatch, + sendInitialEvents: queryArg.sendInitialEvents, + timeoutSeconds: queryArg.timeoutSeconds, + }, + }), + invalidatesTags: ['Stars'], + }), + getStars: build.query({ + query: (queryArg) => ({ + url: `/stars/${queryArg.name}`, + params: { + pretty: queryArg.pretty, + }, + }), + providesTags: ['Stars'], + }), + replaceStars: build.mutation({ + query: (queryArg) => ({ + url: `/stars/${queryArg.name}`, + method: 'PUT', + body: queryArg.stars, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + }, + }), + invalidatesTags: ['Stars'], + }), + deleteStars: build.mutation({ + query: (queryArg) => ({ + url: `/stars/${queryArg.name}`, + method: 'DELETE', + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + gracePeriodSeconds: queryArg.gracePeriodSeconds, + ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, + orphanDependents: queryArg.orphanDependents, + propagationPolicy: queryArg.propagationPolicy, + }, + }), + invalidatesTags: ['Stars'], + }), + updateStars: build.mutation({ + query: (queryArg) => ({ + url: `/stars/${queryArg.name}`, + method: 'PATCH', + body: queryArg.patch, + params: { + pretty: queryArg.pretty, + dryRun: queryArg.dryRun, + fieldManager: queryArg.fieldManager, + fieldValidation: queryArg.fieldValidation, + force: queryArg.force, + }, + }), + invalidatesTags: ['Stars'], + }), + addStar: build.mutation({ + query: (queryArg) => ({ + url: `/stars/${queryArg.name}/update/${queryArg.group}/${queryArg.kind}/${queryArg.id}`, + method: 'PUT', + }), + invalidatesTags: ['Stars'], + }), + removeStar: build.mutation({ + query: (queryArg) => ({ + url: `/stars/${queryArg.name}/update/${queryArg.group}/${queryArg.kind}/${queryArg.id}`, + method: 'DELETE', + }), + invalidatesTags: ['Stars'], + }), + }), + overrideExisting: false, + }); +export { injectedRtkApi as generatedAPI }; +export type GetApiResourcesApiResponse = /** status 200 OK */ ApiResourceList; +export type GetApiResourcesApiArg = void; +export type ListStarsApiResponse = /** status 200 OK */ StarsList; +export type ListStarsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ + allowWatchBookmarks?: boolean; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; + /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ + watch?: boolean; +}; +export type CreateStarsApiResponse = /** status 200 OK */ + | Stars + | /** status 201 Created */ Stars + | /** status 202 Accepted */ Stars; +export type CreateStarsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + stars: Stars; +}; +export type DeletecollectionStarsApiResponse = /** status 200 OK */ Status; +export type DeletecollectionStarsApiArg = { + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". + + This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ + continue?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ + fieldSelector?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ + labelSelector?: string; + /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. + + The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ + limit?: number; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; + /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersion?: string; + /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. + + Defaults to unset */ + resourceVersionMatch?: string; + /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. + + When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan + is interpreted as "data at least as new as the provided `resourceVersion`" + and the bookmark event is send when the state is synced + to a `resourceVersion` at least as fresh as the one provided by the ListOptions. + If `resourceVersion` is unset, this is interpreted as "consistent read" and the + bookmark event is send when the state is synced at least to the moment + when request started being processed. + - `resourceVersionMatch` set to any other value or unset + Invalid error is returned. + + Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ + sendInitialEvents?: boolean; + /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ + timeoutSeconds?: number; +}; +export type GetStarsApiResponse = /** status 200 OK */ Stars; +export type GetStarsApiArg = { + /** name of the Stars */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; +}; +export type ReplaceStarsApiResponse = /** status 200 OK */ Stars | /** status 201 Created */ Stars; +export type ReplaceStarsApiArg = { + /** name of the Stars */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + stars: Stars; +}; +export type DeleteStarsApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; +export type DeleteStarsApiArg = { + /** name of the Stars */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ + gracePeriodSeconds?: number; + /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ + ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; + /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ + orphanDependents?: boolean; + /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ + propagationPolicy?: string; +}; +export type UpdateStarsApiResponse = /** status 200 OK */ Stars | /** status 201 Created */ Stars; +export type UpdateStarsApiArg = { + /** name of the Stars */ + name: string; + /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ + pretty?: string; + /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ + dryRun?: string; + /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ + fieldManager?: string; + /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ + fieldValidation?: string; + /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ + force?: boolean; + patch: Patch; +}; +export type AddStarApiResponse = /** status 200 OK */ Stars; +export type AddStarApiArg = { + /** name of the Stars */ + name: string; + /** API group for stared item */ + group: string; + /** Kind for stared item */ + kind: string; + /** The k8s name for the selected item */ + id: string; +}; +export type RemoveStarApiResponse = /** status 200 OK */ Stars; +export type RemoveStarApiArg = { + /** name of the Stars */ + name: string; + /** API group for stared item */ + group: string; + /** Kind for stared item */ + kind: string; + /** The k8s name for the selected item */ + id: string; +}; +export type ApiResource = { + /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ + categories?: string[]; + /** group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale". */ + group?: string; + /** kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo') */ + kind: string; + /** name is the plural name of the resource. */ + name: string; + /** namespaced indicates if a resource is namespaced or not. */ + namespaced: boolean; + /** shortNames is a list of suggested short names of the resource. */ + shortNames?: string[]; + /** singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface. */ + singularName: string; + /** The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates. */ + storageVersionHash?: string; + /** verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy) */ + verbs: string[]; + /** version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)". */ + version?: string; +}; +export type ApiResourceList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** groupVersion is the group and version this APIResourceList is for. */ + groupVersion: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** resources contains the name of the resources and if they are namespaced. */ + resources: ApiResource[]; +}; +export type Time = string; +export type FieldsV1 = object; +export type ManagedFieldsEntry = { + /** APIVersion defines the version of this resource that this field set applies to. The format is "group/version" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted. */ + apiVersion?: string; + /** FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: "FieldsV1" */ + fieldsType?: string; + /** FieldsV1 holds the first JSON version format as described in the "FieldsV1" type. */ + fieldsV1?: FieldsV1; + /** Manager is an identifier of the workflow managing these fields. */ + manager?: string; + /** Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'. */ + operation?: string; + /** Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource. */ + subresource?: string; + /** Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over. */ + time?: Time; +}; +export type OwnerReference = { + /** API version of the referent. */ + apiVersion: string; + /** If true, AND if the owner has the "foregroundDeletion" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs "delete" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned. */ + blockOwnerDeletion?: boolean; + /** If true, this reference points to the managing controller. */ + controller?: boolean; + /** Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind: string; + /** Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name: string; + /** UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid: string; +}; +export type ObjectMeta = { + /** Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations */ + annotations?: { + [key: string]: string; + }; + /** CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC. + + Populated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + creationTimestamp?: Time; + /** Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only. */ + deletionGracePeriodSeconds?: number; + /** DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested. + + Populated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */ + deletionTimestamp?: Time; + /** Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list. */ + finalizers?: string[]; + /** GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server. + + If this field is specified and the generated name exists, the server will return a 409. + + Applied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency */ + generateName?: string; + /** A sequence number representing a specific generation of the desired state. Populated by the system. Read-only. */ + generation?: number; + /** Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels */ + labels?: { + [key: string]: string; + }; + /** ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like "ci-cd". The set of fields is always in the version that the workflow used when modifying the object. */ + managedFields?: ManagedFieldsEntry[]; + /** Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names */ + name?: string; + /** Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the "default" namespace, but "default" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty. + + Must be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces */ + namespace?: string; + /** List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller. */ + ownerReferences?: OwnerReference[]; + /** An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources. + + Populated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; + /** UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations. + + Populated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type StarsResource = { + group: string; + kind: string; + /** The set of resources */ + names: string[]; +}; +export type StarsSpec = { + resource: StarsResource[]; +}; +export type Stars = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ObjectMeta; + /** Spec is the spec of the Stars */ + spec: StarsSpec; +}; +export type ListMeta = { + /** continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message. */ + continue?: string; + /** remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact. */ + remainingItemCount?: number; + /** String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency */ + resourceVersion?: string; + /** Deprecated: selfLink is a legacy read-only field that is no longer populated by the system. */ + selfLink?: string; +}; +export type StarsList = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + items: Stars[]; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + metadata: ListMeta; +}; +export type StatusCause = { + /** The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional. + + Examples: + "name" - the field "name" on the current resource + "items[0].name" - the field "name" on the first array entry in "items" */ + field?: string; + /** A human-readable description of the cause of the error. This field may be presented as-is to a reader. */ + message?: string; + /** A machine-readable description of the cause of the error. If this value is empty there is no information available. */ + reason?: string; +}; +export type StatusDetails = { + /** The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes. */ + causes?: StatusCause[]; + /** The group attribute of the resource associated with the status StatusReason. */ + group?: string; + /** The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described). */ + name?: string; + /** If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action. */ + retryAfterSeconds?: number; + /** UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids */ + uid?: string; +}; +export type Status = { + /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ + apiVersion?: string; + /** Suggested HTTP return code for this status, 0 if not set. */ + code?: number; + /** Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type. */ + details?: StatusDetails; + /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + kind?: string; + /** A human-readable description of the status of this operation. */ + message?: string; + /** Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ + metadata?: ListMeta; + /** A machine-readable description of why this operation is in the "Failure" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it. */ + reason?: string; + /** Status of the operation. One of: "Success" or "Failure". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status */ + status?: string; +}; +export type Patch = object; +export const { + useGetApiResourcesQuery, + useLazyGetApiResourcesQuery, + useListStarsQuery, + useLazyListStarsQuery, + useCreateStarsMutation, + useDeletecollectionStarsMutation, + useGetStarsQuery, + useLazyGetStarsQuery, + useReplaceStarsMutation, + useDeleteStarsMutation, + useUpdateStarsMutation, + useAddStarMutation, + useRemoveStarMutation, +} = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/index.ts b/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/index.ts new file mode 100644 index 00000000000..d80fd6d553a --- /dev/null +++ b/packages/grafana-api-clients/src/clients/rtkq/collections/v1alpha1/index.ts @@ -0,0 +1,5 @@ +export { BASE_URL, API_GROUP, API_VERSION } from './baseAPI'; +import { generatedAPI as rawAPI } from './endpoints.gen'; + +export * from './endpoints.gen'; +export const generatedAPI = rawAPI.enhanceEndpoints({}); diff --git a/packages/grafana-api-clients/src/clients/rtkq/index.ts b/packages/grafana-api-clients/src/clients/rtkq/index.ts index 4bdede94ed9..68dba9c3e38 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/index.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/index.ts @@ -2,6 +2,7 @@ * Don't manually add to this file! Use the `generate:api-client` command to add new API clients. */ import { generatedAPI as advisorAPIv0alpha1 } from './advisor/v0alpha1'; +import { generatedAPI as collectionsAPIv1alpha1 } from './collections/v1alpha1'; import { generatedAPI as correlationsAPIv0alpha1 } from './correlations/v0alpha1'; import { generatedAPI as dashboardAPIv0alpha1 } from './dashboard/v0alpha1'; import { generatedAPI as folderAPIv1beta1 } from './folder/v1beta1'; @@ -23,8 +24,9 @@ export const allMiddleware = [ iamAPIv0alpha1.middleware, migrateToCloudAPI.middleware, playlistAPIv0alpha1.middleware, + collectionsAPIv1alpha1.middleware, // stars preferencesAPIv1alpha1.middleware, - preferencesUserAPI.middleware, + preferencesUserAPI.middleware, // legacy preferences provisioningAPIv0alpha1.middleware, shortURLAPIv1beta1.middleware, correlationsAPIv0alpha1.middleware, @@ -40,6 +42,7 @@ export const allReducers = { [iamAPIv0alpha1.reducerPath]: iamAPIv0alpha1.reducer, [migrateToCloudAPI.reducerPath]: migrateToCloudAPI.reducer, [playlistAPIv0alpha1.reducerPath]: playlistAPIv0alpha1.reducer, + [collectionsAPIv1alpha1.reducerPath]: collectionsAPIv1alpha1.reducer, [preferencesAPIv1alpha1.reducerPath]: preferencesAPIv1alpha1.reducer, [preferencesUserAPI.reducerPath]: preferencesUserAPI.reducer, [provisioningAPIv0alpha1.reducerPath]: provisioningAPIv0alpha1.reducer, diff --git a/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts index c90f694defb..adfb1d8297a 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/preferences/v1alpha1/endpoints.gen.ts @@ -1,5 +1,5 @@ import { api } from './baseAPI'; -export const addTagTypes = ['API Discovery', 'Preferences', 'Stars'] as const; +export const addTagTypes = ['API Discovery', 'Preferences'] as const; const injectedRtkApi = api .enhanceEndpoints({ addTagTypes, @@ -100,129 +100,6 @@ const injectedRtkApi = api }), invalidatesTags: ['Preferences'], }), - listStars: build.query({ - query: (queryArg) => ({ - url: `/stars`, - params: { - pretty: queryArg.pretty, - allowWatchBookmarks: queryArg.allowWatchBookmarks, - continue: queryArg['continue'], - fieldSelector: queryArg.fieldSelector, - labelSelector: queryArg.labelSelector, - limit: queryArg.limit, - resourceVersion: queryArg.resourceVersion, - resourceVersionMatch: queryArg.resourceVersionMatch, - sendInitialEvents: queryArg.sendInitialEvents, - timeoutSeconds: queryArg.timeoutSeconds, - watch: queryArg.watch, - }, - }), - providesTags: ['Stars'], - }), - createStars: build.mutation({ - query: (queryArg) => ({ - url: `/stars`, - method: 'POST', - body: queryArg.stars, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['Stars'], - }), - deletecollectionStars: build.mutation({ - query: (queryArg) => ({ - url: `/stars`, - method: 'DELETE', - params: { - pretty: queryArg.pretty, - continue: queryArg['continue'], - dryRun: queryArg.dryRun, - fieldSelector: queryArg.fieldSelector, - gracePeriodSeconds: queryArg.gracePeriodSeconds, - ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, - labelSelector: queryArg.labelSelector, - limit: queryArg.limit, - orphanDependents: queryArg.orphanDependents, - propagationPolicy: queryArg.propagationPolicy, - resourceVersion: queryArg.resourceVersion, - resourceVersionMatch: queryArg.resourceVersionMatch, - sendInitialEvents: queryArg.sendInitialEvents, - timeoutSeconds: queryArg.timeoutSeconds, - }, - }), - invalidatesTags: ['Stars'], - }), - getStars: build.query({ - query: (queryArg) => ({ - url: `/stars/${queryArg.name}`, - params: { - pretty: queryArg.pretty, - }, - }), - providesTags: ['Stars'], - }), - replaceStars: build.mutation({ - query: (queryArg) => ({ - url: `/stars/${queryArg.name}`, - method: 'PUT', - body: queryArg.stars, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - }, - }), - invalidatesTags: ['Stars'], - }), - deleteStars: build.mutation({ - query: (queryArg) => ({ - url: `/stars/${queryArg.name}`, - method: 'DELETE', - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - gracePeriodSeconds: queryArg.gracePeriodSeconds, - ignoreStoreReadErrorWithClusterBreakingPotential: queryArg.ignoreStoreReadErrorWithClusterBreakingPotential, - orphanDependents: queryArg.orphanDependents, - propagationPolicy: queryArg.propagationPolicy, - }, - }), - invalidatesTags: ['Stars'], - }), - updateStars: build.mutation({ - query: (queryArg) => ({ - url: `/stars/${queryArg.name}`, - method: 'PATCH', - body: queryArg.patch, - params: { - pretty: queryArg.pretty, - dryRun: queryArg.dryRun, - fieldManager: queryArg.fieldManager, - fieldValidation: queryArg.fieldValidation, - force: queryArg.force, - }, - }), - invalidatesTags: ['Stars'], - }), - addStar: build.mutation({ - query: (queryArg) => ({ - url: `/stars/${queryArg.name}/update/${queryArg.group}/${queryArg.kind}/${queryArg.id}`, - method: 'PUT', - }), - invalidatesTags: ['Stars'], - }), - removeStar: build.mutation({ - query: (queryArg) => ({ - url: `/stars/${queryArg.name}/update/${queryArg.group}/${queryArg.kind}/${queryArg.id}`, - method: 'DELETE', - }), - invalidatesTags: ['Stars'], - }), }), overrideExisting: false, }); @@ -345,193 +222,6 @@ export type UpdatePreferencesApiArg = { force?: boolean; patch: Patch; }; -export type ListStarsApiResponse = /** status 200 OK */ StarsList; -export type ListStarsApiArg = { - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** allowWatchBookmarks requests watch events with type "BOOKMARK". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored. */ - allowWatchBookmarks?: boolean; - /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". - - This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ - continue?: string; - /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ - fieldSelector?: string; - /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ - labelSelector?: string; - /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. - - The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ - limit?: number; - /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. - - Defaults to unset */ - resourceVersion?: string; - /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. - - Defaults to unset */ - resourceVersionMatch?: string; - /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. - - When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan - is interpreted as "data at least as new as the provided `resourceVersion`" - and the bookmark event is send when the state is synced - to a `resourceVersion` at least as fresh as the one provided by the ListOptions. - If `resourceVersion` is unset, this is interpreted as "consistent read" and the - bookmark event is send when the state is synced at least to the moment - when request started being processed. - - `resourceVersionMatch` set to any other value or unset - Invalid error is returned. - - Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ - sendInitialEvents?: boolean; - /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ - timeoutSeconds?: number; - /** Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion. */ - watch?: boolean; -}; -export type CreateStarsApiResponse = /** status 200 OK */ - | Stars - | /** status 201 Created */ Stars - | /** status 202 Accepted */ Stars; -export type CreateStarsApiArg = { - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - stars: Stars; -}; -export type DeletecollectionStarsApiResponse = /** status 200 OK */ Status; -export type DeletecollectionStarsApiArg = { - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the "next key". - - This field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications. */ - continue?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** A selector to restrict the list of returned objects by their fields. Defaults to everything. */ - fieldSelector?: string; - /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ - gracePeriodSeconds?: number; - /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ - ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; - /** A selector to restrict the list of returned objects by their labels. Defaults to everything. */ - labelSelector?: string; - /** limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true. - - The server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned. */ - limit?: number; - /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ - orphanDependents?: boolean; - /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ - propagationPolicy?: string; - /** resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. - - Defaults to unset */ - resourceVersion?: string; - /** resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details. - - Defaults to unset */ - resourceVersionMatch?: string; - /** `sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic "Bookmark" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `"k8s.io/initial-events-end": "true"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched. - - When `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan - is interpreted as "data at least as new as the provided `resourceVersion`" - and the bookmark event is send when the state is synced - to a `resourceVersion` at least as fresh as the one provided by the ListOptions. - If `resourceVersion` is unset, this is interpreted as "consistent read" and the - bookmark event is send when the state is synced at least to the moment - when request started being processed. - - `resourceVersionMatch` set to any other value or unset - Invalid error is returned. - - Defaults to true if `resourceVersion=""` or `resourceVersion="0"` (for backward compatibility reasons) and to false otherwise. */ - sendInitialEvents?: boolean; - /** Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity. */ - timeoutSeconds?: number; -}; -export type GetStarsApiResponse = /** status 200 OK */ Stars; -export type GetStarsApiArg = { - /** name of the Stars */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; -}; -export type ReplaceStarsApiResponse = /** status 200 OK */ Stars | /** status 201 Created */ Stars; -export type ReplaceStarsApiArg = { - /** name of the Stars */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - stars: Stars; -}; -export type DeleteStarsApiResponse = /** status 200 OK */ Status | /** status 202 Accepted */ Status; -export type DeleteStarsApiArg = { - /** name of the Stars */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately. */ - gracePeriodSeconds?: number; - /** if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it */ - ignoreStoreReadErrorWithClusterBreakingPotential?: boolean; - /** Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the "orphan" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both. */ - orphanDependents?: boolean; - /** Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground. */ - propagationPolicy?: string; -}; -export type UpdateStarsApiResponse = /** status 200 OK */ Stars | /** status 201 Created */ Stars; -export type UpdateStarsApiArg = { - /** name of the Stars */ - name: string; - /** If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget). */ - pretty?: string; - /** When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed */ - dryRun?: string; - /** fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch). */ - fieldManager?: string; - /** fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered. */ - fieldValidation?: string; - /** Force is going to "force" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests. */ - force?: boolean; - patch: Patch; -}; -export type AddStarApiResponse = /** status 200 OK */ Stars; -export type AddStarApiArg = { - /** name of the Stars */ - name: string; - /** API group for stared item */ - group: string; - /** Kind for stared item */ - kind: string; - /** The k8s name for the selected item */ - id: string; -}; -export type RemoveStarApiResponse = /** status 200 OK */ Stars; -export type RemoveStarApiArg = { - /** name of the Stars */ - name: string; - /** API group for stared item */ - group: string; - /** Kind for stared item */ - kind: string; - /** The k8s name for the selected item */ - id: string; -}; export type ApiResource = { /** categories is a list of the grouped resources this resource belongs to (e.g. 'all') */ categories?: string[]; @@ -750,32 +440,6 @@ export type Status = { status?: string; }; export type Patch = object; -export type StarsResource = { - group: string; - kind: string; - /** The set of resources */ - names: string[]; -}; -export type StarsSpec = { - resource: StarsResource[]; -}; -export type Stars = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - metadata: ObjectMeta; - /** Spec is the spec of the Stars */ - spec: StarsSpec; -}; -export type StarsList = { - /** APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources */ - apiVersion?: string; - items: Stars[]; - /** Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds */ - kind?: string; - metadata: ListMeta; -}; export const { useGetApiResourcesQuery, useLazyGetApiResourcesQuery, @@ -789,15 +453,4 @@ export const { useReplacePreferencesMutation, useDeletePreferencesMutation, useUpdatePreferencesMutation, - useListStarsQuery, - useLazyListStarsQuery, - useCreateStarsMutation, - useDeletecollectionStarsMutation, - useGetStarsQuery, - useLazyGetStarsQuery, - useReplaceStarsMutation, - useDeleteStarsMutation, - useUpdateStarsMutation, - useAddStarMutation, - useRemoveStarMutation, } = injectedRtkApi; diff --git a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts index 8b211fbca8c..776ad4131ae 100644 --- a/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts +++ b/packages/grafana-api-clients/src/scripts/generate-rtk-apis.ts @@ -104,6 +104,7 @@ const config: ConfigFile = { ...createAPIConfig('folder', 'v1beta1'), ...createAPIConfig('iam', 'v0alpha1'), ...createAPIConfig('playlist', 'v0alpha1'), + ...createAPIConfig('collections', 'v1alpha1'), ...createAPIConfig('preferences', 'v1alpha1'), ...createAPIConfig('provisioning', 'v0alpha1'), ...createAPIConfig('shorturl', 'v1beta1'), diff --git a/packages/grafana-test-utils/src/handlers/all-handlers.ts b/packages/grafana-test-utils/src/handlers/all-handlers.ts index dc2bd7f47d0..5a0f1cdc140 100644 --- a/packages/grafana-test-utils/src/handlers/all-handlers.ts +++ b/packages/grafana-test-utils/src/handlers/all-handlers.ts @@ -6,11 +6,11 @@ import pluginsHandlers from './api/plugins/handlers'; import searchHandlers from './api/search/handlers'; import teamsHandlers from './api/teams/handlers'; import userHandlers from './api/user/handlers'; +import appPlatformCollectionsv1alpha1Handlers from './apis/collections.grafana.app/v1alpha1/handlers'; import appPlatformDashboardv0alpha1Handlers from './apis/dashboard.grafana.app/v0alpha1/handlers'; import appPlatformDashboardv1beta1Handlers from './apis/dashboard.grafana.app/v1beta1/handlers'; import appPlatformFolderv1beta1Handlers from './apis/folder.grafana.app/v1beta1/handlers'; import appPlatformIamv0alpha1Handlers from './apis/iam.grafana.app/v0alpha1/handlers'; -import appPlatformPreferencesv1alpha1Handlers from './apis/preferences.grafana.app/v1alpha1/handlers'; const allHandlers: HttpHandler[] = [ // Legacy handlers @@ -26,7 +26,7 @@ const allHandlers: HttpHandler[] = [ ...appPlatformDashboardv1beta1Handlers, ...appPlatformFolderv1beta1Handlers, ...appPlatformIamv0alpha1Handlers, - ...appPlatformPreferencesv1alpha1Handlers, + ...appPlatformCollectionsv1alpha1Handlers, ]; export default allHandlers; diff --git a/packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/collections.grafana.app/v1alpha1/handlers.ts similarity index 90% rename from packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts rename to packages/grafana-test-utils/src/handlers/apis/collections.grafana.app/v1alpha1/handlers.ts index e5ad85df839..48b9d2757b5 100644 --- a/packages/grafana-test-utils/src/handlers/apis/preferences.grafana.app/v1alpha1/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/apis/collections.grafana.app/v1alpha1/handlers.ts @@ -3,10 +3,10 @@ import { HttpResponse, http } from 'msw'; import { mockStarredDashboardsMap } from '../../../../fixtures/starred'; const getStarsHandler = () => - http.get('/apis/preferences.grafana.app/v1alpha1/namespaces/:namespace/stars', () => { + http.get('/apis/collections.grafana.app/v1alpha1/namespaces/:namespace/stars', () => { const mockStarsResponse = { kind: 'StarsList', - apiVersion: 'preferences.grafana.app/v1alpha1', + apiVersion: 'collections.grafana.app/v1alpha1', metadata: { resourceVersion: '1758126936000', }, @@ -35,7 +35,7 @@ const getStarsHandler = () => }); const UPDATE_STARS_URL = - '/apis/preferences.grafana.app/v1alpha1/namespaces/:namespace/stars/:name/update/:group/:kind/:id'; + '/apis/collections.grafana.app/v1alpha1/namespaces/:namespace/stars/:name/update/:group/:kind/:id'; type UpdateOrDeleteStarsParams = { namespace: string; diff --git a/packages/grafana-test-utils/src/server/index.ts b/packages/grafana-test-utils/src/server/index.ts index 71636cda7ac..8a4c417c77c 100644 --- a/packages/grafana-test-utils/src/server/index.ts +++ b/packages/grafana-test-utils/src/server/index.ts @@ -11,7 +11,7 @@ const server = setupServer(...allHandlers); */ export function setupMockServer( /** - * Additional handlers to add to server initialisation. Handlers will be `.use`d in a `beforeEach` hook + * Additional handlers to add to server initialization. Handlers will be `.use`d in a `beforeEach` hook */ additionalHandlers?: HttpHandler[] ) { diff --git a/pkg/apimachinery/identity/context.go b/pkg/apimachinery/identity/context.go index 00488ed9d85..6b39c1af046 100644 --- a/pkg/apimachinery/identity/context.go +++ b/pkg/apimachinery/identity/context.go @@ -158,7 +158,8 @@ var serviceIdentityTokenPermissions = []string{ "secret.grafana.app:*", "query.grafana.app:*", "iam.grafana.app:*", - "preferences.grafana.app:*", + "preferences.grafana.app:*", // user, team, and org preferences + "collections.grafana.app:*", // user stars // Secrets Manager uses a custom verb for secret decryption, and its authorizer does not allow wildcard permissions. "secret.grafana.app/securevalues:decrypt", diff --git a/pkg/registry/apis/apis.go b/pkg/registry/apis/apis.go index a1d323f996a..10f1d7e52be 100644 --- a/pkg/registry/apis/apis.go +++ b/pkg/registry/apis/apis.go @@ -1,6 +1,7 @@ package apiregistry import ( + "github.com/grafana/grafana/pkg/registry/apis/collections" dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" @@ -27,6 +28,7 @@ func ProvideRegistryServiceSink( _ *query.QueryAPIBuilder, _ *userstorage.UserStorageAPIBuilder, _ *preferences.APIBuilder, + _ *collections.APIBuilder, _ *provisioning.APIBuilder, _ *ofrep.APIBuilder, _ *secret.DependencyRegisterer, diff --git a/pkg/registry/apis/preferences/admission.go b/pkg/registry/apis/collections/admission.go similarity index 79% rename from pkg/registry/apis/preferences/admission.go rename to pkg/registry/apis/collections/admission.go index 0703b37b8df..0811099b274 100644 --- a/pkg/registry/apis/preferences/admission.go +++ b/pkg/registry/apis/collections/admission.go @@ -1,4 +1,4 @@ -package preferences +package collections import ( "context" @@ -6,7 +6,7 @@ import ( "k8s.io/apiserver/pkg/admission" - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" + collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" ) func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) (err error) { @@ -24,7 +24,7 @@ func (b *APIBuilder) Mutate(ctx context.Context, a admission.Attributes, o admis switch a.GetResource().Resource { case "stars": - stars, ok := obj.(*preferences.Stars) + stars, ok := obj.(*collections.Stars) if !ok { return fmt.Errorf("expected stars object: (%T)", obj) } diff --git a/pkg/registry/apis/collections/legacy/queries.go b/pkg/registry/apis/collections/legacy/queries.go new file mode 100644 index 00000000000..67153b6c07e --- /dev/null +++ b/pkg/registry/apis/collections/legacy/queries.go @@ -0,0 +1,67 @@ +package legacy + +import ( + "embed" + "fmt" + "text/template" + + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +// Templates setup. +var ( + //go:embed *.sql + sqlTemplatesFS embed.FS + + sqlTemplates = template.Must(template.New("sql").ParseFS(sqlTemplatesFS, `*.sql`)) +) + +func mustTemplate(filename string) *template.Template { + if t := sqlTemplates.Lookup(filename); t != nil { + return t + } + panic(fmt.Sprintf("template file not found: %s", filename)) +} + +// Templates. +var ( + sqlDashboardStarsQuery = mustTemplate("sql_dashboard_stars.sql") + sqlDashboardStarsRV = mustTemplate("sql_dashboard_stars_rv.sql") +) + +type starQuery struct { + sqltemplate.SQLTemplate + + OrgID int64 // >= 1 if UserID != "" + UserUID string + UserID int64 // for stars + QueryUIDs []string + QueryUID string + + StarTable string + UserTable string + QueryHistoryStarsTable string + QueryHistoryTable string +} + +func (r starQuery) Validate() error { + if r.UserUID != "" && r.OrgID < 1 { + return fmt.Errorf("requests with a userid, must include an orgID") + } + return nil +} + +func newStarQueryReq(sql *legacysql.LegacyDatabaseHelper, user string, orgId int64) starQuery { + return starQuery{ + SQLTemplate: sqltemplate.New(sql.DialectForDriver()), + + UserUID: user, + OrgID: orgId, + + StarTable: sql.Table("star"), + UserTable: sql.Table("user"), + QueryHistoryStarsTable: sql.Table("query_history_star"), + QueryHistoryTable: sql.Table("query_history"), + } +} diff --git a/pkg/registry/apis/collections/legacy/queries_test.go b/pkg/registry/apis/collections/legacy/queries_test.go new file mode 100644 index 00000000000..2e94a8af564 --- /dev/null +++ b/pkg/registry/apis/collections/legacy/queries_test.go @@ -0,0 +1,52 @@ +package legacy + +import ( + "testing" + "text/template" + + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" +) + +func TestStarsQueries(t *testing.T) { + // prefix tables with grafana + nodb := &legacysql.LegacyDatabaseHelper{ + Table: func(n string) string { + return "grafana." + n + }, + } + + getStarQuery := func(orgId int64, user string) sqltemplate.SQLTemplate { + v := newStarQueryReq(nodb, user, orgId) + v.SQLTemplate = mocks.NewTestingSQLTemplate() + return &v + } + + mocks.CheckQuerySnapshots(t, mocks.TemplateTestSetup{ + RootDir: "testdata", + SQLTemplatesFS: sqlTemplatesFS, + Templates: map[*template.Template][]mocks.TemplateTestCase{ + sqlDashboardStarsQuery: { + { + Name: "all", + Data: getStarQuery(0, ""), + }, + { + Name: "org", + Data: getStarQuery(3, ""), + }, + { + Name: "user", + Data: getStarQuery(3, "abc"), + }, + }, + sqlDashboardStarsRV: { + { + Name: "get", + Data: getStarQuery(0, ""), + }, + }, + }, + }) +} diff --git a/pkg/registry/apis/collections/legacy/sql.go b/pkg/registry/apis/collections/legacy/sql.go new file mode 100644 index 00000000000..4f8ff3333a9 --- /dev/null +++ b/pkg/registry/apis/collections/legacy/sql.go @@ -0,0 +1,116 @@ +package legacy + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" +) + +type dashboardStars struct { + OrgID int64 + UserUID string + First int64 + Last int64 + + Dashboards []string +} + +type LegacySQL struct { + db legacysql.LegacyDatabaseProvider + startup time.Time +} + +func NewLegacySQL(db legacysql.LegacyDatabaseProvider) *LegacySQL { + return &LegacySQL{db: db, startup: time.Now()} +} + +// NOTE: this does not support paging -- lets check if that will be a problem in cloud +func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user string) ([]dashboardStars, int64, error) { + var max sql.NullString + sql, err := s.db(ctx) + if err != nil { + return nil, 0, err + } + + req := newStarQueryReq(sql, user, orgId) + + q, err := sqltemplate.Execute(sqlDashboardStarsQuery, req) + if err != nil { + return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsQuery.Name(), err) + } + + sess := sql.DB.GetSqlxSession() + rows, err := sess.Query(ctx, q, req.GetArgs()...) + if err != nil { + return nil, 0, err + } + defer func() { + if rows != nil { + _ = rows.Close() + } + }() + + stars := []dashboardStars{} + current := &dashboardStars{} + var orgID int64 + var userUID string + var dashboardUID string + var updated time.Time + + for rows.Next() { + err := rows.Scan(&orgID, &userUID, &dashboardUID, &updated) + if err != nil { + return nil, 0, err + } + + if orgID != current.OrgID || userUID != current.UserUID { + if current.UserUID != "" { + stars = append(stars, *current) + } + current = &dashboardStars{ + OrgID: orgID, + UserUID: userUID, + } + } + ts := updated.UnixMilli() + if ts > current.Last { + current.Last = ts + } + if ts < current.First || current.First == 0 { + current.First = ts + } + current.Dashboards = append(current.Dashboards, dashboardUID) + } + + // Add the last value + if current.UserUID != "" { + stars = append(stars, *current) + } + + // Find the RV unless it is a user query + if userUID == "" { + req.Reset() + q, err = sqltemplate.Execute(sqlDashboardStarsRV, req) + if err != nil { + return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsRV.Name(), err) + } + err = sess.Get(ctx, &max, q) + if err != nil { + return nil, 0, fmt.Errorf("unable to get RV %w", err) + } + if max.Valid && max.String != "" { + t, _ := time.Parse(time.RFC3339, max.String) + if !t.IsZero() { + updated = t + } + } else { + updated = s.startup + } + } + + return stars, updated.UnixMilli(), err +} diff --git a/pkg/registry/apis/preferences/legacy/sql_dashboard_stars.sql b/pkg/registry/apis/collections/legacy/sql_dashboard_stars.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/sql_dashboard_stars.sql rename to pkg/registry/apis/collections/legacy/sql_dashboard_stars.sql diff --git a/pkg/registry/apis/preferences/legacy/sql_dashboard_stars_rv.sql b/pkg/registry/apis/collections/legacy/sql_dashboard_stars_rv.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/sql_dashboard_stars_rv.sql rename to pkg/registry/apis/collections/legacy/sql_dashboard_stars_rv.sql diff --git a/pkg/registry/apis/preferences/legacy/stars.go b/pkg/registry/apis/collections/legacy/stars.go similarity index 78% rename from pkg/registry/apis/preferences/legacy/stars.go rename to pkg/registry/apis/collections/legacy/stars.go index f38f2fd786b..8c7d9e711ea 100644 --- a/pkg/registry/apis/preferences/legacy/stars.go +++ b/pkg/registry/apis/collections/legacy/stars.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "math/rand" - "slices" "strconv" "strings" "time" @@ -18,8 +17,8 @@ import ( "k8s.io/utils/ptr" authlib "github.com/grafana/authlib/types" + collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" dashboardsV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" @@ -50,7 +49,7 @@ func NewDashboardStarsStorage( users: users, namespacer: namespacer, sql: sql, - tableConverter: preferences.StarsResourceInfo.TableConverter(), + tableConverter: collections.StarsResourceInfo.TableConverter(), } } @@ -63,7 +62,7 @@ type DashboardStarsStorage struct { } func (s *DashboardStarsStorage) New() runtime.Object { - return preferences.StarsKind().ZeroValue() + return collections.StarsKind().ZeroValue() } func (s *DashboardStarsStorage) Destroy() {} @@ -73,11 +72,11 @@ func (s *DashboardStarsStorage) NamespaceScoped() bool { } func (s *DashboardStarsStorage) GetSingularName() string { - return strings.ToLower(preferences.StarsKind().Kind()) + return strings.ToLower(collections.StarsKind().Kind()) } func (s *DashboardStarsStorage) NewList() runtime.Object { - return preferences.StarsKind().ZeroListValue() + return collections.StarsKind().ZeroListValue() } func (s *DashboardStarsStorage) ConvertToTable(ctx context.Context, object runtime.Object, tableOptions runtime.Object) (*metav1.Table, error) { @@ -104,18 +103,14 @@ func (s *DashboardStarsStorage) List(ctx context.Context, options *internalversi user = "" // can see everything } - list := &preferences.StarsList{} + list := &collections.StarsList{} found, rv, err := s.sql.getDashboardStars(ctx, ns.OrgID, user) if err != nil { return nil, err } - history, err := s.sql.getHistoryStars(ctx, ns.OrgID, "") - if err != nil { - return nil, err - } for _, v := range found { list.Items = append(list.Items, - asStarsResource(s.namespacer(v.OrgID), &v, history[v.UserUID])) + asStarsResource(s.namespacer(v.OrgID), &v)) } if rv > 0 { list.ResourceVersion = strconv.FormatInt(rv, 10) @@ -149,19 +144,14 @@ func (s *DashboardStarsStorage) Get(ctx context.Context, name string, options *m return nil, err } - history, err := s.sql.getHistoryStars(ctx, ns.OrgID, owner.Identifier) - if err != nil { - return nil, err - } - if len(found) == 0 || len(found[0].Dashboards) == 0 { - return nil, apiserrors.NewNotFound(preferences.StarsResourceInfo.GroupResource(), name) + return nil, apiserrors.NewNotFound(collections.StarsResourceInfo.GroupResource(), name) } - obj := asStarsResource(ns.Value, &found[0], history[owner.Identifier]) + obj := asStarsResource(ns.Value, &found[0]) return &obj, nil } -func getStars(stars *preferences.Stars, gk schema.GroupKind) []string { +func getStars(stars *collections.Stars, gk schema.GroupKind) []string { if stars == nil || len(stars.Spec.Resource) == 0 { return []string{} } @@ -174,7 +164,7 @@ func getStars(stars *preferences.Stars, gk schema.GroupKind) []string { } // Create implements rest.Creater. -func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Stars) (runtime.Object, error) { +func (s *DashboardStarsStorage) write(ctx context.Context, obj *collections.Stars) (runtime.Object, error) { ns, owner, err := getNamespaceAndOwner(ctx, obj.Name) if err != nil { return nil, err @@ -193,7 +183,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star stars := getStars(obj, schema.GroupKind{Group: "dashboard.grafana.app", Kind: "Dashboard"}) if len(stars) == 0 { err = s.stars.DeleteByUser(ctx, user.ID) - return &preferences.Stars{ObjectMeta: metav1.ObjectMeta{ + return &collections.Stars{ObjectMeta: metav1.ObjectMeta{ Name: obj.Name, Namespace: obj.Namespace, DeletionTimestamp: ptr.To(metav1.Now()), @@ -245,31 +235,6 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star changed = true } - // Apply history stars - stars = getStars(obj, schema.GroupKind{Group: "history.grafana.app", Kind: "Query"}) - res, err := s.sql.getHistoryStars(ctx, user.OrgID, user.UID) - if err != nil { - return nil, err - } - history := res[user.UID] - if !slices.Equal(stars, history) { - changed = true - if len(stars) == 0 { - err = s.sql.removeHistoryStar(ctx, user, nil) - if err != nil { - return nil, err - } - } else { - added, removed, _ := preferences.Changes(history, stars) - if len(removed) > 0 { - _ = s.sql.removeHistoryStar(ctx, user, nil) - } - for _, v := range added { - _ = s.sql.addHistoryStar(ctx, user, v) // one at a time so duplicates do not fail everything - } - } - } - if changed { return s.Get(ctx, obj.Name, &metav1.GetOptions{}) } @@ -278,7 +243,7 @@ func (s *DashboardStarsStorage) write(ctx context.Context, obj *preferences.Star // Create implements rest.Creater. func (s *DashboardStarsStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) { - stars, ok := obj.(*preferences.Stars) + stars, ok := obj.(*collections.Stars) if !ok { return nil, fmt.Errorf("expected stars object") } @@ -298,7 +263,7 @@ func (s *DashboardStarsStorage) Update(ctx context.Context, name string, objInfo return nil, false, err } - stars, ok := obj.(*preferences.Stars) + stars, ok := obj.(*collections.Stars) if !ok { return nil, false, fmt.Errorf("expected stars object") } @@ -309,7 +274,7 @@ func (s *DashboardStarsStorage) Update(ctx context.Context, name string, objInfo // Delete implements rest.GracefulDeleter. func (s *DashboardStarsStorage) Delete(ctx context.Context, name string, deleteValidation rest.ValidateObjectFunc, options *metav1.DeleteOptions) (runtime.Object, bool, error) { - obj, err := s.write(ctx, &preferences.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}) + obj, err := s.write(ctx, &collections.Stars{ObjectMeta: metav1.ObjectMeta{Name: name}}) if err != nil { return nil, false, err } @@ -321,29 +286,22 @@ func (s *DashboardStarsStorage) DeleteCollection(ctx context.Context, deleteVali return nil, fmt.Errorf("not implemented yet") } -func asStarsResource(ns string, v *dashboardStars, history []string) preferences.Stars { - stars := preferences.Stars{ +func asStarsResource(ns string, v *dashboardStars) collections.Stars { + stars := collections.Stars{ ObjectMeta: metav1.ObjectMeta{ Name: fmt.Sprintf("user-%s", v.UserUID), Namespace: ns, ResourceVersion: strconv.FormatInt(v.Last, 10), CreationTimestamp: metav1.NewTime(time.UnixMilli(v.First)), }, - Spec: preferences.StarsSpec{ - Resource: []preferences.StarsResource{{ + Spec: collections.StarsSpec{ + Resource: []collections.StarsResource{{ Group: dashboardsV1.APIGroup, Kind: "Dashboard", Names: v.Dashboards, }}, }, } - if len(history) > 0 { - stars.Spec.Resource = append(stars.Spec.Resource, preferences.StarsResource{ - Group: "history.grafana.app", - Kind: "Query", - Names: history, - }) - } stars.Spec.Normalize() return stars } diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-all.sql b/pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-all.sql rename to pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-org.sql b/pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-org.sql rename to pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-user.sql b/pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars-user.sql rename to pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql b/pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql rename to pkg/registry/apis/collections/legacy/testdata/mysql--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-all.sql b/pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-all.sql rename to pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-org.sql b/pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-org.sql rename to pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-user.sql b/pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars-user.sql rename to pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql b/pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql rename to pkg/registry/apis/collections/legacy/testdata/postgres--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-all.sql b/pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars-all.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-all.sql rename to pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars-all.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-org.sql b/pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars-org.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-org.sql rename to pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars-org.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-user.sql b/pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars-user.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars-user.sql rename to pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars-user.sql diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql b/pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql similarity index 100% rename from pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql rename to pkg/registry/apis/collections/legacy/testdata/sqlite--sql_dashboard_stars_rv-get.sql diff --git a/pkg/registry/apis/collections/register.go b/pkg/registry/apis/collections/register.go new file mode 100644 index 00000000000..9be4412c377 --- /dev/null +++ b/pkg/registry/apis/collections/register.go @@ -0,0 +1,178 @@ +package collections + +import ( + "fmt" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apiserver/pkg/authorization/authorizer" + "k8s.io/apiserver/pkg/registry/rest" + genericapiserver "k8s.io/apiserver/pkg/server" + "k8s.io/kube-openapi/pkg/common" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" + grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" + grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/registry/apis/collections/legacy" + "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" + "github.com/grafana/grafana/pkg/services/apiserver/builder" + "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" + "github.com/grafana/grafana/pkg/services/featuremgmt" + "github.com/grafana/grafana/pkg/services/star" + "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql" +) + +var ( + _ builder.APIGroupBuilder = (*APIBuilder)(nil) + _ builder.APIGroupMutation = (*APIBuilder)(nil) +) + +type APIBuilder struct { + authorizer authorizer.Authorizer + legacyStars *legacy.DashboardStarsStorage +} + +func RegisterAPIService( + cfg *setting.Cfg, + features featuremgmt.FeatureToggles, + db db.DB, + stars star.Service, + users user.Service, + apiregistration builder.APIRegistrar, +) *APIBuilder { + // Requires development settings and clearly experimental + //nolint:staticcheck // not yet migrated to OpenFeature + if !features.IsEnabledGlobally(featuremgmt.FlagGrafanaAPIServerWithExperimentalAPIs) { + return nil + } + + sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db)) + builder := &APIBuilder{ + authorizer: &utils.AuthorizeFromName{ + Resource: map[string][]utils.ResourceOwner{ + "stars": {utils.UserResourceOwner}, + }, + }, + } + + namespacer := request.GetNamespaceMapper(cfg) + if stars != nil { + builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql) + } + + apiregistration.RegisterAPI(builder) + return builder +} + +// AllowedV0Alpha1Resources implements builder.APIGroupBuilder. +func (b *APIBuilder) AllowedV0Alpha1Resources() []string { + return nil +} + +func (b *APIBuilder) GetGroupVersion() schema.GroupVersion { + return collections.GroupVersion +} + +func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { + gv := collections.GroupVersion + err := collections.AddToScheme(scheme) + if err != nil { + return err + } + + metav1.AddToGroupVersion(scheme, gv) + return scheme.SetVersionPriority(gv) +} + +func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { + storage := map[string]rest.Storage{} + + // Configure Stars Dual writer + resource := collections.StarsResourceInfo + var stars grafanarest.Storage + stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter) + if err != nil { + return err + } + stars = &starStorage{Storage: stars} // wrap List so we only return one value + if b.legacyStars != nil && opts.DualWriteBuilder != nil { + stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars) + if err != nil { + return err + } + } + storage[resource.StoragePath()] = stars + storage[resource.StoragePath("update")] = &starsREST{store: stars} + + apiGroupInfo.VersionedResourcesStorageMap[collections.APIVersion] = storage + return nil +} + +func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { + return b.authorizer +} + +func (b *APIBuilder) GetOpenAPIDefinitions() common.GetOpenAPIDefinitions { + return collections.GetOpenAPIDefinitions +} + +func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { + oas.Info.Description = "Grafana collections" + + root := "/apis/" + b.GetGroupVersion().String() + "/" + updateKey := root + "namespaces/{namespace}/stars/{name}/update" + delete(oas.Paths.Paths, updateKey) + + // Add the group/kind/id properties to the path + stars, ok := oas.Paths.Paths[updateKey+"/{path}"] + if !ok || stars == nil { + return nil, fmt.Errorf("unable to find write path") + } + stars.Parameters = []*spec3.Parameter{ + stars.Parameters[0], // name + stars.Parameters[1], // namespace + { + ParameterProps: spec3.ParameterProps{ + Name: "group", + In: "path", + Example: "dashboard.grafana.app", + Description: "API group for stared item", + Schema: spec.StringProperty(), + Required: true, + }, + }, { + ParameterProps: spec3.ParameterProps{ + Name: "kind", + In: "path", + Example: "Dashboard", + Description: "Kind for stared item", + Schema: spec.StringProperty(), + Required: true, + }, + }, { + ParameterProps: spec3.ParameterProps{ + Name: "id", + In: "path", + Example: "", + Description: "The k8s name for the selected item", + Schema: spec.StringProperty(), + Required: true, + }, + }, + } + stars.Put.Description = "Add a starred item" + stars.Put.OperationId = "addStar" + stars.Delete.Description = "Remove a starred item" + stars.Delete.OperationId = "removeStar" + + delete(oas.Paths.Paths, updateKey+"/{path}") + oas.Paths.Paths[updateKey+"/{group}/{kind}/{id}"] = stars + + return oas, nil +} diff --git a/pkg/registry/apis/preferences/stars.go b/pkg/registry/apis/collections/stars.go similarity index 81% rename from pkg/registry/apis/preferences/stars.go rename to pkg/registry/apis/collections/stars.go index b5c8d0b0dcf..1fb16190d72 100644 --- a/pkg/registry/apis/preferences/stars.go +++ b/pkg/registry/apis/collections/stars.go @@ -1,4 +1,4 @@ -package preferences +package collections import ( "context" @@ -8,7 +8,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" authlib "github.com/grafana/authlib/types" - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" + collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) @@ -32,12 +32,12 @@ func (s *starStorage) List(ctx context.Context, options *internalversion.ListOpt // Get the single user stars case authlib.TypeUser: - stars := &preferences.StarsList{} + stars := &collections.StarsList{} obj, _ := s.Get(ctx, "user-"+user.GetIdentifier(), &v1.GetOptions{}) if obj != nil { - s, ok := obj.(*preferences.Stars) + s, ok := obj.(*collections.Stars) if ok { - stars.Items = []preferences.Stars{*s} + stars.Items = []collections.Stars{*s} } } return stars, nil diff --git a/pkg/registry/apis/preferences/stars_update.go b/pkg/registry/apis/collections/stars_update.go similarity index 90% rename from pkg/registry/apis/preferences/stars_update.go rename to pkg/registry/apis/collections/stars_update.go index 2280a448e07..1a1de8c867c 100644 --- a/pkg/registry/apis/preferences/stars_update.go +++ b/pkg/registry/apis/collections/stars_update.go @@ -1,4 +1,4 @@ -package preferences +package collections import ( "context" @@ -11,7 +11,7 @@ import ( "k8s.io/apimachinery/pkg/runtime" "k8s.io/apiserver/pkg/registry/rest" - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" + collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" @@ -33,7 +33,7 @@ var ( ) func (r *starsREST) New() runtime.Object { - return &preferences.Stars{} + return &collections.Stars{} } func (r *starsREST) Destroy() {} @@ -47,7 +47,7 @@ func (r *starsREST) ProducesMIMETypes(verb string) []string { } func (r *starsREST) ProducesObject(verb string) interface{} { - return &preferences.Stars{} + return &collections.Stars{} } func (r *starsREST) NewConnectOptions() (runtime.Object, bool, string) { @@ -81,7 +81,7 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, case "PUT": remove = false default: - responder.Error(apierrors.NewMethodNotSupported(preferences.PreferencesResourceInfo.GroupResource(), req.Method)) + responder.Error(apierrors.NewMethodNotSupported(collections.StarsResourceInfo.GroupResource(), req.Method)) return } @@ -94,7 +94,7 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, }) return } - current = &preferences.Stars{ + current = &collections.Stars{ ObjectMeta: v1.ObjectMeta{ Name: name, Namespace: user.GetNamespace(), @@ -103,7 +103,7 @@ func (r *starsREST) Connect(ctx context.Context, name string, _ runtime.Object, } } - obj, ok := current.(*preferences.Stars) + obj, ok := current.(*collections.Stars) if !ok { responder.Error(fmt.Errorf("expected stars object")) return diff --git a/pkg/registry/apis/preferences/stars_update_test.go b/pkg/registry/apis/collections/stars_update_test.go similarity index 84% rename from pkg/registry/apis/preferences/stars_update_test.go rename to pkg/registry/apis/collections/stars_update_test.go index c4b58804cbc..aee38397ada 100644 --- a/pkg/registry/apis/preferences/stars_update_test.go +++ b/pkg/registry/apis/collections/stars_update_test.go @@ -1,4 +1,4 @@ -package preferences +package collections import ( "testing" @@ -16,7 +16,7 @@ func TestStarsWrite(t *testing.T) { err string }{{ name: "normal", - url: "http://localhost:3000/apis/preferences.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127", + url: "http://localhost:3000/apis/collections.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127", prefix: "/user-abc/write", item: starItem{ group: "dashboard.grafana.app", @@ -25,12 +25,12 @@ func TestStarsWrite(t *testing.T) { }, }, { name: "prefix not found", - url: "http://localhost:3000/apis/preferences.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127", + url: "http://localhost:3000/apis/collections.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/Dashboard/000000127", prefix: "/something/write", err: "invalid request path", }, { name: "missing three parts", - url: "http://localhost:3000/apis/preferences.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/000000127", + url: "http://localhost:3000/apis/collections.grafana.app/v1alpha1/namespaces/default/stars/user-abc/write/dashboard.grafana.app/000000127", prefix: "/user-abc/write", err: "expected {group}/{kind}/{id}", }} diff --git a/pkg/registry/apis/preferences/legacy/queries.go b/pkg/registry/apis/preferences/legacy/queries.go index d51904a0601..8ec075ec6de 100644 --- a/pkg/registry/apis/preferences/legacy/queries.go +++ b/pkg/registry/apis/preferences/legacy/queries.go @@ -26,52 +26,11 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlDashboardStarsQuery = mustTemplate("sql_dashboard_stars.sql") - sqlDashboardStarsRV = mustTemplate("sql_dashboard_stars_rv.sql") - sqlHistoryStarsQuery = mustTemplate("sql_history_stars.sql") - sqlHistoryStarsInsert = mustTemplate("sql_history_stars_insert.sql") - sqlHistoryStarsDelete = mustTemplate("sql_history_stars_delete.sql") - sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql") - sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql") - sqlTeams = mustTemplate("sql_teams.sql") + sqlPreferencesQuery = mustTemplate("sql_preferences_query.sql") + sqlPreferencesRV = mustTemplate("sql_preferences_rv.sql") + sqlTeams = mustTemplate("sql_teams.sql") ) -type starQuery struct { - sqltemplate.SQLTemplate - - OrgID int64 // >= 1 if UserID != "" - UserUID string - UserID int64 // for stars - QueryUIDs []string - QueryUID string - - StarTable string - UserTable string - QueryHistoryStarsTable string - QueryHistoryTable string -} - -func (r starQuery) Validate() error { - if r.UserUID != "" && r.OrgID < 1 { - return fmt.Errorf("requests with a userid, must include an orgID") - } - return nil -} - -func newStarQueryReq(sql *legacysql.LegacyDatabaseHelper, user string, orgId int64) starQuery { - return starQuery{ - SQLTemplate: sqltemplate.New(sql.DialectForDriver()), - - UserUID: user, - OrgID: orgId, - - StarTable: sql.Table("star"), - UserTable: sql.Table("user"), - QueryHistoryStarsTable: sql.Table("query_history_star"), - QueryHistoryTable: sql.Table("query_history"), - } -} - type preferencesQuery struct { sqltemplate.SQLTemplate diff --git a/pkg/registry/apis/preferences/legacy/queries_test.go b/pkg/registry/apis/preferences/legacy/queries_test.go index 726049af7f5..8b4856a04dc 100644 --- a/pkg/registry/apis/preferences/legacy/queries_test.go +++ b/pkg/registry/apis/preferences/legacy/queries_test.go @@ -17,21 +17,6 @@ func TestStarsQueries(t *testing.T) { }, } - getStarQuery := func(orgId int64, user string) sqltemplate.SQLTemplate { - v := newStarQueryReq(nodb, user, orgId) - v.SQLTemplate = mocks.NewTestingSQLTemplate() - return &v - } - - getHistoryReq := func(orgId int64, userId int64, stars []string, star string) sqltemplate.SQLTemplate { - v := newStarQueryReq(nodb, "", orgId) - v.UserID = userId - v.QueryUIDs = stars - v.QueryUID = star - v.SQLTemplate = mocks.NewTestingSQLTemplate() - return &v - } - getPreferencesQuery := func(orgId int64, cb func(q *preferencesQuery)) sqltemplate.SQLTemplate { v := newPreferencesQueryReq(nodb, orgId) v.SQLTemplate = mocks.NewTestingSQLTemplate() @@ -49,56 +34,6 @@ func TestStarsQueries(t *testing.T) { RootDir: "testdata", SQLTemplatesFS: sqlTemplatesFS, Templates: map[*template.Template][]mocks.TemplateTestCase{ - sqlDashboardStarsQuery: { - { - Name: "all", - Data: getStarQuery(0, ""), - }, - { - Name: "org", - Data: getStarQuery(3, ""), - }, - { - Name: "user", - Data: getStarQuery(3, "abc"), - }, - }, - sqlDashboardStarsRV: { - { - Name: "get", - Data: getStarQuery(0, ""), - }, - }, - sqlHistoryStarsQuery: { - { - Name: "user", - Data: getStarQuery(1, "abc"), - }, - }, - sqlHistoryStarsQuery: { - { - Name: "org", - Data: getStarQuery(1, ""), - }, - }, - sqlHistoryStarsInsert: { - { - Name: "add star", - Data: getHistoryReq(1, 3, nil, "XXX"), - }, - }, - sqlHistoryStarsDelete: { - { - Name: "remove star", - Data: getHistoryReq(1, 3, []string{"xxx", "yyy"}, ""), - }, - }, - sqlHistoryStarsDelete: { - { - Name: "remove all star", - Data: getHistoryReq(1, 3, nil, ""), - }, - }, sqlPreferencesQuery: { { Name: "all", diff --git a/pkg/registry/apis/preferences/legacy/sql.go b/pkg/registry/apis/preferences/legacy/sql.go index 93f4ea39949..04cd7a3cf4f 100644 --- a/pkg/registry/apis/preferences/legacy/sql.go +++ b/pkg/registry/apis/preferences/legacy/sql.go @@ -12,20 +12,10 @@ import ( preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" pref "github.com/grafana/grafana/pkg/services/preference" - "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) -type dashboardStars struct { - OrgID int64 - UserUID string - First int64 - Last int64 - - Dashboards []string -} - type preferenceModel struct { ID int64 OrgID int64 @@ -49,177 +39,6 @@ func NewLegacySQL(db legacysql.LegacyDatabaseProvider) *LegacySQL { return &LegacySQL{db: db, startup: time.Now()} } -// NOTE: this does not support paging -- lets check if that will be a problem in cloud -func (s *LegacySQL) getDashboardStars(ctx context.Context, orgId int64, user string) ([]dashboardStars, int64, error) { - var max sql.NullString - sql, err := s.db(ctx) - if err != nil { - return nil, 0, err - } - - req := newStarQueryReq(sql, user, orgId) - - q, err := sqltemplate.Execute(sqlDashboardStarsQuery, req) - if err != nil { - return nil, 0, fmt.Errorf("execute template %q: %w", sqlDashboardStarsQuery.Name(), err) - } - - sess := sql.DB.GetSqlxSession() - rows, err := sess.Query(ctx, q, req.GetArgs()...) - if err != nil { - return nil, 0, err - } - defer func() { - if rows != nil { - _ = rows.Close() - } - }() - - stars := []dashboardStars{} - current := &dashboardStars{} - var orgID int64 - var userUID string - var dashboardUID string - var updated time.Time - - for rows.Next() { - err := rows.Scan(&orgID, &userUID, &dashboardUID, &updated) - if err != nil { - return nil, 0, err - } - - if orgID != current.OrgID || userUID != current.UserUID { - if current.UserUID != "" { - stars = append(stars, *current) - } - current = &dashboardStars{ - OrgID: orgID, - UserUID: userUID, - } - } - ts := updated.UnixMilli() - if ts > current.Last { - current.Last = ts - } - if ts < current.First || current.First == 0 { - current.First = ts - } - current.Dashboards = append(current.Dashboards, dashboardUID) - } - - // Add the last value - if current.UserUID != "" { - stars = append(stars, *current) - } - - // Find the RV unless it is a user query - if userUID == "" { - req.Reset() - q, err = sqltemplate.Execute(sqlDashboardStarsRV, req) - if err != nil { - return nil, 0, fmt.Errorf("execute template %q: %w", sqlPreferencesRV.Name(), err) - } - err = sess.Get(ctx, &max, q) - if err != nil { - return nil, 0, fmt.Errorf("unable to get RV %w", err) - } - if max.Valid && max.String != "" { - t, _ := time.Parse(time.RFC3339, max.String) - if !t.IsZero() { - updated = t - } - } else { - updated = s.startup - } - } - - return stars, updated.UnixMilli(), err -} - -func (s *LegacySQL) getHistoryStars(ctx context.Context, orgId int64, user string) (map[string][]string, error) { - sql, err := s.db(ctx) - if err != nil { - return nil, err - } - req := newStarQueryReq(sql, user, orgId) - - q, err := sqltemplate.Execute(sqlHistoryStarsQuery, req) - if err != nil { - return nil, fmt.Errorf("execute template %q: %w", sqlHistoryStarsQuery.Name(), err) - } - - sess := sql.DB.GetSqlxSession() - rows, err := sess.Query(ctx, q, req.GetArgs()...) - if err != nil { - return nil, err - } - defer func() { - if rows != nil { - _ = rows.Close() - } - }() - - last := user - res := make(map[string][]string) - buffer := make([]string, 0, 10) - var uid string - - for rows.Next() { - err := rows.Scan(&uid, &user) - if err != nil { - return nil, err - } - if user != last && len(buffer) > 0 { - res[last] = buffer - buffer = make([]string, 0, 10) - } - buffer = append(buffer, uid) - last = user - } - res[last] = buffer - return res, nil -} - -func (s *LegacySQL) removeHistoryStar(ctx context.Context, user *user.User, stars []string) error { - sql, err := s.db(ctx) - if err != nil { - return err - } - req := newStarQueryReq(sql, "", user.OrgID) - req.UserID = user.ID - if len(stars) > 0 { - req.QueryUIDs = stars - } - - q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req) - if err != nil { - return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err) - } - - sess := sql.DB.GetSqlxSession() - _, err = sess.Exec(ctx, q, req.GetArgs()...) - return err -} - -func (s *LegacySQL) addHistoryStar(ctx context.Context, user *user.User, star string) error { - sql, err := s.db(ctx) - if err != nil { - return err - } - req := newStarQueryReq(sql, "", user.OrgID) - req.UserID = user.ID - req.QueryUID = star - - q, err := sqltemplate.Execute(sqlHistoryStarsDelete, req) - if err != nil { - return fmt.Errorf("execute template %q: %w", sqlHistoryStarsDelete.Name(), err) - } - - sess := sql.DB.GetSqlxSession() - _, err = sess.Exec(ctx, q, req.GetArgs()...) - return err -} - // List all defined preferences in an org (valid for admin users only) func (s *LegacySQL) listPreferences(ctx context.Context, ns string, orgId int64, diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars.sql deleted file mode 100644 index 93f59103707..00000000000 --- a/pkg/registry/apis/preferences/legacy/sql_history_stars.sql +++ /dev/null @@ -1,9 +0,0 @@ -SELECT s.query_uid, u.uid as user_uid - FROM {{ .Ident .QueryHistoryStarsTable }} as s - JOIN {{ .Ident .QueryHistoryTable }} as h ON s.query_uid = h.uid - JOIN {{ .Ident .UserTable }} as u ON s.user_id = u.id - WHERE s.org_id = {{ .Arg .OrgID }} - {{ if .UserUID }} - AND u.uid = {{ .Arg .UserUID }} -{{ end }} - ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql deleted file mode 100644 index 4804740a42f..00000000000 --- a/pkg/registry/apis/preferences/legacy/sql_history_stars_delete.sql +++ /dev/null @@ -1,6 +0,0 @@ -DELETE FROM {{ .Ident .QueryHistoryStarsTable }} - WHERE org_id = {{ .Arg .OrgID }} - AND user_id = {{ .Arg .UserID }} - {{ if .QueryUIDs }} - AND query_uid IN ({{ .ArgList .QueryUIDs }}) -{{ end }} \ No newline at end of file diff --git a/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql b/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql deleted file mode 100644 index a85d14f2ed9..00000000000 --- a/pkg/registry/apis/preferences/legacy/sql_history_stars_insert.sql +++ /dev/null @@ -1,4 +0,0 @@ -INSERT INTO {{ .Ident .QueryHistoryStarsTable }} - ( query_uid, user_id, org_id ) -VALUES - ( {{ .Arg .QueryUID }}, {{ .Arg .UserID }}, {{ .Arg .OrgID }} ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql deleted file mode 100755 index e651c6bc37c..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars-org.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT s.query_uid, u.uid as user_uid - FROM `grafana`.`query_history_star` as s - JOIN `grafana`.`query_history` as h ON s.query_uid = h.uid - JOIN `grafana`.`user` as u ON s.user_id = u.id - WHERE s.org_id = 1 - ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql deleted file mode 100755 index 0c18ffdc0a8..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_delete-remove all star.sql +++ /dev/null @@ -1,3 +0,0 @@ -DELETE FROM `grafana`.`query_history_star` - WHERE org_id = 1 - AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql deleted file mode 100755 index 5a050187451..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/mysql--sql_history_stars_insert-add star.sql +++ /dev/null @@ -1,4 +0,0 @@ -INSERT INTO `grafana`.`query_history_star` - ( query_uid, user_id, org_id ) -VALUES - ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql deleted file mode 100755 index d0fe6688c97..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars-org.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT s.query_uid, u.uid as user_uid - FROM "grafana"."query_history_star" as s - JOIN "grafana"."query_history" as h ON s.query_uid = h.uid - JOIN "grafana"."user" as u ON s.user_id = u.id - WHERE s.org_id = 1 - ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql deleted file mode 100755 index 0b31729f72e..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_delete-remove all star.sql +++ /dev/null @@ -1,3 +0,0 @@ -DELETE FROM "grafana"."query_history_star" - WHERE org_id = 1 - AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql deleted file mode 100755 index d4729521f0d..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/postgres--sql_history_stars_insert-add star.sql +++ /dev/null @@ -1,4 +0,0 @@ -INSERT INTO "grafana"."query_history_star" - ( query_uid, user_id, org_id ) -VALUES - ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql deleted file mode 100755 index d0fe6688c97..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars-org.sql +++ /dev/null @@ -1,6 +0,0 @@ -SELECT s.query_uid, u.uid as user_uid - FROM "grafana"."query_history_star" as s - JOIN "grafana"."query_history" as h ON s.query_uid = h.uid - JOIN "grafana"."user" as u ON s.user_id = u.id - WHERE s.org_id = 1 - ORDER BY s.org_id asc, s.user_id asc, s.query_uid asc diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql deleted file mode 100755 index 0b31729f72e..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_delete-remove all star.sql +++ /dev/null @@ -1,3 +0,0 @@ -DELETE FROM "grafana"."query_history_star" - WHERE org_id = 1 - AND user_id = 3 diff --git a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql b/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql deleted file mode 100755 index d4729521f0d..00000000000 --- a/pkg/registry/apis/preferences/legacy/testdata/sqlite--sql_history_stars_insert-add star.sql +++ /dev/null @@ -1,4 +0,0 @@ -INSERT INTO "grafana"."query_history_star" - ( query_uid, user_id, org_id ) -VALUES - ( 'XXX', 3, 1 ) diff --git a/pkg/registry/apis/preferences/register.go b/pkg/registry/apis/preferences/register.go index a33cbed4b54..8964b0e0cd7 100644 --- a/pkg/registry/apis/preferences/register.go +++ b/pkg/registry/apis/preferences/register.go @@ -1,8 +1,6 @@ package preferences import ( - "fmt" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" @@ -10,12 +8,9 @@ import ( "k8s.io/apiserver/pkg/registry/rest" genericapiserver "k8s.io/apiserver/pkg/server" "k8s.io/kube-openapi/pkg/common" - "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" - grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" - grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/registry/apis/preferences/legacy" "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" @@ -23,20 +18,17 @@ import ( "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/featuremgmt" pref "github.com/grafana/grafana/pkg/services/preference" - "github.com/grafana/grafana/pkg/services/star" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" ) var ( - _ builder.APIGroupBuilder = (*APIBuilder)(nil) - _ builder.APIGroupMutation = (*APIBuilder)(nil) + _ builder.APIGroupBuilder = (*APIBuilder)(nil) ) type APIBuilder struct { authorizer authorizer.Authorizer - legacyStars *legacy.DashboardStarsStorage legacyPrefs rest.Storage merger *merger // joins all preferences @@ -47,7 +39,6 @@ func RegisterAPIService( features featuremgmt.FeatureToggles, db db.DB, prefs pref.Service, - stars star.Service, users user.Service, apiregistration builder.APIRegistrar, ) *APIBuilder { @@ -60,11 +51,10 @@ func RegisterAPIService( sql := legacy.NewLegacySQL(legacysql.NewDatabaseProvider(db)) builder := &APIBuilder{ merger: newMerger(cfg, sql), - authorizer: &authorizeFromName{ - oknames: []string{"merged"}, - teams: sql, // should be from the IAM service - resource: map[string][]utils.ResourceOwner{ - "stars": {utils.UserResourceOwner}, + authorizer: &utils.AuthorizeFromName{ + OKNames: []string{"merged"}, + Teams: sql, // should be from the IAM service + Resource: map[string][]utils.ResourceOwner{ "preferences": { utils.NamespaceResourceOwner, utils.TeamResourceOwner, @@ -78,10 +68,6 @@ func RegisterAPIService( if prefs != nil { builder.legacyPrefs = legacy.NewPreferencesStorage(prefs, namespacer, sql) } - if stars != nil { - builder.legacyStars = legacy.NewDashboardStarsStorage(stars, users, namespacer, sql) - } - apiregistration.RegisterAPI(builder) return builder } @@ -109,24 +95,6 @@ func (b *APIBuilder) InstallSchema(scheme *runtime.Scheme) error { func (b *APIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} - // Configure Stars Dual writer - resource := preferences.StarsResourceInfo - var stars grafanarest.Storage - stars, err := grafanaregistry.NewRegistryStore(opts.Scheme, resource, opts.OptsGetter) - if err != nil { - return err - } - stars = &starStorage{Storage: stars} // wrap List so we only return one value - if b.legacyStars != nil && opts.DualWriteBuilder != nil { - stars, err = opts.DualWriteBuilder(resource.GroupResource(), b.legacyStars, stars) - if err != nil { - return err - } - } - storage[resource.StoragePath()] = stars - storage[resource.StoragePath("update")] = &starsREST{store: stars} - - // Configure Preferences prefs := preferences.PreferencesResourceInfo storage[prefs.StoragePath()] = b.legacyPrefs @@ -146,58 +114,3 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes { defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} }) return b.merger.GetAPIRoutes(defs) } - -func (b *APIBuilder) PostProcessOpenAPI(oas *spec3.OpenAPI) (*spec3.OpenAPI, error) { - oas.Info.Description = "Grafana preferences" - - root := "/apis/" + b.GetGroupVersion().String() + "/" - updateKey := root + "namespaces/{namespace}/stars/{name}/update" - delete(oas.Paths.Paths, updateKey) - - // Add the group/kind/id properties to the path - stars, ok := oas.Paths.Paths[updateKey+"/{path}"] - if !ok || stars == nil { - return nil, fmt.Errorf("unable to find write path") - } - stars.Parameters = []*spec3.Parameter{ - stars.Parameters[0], // name - stars.Parameters[1], // namespace - { - ParameterProps: spec3.ParameterProps{ - Name: "group", - In: "path", - Example: "dashboard.grafana.app", - Description: "API group for stared item", - Schema: spec.StringProperty(), - Required: true, - }, - }, { - ParameterProps: spec3.ParameterProps{ - Name: "kind", - In: "path", - Example: "Dashboard", - Description: "Kind for stared item", - Schema: spec.StringProperty(), - Required: true, - }, - }, { - ParameterProps: spec3.ParameterProps{ - Name: "id", - In: "path", - Example: "", - Description: "The k8s name for the selected item", - Schema: spec.StringProperty(), - Required: true, - }, - }, - } - stars.Put.Description = "Add a starred item" - stars.Put.OperationId = "addStar" - stars.Delete.Description = "Remove a starred item" - stars.Delete.OperationId = "removeStar" - - delete(oas.Paths.Paths, updateKey+"/{path}") - oas.Paths.Paths[updateKey+"/{group}/{kind}/{id}"] = stars - - return oas, nil -} diff --git a/pkg/registry/apis/preferences/authorizer.go b/pkg/registry/apis/preferences/utils/authorizer.go similarity index 78% rename from pkg/registry/apis/preferences/authorizer.go rename to pkg/registry/apis/preferences/utils/authorizer.go index 8eae3c4248f..a73e6e2c2ea 100644 --- a/pkg/registry/apis/preferences/authorizer.go +++ b/pkg/registry/apis/preferences/utils/authorizer.go @@ -1,4 +1,4 @@ -package preferences +package utils import ( "context" @@ -9,16 +9,15 @@ import ( "github.com/grafana/authlib/authz" "github.com/grafana/grafana-app-sdk/logging" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" ) -type authorizeFromName struct { - teams utils.TeamService - oknames []string - resource map[string][]utils.ResourceOwner // may include unknown +type AuthorizeFromName struct { + Teams TeamService + OKNames []string + Resource map[string][]ResourceOwner // may include unknown } -func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { +func (a *AuthorizeFromName) Authorize(ctx context.Context, attr authorizer.Attributes) (authorizer.Decision, string, error) { user, err := identity.GetRequester(ctx) if err != nil || user == nil { return authorizer.DecisionDeny, "valid user is required", err @@ -28,7 +27,7 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri return authorizer.DecisionNoOpinion, "", nil } - owners, ok := a.resource[attr.GetResource()] + owners, ok := a.Resource[attr.GetResource()] if !ok { return authorizer.DecisionDeny, "missing resource name", nil } @@ -55,17 +54,17 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri } // the pseudo sub-resource - if a.oknames != nil && slices.Contains(a.oknames, attr.GetName()) { + if a.OKNames != nil && slices.Contains(a.OKNames, attr.GetName()) { return authorizer.DecisionAllow, "", nil } - info, _ := utils.ParseOwnerFromName(attr.GetName()) + info, _ := ParseOwnerFromName(attr.GetName()) if !slices.Contains(owners, info.Owner) { return authorizer.DecisionDeny, "unsupported owner type", nil } switch info.Owner { - case utils.NamespaceResourceOwner: + case NamespaceResourceOwner: if attr.IsReadOnly() { // Everyone can see the namespace return authorizer.DecisionAllow, "", nil @@ -75,17 +74,17 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri } return authorizer.DecisionDeny, "must be an org admin to edit", nil - case utils.UserResourceOwner: + case UserResourceOwner: if user.GetIdentifier() == info.Identifier { return authorizer.DecisionAllow, "", nil } return authorizer.DecisionDeny, "your are not the owner of the resource", nil - case utils.TeamResourceOwner: - if a.teams == nil { + case TeamResourceOwner: + if a.Teams == nil { return authorizer.DecisionDeny, "team checker not configured", err } - ok, err := a.teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly()) + ok, err := a.Teams.InTeam(ctx, user, info.Identifier, !attr.IsReadOnly()) if err != nil { return authorizer.DecisionDeny, "error fetching teams", err } @@ -94,7 +93,7 @@ func (a *authorizeFromName) Authorize(ctx context.Context, attr authorizer.Attri } return authorizer.DecisionDeny, "you are not a member of the referenced team", nil - case utils.UnknownResourceOwner: + case UnknownResourceOwner: return authorizer.DecisionAllow, "", nil } diff --git a/pkg/registry/apis/preferences/authorizer_test.go b/pkg/registry/apis/preferences/utils/authorizer_test.go similarity index 90% rename from pkg/registry/apis/preferences/authorizer_test.go rename to pkg/registry/apis/preferences/utils/authorizer_test.go index 2b060bf6712..a1d10c24039 100644 --- a/pkg/registry/apis/preferences/authorizer_test.go +++ b/pkg/registry/apis/preferences/utils/authorizer_test.go @@ -1,4 +1,4 @@ -package preferences +package utils import ( "context" @@ -11,7 +11,6 @@ import ( "github.com/grafana/authlib/authn" "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/registry/apis/preferences/utils" ) type expect struct { @@ -41,14 +40,14 @@ func TestAuthorizer_Authorize(t *testing.T) { tests := []struct { name string - teams func(t *testing.T) utils.TeamService - resource map[string][]utils.ResourceOwner + teams func(t *testing.T) TeamService + resource map[string][]ResourceOwner check []testCase }{ { name: "stars", - resource: map[string][]utils.ResourceOwner{ - "stars": {utils.UserResourceOwner}, + resource: map[string][]ResourceOwner{ + "stars": {UserResourceOwner}, }, check: []testCase{{ name: "matches user", @@ -80,9 +79,9 @@ func TestAuthorizer_Authorize(t *testing.T) { }}, }, { name: "fast path", - resource: map[string][]utils.ResourceOwner{ - "stars": {utils.UserResourceOwner}, - "preferences": {utils.TeamResourceOwner}, + resource: map[string][]ResourceOwner{ + "stars": {UserResourceOwner}, + "preferences": {TeamResourceOwner}, }, check: []testCase{{ name: "missing user", @@ -185,8 +184,8 @@ func TestAuthorizer_Authorize(t *testing.T) { }}, }, { name: "unknown owner", - resource: map[string][]utils.ResourceOwner{ - "stars": {utils.UnknownResourceOwner}, + resource: map[string][]ResourceOwner{ + "stars": {UnknownResourceOwner}, }, check: []testCase{{ name: "get", @@ -204,8 +203,8 @@ func TestAuthorizer_Authorize(t *testing.T) { }}, }, { name: "namespace", - resource: map[string][]utils.ResourceOwner{ - "ns": {utils.NamespaceResourceOwner}, + resource: map[string][]ResourceOwner{ + "ns": {NamespaceResourceOwner}, }, check: []testCase{{ name: "readonly", @@ -258,16 +257,16 @@ func TestAuthorizer_Authorize(t *testing.T) { }}, }, { name: "preferences teams", - teams: func(t *testing.T) utils.TeamService { - teams := utils.NewMockTeamService(t) + teams: func(t *testing.T) TeamService { + teams := NewMockTeamService(t) teams.On("InTeam", mock.Anything, userABC, "xyz", false).Return(true, nil) teams.On("InTeam", mock.Anything, userABC, "456", false).Return(false, nil) teams.On("InTeam", mock.Anything, userABC, "XXX", false).Return(true, fmt.Errorf("error from team")) return teams }, - resource: map[string][]utils.ResourceOwner{ + resource: map[string][]ResourceOwner{ "preferences": { - utils.TeamResourceOwner, + TeamResourceOwner, }, }, check: []testCase{{ @@ -318,11 +317,11 @@ func TestAuthorizer_Authorize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - authz := &authorizeFromName{ - resource: tt.resource, + authz := &AuthorizeFromName{ + Resource: tt.resource, } if tt.teams != nil { - authz.teams = tt.teams(t) + authz.Teams = tt.teams(t) } for _, check := range tt.check { t.Run(check.name, func(t *testing.T) { diff --git a/pkg/registry/apis/wireset.go b/pkg/registry/apis/wireset.go index 58e22810c7b..5d76f64dfcd 100644 --- a/pkg/registry/apis/wireset.go +++ b/pkg/registry/apis/wireset.go @@ -3,6 +3,7 @@ package apiregistry import ( "github.com/google/wire" + "github.com/grafana/grafana/pkg/registry/apis/collections" dashboardinternal "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" "github.com/grafana/grafana/pkg/registry/apis/datasource" @@ -63,6 +64,7 @@ var WireSet = wire.NewSet( service.RegisterAPIService, query.RegisterAPIService, preferences.RegisterAPIService, + collections.RegisterAPIService, userstorage.RegisterAPIService, ofrep.RegisterAPIService, ) diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 6565d0e9062..02b1db115b9 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -48,6 +48,7 @@ import ( "github.com/grafana/grafana/pkg/plugins/pluginscdn" "github.com/grafana/grafana/pkg/plugins/repo" "github.com/grafana/grafana/pkg/registry/apis" + "github.com/grafana/grafana/pkg/registry/apis/collections" "github.com/grafana/grafana/pkg/registry/apis/dashboard" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/registry/apis/dashboardsnapshot" @@ -866,7 +867,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) - apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, starService, userService, apiserverService) + apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, userService, apiserverService) + collectionsAPIBuilder := collections.RegisterAPIService(cfg, featureToggles, sqlStore, starService, userService, apiserverService) legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) webhookExtraBuilder := webhooks.ProvideWebhooksWithImages(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) v3 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) @@ -900,7 +902,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, collectionsAPIBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err @@ -1507,7 +1509,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) - apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, starService, userService, apiserverService) + apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, userService, apiserverService) + collectionsAPIBuilder := collections.RegisterAPIService(cfg, featureToggles, sqlStore, starService, userService, apiserverService) legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) webhookExtraBuilder := webhooks.ProvideWebhooksWithImages(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) v3 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) @@ -1541,7 +1544,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) + apiregistryService := apiregistry.ProvideRegistryServiceSink(dashboardsAPIBuilder, snapshotsAPIBuilder, dataSourceAPIBuilder, folderAPIBuilder, identityAccessManagementAPIBuilder, queryAPIBuilder, userStorageAPIBuilder, apiBuilder, collectionsAPIBuilder, provisioningAPIBuilder, ofrepAPIBuilder, dependencyRegisterer, provisioningDependencyRegisterer) teamPermissionsService, err := ossaccesscontrol.ProvideTeamPermissions(cfg, featureToggles, routeRegisterImpl, sqlStore, accessControl, ossLicensingService, acimplService, teamService, userService, actionSetService) if err != nil { return nil, err diff --git a/pkg/services/queryhistory/api.go b/pkg/services/queryhistory/api.go index 0d26718170e..797f0aaabc9 100644 --- a/pkg/services/queryhistory/api.go +++ b/pkg/services/queryhistory/api.go @@ -168,16 +168,6 @@ func (s *QueryHistoryService) starHandler(c *contextmodel.ReqContext) response.R if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } - if s.k8sClients != nil { - if err := s.k8sClients.AddStar(c, queryUID); err != nil { - return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) - } - return response.JSON(http.StatusOK, QueryHistoryResponse{ - Result: QueryHistoryDTO{ - UID: queryUID, - Starred: true, - }}) - } query, err := s.StarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) if err != nil { @@ -202,17 +192,6 @@ func (s *QueryHistoryService) unstarHandler(c *contextmodel.ReqContext) response if len(queryUID) > 0 && !util.IsValidShortUID(queryUID) { return response.Error(http.StatusNotFound, "Query in query history not found", nil) } - if s.k8sClients != nil { - if err := s.k8sClients.RemoveStar(c, queryUID); err != nil { - return response.Error(http.StatusInternalServerError, "Failed to star query in query history", err) - } - return response.JSON(http.StatusOK, QueryHistoryResponse{ - Result: QueryHistoryDTO{ - UID: queryUID, - Starred: true, - }}) - } - query, err := s.UnstarQueryInQueryHistory(c.Req.Context(), c.SignedInUser, queryUID) if err != nil { return response.Error(http.StatusInternalServerError, "Failed to unstar query in query history", err) diff --git a/pkg/services/queryhistory/client.go b/pkg/services/queryhistory/client.go deleted file mode 100644 index a134ef9e186..00000000000 --- a/pkg/services/queryhistory/client.go +++ /dev/null @@ -1,103 +0,0 @@ -package queryhistory - -import ( - v1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" - "k8s.io/client-go/dynamic" - "k8s.io/client-go/kubernetes" - - authlib "github.com/grafana/authlib/types" - preferencesV1 "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/services/apiserver" - contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" -) - -type k8sClients struct { - namespacer authlib.NamespaceFormatter - configProvider apiserver.DirectRestConfigProvider -} - -// GetStars implements K8sClients. -func (k *k8sClients) GetStars(c *contextmodel.ReqContext) ([]string, error) { - dyn, err := dynamic.NewForConfig(k.configProvider.GetDirectRestConfig(c)) - if err != nil { - return nil, err - } - client := dyn.Resource(preferencesV1.StarsResourceInfo.GroupVersionResource()).Namespace(k.namespacer(c.OrgID)) - - ctx := c.Req.Context() - user, err := identity.GetRequester(ctx) - if err != nil { - return nil, err - } - - obj, _ := client.Get(ctx, "user-"+user.GetIdentifier(), v1.GetOptions{}) - if obj != nil { - resources, ok, _ := unstructured.NestedSlice(obj.Object, "spec", "resource") - if ok && resources != nil { - for _, r := range resources { - tmp, ok := r.(map[string]any) - if ok { - g, _, _ := unstructured.NestedString(tmp, "group") - k, _, _ := unstructured.NestedString(tmp, "kind") - if k == "Query" && g == "history.grafana.app" { - names, _, _ := unstructured.NestedStringSlice(tmp, "names") - return names, nil - } - } - } - } - } - return []string{}, nil -} - -// AddStar implements K8sClients. -func (k *k8sClients) AddStar(c *contextmodel.ReqContext, uid string) error { - dyn, err := kubernetes.NewForConfig(k.configProvider.GetDirectRestConfig(c)) - if err != nil { - return err - } - - ctx := c.Req.Context() - user, err := identity.GetRequester(ctx) - if err != nil { - return err - } - - ns := k.namespacer(c.OrgID) - - client := dyn.RESTClient() - rsp := client.Put().AbsPath( - "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, - "stars", "user-"+user.GetIdentifier(), - "update", "history.grafana.app", "Query", uid, - ).Do(ctx) - - return rsp.Error() -} - -// RemoveStar implements K8sClients. -func (k *k8sClients) RemoveStar(c *contextmodel.ReqContext, uid string) error { - dyn, err := kubernetes.NewForConfig(k.configProvider.GetDirectRestConfig(c)) - if err != nil { - return err - } - - ctx := c.Req.Context() - user, err := identity.GetRequester(ctx) - if err != nil { - return err - } - - ns := k.namespacer(c.OrgID) - - client := dyn.RESTClient() - rsp := client.Delete().AbsPath( - "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, - "stars", "user-"+user.GetIdentifier(), - "update", "history.grafana.app", "Query", uid, - ).Do(ctx) - - return rsp.Error() -} diff --git a/pkg/services/queryhistory/queryhistory.go b/pkg/services/queryhistory/queryhistory.go index b2ddf5b5cd5..90979025d5b 100644 --- a/pkg/services/queryhistory/queryhistory.go +++ b/pkg/services/queryhistory/queryhistory.go @@ -9,7 +9,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" ac "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/apiserver" - "github.com/grafana/grafana/pkg/services/apiserver/endpoints/request" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -33,13 +32,6 @@ func ProvideService(cfg *setting.Cfg, // Register routes only when query history is enabled if s.Cfg.QueryHistoryEnabled { - //nolint:staticcheck // not yet migrated to OpenFeature - if features.IsEnabledGlobally(featuremgmt.FlagKubernetesStars) { - s.k8sClients = &k8sClients{ - namespacer: request.GetNamespaceMapper(s.Cfg), - configProvider: configProvider, - } - } s.registerAPIEndpoints() } @@ -64,7 +56,6 @@ type QueryHistoryService struct { log log.Logger now func() time.Time accessControl ac.AccessControl - k8sClients *k8sClients } func (s QueryHistoryService) CreateQueryInQueryHistory(ctx context.Context, user *user.SignedInUser, cmd CreateQueryInQueryHistoryCommand) (QueryHistoryDTO, error) { diff --git a/pkg/services/star/api/client.go b/pkg/services/star/api/client.go index 2f4cd238cc7..661455b02ef 100644 --- a/pkg/services/star/api/client.go +++ b/pkg/services/star/api/client.go @@ -9,8 +9,8 @@ import ( "k8s.io/client-go/kubernetes" authlib "github.com/grafana/authlib/types" + collectionsV1 "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" dashboardsV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - preferencesV1 "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -59,7 +59,7 @@ func (k *k8sClients) GetStars(c *contextmodel.ReqContext) ([]string, error) { if err != nil { return nil, err } - client := dyn.Resource(preferencesV1.StarsResourceInfo.GroupVersionResource()).Namespace(k.namespacer(c.OrgID)) + client := dyn.Resource(collectionsV1.StarsResourceInfo.GroupVersionResource()).Namespace(k.namespacer(c.OrgID)) ctx := c.Req.Context() user, err := identity.GetRequester(ctx) @@ -104,7 +104,7 @@ func (k *k8sClients) AddStar(c *contextmodel.ReqContext, uid string) error { client := dyn.RESTClient() rsp := client.Put().AbsPath( - "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, + "apis", collectionsV1.APIGroup, collectionsV1.APIVersion, "namespaces", ns, "stars", "user-"+user.GetIdentifier(), "update", dashboardsV1.APIGroup, dashboardsV1.DashboardKind().Kind(), uid, ).Do(ctx) @@ -129,7 +129,7 @@ func (k *k8sClients) RemoveStar(c *contextmodel.ReqContext, uid string) error { client := dyn.RESTClient() rsp := client.Delete().AbsPath( - "apis", preferencesV1.APIGroup, preferencesV1.APIVersion, "namespaces", ns, + "apis", collectionsV1.APIGroup, collectionsV1.APIVersion, "namespaces", ns, "stars", "user-"+user.GetIdentifier(), "update", dashboardsV1.APIGroup, dashboardsV1.DashboardKind().Kind(), uid, ).Do(ctx) diff --git a/pkg/tests/apis/preferences/stars_test.go b/pkg/tests/apis/collections/stars_test.go similarity index 78% rename from pkg/tests/apis/preferences/stars_test.go rename to pkg/tests/apis/collections/stars_test.go index f1050593926..fe841fb4851 100644 --- a/pkg/tests/apis/preferences/stars_test.go +++ b/pkg/tests/apis/collections/stars_test.go @@ -1,4 +1,4 @@ -package preferences +package collections import ( "context" @@ -11,17 +11,21 @@ import ( metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + collections "github.com/grafana/grafana/apps/collections/pkg/apis/collections/v1alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - preferences "github.com/grafana/grafana/apps/preferences/pkg/apis/preferences/v1alpha1" grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/queryhistory" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/tests/apis" "github.com/grafana/grafana/pkg/tests/testinfra" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/grafana/grafana/pkg/util/testutil" ) +func TestMain(m *testing.M) { + testsuite.Run(m) +} + func TestIntegrationStars(t *testing.T) { testutil.SkipIntegrationTestInShortMode(t) @@ -47,10 +51,10 @@ func TestIntegrationStars(t *testing.T) { "folders.folder.grafana.app": { DualWriterMode: mode, }, - "stars.preferences.grafana.app": { + "stars.collections.grafana.app": { DualWriterMode: mode, }, - "preferences.preferences.grafana.app": { + "collections.collections.grafana.app": { DualWriterMode: mode, }, }, @@ -60,28 +64,17 @@ func TestIntegrationStars(t *testing.T) { ctx := context.Background() starsClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, - GVR: preferences.StarsResourceInfo.GroupVersionResource(), + GVR: collections.StarsResourceInfo.GroupVersionResource(), }) starsClientViewer := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Viewer, - GVR: preferences.StarsResourceInfo.GroupVersionResource(), + GVR: collections.StarsResourceInfo.GroupVersionResource(), }) dashboardClient := helper.GetResourceClient(apis.ResourceClientArgs{ User: helper.Org1.Admin, GVR: dashboardV1.DashboardResourceInfo.GroupVersionResource(), }) - history := &queryhistory.QueryHistoryResponse{} - legacyHistoryResponse := apis.DoRequest(helper, apis.RequestParams{ - User: starsClient.Args.User, - Method: http.MethodPost, - Path: "/api/query-history", - Body: []byte(`{"dataSourceUid":"eez1ebbdn3pq8b","queries":[{"scenarioId":"random_walk","seriesCount":1,"refId":"A","datasource":{"type":"grafana-testdata-datasource","uid":"eez1ebbdn3pq8b","apiVersion":"v0alpha1"}}]}`), - }, &history) - require.Equal(t, http.StatusOK, legacyHistoryResponse.Response.StatusCode, "add query history") - queryHistoryStarUID := history.Result.UID - require.NotEmpty(t, queryHistoryStarUID, "expect a query history UID") - // Create 5 dashboards for i := range 5 { _, err := dashboardClient.Resource.Create(context.Background(), &unstructured.Unstructured{ @@ -126,7 +119,7 @@ func TestIntegrationStars(t *testing.T) { // List values and compare results rsp, err = starsClient.Resource.List(ctx, metav1.ListOptions{}) require.NoError(t, err) - stars := typed(t, rsp, &preferences.StarsList{}) + stars := typed(t, rsp, &collections.StarsList{}) require.Len(t, stars.Items, 1, "user stars should exist") require.Equal(t, "user-"+starsClient.Args.User.Identity.GetIdentifier(), @@ -148,7 +141,7 @@ func TestIntegrationStars(t *testing.T) { rspObj, err := starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) require.NoError(t, err) - after := typed(t, rspObj, &preferences.Stars{}) + after := typed(t, rspObj, &collections.Stars{}) resources = after.Spec.Resource require.Len(t, resources, 1) require.Equal(t, "dashboard.grafana.app", resources[0].Group) @@ -175,7 +168,7 @@ func TestIntegrationStars(t *testing.T) { }, metav1.UpdateOptions{}) require.NoError(t, err) - after = typed(t, rspObj, &preferences.Stars{}) + after = typed(t, rspObj, &collections.Stars{}) resources = after.Spec.Resource require.Len(t, resources, 1) require.Equal(t, "dashboard.grafana.app", resources[0].Group) @@ -184,19 +177,10 @@ func TestIntegrationStars(t *testing.T) { []string{"aaa", "bbb", "test-2"}, // NOTE 2 stays, 3 removed, added aaa+bbb (and sorted!) resources[0].Names) - // Query history stars - legacyHistoryResponse = apis.DoRequest(helper, apis.RequestParams{ - User: starsClient.Args.User, - Method: http.MethodPost, - Path: "/api/query-history/star/" + queryHistoryStarUID, - }, &history) - require.Equal(t, http.StatusOK, legacyHistoryResponse.Response.StatusCode, "add query history") - require.True(t, history.Result.Starred, "expect the value to be starred") - rspObj, err = starsClient.Resource.Get(ctx, "user-"+starsClient.Args.User.Identity.GetIdentifier(), metav1.GetOptions{}) require.NoError(t, err) - after = typed(t, rspObj, &preferences.Stars{}) + after = typed(t, rspObj, &collections.Stars{}) jj, err := json.MarshalIndent(after.Spec, "", " ") require.NoError(t, err) require.JSONEq(t, `{ @@ -209,13 +193,6 @@ func TestIntegrationStars(t *testing.T) { "bbb", "test-2" ] - }, - { - "group": "history.grafana.app", - "kind": "Query", - "names": [ - "`+queryHistoryStarUID+`" - ] } ] }`, string(jj)) diff --git a/pkg/tests/apis/openapi_snapshots/collections.grafana.app-v1alpha1.json b/pkg/tests/apis/openapi_snapshots/collections.grafana.app-v1alpha1.json new file mode 100644 index 00000000000..8f399c50f15 --- /dev/null +++ b/pkg/tests/apis/openapi_snapshots/collections.grafana.app-v1alpha1.json @@ -0,0 +1,1615 @@ +{ + "openapi": "3.0.0", + "info": { + "description": "Grafana collections", + "title": "collections.grafana.app/v1alpha1" + }, + "paths": { + "/apis/collections.grafana.app/v1alpha1/": { + "get": { + "tags": [ + "API Discovery" + ], + "description": "Describe the available kubernetes resources", + "operationId": "getAPIResources", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList" + } + } + } + } + } + } + }, + "/apis/collections.grafana.app/v1alpha1/namespaces/{namespace}/stars": { + "get": { + "tags": [ + "Stars" + ], + "description": "list objects of kind Stars", + "operationId": "listStars", + "parameters": [ + { + "name": "allowWatchBookmarks", + "in": "query", + "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "watch", + "in": "query", + "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsList" + } + }, + "application/json;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsList" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsList" + } + }, + "application/vnd.kubernetes.protobuf;stream=watch": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsList" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsList" + } + } + } + } + }, + "x-kubernetes-action": "list", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "post": { + "tags": [ + "Stars" + ], + "description": "create Stars", + "operationId": "createStars", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + } + }, + "x-kubernetes-action": "post", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "delete": { + "tags": [ + "Stars" + ], + "description": "delete collection of Stars", + "operationId": "deletecollectionStars", + "parameters": [ + { + "name": "continue", + "in": "query", + "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "labelSelector", + "in": "query", + "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "limit", + "in": "query", + "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersion", + "in": "query", + "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "resourceVersionMatch", + "in": "query", + "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "sendInitialEvents", + "in": "query", + "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "timeoutSeconds", + "in": "query", + "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", + "schema": { + "type": "integer", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "deletecollection", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/collections.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}": { + "get": { + "tags": [ + "Stars" + ], + "description": "read the specified Stars", + "operationId": "getStars", + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + } + }, + "x-kubernetes-action": "get", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "put": { + "tags": [ + "Stars" + ], + "description": "replace the specified Stars", + "operationId": "replaceStars", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + } + }, + "x-kubernetes-action": "put", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "delete": { + "tags": [ + "Stars" + ], + "description": "delete Stars", + "operationId": "deleteStars", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "gracePeriodSeconds", + "in": "query", + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "schema": { + "type": "integer", + "uniqueItems": true + } + }, + { + "name": "ignoreStoreReadErrorWithClusterBreakingPotential", + "in": "query", + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "orphanDependents", + "in": "query", + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + }, + { + "name": "propagationPolicy", + "in": "query", + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ], + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + }, + "202": { + "description": "Accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" + } + } + } + } + }, + "x-kubernetes-action": "delete", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "patch": { + "tags": [ + "Stars" + ], + "description": "partially update the specified Stars", + "operationId": "updateStars", + "parameters": [ + { + "name": "dryRun", + "in": "query", + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldManager", + "in": "query", + "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "fieldValidation", + "in": "query", + "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "force", + "in": "query", + "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", + "schema": { + "type": "boolean", + "uniqueItems": true + } + } + ], + "requestBody": { + "content": { + "application/apply-patch+yaml": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/json-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + }, + "application/strategic-merge-patch+json": { + "schema": { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "OK", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + }, + "201": { + "description": "Created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/vnd.kubernetes.protobuf": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + }, + "application/yaml": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + } + }, + "x-kubernetes-action": "patch", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the Stars", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "pretty", + "in": "query", + "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", + "schema": { + "type": "string", + "uniqueItems": true + } + } + ] + }, + "/apis/collections.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}/update/{group}/{kind}/{id}": { + "put": { + "tags": [ + "Stars" + ], + "description": "Add a starred item", + "operationId": "addStar", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "delete": { + "tags": [ + "Stars" + ], + "description": "Remove a starred item", + "operationId": "removeStar", + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + } + } + } + }, + "x-kubernetes-action": "connect", + "x-kubernetes-group-version-kind": { + "group": "collections.grafana.app", + "version": "v1alpha1", + "kind": "Stars" + } + }, + "parameters": [ + { + "name": "name", + "in": "path", + "description": "name of the Stars", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "namespace", + "in": "path", + "description": "object name and auth scope, such as for teams and projects", + "required": true, + "schema": { + "type": "string", + "uniqueItems": true + } + }, + { + "name": "group", + "in": "path", + "description": "API group for stared item", + "required": true, + "schema": { + "type": "string" + }, + "example": "dashboard.grafana.app" + }, + { + "name": "kind", + "in": "path", + "description": "Kind for stared item", + "required": true, + "schema": { + "type": "string" + }, + "example": "Dashboard" + }, + { + "name": "id", + "in": "path", + "description": "The k8s name for the selected item", + "required": true, + "schema": { + "type": "string" + } + } + ] + } + }, + "components": { + "schemas": { + "com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars": { + "type": "object", + "required": [ + "metadata", + "spec" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" + } + ] + }, + "spec": { + "description": "Spec is the spec of the Stars", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsSpec" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "collections.grafana.app", + "kind": "Stars", + "version": "v1alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsList": { + "type": "object", + "required": [ + "metadata", + "items" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "items": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.Stars" + } + ] + } + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "metadata": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + } + }, + "x-kubernetes-group-version-kind": [ + { + "group": "collections.grafana.app", + "kind": "StarsList", + "version": "v1alpha1" + } + ] + }, + "com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsResource": { + "type": "object", + "required": [ + "group", + "kind", + "names" + ], + "properties": { + "group": { + "type": "string", + "default": "" + }, + "kind": { + "type": "string", + "default": "" + }, + "names": { + "description": "The set of resources", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set" + } + } + }, + "com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsSpec": { + "type": "object", + "required": [ + "resource" + ], + "properties": { + "resource": { + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/com.github.grafana.grafana.apps.collections.pkg.apis.collections.v1alpha1.StarsResource" + } + ] + } + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { + "description": "APIResource specifies the name of a resource and whether it is namespaced.", + "type": "object", + "required": [ + "name", + "singularName", + "namespaced", + "kind", + "verbs" + ], + "properties": { + "categories": { + "description": "categories is a list of the grouped resources this resource belongs to (e.g. 'all')", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "group": { + "description": "group is the preferred group of the resource. Empty implies the group of the containing resource list. For subresources, this may have a different value, for example: Scale\".", + "type": "string" + }, + "kind": { + "description": "kind is the kind for the resource (e.g. 'Foo' is the kind for a resource 'foo')", + "type": "string", + "default": "" + }, + "name": { + "description": "name is the plural name of the resource.", + "type": "string", + "default": "" + }, + "namespaced": { + "description": "namespaced indicates if a resource is namespaced or not.", + "type": "boolean", + "default": false + }, + "shortNames": { + "description": "shortNames is a list of suggested short names of the resource.", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "singularName": { + "description": "singularName is the singular name of the resource. This allows clients to handle plural and singular opaquely. The singularName is more correct for reporting status on a single item and both singular and plural are allowed from the kubectl CLI interface.", + "type": "string", + "default": "" + }, + "storageVersionHash": { + "description": "The hash value of the storage version, the version this resource is converted to when written to the data store. Value must be treated as opaque by clients. Only equality comparison on the value is valid. This is an alpha feature and may change or be removed in the future. The field is populated by the apiserver only if the StorageVersionHash feature gate is enabled. This field will remain optional even if it graduates.", + "type": "string" + }, + "verbs": { + "description": "verbs is a list of supported kube verbs (this includes get, list, watch, create, update, patch, delete, deletecollection, and proxy)", + "type": "array", + "items": { + "type": "string", + "default": "" + } + }, + "version": { + "description": "version is the preferred version of the resource. Empty implies the version of the containing resource list For subresources, this may have a different value, for example: v1 (while inside a v1beta1 version of the core resource's group)\".", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.APIResourceList": { + "description": "APIResourceList is a list of APIResource, it is used to expose the name of the resources supported in a specific group and version, and if the resource is namespaced.", + "type": "object", + "required": [ + "groupVersion", + "resources" + ], + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "groupVersion": { + "description": "groupVersion is the group and version this APIResourceList is for.", + "type": "string", + "default": "" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "resources": { + "description": "resources contains the name of the resources and if they are namespaced.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.APIResource" + } + ] + }, + "x-kubernetes-list-type": "atomic" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.DeleteOptions": { + "description": "DeleteOptions may be provided when deleting an API object.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "dryRun": { + "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "atomic" + }, + "gracePeriodSeconds": { + "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", + "type": "integer", + "format": "int64" + }, + "ignoreStoreReadErrorWithClusterBreakingPotential": { + "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", + "type": "boolean" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "orphanDependents": { + "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", + "type": "boolean" + }, + "preconditions": { + "description": "Must be fulfilled before a deletion is carried out. If not possible, a 409 Conflict status will be returned.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Preconditions" + } + ] + }, + "propagationPolicy": { + "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1": { + "description": "FieldsV1 stores a set of fields in a data structure like a Trie, in JSON format.\n\nEach key is either a '.' representing the field itself, and will always map to an empty set, or a string representing a sub-field or item. The string will follow one of these four formats: 'f:\u003cname\u003e', where \u003cname\u003e is the name of a field in a struct, or key in a map 'v:\u003cvalue\u003e', where \u003cvalue\u003e is the exact json formatted value of a list item 'i:\u003cindex\u003e', where \u003cindex\u003e is position of a item in a list 'k:\u003ckeys\u003e', where \u003ckeys\u003e is a map of a list item's key fields to their unique values If a key maps to an empty Fields value, the field that key represents is part of the set.\n\nThe exact format is defined in sigs.k8s.io/structured-merge-diff", + "type": "object" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta": { + "description": "ListMeta describes metadata that synthetic resources must have, including lists and various status objects. A resource may have only one of {ObjectMeta, ListMeta}.", + "type": "object", + "properties": { + "continue": { + "description": "continue may be set if the user set a limit on the number of items returned, and indicates that the server has more data available. The value is opaque and may be used to issue another request to the endpoint that served this list to retrieve the next set of available objects. Continuing a consistent list may not be possible if the server configuration has changed or more than a few minutes have passed. The resourceVersion field returned when using this continue value will be identical to the value in the first response, unless you have received this token from an error message.", + "type": "string" + }, + "remainingItemCount": { + "description": "remainingItemCount is the number of subsequent items in the list which are not included in this list response. If the list request contained label or field selectors, then the number of remaining items is unknown and the field will be left unset and omitted during serialization. If the list is complete (either because it is not chunking or because this is the last chunk), then there are no more remaining items and this field will be left unset and omitted during serialization. Servers older than v1.15 do not set this field. The intended use of the remainingItemCount is *estimating* the size of a collection. Clients should not rely on the remainingItemCount to be set or to be exact.", + "type": "integer", + "format": "int64" + }, + "resourceVersion": { + "description": "String that identifies the server's internal version of this object that can be used by clients to determine when objects have changed. Value must be treated as opaque by clients and passed unmodified back to the server. Populated by the system. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", + "type": "string" + }, + "selfLink": { + "description": "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry": { + "description": "ManagedFieldsEntry is a workflow-id, a FieldSet and the group version of the resource that the fieldset applies to.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the version of this resource that this field set applies to. The format is \"group/version\" just like the top-level APIVersion field. It is necessary to track the version of a field set because it cannot be automatically converted.", + "type": "string" + }, + "fieldsType": { + "description": "FieldsType is the discriminator for the different fields format and version. There is currently only one possible value: \"FieldsV1\"", + "type": "string" + }, + "fieldsV1": { + "description": "FieldsV1 holds the first JSON version format as described in the \"FieldsV1\" type.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.FieldsV1" + } + ] + }, + "manager": { + "description": "Manager is an identifier of the workflow managing these fields.", + "type": "string" + }, + "operation": { + "description": "Operation is the type of operation which lead to this ManagedFieldsEntry being created. The only valid values for this field are 'Apply' and 'Update'.", + "type": "string" + }, + "subresource": { + "description": "Subresource is the name of the subresource used to update that object, or empty string if the object was updated through the main resource. The value of this field is used to distinguish between managers, even if they share the same name. For example, a status update will be distinct from a regular update using the same manager name. Note that the APIVersion field is not related to the Subresource field and it always corresponds to the version of the main resource.", + "type": "string" + }, + "time": { + "description": "Time is the timestamp of when the ManagedFields entry was added. The timestamp will also be updated if a field is added, the manager changes any of the owned fields value or removes a field. The timestamp does not update when a field is removed from the entry because another manager took it over.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta": { + "description": "ObjectMeta is metadata that all persisted resources must have, which includes all objects users must create.", + "type": "object", + "properties": { + "annotations": { + "description": "Annotations is an unstructured key value map stored with a resource that may be set by external tools to store and retrieve arbitrary metadata. They are not queryable and should be preserved when modifying objects. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations", + "type": "object", + "additionalProperties": { + "type": "string", + "default": "" + } + }, + "creationTimestamp": { + "description": "CreationTimestamp is a timestamp representing the server time when this object was created. It is not guaranteed to be set in happens-before order across separate operations. Clients may not set this value. It is represented in RFC3339 form and is in UTC.\n\nPopulated by the system. Read-only. Null for lists. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + }, + "deletionGracePeriodSeconds": { + "description": "Number of seconds allowed for this object to gracefully terminate before it will be removed from the system. Only set when deletionTimestamp is also set. May only be shortened. Read-only.", + "type": "integer", + "format": "int64" + }, + "deletionTimestamp": { + "description": "DeletionTimestamp is RFC 3339 date and time at which this resource will be deleted. This field is set by the server when a graceful deletion is requested by the user, and is not directly settable by a client. The resource is expected to be deleted (no longer visible from resource lists, and not reachable by name) after the time in this field, once the finalizers list is empty. As long as the finalizers list contains items, deletion is blocked. Once the deletionTimestamp is set, this value may not be unset or be set further into the future, although it may be shortened or the resource may be deleted prior to this time. For example, a user may request that a pod is deleted in 30 seconds. The Kubelet will react by sending a graceful termination signal to the containers in the pod. After that 30 seconds, the Kubelet will send a hard termination signal (SIGKILL) to the container and after cleanup, remove the pod from the API. In the presence of network partitions, this object may still exist after this timestamp, until an administrator or automated process can determine the resource is fully terminated. If not set, graceful deletion of the object has not been requested.\n\nPopulated by the system when a graceful deletion is requested. Read-only. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Time" + } + ] + }, + "finalizers": { + "description": "Must be empty before the object is deleted from the registry. Each entry is an identifier for the responsible component that will remove the entry from the list. If the deletionTimestamp of the object is non-nil, entries in this list can only be removed. Finalizers may be processed and removed in any order. Order is NOT enforced because it introduces significant risk of stuck finalizers. finalizers is a shared field, any actor with permission can reorder it. If the finalizer list is processed in order, then this can lead to a situation in which the component responsible for the first finalizer in the list is waiting for a signal (field value, external system, or other) produced by a component responsible for a finalizer later in the list, resulting in a deadlock. Without enforced ordering finalizers are free to order amongst themselves and are not vulnerable to ordering changes in the list.", + "type": "array", + "items": { + "type": "string", + "default": "" + }, + "x-kubernetes-list-type": "set", + "x-kubernetes-patch-strategy": "merge" + }, + "generateName": { + "description": "GenerateName is an optional prefix, used by the server, to generate a unique name ONLY IF the Name field has not been provided. If this field is used, the name returned to the client will be different than the name passed. This value will also be combined with a unique suffix. The provided value has the same validation rules as the Name field, and may be truncated by the length of the suffix required to make the value unique on the server.\n\nIf this field is specified and the generated name exists, the server will return a 409.\n\nApplied only if Name is not specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#idempotency", + "type": "string" + }, + "generation": { + "description": "A sequence number representing a specific generation of the desired state. Populated by the system. Read-only.", + "type": "integer", + "format": "int64" + }, + "labels": { + "description": "Map of string keys and values that can be used to organize and categorize (scope and select) objects. May match selectors of replication controllers and services. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels", + "type": "object", + "additionalProperties": { + "type": "string", + "default": "" + } + }, + "managedFields": { + "description": "ManagedFields maps workflow-id and version to the set of fields that are managed by that workflow. This is mostly for internal housekeeping, and users typically shouldn't need to set or understand this field. A workflow can be the user's name, a controller's name, or the name of a specific apply path like \"ci-cd\". The set of fields is always in the version that the workflow used when modifying the object.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "name": { + "description": "Name must be unique within a namespace. Is required when creating resources, although some resources may allow a client to request the generation of an appropriate name automatically. Name is primarily intended for creation idempotence and configuration definition. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", + "type": "string" + }, + "namespace": { + "description": "Namespace defines the space within which each name must be unique. An empty namespace is equivalent to the \"default\" namespace, but \"default\" is the canonical representation. Not all objects are required to be scoped to a namespace - the value of this field for those objects will be empty.\n\nMust be a DNS_LABEL. Cannot be updated. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces", + "type": "string" + }, + "ownerReferences": { + "description": "List of objects depended by this object. If ALL objects in the list have been deleted, this object will be garbage collected. If this object is managed by a controller, then an entry in this list will point to this controller, with the controller field set to true. There cannot be more than one managing controller.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference" + } + ] + }, + "x-kubernetes-list-map-keys": [ + "uid" + ], + "x-kubernetes-list-type": "map", + "x-kubernetes-patch-merge-key": "uid", + "x-kubernetes-patch-strategy": "merge" + }, + "resourceVersion": { + "description": "An opaque value that represents the internal version of this object that can be used by clients to determine when objects have changed. May be used for optimistic concurrency, change detection, and the watch operation on a resource or set of resources. Clients must treat these values as opaque and passed unmodified back to the server. They may only be valid for a particular resource or set of resources.\n\nPopulated by the system. Read-only. Value must be treated as opaque by clients and . More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#concurrency-control-and-consistency", + "type": "string" + }, + "selfLink": { + "description": "Deprecated: selfLink is a legacy read-only field that is no longer populated by the system.", + "type": "string" + }, + "uid": { + "description": "UID is the unique in time and space value for this object. It is typically generated by the server on successful creation of a resource and is not allowed to change on PUT operations.\n\nPopulated by the system. Read-only. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.OwnerReference": { + "description": "OwnerReference contains enough information to let you identify an owning object. An owning object must be in the same namespace as the dependent, or be cluster-scoped, so there is no namespace field.", + "type": "object", + "required": [ + "apiVersion", + "kind", + "name", + "uid" + ], + "properties": { + "apiVersion": { + "description": "API version of the referent.", + "type": "string", + "default": "" + }, + "blockOwnerDeletion": { + "description": "If true, AND if the owner has the \"foregroundDeletion\" finalizer, then the owner cannot be deleted from the key-value store until this reference is removed. See https://kubernetes.io/docs/concepts/architecture/garbage-collection/#foreground-deletion for how the garbage collector interacts with this field and enforces the foreground deletion. Defaults to false. To set this field, a user needs \"delete\" permission of the owner, otherwise 422 (Unprocessable Entity) will be returned.", + "type": "boolean" + }, + "controller": { + "description": "If true, this reference points to the managing controller.", + "type": "boolean" + }, + "kind": { + "description": "Kind of the referent. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string", + "default": "" + }, + "name": { + "description": "Name of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#names", + "type": "string", + "default": "" + }, + "uid": { + "description": "UID of the referent. More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string", + "default": "" + } + }, + "x-kubernetes-map-type": "atomic" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Patch": { + "description": "Patch is provided to give a concrete name and type to the Kubernetes PATCH request body.", + "type": "object" + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Preconditions": { + "description": "Preconditions must be fulfilled before an operation (update, delete, etc.) is carried out.", + "type": "object", + "properties": { + "resourceVersion": { + "description": "Specifies the target ResourceVersion", + "type": "string" + }, + "uid": { + "description": "Specifies the target UID.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Status": { + "description": "Status is a return value for calls that don't return other objects.", + "type": "object", + "properties": { + "apiVersion": { + "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", + "type": "string" + }, + "code": { + "description": "Suggested HTTP return code for this status, 0 if not set.", + "type": "integer", + "format": "int32" + }, + "details": { + "description": "Extended data associated with the reason. Each reason may define its own extended details. This field is optional and the data returned is not guaranteed to conform to any schema except that defined by the reason type.", + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails" + } + ], + "x-kubernetes-list-type": "atomic" + }, + "kind": { + "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "message": { + "description": "A human-readable description of the status of this operation.", + "type": "string" + }, + "metadata": { + "description": "Standard list metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" + } + ] + }, + "reason": { + "description": "A machine-readable description of why this operation is in the \"Failure\" status. If this value is empty there is no information available. A Reason clarifies an HTTP status code but does not override it.", + "type": "string" + }, + "status": { + "description": "Status of the operation. One of: \"Success\" or \"Failure\". More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusCause": { + "description": "StatusCause provides more information about an api.Status failure, including cases when multiple errors are encountered.", + "type": "object", + "properties": { + "field": { + "description": "The field of the resource that has caused this error, as named by its JSON serialization. May include dot and postfix notation for nested attributes. Arrays are zero-indexed. Fields may appear more than once in an array of causes due to fields having multiple errors. Optional.\n\nExamples:\n \"name\" - the field \"name\" on the current resource\n \"items[0].name\" - the field \"name\" on the first array entry in \"items\"", + "type": "string" + }, + "message": { + "description": "A human-readable description of the cause of the error. This field may be presented as-is to a reader.", + "type": "string" + }, + "reason": { + "description": "A machine-readable description of the cause of the error. If this value is empty there is no information available.", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.StatusDetails": { + "description": "StatusDetails is a set of additional properties that MAY be set by the server to provide additional information about a response. The Reason field of a Status object defines what attributes will be set. Clients must ignore fields that do not match the defined type of each attribute, and should assume that any attribute may be empty, invalid, or under defined.", + "type": "object", + "properties": { + "causes": { + "description": "The Causes array includes more details associated with the StatusReason failure. Not all StatusReasons may provide detailed causes.", + "type": "array", + "items": { + "default": {}, + "allOf": [ + { + "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.StatusCause" + } + ] + }, + "x-kubernetes-list-type": "atomic" + }, + "group": { + "description": "The group attribute of the resource associated with the status StatusReason.", + "type": "string" + }, + "kind": { + "description": "The kind attribute of the resource associated with the status StatusReason. On some operations may differ from the requested resource Kind. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", + "type": "string" + }, + "name": { + "description": "The name attribute of the resource associated with the status StatusReason (when there is a single name which can be described).", + "type": "string" + }, + "retryAfterSeconds": { + "description": "If specified, the time in seconds before the operation should be retried. Some errors may indicate the client must take an alternate action - for those errors this field may indicate how long to wait before taking the alternate action.", + "type": "integer", + "format": "int32" + }, + "uid": { + "description": "UID of the resource. (when there is a single resource which can be described). More info: https://kubernetes.io/docs/concepts/overview/working-with-objects/names#uids", + "type": "string" + } + } + }, + "io.k8s.apimachinery.pkg.apis.meta.v1.Time": { + "description": "Time is a wrapper around time.Time which supports correct marshaling to YAML and JSON. Wrappers are provided for many of the factory methods that the time package offers.", + "type": "string", + "format": "date-time" + } + } + } +} \ No newline at end of file diff --git a/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json b/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json index 1b9b680af34..a2a645aabf2 100644 --- a/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/preferences.grafana.app-v1alpha1.json @@ -1,7 +1,6 @@ { "openapi": "3.0.0", "info": { - "description": "Grafana preferences", "title": "preferences.grafana.app/v1alpha1" }, "paths": { @@ -741,939 +740,6 @@ } } ] - }, - "/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars": { - "get": { - "tags": [ - "Stars" - ], - "description": "list objects of kind Stars", - "operationId": "listStars", - "parameters": [ - { - "name": "allowWatchBookmarks", - "in": "query", - "description": "allowWatchBookmarks requests watch events with type \"BOOKMARK\". Servers that do not implement bookmarks may ignore this flag and bookmarks are sent at the server's discretion. Clients should not assume bookmarks are returned at any specific interval, nor may they assume the server will send any BOOKMARK event during a session. If this is not a watch, this field is ignored.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "watch", - "in": "query", - "description": "Watch for changes to the described resources and return them as a stream of add, update, and remove notifications. Specify resourceVersion.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsList" - } - }, - "application/json;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsList" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsList" - } - }, - "application/vnd.kubernetes.protobuf;stream=watch": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsList" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsList" - } - } - } - } - }, - "x-kubernetes-action": "list", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "post": { - "tags": [ - "Stars" - ], - "description": "create Stars", - "operationId": "createStars", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldManager", - "in": "query", - "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldValidation", - "in": "query", - "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - }, - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - } - }, - "x-kubernetes-action": "post", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "delete": { - "tags": [ - "Stars" - ], - "description": "delete collection of Stars", - "operationId": "deletecollectionStars", - "parameters": [ - { - "name": "continue", - "in": "query", - "description": "The continue option should be set when retrieving more results from the server. Since this value is server defined, clients may only use the continue value from a previous query result with identical query parameters (except for the value of continue) and the server may reject a continue value it does not recognize. If the specified continue value is no longer valid whether due to expiration (generally five to fifteen minutes) or a configuration change on the server, the server will respond with a 410 ResourceExpired error together with a continue token. If the client needs a consistent list, it must restart their list without the continue field. Otherwise, the client may send another list request with the token received with the 410 error, the server will respond with a list starting from the next key, but from the latest snapshot, which is inconsistent from the previous list results - objects that are created, modified, or deleted after the first list request will be included in the response, as long as their keys are after the \"next key\".\n\nThis field is not supported when watch is true. Clients may start a watch from the last resourceVersion value returned by the server and not miss any modifications.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their fields. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "labelSelector", - "in": "query", - "description": "A selector to restrict the list of returned objects by their labels. Defaults to everything.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "limit", - "in": "query", - "description": "limit is a maximum number of responses to return for a list call. If more items exist, the server will set the `continue` field on the list metadata to a value that can be used with the same initial query to retrieve the next set of results. Setting a limit may return fewer than the requested amount of items (up to zero items) in the event all requested objects are filtered out and clients should only use the presence of the continue field to determine whether more results are available. Servers may choose not to support the limit argument and will return all of the available results. If limit is specified and the continue field is empty, clients may assume that no more results are available. This field is not supported if watch is true.\n\nThe server guarantees that the objects returned when using continue will be identical to issuing a single list call without a limit - that is, no objects created, modified, or deleted after the first request is issued will be included in any subsequent continued requests. This is sometimes referred to as a consistent snapshot, and ensures that a client that is using limit to receive smaller chunks of a very large result can ensure they see all possible objects. If objects are updated during a chunked list the version of the object that was present at the time the first list result was calculated is returned.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersion", - "in": "query", - "description": "resourceVersion sets a constraint on what resource versions a request may be served from. See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "resourceVersionMatch", - "in": "query", - "description": "resourceVersionMatch determines how resourceVersion is applied to list calls. It is highly recommended that resourceVersionMatch be set for list calls where resourceVersion is set See https://kubernetes.io/docs/reference/using-api/api-concepts/#resource-versions for details.\n\nDefaults to unset", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "sendInitialEvents", - "in": "query", - "description": "`sendInitialEvents=true` may be set together with `watch=true`. In that case, the watch stream will begin with synthetic events to produce the current state of objects in the collection. Once all such events have been sent, a synthetic \"Bookmark\" event will be sent. The bookmark will report the ResourceVersion (RV) corresponding to the set of objects, and be marked with `\"k8s.io/initial-events-end\": \"true\"` annotation. Afterwards, the watch stream will proceed as usual, sending watch events corresponding to changes (subsequent to the RV) to objects watched.\n\nWhen `sendInitialEvents` option is set, we require `resourceVersionMatch` option to also be set. The semantic of the watch request is as following: - `resourceVersionMatch` = NotOlderThan\n is interpreted as \"data at least as new as the provided `resourceVersion`\"\n and the bookmark event is send when the state is synced\n to a `resourceVersion` at least as fresh as the one provided by the ListOptions.\n If `resourceVersion` is unset, this is interpreted as \"consistent read\" and the\n bookmark event is send when the state is synced at least to the moment\n when request started being processed.\n- `resourceVersionMatch` set to any other value or unset\n Invalid error is returned.\n\nDefaults to true if `resourceVersion=\"\"` or `resourceVersion=\"0\"` (for backward compatibility reasons) and to false otherwise.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "timeoutSeconds", - "in": "query", - "description": "Timeout for the list/watch call. This limits the duration of the call, regardless of any activity or inactivity.", - "schema": { - "type": "integer", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "deletecollection", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "parameters": [ - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "pretty", - "in": "query", - "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ] - }, - "/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}": { - "get": { - "tags": [ - "Stars" - ], - "description": "read the specified Stars", - "operationId": "getStars", - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - } - }, - "x-kubernetes-action": "get", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "put": { - "tags": [ - "Stars" - ], - "description": "replace the specified Stars", - "operationId": "replaceStars", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldManager", - "in": "query", - "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldValidation", - "in": "query", - "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - }, - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - } - }, - "x-kubernetes-action": "put", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "delete": { - "tags": [ - "Stars" - ], - "description": "delete Stars", - "operationId": "deleteStars", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "gracePeriodSeconds", - "in": "query", - "description": "The duration in seconds before the object should be deleted. Value must be non-negative integer. The value zero indicates delete immediately. If this value is nil, the default grace period for the specified type will be used. Defaults to a per object value if not specified. zero means delete immediately.", - "schema": { - "type": "integer", - "uniqueItems": true - } - }, - { - "name": "ignoreStoreReadErrorWithClusterBreakingPotential", - "in": "query", - "description": "if set to true, it will trigger an unsafe deletion of the resource in case the normal deletion flow fails with a corrupt object error. A resource is considered corrupt if it can not be retrieved from the underlying storage successfully because of a) its data can not be transformed e.g. decryption failure, or b) it fails to decode into an object. NOTE: unsafe deletion ignores finalizer constraints, skips precondition checks, and removes the object from the storage. WARNING: This may potentially break the cluster if the workload associated with the resource being unsafe-deleted relies on normal deletion flow. Use only if you REALLY know what you are doing. The default value is false, and the user must opt in to enable it", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "orphanDependents", - "in": "query", - "description": "Deprecated: please use the PropagationPolicy, this field will be deprecated in 1.7. Should the dependent objects be orphaned. If true/false, the \"orphan\" finalizer will be added to/removed from the object's finalizers list. Either this field or PropagationPolicy may be set, but not both.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - }, - { - "name": "propagationPolicy", - "in": "query", - "description": "Whether and how garbage collection will be performed. Either this field or OrphanDependents may be set, but not both. The default policy is decided by the existing finalizer set in the metadata.finalizers and the resource-specific default policy. Acceptable values are: 'Orphan' - orphan the dependents; 'Background' - allow the garbage collector to delete the dependents in the background; 'Foreground' - a cascading policy that deletes all dependents in the foreground.", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ], - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - }, - "202": { - "description": "Accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Status" - } - } - } - } - }, - "x-kubernetes-action": "delete", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "patch": { - "tags": [ - "Stars" - ], - "description": "partially update the specified Stars", - "operationId": "updateStars", - "parameters": [ - { - "name": "dryRun", - "in": "query", - "description": "When present, indicates that modifications should not be persisted. An invalid or unrecognized dryRun directive will result in an error response and no further processing of the request. Valid values are: - All: all dry run stages will be processed", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldManager", - "in": "query", - "description": "fieldManager is a name associated with the actor or entity that is making these changes. The value must be less than or 128 characters long, and only contain printable characters, as defined by https://golang.org/pkg/unicode/#IsPrint. This field is required for apply requests (application/apply-patch) but optional for non-apply patch types (JsonPatch, MergePatch, StrategicMergePatch).", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "fieldValidation", - "in": "query", - "description": "fieldValidation instructs the server on how to handle objects in the request (POST/PUT/PATCH) containing unknown or duplicate fields. Valid values are: - Ignore: This will ignore any unknown fields that are silently dropped from the object, and will ignore all but the last duplicate field that the decoder encounters. This is the default behavior prior to v1.23. - Warn: This will send a warning via the standard warning response header for each unknown field that is dropped from the object, and for each duplicate field that is encountered. The request will still succeed if there are no other errors, and will only persist the last of any duplicate fields. This is the default in v1.23+ - Strict: This will fail the request with a BadRequest error if any unknown fields would be dropped from the object, or if any duplicate fields are present. The error returned from the server will contain all unknown and duplicate fields encountered.", - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "force", - "in": "query", - "description": "Force is going to \"force\" Apply requests. It means user will re-acquire conflicting fields owned by other people. Force flag must be unset for non-apply patch requests.", - "schema": { - "type": "boolean", - "uniqueItems": true - } - } - ], - "requestBody": { - "content": { - "application/apply-patch+yaml": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" - } - }, - "application/json-patch+json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" - } - }, - "application/merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" - } - }, - "application/strategic-merge-patch+json": { - "schema": { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.Patch" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "OK", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - }, - "201": { - "description": "Created", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/vnd.kubernetes.protobuf": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - }, - "application/yaml": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - } - }, - "x-kubernetes-action": "patch", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "parameters": [ - { - "name": "name", - "in": "path", - "description": "name of the Stars", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "pretty", - "in": "query", - "description": "If 'true', then the output is pretty printed. Defaults to 'false' unless the user-agent indicates a browser or command-line HTTP tool (curl and wget).", - "schema": { - "type": "string", - "uniqueItems": true - } - } - ] - }, - "/apis/preferences.grafana.app/v1alpha1/namespaces/{namespace}/stars/{name}/update/{group}/{kind}/{id}": { - "put": { - "tags": [ - "Stars" - ], - "description": "Add a starred item", - "operationId": "addStar", - "responses": { - "200": { - "description": "OK", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - } - }, - "x-kubernetes-action": "connect", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "delete": { - "tags": [ - "Stars" - ], - "description": "Remove a starred item", - "operationId": "removeStar", - "responses": { - "200": { - "description": "OK", - "content": { - "*/*": { - "schema": { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - } - } - } - }, - "x-kubernetes-action": "connect", - "x-kubernetes-group-version-kind": { - "group": "preferences.grafana.app", - "version": "v1alpha1", - "kind": "Stars" - } - }, - "parameters": [ - { - "name": "name", - "in": "path", - "description": "name of the Stars", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "namespace", - "in": "path", - "description": "object name and auth scope, such as for teams and projects", - "required": true, - "schema": { - "type": "string", - "uniqueItems": true - } - }, - { - "name": "group", - "in": "path", - "description": "API group for stared item", - "required": true, - "schema": { - "type": "string" - }, - "example": "dashboard.grafana.app" - }, - { - "name": "kind", - "in": "path", - "description": "Kind for stared item", - "required": true, - "schema": { - "type": "string" - }, - "example": "Dashboard" - }, - { - "name": "id", - "in": "path", - "description": "The k8s name for the selected item", - "required": true, - "schema": { - "type": "string" - } - } - ] } }, "components": { @@ -1853,136 +919,6 @@ } } }, - "com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars": { - "type": "object", - "required": [ - "metadata", - "spec" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta" - } - ] - }, - "spec": { - "description": "Spec is the spec of the Stars", - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsSpec" - } - ] - } - }, - "x-kubernetes-group-version-kind": [ - { - "group": "preferences.grafana.app", - "kind": "Stars", - "version": "v1alpha1" - } - ] - }, - "com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsList": { - "type": "object", - "required": [ - "metadata", - "items" - ], - "properties": { - "apiVersion": { - "description": "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources", - "type": "string" - }, - "items": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.Stars" - } - ] - } - }, - "kind": { - "description": "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds", - "type": "string" - }, - "metadata": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/io.k8s.apimachinery.pkg.apis.meta.v1.ListMeta" - } - ] - } - }, - "x-kubernetes-group-version-kind": [ - { - "group": "preferences.grafana.app", - "kind": "StarsList", - "version": "v1alpha1" - } - ] - }, - "com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsResource": { - "type": "object", - "required": [ - "group", - "kind", - "names" - ], - "properties": { - "group": { - "type": "string", - "default": "" - }, - "kind": { - "type": "string", - "default": "" - }, - "names": { - "description": "The set of resources", - "type": "array", - "items": { - "type": "string", - "default": "" - }, - "x-kubernetes-list-type": "set" - } - } - }, - "com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsSpec": { - "type": "object", - "required": [ - "resource" - ], - "properties": { - "resource": { - "type": "array", - "items": { - "default": {}, - "allOf": [ - { - "$ref": "#/components/schemas/com.github.grafana.grafana.apps.preferences.pkg.apis.preferences.v1alpha1.StarsResource" - } - ] - } - } - } - }, "io.k8s.apimachinery.pkg.apis.meta.v1.APIResource": { "description": "APIResource specifies the name of a resource and whether it is namespaced.", "type": "object", diff --git a/pkg/tests/apis/openapi_test.go b/pkg/tests/apis/openapi_test.go index b218095d938..3df0b3db0ff 100644 --- a/pkg/tests/apis/openapi_test.go +++ b/pkg/tests/apis/openapi_test.go @@ -97,6 +97,9 @@ func TestIntegrationOpenAPIs(t *testing.T) { }, { Group: "preferences.grafana.app", Version: "v1alpha1", + }, { + Group: "collections.grafana.app", + Version: "v1alpha1", }, { Group: "notifications.alerting.grafana.app", Version: "v0alpha1", diff --git a/public/app/api/clients/preferences/v1alpha1/index.ts b/public/app/api/clients/collections/v1alpha1/index.ts similarity index 86% rename from public/app/api/clients/preferences/v1alpha1/index.ts rename to public/app/api/clients/collections/v1alpha1/index.ts index 12d87999e8e..cd2102f6b29 100644 --- a/public/app/api/clients/preferences/v1alpha1/index.ts +++ b/public/app/api/clients/collections/v1alpha1/index.ts @@ -1,9 +1,9 @@ -import { generatedAPI } from '@grafana/api-clients/rtkq/preferences/v1alpha1'; +import { generatedAPI } from '@grafana/api-clients/rtkq/collections/v1alpha1'; import { t } from '@grafana/i18n'; import { notifyApp } from 'app/core/actions'; import { createSuccessNotification, createErrorNotification } from 'app/core/copy/appNotification'; -export const preferencesAPIv1alpha1 = generatedAPI.enhanceEndpoints({ +export const collectionsAPIv1alpha1 = generatedAPI.enhanceEndpoints({ endpoints: { addStar: { onQueryStarted: async (_, { queryFulfilled, dispatch }) => { @@ -39,4 +39,4 @@ export const preferencesAPIv1alpha1 = generatedAPI.enhanceEndpoints({ }); // eslint-disable-next-line no-barrel-files/no-barrel-files -export * from '@grafana/api-clients/rtkq/preferences/v1alpha1'; +export * from '@grafana/api-clients/rtkq/collections/v1alpha1'; diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index f7c75b82e6a..b352d61f038 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -5,7 +5,7 @@ import { generatedAPI as legacyUserAPI } from '@grafana/api-clients/rtkq/legacy/ import { DataFrame, DataFrameView, getDisplayProcessor, SelectableValue, toDataFrame } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config, getBackendSrv } from '@grafana/runtime'; -import { generatedAPI, ListStarsApiResponse } from 'app/api/clients/preferences/v1alpha1'; +import { generatedAPI, ListStarsApiResponse } from 'app/api/clients/collections/v1alpha1'; import { getAPIBaseURL } from 'app/api/utils'; import { TermCount } from 'app/core/components/TagFilter/TagFilter'; import { contextSrv } from 'app/core/services/context_srv'; diff --git a/public/app/features/stars/hooks.ts b/public/app/features/stars/hooks.ts index fa95eecd751..d7768c1c53c 100644 --- a/public/app/features/stars/hooks.ts +++ b/public/app/features/stars/hooks.ts @@ -8,7 +8,7 @@ import { } from '@grafana/api-clients/rtkq/legacy/user'; import { locationUtil } from '@grafana/data'; import { config } from '@grafana/runtime'; -import { useAddStarMutation, useRemoveStarMutation, useListStarsQuery } from 'app/api/clients/preferences/v1alpha1'; +import { useAddStarMutation, useRemoveStarMutation, useListStarsQuery } from 'app/api/clients/collections/v1alpha1'; import { setStarred } from 'app/core/reducers/navBarTree'; import { contextSrv } from 'app/core/services/context_srv'; import { dispatch } from 'app/store/store'; From 92bee5e510743d8aa1b39270fb1ab395f9b551fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jara=20Su=C3=A1rez=20de=20Puga=20Garc=C3=ADa?= Date: Wed, 19 Nov 2025 08:43:38 +0100 Subject: [PATCH 049/125] Docs: Update documentation on PDF/Email generation with logo (#114005) Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- .../dashboards/create-reports/report-settings/index.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/sources/visualizations/dashboards/create-reports/report-settings/index.md b/docs/sources/visualizations/dashboards/create-reports/report-settings/index.md index 6f131c676a2..0282cc6d84e 100644 --- a/docs/sources/visualizations/dashboards/create-reports/report-settings/index.md +++ b/docs/sources/visualizations/dashboards/create-reports/report-settings/index.md @@ -46,7 +46,7 @@ The options in this section control the branding and theming of the report attac - **Company logo** - Company logo displayed in the report PDF. Configure it by specifying a URL or uploading a file. The maximum file size is 16 MB. - Defaults to the Grafana logo. + If not set, defaults to the Grafana logo. If the specified URL isn't valid, the logo image appears as broken. - **Theme** - Theme of the PDF attached to the report. The selected theme is also applied to the PDFs generated when you click **Preview PDF** during report creation or select the **Export as PDF** option on a dashboard. @@ -66,7 +66,7 @@ The options in this section control the branding and theming of the report attac -- **Company logo** - Company logo displayed in the report email. Configure it by specifying a URL or uploading a file. The maximum file size is 16 MB. Defaults to the Grafana logo. +- **Company logo** - Company logo displayed in the report email. Configure it by specifying a URL or uploading a file. The maximum file size is 16 MB. If not set, defaults to the Grafana logo. If the specified URL isn't valid, the logo image appears as broken. - **Email footer** - Toggle to enable the report email footer. Select **Sent by** or **None**. - **Footer link text** - Text of the link in the report email footer. Defaults to `Grafana`. - **Footer link URL** - Link of the report email footer. From 0d4ad01b65f9ce85342c3e46a20cabece7d4b823 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:09:08 +0100 Subject: [PATCH 050/125] feat: add unified data migrations for dashboard and folders (#113853) --- pkg/registry/apis/dashboard/legacy/migrate.go | 7 +- .../backgroundsvcs/background_services.go | 3 + pkg/server/wire.go | 3 + pkg/server/wire_gen.go | 13 +- pkg/storage/unified/client.go | 9 +- .../migrations/dashboard_folder_migration.go | 93 ++++++++++ pkg/storage/unified/migrations/migrations.go | 108 ++++++++++++ pkg/storage/unified/migrations/migrator.go | 166 ++++++++++++++++++ 8 files changed, 385 insertions(+), 17 deletions(-) create mode 100644 pkg/storage/unified/migrations/dashboard_folder_migration.go create mode 100644 pkg/storage/unified/migrations/migrations.go create mode 100644 pkg/storage/unified/migrations/migrator.go diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go index bdc47bf985a..e43a0fb7629 100644 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ b/pkg/registry/apis/dashboard/legacy/migrate.go @@ -34,9 +34,8 @@ type MigrateOptions struct { LargeObjects apistore.LargeObjectSupport BlobStore resourcepb.BlobStoreClient Resources []schema.GroupResource - WithHistory bool // only applies to dashboards - OnlyCount bool // just count the values - StackID string // stack identifier for logging + WithHistory bool // only applies to dashboards + OnlyCount bool // just count the values Progress func(count int, msg string) } @@ -140,7 +139,6 @@ func (a *dashboardSqlAccess) Migrate(ctx context.Context, opts MigrateOptions) ( // Now run each migration blobStore := BlobStoreInfo{} - opts.StackID = fmt.Sprintf("%d", info.StackID) // Pass stack ID through options a.log.Info("start migrating legacy resources", "namespace", opts.Namespace, "orgId", info.OrgID, "stackId", info.StackID) for _, m := range migratorFuncs { blobs, err := m(ctx, info.OrgID, opts, stream) @@ -318,7 +316,6 @@ func (a *dashboardSqlAccess) migrateDashboards(ctx context.Context, orgId int64, "uid", row.Dash.UID, "id", id, "version", row.Dash.Generation, - "stackId", opts.StackID, ) opts.Progress(-2, fmt.Sprintf("rejected: id:%s, uid:%s", id, row.Dash.Name)) } diff --git a/pkg/registry/backgroundsvcs/background_services.go b/pkg/registry/backgroundsvcs/background_services.go index e80bcb720c9..5f997875985 100644 --- a/pkg/registry/backgroundsvcs/background_services.go +++ b/pkg/registry/backgroundsvcs/background_services.go @@ -48,6 +48,7 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlesimpl" "github.com/grafana/grafana/pkg/services/team/teamapi" "github.com/grafana/grafana/pkg/services/updatemanager" + unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations" ) func ProvideBackgroundServiceRegistry( @@ -73,6 +74,7 @@ func ProvideBackgroundServiceRegistry( dashboardServiceImpl *service.DashboardServiceImpl, secretsGarbageCollectionWorker *secretsgarbagecollectionworker.Worker, fixedRolesLoader *accesscontrol.FixedRolesLoader, + unifiedStorageMigrationProvider unifiedmigrations.UnifiedStorageMigrationProvider, // Need to make sure these are initialized, is there a better place to put them? _ dashboardsnapshots.Service, _ serviceaccounts.Service, @@ -89,6 +91,7 @@ func ProvideBackgroundServiceRegistry( notifications, rendering, tokenService, + unifiedStorageMigrationProvider, provisioning, grafanaUpdateChecker, pluginsUpdateChecker, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 179b371760d..2fe6db16b31 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -181,6 +181,7 @@ import ( secretencryption "github.com/grafana/grafana/pkg/storage/secret/encryption" secretmetadata "github.com/grafana/grafana/pkg/storage/secret/metadata" secretmigrator "github.com/grafana/grafana/pkg/storage/secret/migrator" + unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" unifiedsearch "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/tsdb/azuremonitor" @@ -465,6 +466,8 @@ var wireBasicSet = wire.NewSet( // Unified storage resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, + unifiedmigrations.ProvideUnifiedStorageMigrationProvider, + wire.Bind(new(unifiedmigrations.UnifiedStorageMigrationProvider), new(*unifiedmigrations.UnifiedStorageMigrationProviderImpl)), // Kubernetes API server grafanaapiserver.WireSet, apiregistry.WireSet, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 02b1db115b9..91a1e50a224 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -261,6 +261,7 @@ import ( "github.com/grafana/grafana/pkg/storage/secret/metadata" "github.com/grafana/grafana/pkg/storage/secret/migrator" "github.com/grafana/grafana/pkg/storage/unified" + migrations3 "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/storage/unified/sql" @@ -837,6 +838,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api dashboardUpdater := service8.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) worker := garbagecollectionworker.ProvideWorker(cfg, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService) fixedRolesLoader := accesscontrol.ProvideFixedRolesLoader(acimplService, featureToggles) + legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) + unifiedStorageMigrationProviderImpl := migrations3.ProvideUnifiedStorageMigrationProvider(legacyMigrator, cfg, resourceClient, sqlStore) healthService, err := grpcserver.ProvideHealthService(cfg, grpcserverProvider) if err != nil { return nil, err @@ -869,7 +872,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, userService, apiserverService) collectionsAPIBuilder := collections.RegisterAPIService(cfg, featureToggles, sqlStore, starService, userService, apiserverService) - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) webhookExtraBuilder := webhooks.ProvideWebhooksWithImages(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) v3 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) pullRequestWorker := pullrequest.ProvidePullRequestWorker(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) @@ -918,7 +920,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } ossUserProtectionImpl := authinfoimpl.ProvideOSSUserProtectionService() registration := authnimpl.ProvideRegistration(cfg, authnService, orgService, userAuthTokenService, acimplService, permissionRegistry, apikeyService, userService, authService, ossUserProtectionImpl, loginattemptimplService, quotaService, authinfoimplService, renderingService, featureToggles, oauthtokenService, socialService, remoteCache, ldapImpl, ossImpl, tracingService, tempuserService, notificationService) - backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) + backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, unifiedStorageMigrationProviderImpl, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) usageStatsProvidersRegistry := usagestatssvcs.ProvideUsageStatsProvidersRegistry(acimplService, userService) server, err := New(opts, cfg, httpServer, acimplService, provisioningServiceImpl, backgroundServiceRegistry, usageStatsProvidersRegistry, statscollectorService, tracingService, featureToggles, registerer) if err != nil { @@ -1479,6 +1481,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac dashboardUpdater := service8.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) worker := garbagecollectionworker.ProvideWorker(cfg, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService) fixedRolesLoader := accesscontrol.ProvideFixedRolesLoader(acimplService, featureToggles) + legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) + unifiedStorageMigrationProviderImpl := migrations3.ProvideUnifiedStorageMigrationProvider(legacyMigrator, cfg, resourceClient, sqlStore) healthService, err := grpcserver.ProvideHealthService(cfg, grpcserverProvider) if err != nil { return nil, err @@ -1511,7 +1515,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac userStorageAPIBuilder := userstorage.RegisterAPIService(featureToggles, apiserverService, registerer) apiBuilder := preferences.RegisterAPIService(cfg, featureToggles, sqlStore, prefService, userService, apiserverService) collectionsAPIBuilder := collections.RegisterAPIService(cfg, featureToggles, sqlStore, starService, userService, apiserverService) - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) webhookExtraBuilder := webhooks.ProvideWebhooksWithImages(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) v3 := extras.ProvideProvisioningExtraAPIs(webhookExtraBuilder) pullRequestWorker := pullrequest.ProvidePullRequestWorker(cfg, renderingService, resourceClient, eventualRestConfigProvider, registerer) @@ -1560,7 +1563,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } ossUserProtectionImpl := authinfoimpl.ProvideOSSUserProtectionService() registration := authnimpl.ProvideRegistration(cfg, authnService, orgService, userAuthTokenService, acimplService, permissionRegistry, apikeyService, userService, authService, ossUserProtectionImpl, loginattemptimplService, quotaService, authinfoimplService, renderingService, featureToggles, oauthtokentestService, socialService, remoteCache, ldapImpl, ossImpl, tracingService, tempuserService, notificationServiceMock) - backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) + backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, unifiedStorageMigrationProviderImpl, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) usageStatsProvidersRegistry := usagestatssvcs.ProvideUsageStatsProvidersRegistry(acimplService, userService) server, err := New(opts, cfg, httpServer, acimplService, provisioningServiceImpl, backgroundServiceRegistry, usageStatsProvidersRegistry, statscollectorService, tracingService, featureToggles, registerer) if err != nil { @@ -1756,7 +1759,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations3.ProvideUnifiedStorageMigrationProvider, wire.Bind(new(migrations3.UnifiedStorageMigrationProvider), new(*migrations3.UnifiedStorageMigrationProviderImpl)), apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/storage/unified/client.go b/pkg/storage/unified/client.go index 0a4d3e630ff..07165937199 100644 --- a/pkg/storage/unified/client.go +++ b/pkg/storage/unified/client.go @@ -57,7 +57,6 @@ func ProvideUnifiedStorageClient(opts *Options, storageMetrics *resource.StorageMetrics, indexMetrics *resource.BleveIndexMetrics, ) (resource.ResourceClient, error) { - // See: apiserver.applyAPIServerConfig(cfg, features, o) apiserverCfg := opts.Cfg.SectionWithEnvOverrides("grafana-apiserver") client, err := newClient(options.StorageOptions{ StorageType: options.StorageType(apiserverCfg.Key("storage_type").MustString(string(options.StorageTypeUnified))), @@ -165,12 +164,8 @@ func newClient(opts options.StorageOptions, indexConn = conn } - // Create a client instance - client, err := resource.NewResourceClient(conn, indexConn, cfg, features, tracer) - if err != nil { - return nil, err - } - return client, nil + // Create a resource client + return resource.NewResourceClient(conn, indexConn, cfg, features, tracer) default: searchOptions, err := search.NewSearchOptions(features, cfg, tracer, docs, indexMetrics, nil) diff --git a/pkg/storage/unified/migrations/dashboard_folder_migration.go b/pkg/storage/unified/migrations/dashboard_folder_migration.go new file mode 100644 index 00000000000..96c5a824c8a --- /dev/null +++ b/pkg/storage/unified/migrations/dashboard_folder_migration.go @@ -0,0 +1,93 @@ +package migrations + +import ( + "context" + "fmt" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/util/xorm" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + FoldersAndDashboardsMigrationID = "folders and dashboards migration" + UnifiedStorageDataMigrationSQL = "unified storage data migration" +) + +type dashboardAndFolderMigration struct { + migrator.MigrationBase + legacyMigrator legacy.LegacyMigrator + bulkStoreClient resource.ResourceClient +} + +var _ migrator.CodeMigration = (*dashboardAndFolderMigration)(nil) + +// SQL implements migrator.Migration interface. Returns a description string. +func (sp *dashboardAndFolderMigration) SQL(dialect migrator.Dialect) string { + return UnifiedStorageDataMigrationSQL +} + +func (sp *dashboardAndFolderMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + ctx := context.Background() + logger := mg.Logger + + resources := []schema.GroupResource{ + { + Group: "folder.grafana.app", + Resource: "folders", + }, + { + Group: "dashboard.grafana.app", + Resource: "dashboards", + }, + } + + storageMigrator := newUnifiedStorageMigrator(sp.legacyMigrator, sp.bulkStoreClient, resources, "unified-storage-migration.folders-dashboards") + + orgs, err := sp.getAllOrgs(sess) + if err != nil { + logger.Error("failed to get organizations for folders and dashboards migration", "error", err) + return fmt.Errorf("failed to get organizations: %w", err) + } + + if len(orgs) == 0 { + logger.Info("No organizations found to migrate, skipping migration") + return nil + } + + logger.Info("Starting migration for all organizations", "org_count", len(orgs)) + + for _, org := range orgs { + namespace := types.OrgNamespaceFormatter(org.ID) + logger.Info("Migrating organization", "org_id", org.ID, "org_name", org.Name, "namespace", namespace) + + // Create a service identity context for this namespace to authenticate with unified storage + migrationCtx, _ := identity.WithServiceIdentityForSingleNamespace(ctx, namespace) + + if err := storageMigrator.executeMigration(migrationCtx, sess, mg, namespace); err != nil { + logger.Error("migration failed for organization", "org_id", org.ID, "org_name", org.Name, "error", err) + return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, err) + } + } + + logger.Info("Migration completed successfully for all organizations", "org_count", len(orgs)) + return nil +} + +type orgInfo struct { + ID int64 `xorm:"id"` + Name string `xorm:"name"` +} + +func (sp *dashboardAndFolderMigration) getAllOrgs(sess *xorm.Session) ([]orgInfo, error) { + var orgs []orgInfo + err := sess.Table("org").Cols("id", "name").Find(&orgs) + if err != nil { + return nil, err + } + return orgs, nil +} diff --git a/pkg/storage/unified/migrations/migrations.go b/pkg/storage/unified/migrations/migrations.go new file mode 100644 index 00000000000..195b7f5d154 --- /dev/null +++ b/pkg/storage/unified/migrations/migrations.go @@ -0,0 +1,108 @@ +package migrations + +import ( + "context" + "fmt" + "os" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel" +) + +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") + +// UnifiedStorageMigrationProvider provides unified storage migrations as a background service +type UnifiedStorageMigrationProvider interface { + registry.BackgroundService +} + +type UnifiedStorageMigrationProviderImpl struct { + legacyMigrator legacy.LegacyMigrator + cfg *setting.Cfg + client resource.ResourceClient + sqlStore db.DB +} + +var _ UnifiedStorageMigrationProvider = (*UnifiedStorageMigrationProviderImpl)(nil) + +// ProvideUnifiedStorageMigrationProvider is a Wire provider that creates the migration service. +// The service implements registry.BackgroundService and runs migrations during server startup. +func ProvideUnifiedStorageMigrationProvider( + legacyMigrator legacy.LegacyMigrator, + cfg *setting.Cfg, + client resource.ResourceClient, + sqlStore db.DB, +) *UnifiedStorageMigrationProviderImpl { + return &UnifiedStorageMigrationProviderImpl{ + legacyMigrator: legacyMigrator, + cfg: cfg, + client: client, + sqlStore: sqlStore, + } +} + +// Run executes unified storage migrations as a background service. +// This blocks until migrations complete. If migrations fail, an error is returned +// which will prevent Grafana from starting. +func (p *UnifiedStorageMigrationProviderImpl) Run(ctx context.Context) error { + // skip migrations in test environments to prevent integration test timeouts. + if os.Getenv("GRAFANA_TEST_DB") != "" { + return nil + } + + // TODO: Re-enable once migrations are ready + // return RegisterMigrations(p.legacyMigrator, p.cfg, p.client, p.sqlStore) + return nil +} + +// RegisterMigrations initializes and registers all unified storage migrations. +// This function is the entry point for all data migrations from legacy storage +// to unified storage. It returns an error if migrations fail, preventing Grafana +// from starting with inconsistent data. +func RegisterMigrations( + legacyMigrator legacy.LegacyMigrator, + cfg *setting.Cfg, + client resource.ResourceClient, + sqlStore db.DB, +) error { + ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations") + defer span.End() + logger := log.New("storage.unified.migrations.folders-dashboards") + mg := migrator.NewScopedMigrator(sqlStore.GetEngine(), cfg, "unified_storage") + mg.AddCreateMigration() + + if err := prometheus.Register(mg); err != nil { + logger.Warn("Failed to register migrator metrics", "error", err) + } + + // Add new migration registrations here for each resource type + registerDashboardAndFolderMigration(mg, legacyMigrator, client) + + // Run all registered migrations (blocking) + sec := cfg.Raw.Section("database") + if err := mg.RunMigrations(ctx, sec.Key("migration_locking").MustBool(true), sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { + return fmt.Errorf("unified storage data migration failed: %w", err) + } + + logger.Info("Unified storage migrations completed successfully") + return nil +} + +func registerDashboardAndFolderMigration( + mg *migrator.Migrator, + legacyMigrator legacy.LegacyMigrator, + bulkStoreClient resource.ResourceClient, +) { + migration := &dashboardAndFolderMigration{ + legacyMigrator: legacyMigrator, + bulkStoreClient: bulkStoreClient, + } + mg.AddMigration(FoldersAndDashboardsMigrationID, migration) +} diff --git a/pkg/storage/unified/migrations/migrator.go b/pkg/storage/unified/migrations/migrator.go new file mode 100644 index 00000000000..cab6ce38432 --- /dev/null +++ b/pkg/storage/unified/migrations/migrator.go @@ -0,0 +1,166 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/util/xorm" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// StorageMigrator defines the interface for executing unified storage migrations +type StorageMigrator interface { + executeMigration(ctx context.Context, sess *xorm.Session, mg *migrator.Migrator, namespace string) error +} + +type unifiedStorageMigrator struct { + migrator legacy.LegacyMigrator + bulkStoreClient resource.ResourceClient + resources []schema.GroupResource + log log.Logger +} + +func newUnifiedStorageMigrator(migrator legacy.LegacyMigrator, bulkStoreClient resource.ResourceClient, resources []schema.GroupResource, logPrefix string) StorageMigrator { + return &unifiedStorageMigrator{ + migrator: migrator, + bulkStoreClient: bulkStoreClient, + resources: resources, + log: log.New(logPrefix), + } +} + +func (m *unifiedStorageMigrator) executeMigration(ctx context.Context, sess *xorm.Session, mg *migrator.Migrator, namespace string) error { + startTime := time.Now() + m.log.Info("Starting unified storage migration", "namespace", namespace, "resources", m.resources) + + opts := legacy.MigrateOptions{ + Namespace: namespace, + Store: m.bulkStoreClient, + LargeObjects: nil, // Not using large object support to avoid import cycles + Resources: m.resources, + WithHistory: true, // Migrate with full history + OnlyCount: false, + Progress: func(count int, msg string) { + m.log.Info("Migration progress", "count", count, "message", msg) + }, + } + + // Execute the migration via legacy migrator + response, err := m.migrator.Migrate(ctx, opts) + if err != nil { + m.log.Error("Migration failed", "error", err, "duration", time.Since(startTime)) + return fmt.Errorf("failed to migrate resources: %w", err) + } + + // Validate the migration results + if err := m.validateMigration(sess, response); err != nil { + m.log.Error("Migration validation failed", "error", err, "duration", time.Since(startTime)) + return fmt.Errorf("migration validation failed: %w", err) + } + + m.log.Info("Migration completed successfully", + "duration", time.Since(startTime), + "processed", response.Processed, + "summaries", len(response.Summary), + "rejected", len(response.Rejected)) + + return nil +} + +func (m *unifiedStorageMigrator) validateMigration(sess *xorm.Session, response *resourcepb.BulkResponse) error { + // Check for rejected items + if len(response.Rejected) > 0 { + m.log.Warn("Migration had rejected items", "count", len(response.Rejected)) + for i, rejected := range response.Rejected { + if i < 10 { // Log first 10 rejected items + m.log.Warn("Rejected item", + "namespace", rejected.Key.Namespace, + "group", rejected.Key.Group, + "resource", rejected.Key.Resource, + "name", rejected.Key.Name, + "reason", rejected.Error) + } + } + // Rejections are not fatal - they may be expected for invalid data + } + + // Validate counts for each resource type + for _, summary := range response.Summary { + legacyCount, err := m.getLegacyCount(sess, summary.Group, summary.Resource, summary.Namespace) + if err != nil { + return fmt.Errorf("failed to get legacy count for %s/%s: %w", summary.Group, summary.Resource, err) + } + + // Account for rejected items in validation + expectedCount := summary.Count + int64(len(response.Rejected)) + + m.log.Info("Count validation", + "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), + "namespace", summary.Namespace, + "legacy_count", legacyCount, + "unified_count", summary.Count, + "rejected", len(response.Rejected), + "history", summary.History) + + // Validate that we migrated all items (allowing for rejected items) + if legacyCount > expectedCount { + return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d", + summary.Resource, summary.Group, summary.Namespace, + legacyCount, summary.Count, len(response.Rejected)) + } + } + + return nil +} + +func (m *unifiedStorageMigrator) getLegacyCount(sess *xorm.Session, group, resourceType, namespace string) (int64, error) { + // Parse namespace to get org ID + orgID, err := ParseOrgIDFromNamespace(namespace) + if err != nil { + return 0, fmt.Errorf("invalid namespace %s: %w", namespace, err) + } + + // Map group/resource to legacy table + tableName, whereClause := m.getLegacyTableInfo(group, resourceType) + if tableName == "" { + return 0, fmt.Errorf("unknown resource type: %s.%s", resourceType, group) + } + + // Count items in legacy table using Table() before Count() + count, err := sess.Table(tableName).Where(whereClause, orgID).Count() + if err != nil { + return 0, fmt.Errorf("failed to count %s: %w", tableName, err) + } + + return count, nil +} + +func (m *unifiedStorageMigrator) getLegacyTableInfo(group, resource string) (table string, whereClause string) { + // Map unified storage group/resource to legacy tables + switch { + case group == "dashboard.grafana.app" && resource == "dashboards": + return "dashboard", "org_id = ? and is_folder = false" + case group == "folder.grafana.app" && resource == "folders": + return "dashboard", "org_id = ? and is_folder = true" + case group == "playlist.grafana.app" && resource == "playlists": + return "playlist", "org_id = ?" + default: + return "", "" + } +} + +func ParseOrgIDFromNamespace(namespace string) (int64, error) { + // Use authlib to properly parse all namespace formats including "default" for org 1 + info, err := types.ParseNamespace(namespace) + if err != nil { + return 0, fmt.Errorf("failed to parse namespace: %w", err) + } + return info.OrgID, nil +} From 56c2c1cfe2421a8817d5bdde6e5b9f97806b2b1b Mon Sep 17 00:00:00 2001 From: Misi Date: Wed, 19 Nov 2025 09:48:09 +0100 Subject: [PATCH 051/125] IAM: Add validation to ExternalGroupMapping (#113957) * Add validation before ExternalGroupMapping creation * Add FIXME to implement team lookup * Lint --- .../apis/iam/externalgroupmapping/validate.go | 22 +++++++++++++++++++ pkg/registry/apis/iam/register.go | 3 +++ 2 files changed, 25 insertions(+) create mode 100644 pkg/registry/apis/iam/externalgroupmapping/validate.go diff --git a/pkg/registry/apis/iam/externalgroupmapping/validate.go b/pkg/registry/apis/iam/externalgroupmapping/validate.go new file mode 100644 index 00000000000..851b77d8bd5 --- /dev/null +++ b/pkg/registry/apis/iam/externalgroupmapping/validate.go @@ -0,0 +1,22 @@ +package externalgroupmapping + +import ( + iamv0alpha1 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +func ValidateOnCreate(obj *iamv0alpha1.ExternalGroupMapping) error { + if obj == nil { + return apierrors.NewBadRequest("object must not be nil") + } + if obj.Spec.TeamRef.Name == "" { + return apierrors.NewBadRequest("teamRef.name is required") + } + + // FIXME: Add the ability to verify that the team exists in a follow up PR + + if obj.Spec.ExternalGroupId == "" { + return apierrors.NewBadRequest("externalGroupId is required") + } + return nil +} diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index e6b58f3c536..cc229f2ed4b 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -29,6 +29,7 @@ import ( grafanaregistry "github.com/grafana/grafana/pkg/apiserver/registry/generic" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" "github.com/grafana/grafana/pkg/registry/apis/iam/resourcepermission" @@ -490,6 +491,8 @@ func (b *IdentityAccessManagementAPIBuilder) Validate(ctx context.Context, a adm return teambinding.ValidateOnCreate(ctx, typedObj) case *iamv0.ResourcePermission: return resourcepermission.ValidateCreateAndUpdateInput(ctx, typedObj) + case *iamv0.ExternalGroupMapping: + return externalgroupmapping.ValidateOnCreate(typedObj) } return nil case admission.Update: From 6dcb921333eb6da1f5672e31cfb0cab1fc6e3d35 Mon Sep 17 00:00:00 2001 From: Lauren <61048546+laurenashleigh@users.noreply.github.com> Date: Wed, 19 Nov 2025 09:11:07 +0000 Subject: [PATCH 052/125] Alerting: Fix for fetching evaluation group in new filter (#113694) * Alerting: Fix for fetching evaluation group in new filter * WIP: Add async evaluation groups dropdown with 500 group limit * Add text to query param if threshold limit reached * update translations, remove group info tooltip * resolve PR comment --- .../rules/Filter/useRuleFilterAutocomplete.ts | 83 +++++++++--- .../rule-list/filter/RulesFilter.v2.tsx | 118 +++++++++++------- public/locales/en-US/grafana.json | 2 + 3 files changed, 142 insertions(+), 61 deletions(-) diff --git a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts index 7cfe57b95f6..df6a317d071 100644 --- a/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts +++ b/public/app/features/alerting/unified/components/rules/Filter/useRuleFilterAutocomplete.ts @@ -15,10 +15,19 @@ function getExternalRuleDataSources() { return getRulesDataSources().filter((ds: DataSourceInstanceSettings) => !!ds?.url); } +const THRESHOLD_LIMIT = 500; + +function createInfoOption(message: string): ComboboxOption { + return { + label: message, + value: '__GRAFANA_INFO_OPTION__', + infoOption: true, + }; +} + export function useNamespaceAndGroupOptions(): { namespaceOptions: (inputValue: string) => Promise>>; - allGroupNames: string[]; - isLoadingNamespaces: boolean; + groupOptions: (inputValue: string) => Promise>>; namespacePlaceholder: string; groupPlaceholder: string; } { @@ -50,17 +59,11 @@ export function useNamespaceAndGroupOptions(): { const namespaceOptions = useCallback( async (inputValue: string) => { - // Grafana namespaces - const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: 1000 }).unwrap(); - const grafanaFolders: Array> = Array.from( + // Grafana namespaces - fetch with limit to check threshold + const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: THRESHOLD_LIMIT + 1 }).unwrap(); + const grafanaFolderNames = Array.from( new Set(grafanaResponse.data.groups.map((g: GrafanaPromRuleGroupDTO) => g.file || 'default')) - ) - .map((name) => ({ - label: name, - value: name, - description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), - })) - .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + ); // External namespaces const namespaceNameSet = new Set(); @@ -68,7 +71,7 @@ export function useNamespaceAndGroupOptions(): { fetchExternalGroups({ ruleSource: { uid: ds.uid }, excludeAlerts: true, - groupLimit: 500, + groupLimit: THRESHOLD_LIMIT + 1, notificationOptions: { showErrorAlert: false }, }).unwrap() ); @@ -78,6 +81,29 @@ export function useNamespaceAndGroupOptions(): { res.value.data.groups.forEach((group: { file?: string }) => namespaceNameSet.add(group.file || 'default')); } } + + const totalNamespaces = grafanaFolderNames.length + namespaceNameSet.size; + + // If we have more than THRESHOLD_LIMIT unique namespaces, show info message + if (totalNamespaces > THRESHOLD_LIMIT) { + return [ + createInfoOption( + t( + 'alerting.rules-filter.namespace-autocomplete-unavailable', + 'Due to large number of folders, autocomplete is not available' + ) + ), + ]; + } + + const grafanaFolders: Array> = grafanaFolderNames + .map((name) => ({ + label: name, + value: name, + description: t('alerting.rules-filter.grafana-folder', 'Grafana folder'), + })) + .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + const externalNamespaces = Array.from(namespaceNameSet) .map(formatNamespaceOption) .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); @@ -89,12 +115,37 @@ export function useNamespaceAndGroupOptions(): { [fetchGrafanaGroups, fetchExternalGroups, formatNamespaceOption] ); - const allGroupNames: string[] = []; - const isLoadingNamespaces = false; + const groupOptions = useCallback( + async (inputValue: string) => { + const grafanaResponse = await fetchGrafanaGroups({ limitAlerts: 0, groupLimit: THRESHOLD_LIMIT + 1 }).unwrap(); + const groupNames = Array.from(new Set(grafanaResponse.data.groups.map((g: GrafanaPromRuleGroupDTO) => g.name))); + + // If we have more than THRESHOLD_LIMIT unique groups, show info message + if (groupNames.length > THRESHOLD_LIMIT) { + return [ + createInfoOption( + t( + 'alerting.rules-filter.group-autocomplete-unavailable', + 'Due to large number of groups, autocomplete is not available' + ) + ), + ]; + } + + const options: Array> = groupNames + .map((name) => ({ label: name, value: name })) + .sort((a, b) => collator.compare(a.label ?? '', b.label ?? '')); + + const filtered = filterBySearch(options, inputValue); + return filtered; + }, + [fetchGrafanaGroups] + ); + const namespacePlaceholder = t('alerting.rules-filter.filter-options.placeholder-namespace', 'Select namespace'); const groupPlaceholder = t('grafana.select-group', 'Select group'); - return { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder }; + return { namespaceOptions, groupOptions, namespacePlaceholder, groupPlaceholder }; } export function useLabelOptions(): { diff --git a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx index dde3e983ad9..e55b4c9d930 100644 --- a/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx +++ b/public/app/features/alerting/unified/rule-list/filter/RulesFilter.v2.tsx @@ -1,5 +1,5 @@ import { css } from '@emotion/css'; -import { useEffect, useRef, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, FormProvider, SubmitHandler, useForm, useFormContext } from 'react-hook-form'; import { ContactPointSelector } from '@grafana/alerting/unstable'; @@ -55,6 +55,25 @@ const canRenderContactPointSelector = contextSrv.hasPermission(AccessControlActi const radioGroupCompactClass = css({ width: 'max-content' }); +// Helper to create a wrapped options function that captures typed input when threshold is exceeded +function createThresholdAwareOptions( + optionsFunc: (inputValue: string) => Promise, + setValue: (value: string) => void, + fieldName: string +) { + return async (inputValue: string): Promise => { + const options = await optionsFunc(inputValue); + const exceeded = options.length === 1 && options[0].infoOption === true; + + // If threshold exceeded and user is typing, capture the typed value + if (exceeded && inputValue) { + setValue(inputValue); + } + + return options; + }; +} + type SearchQueryForm = { query: string; }; @@ -85,11 +104,9 @@ export default function RulesFilter({ viewMode, onViewModeChange }: RulesFilterP }; const handleAdvancedFilters: SubmitHandler = (values) => { - const newFilter = formAdvancedFiltersToRuleFilter(values); - updateFilters(newFilter); - + updateFilters(formAdvancedFiltersToRuleFilter(values)); trackFilterButtonApplyClick(values, pluginsFilterEnabled); - setIsPopupOpen(false); // Should close popup after applying filters? + setIsPopupOpen(false); }; const handleClearFilters = () => { @@ -248,8 +265,7 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption const defaultValues = searchQueryToDefaultValues(filterState); // Fetch namespace and group data from all sources (optimized for filter UI) - const { namespaceOptions, allGroupNames, isLoadingNamespaces, namespacePlaceholder, groupPlaceholder } = - useNamespaceAndGroupOptions(); + const { namespaceOptions, groupOptions, namespacePlaceholder, groupPlaceholder } = useNamespaceAndGroupOptions(); const { labelOptions } = useLabelOptions(); @@ -294,13 +310,11 @@ const FilterOptions = ({ onSubmit, onClear, pluginsFilterEnabled }: FilterOption @@ -373,15 +387,22 @@ function LabelsField({ function NamespaceField({ namespaceOptions, namespacePlaceholder, - isLoadingNamespaces, portalContainer, }: { - namespaceOptions: (inputValue: string) => Promise>; + namespaceOptions: ( + inputValue: string + ) => Promise>; namespacePlaceholder: string; - isLoadingNamespaces: boolean; portalContainer?: HTMLElement; }) { - const { control } = useFormContext(); + const { control, setValue } = useFormContext(); + + const wrappedOptions = useCallback( + (inputValue: string) => + createThresholdAwareOptions(namespaceOptions, (value) => setValue('namespace', value), 'namespace')(inputValue), + [namespaceOptions, setValue] + ); + return ( <>
)}
diff --git a/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx b/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx index 4eb223b4f59..3897066cbd7 100644 --- a/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx +++ b/public/app/core/components/NavLandingPage/NavLandingPageCard.tsx @@ -1,4 +1,5 @@ -import { css } from '@emotion/css'; +import { css, cx } from '@emotion/css'; +import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { Card, useStyles2 } from '@grafana/ui'; @@ -7,12 +8,24 @@ interface Props { description?: string; text: string; url: string; + category?: string; + onClick?: (event?: React.MouseEvent) => void; } -export function NavLandingPageCard({ description, text, url }: Props) { +const CATEGORY_STYLES = ['primary', 'secondary', 'success', 'warning', 'error'] as const; +type CategoryStyle = (typeof CATEGORY_STYLES)[number]; + +function isCategoryStyle(cat: string): cat is CategoryStyle { + return CATEGORY_STYLES.some((style) => style === cat); +} + +export function NavLandingPageCard({ description, text, url, category, onClick }: Props) { const styles = useStyles2(getStyles); + + const categoryClass = category && isCategoryStyle(category) ? styles[category] : undefined; + return ( - + {text} {description} @@ -31,4 +44,45 @@ const getStyles = (theme: GrafanaTheme2) => ({ display: '-webkit-box', overflow: 'hidden', }), + // Category-based styling + primary: css({ + border: `1px solid ${theme.colors.primary.borderTransparent}`, + backgroundColor: theme.colors.primary.transparent, + '&:hover': { + backgroundColor: theme.colors.primary.transparent, + borderColor: theme.colors.primary.border, + }, + }), + secondary: css({ + border: `1px solid ${theme.colors.secondary.borderTransparent}`, + backgroundColor: theme.colors.secondary.transparent, + '&:hover': { + backgroundColor: theme.colors.secondary.transparent, + borderColor: theme.colors.secondary.border, + }, + }), + success: css({ + border: `1px solid ${theme.colors.success.borderTransparent}`, + backgroundColor: theme.colors.success.transparent, + '&:hover': { + backgroundColor: theme.colors.success.transparent, + borderColor: theme.colors.success.border, + }, + }), + warning: css({ + border: `1px solid ${theme.colors.warning.borderTransparent}`, + backgroundColor: theme.colors.warning.transparent, + '&:hover': { + backgroundColor: theme.colors.warning.transparent, + borderColor: theme.colors.warning.border, + }, + }), + error: css({ + border: `1px solid ${theme.colors.error.borderTransparent}`, + backgroundColor: theme.colors.error.transparent, + '&:hover': { + backgroundColor: theme.colors.error.transparent, + borderColor: theme.colors.error.border, + }, + }), }); From 164873591b2f5224cc3777a0ab6406f76083167d Mon Sep 17 00:00:00 2001 From: Victor Cinaglia Date: Wed, 19 Nov 2025 15:21:40 -0300 Subject: [PATCH 084/125] IAM: Optionally make refresh tokens required if use_refresh_token is enabled (#114174) * OAuth: Optionally make refresh tokens required if use_refresh_token is enabled * make linter happy * feedback: log missing refresh token during token refresh * feedback: tweak wording in the message & change level --- .../src/types/featureToggles.gen.ts | 4 ++ pkg/services/authn/clients/oauth.go | 11 ++++ pkg/services/authn/clients/oauth_test.go | 57 +++++++++++++++++++ pkg/services/featuremgmt/registry.go | 8 +++ pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 ++ pkg/services/featuremgmt/toggles_gen.json | 14 +++++ pkg/services/oauthtoken/oauth_token.go | 4 ++ 8 files changed, 103 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index edfb1056977..b9afd13cec0 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -477,6 +477,10 @@ export interface FeatureToggles { */ oauthRequireSubClaim?: boolean; /** + * Require that refresh tokens are present in oauth tokens. + */ + refreshTokenRequired?: boolean; + /** * Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering. */ newDashboardWithFiltersAndGroupBy?: boolean; diff --git a/pkg/services/authn/clients/oauth.go b/pkg/services/authn/clients/oauth.go index b65202b71d4..f7798cc365f 100644 --- a/pkg/services/authn/clients/oauth.go +++ b/pkg/services/authn/clients/oauth.go @@ -55,6 +55,8 @@ var ( errOAuthTokenExchange = errutil.Internal("auth.oauth.token.exchange", errutil.WithPublicMessage("Failed to get token from provider")) errOAuthUserInfo = errutil.Internal("auth.oauth.userinfo.error") + errOAuthMissingRefreshToken = errutil.Unauthorized("auth.oauth.token.refresh-token.missing", errutil.WithPublicMessage("Provider did not return a refresh token")) + errOAuthMissingRequiredEmail = errutil.Unauthorized("auth.oauth.email.missing", errutil.WithPublicMessage("Provider didn't return an email address")) errOAuthEmailNotAllowed = errutil.Unauthorized("auth.oauth.email.not-allowed", errutil.WithPublicMessage("Required email domain not fulfilled")) ) @@ -166,6 +168,15 @@ func (c *OAuth) Authenticate(ctx context.Context, r *authn.Request) (*authn.Iden } token.TokenType = "Bearer" + if oauthCfg.UseRefreshToken && token.RefreshToken == "" { + c.log.FromContext(ctx).Warn("No refresh token available with use_refresh_token enabled", "authmodule", c.moduleName) + + //nolint:staticcheck // not yet migrated to OpenFeature + if c.features.IsEnabledGlobally(featuremgmt.FlagRefreshTokenRequired) { + return nil, errOAuthMissingRefreshToken.Errorf("provider did not return a refresh token") + } + } + userInfo, err := connector.UserInfo(ctx, connector.Client(clientCtx, token), token) if err != nil { var sErr *connectors.SocialError diff --git a/pkg/services/authn/clients/oauth_test.go b/pkg/services/authn/clients/oauth_test.go index b6a9a07c488..324b956bd36 100644 --- a/pkg/services/authn/clients/oauth_test.go +++ b/pkg/services/authn/clients/oauth_test.go @@ -265,6 +265,63 @@ func TestOAuth_Authenticate(t *testing.T) { }, }, }, + { + desc: "should return error when no refresh token is available and feature toggle is enabled", + req: &authn.Request{ + HTTPRequest: &http.Request{ + Header: map[string][]string{}, + URL: mustParseURL("http://grafana.com/?state=some-state"), + }, + }, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true, UseRefreshToken: true}, + features: []any{featuremgmt.FlagRefreshTokenRequired}, + allowInsecureTakeover: true, + addStateCookie: true, + stateCookieValue: "some-state", + addPKCECookie: true, + pkceCookieValue: "some-pkce-value", + isEmailAllowed: true, + expectedErr: errOAuthMissingRefreshToken, + }, + { + desc: "should return identity when no refresh token is available and feature toggle is disabled", + req: &authn.Request{ + HTTPRequest: &http.Request{ + Header: map[string][]string{}, + URL: mustParseURL("http://grafana.com/?state=some-state"), + }, + }, + oauthCfg: &social.OAuthInfo{UsePKCE: true, Enabled: true, UseRefreshToken: true}, + addStateCookie: true, + stateCookieValue: "some-state", + addPKCECookie: true, + pkceCookieValue: "some-pkce-value", + isEmailAllowed: true, + userInfo: &social.BasicUserInfo{ + Id: "123", + Name: "name", + Email: "some@email.com", + Role: "Admin", + Groups: []string{"grp1", "grp2"}, + }, + expectedIdentity: &authn.Identity{ + Email: "some@email.com", + AuthenticatedBy: login.AzureADAuthModule, + AuthID: "123", + Name: "name", + Groups: []string{"grp1", "grp2"}, + OAuthToken: &oauth2.Token{}, + OrgRoles: map[int64]org.RoleType{1: org.RoleAdmin}, + ClientParams: authn.ClientParams{ + SyncUser: true, + SyncTeams: true, + AllowSignUp: true, + FetchSyncedUser: true, + SyncOrgRoles: true, + LookUpParams: login.UserLookupParams{}, + }, + }, + }, } for _, tt := range tests { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index b7362496e38..68e88830be4 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -817,6 +817,14 @@ var ( HideFromDocs: true, HideFromAdminPage: true, }, + { + Name: "refreshTokenRequired", + Description: "Require that refresh tokens are present in oauth tokens.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, + HideFromAdminPage: true, + }, { Name: "newDashboardWithFiltersAndGroupBy", Description: "Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 2d2688c285c..e0f2bec13bc 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -107,6 +107,7 @@ kubernetesAggregatorCapTokenAuth,experimental,@grafana/grafana-app-platform-squa groupByVariable,experimental,@grafana/dashboards-squad,false,false,false scopeFilters,experimental,@grafana/dashboards-squad,false,false,false oauthRequireSubClaim,experimental,@grafana/identity-access-team,false,false,false +refreshTokenRequired,experimental,@grafana/identity-access-team,false,false,false newDashboardWithFiltersAndGroupBy,experimental,@grafana/dashboards-squad,false,false,false cloudWatchNewLabelParsing,GA,@grafana/aws-datasources,false,false,false disableNumericMetricsSortingInExpressions,experimental,@grafana/oss-big-tent,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f5792d32d88..9fb1c573406 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -439,6 +439,10 @@ const ( // Require that sub claims is present in oauth tokens. FlagOauthRequireSubClaim = "oauthRequireSubClaim" + // FlagRefreshTokenRequired + // Require that refresh tokens are present in oauth tokens. + FlagRefreshTokenRequired = "refreshTokenRequired" + // FlagNewDashboardWithFiltersAndGroupBy // Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering. FlagNewDashboardWithFiltersAndGroupBy = "newDashboardWithFiltersAndGroupBy" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 0fc14a616c5..daf3c48c40c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -3543,6 +3543,20 @@ "hideFromAdminPage": true } }, + { + "metadata": { + "name": "refreshTokenRequired", + "resourceVersion": "1763561990273", + "creationTimestamp": "2025-11-19T14:19:50Z" + }, + "spec": { + "description": "Require that refresh tokens are present in oauth tokens.", + "stage": "experimental", + "codeowner": "@grafana/identity-access-team", + "hideFromAdminPage": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "regressionTransformation", diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index ee0de1007b2..0efe5e553f3 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -473,6 +473,10 @@ func (o *Service) tryGetOrRefreshOAuthToken(ctx context.Context, persistedToken ) } + if token.RefreshToken == "" { + ctxLogger.Warn("Refresh token is missing after token refresh", "authmodule", tokenRefreshMetadata.AuthModule) + } + //nolint:staticcheck // not yet migrated to OpenFeature if !o.features.IsEnabledGlobally(featuremgmt.FlagImprovedExternalSessionHandling) { updateAuthCommand := &login.UpdateAuthInfoCommand{ From 5a0e9e41830cc7a184af2abba8c502f79abe233d Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 19 Nov 2025 20:01:40 +0100 Subject: [PATCH 085/125] Alerting: Add rule_uid filter support to the Prometheus-compatible list rules API (#114182) --- .../ngalert/api/api_prometheus_test.go | 33 +++++++++++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 4 +++ 2 files changed, 37 insertions(+) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 7f1e9c8e4a2..2b8ef4b48e7 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -912,6 +912,39 @@ func TestRouteGetRuleStatuses(t *testing.T) { require.Len(t, result.Data.RuleGroups[0].Rules, 1) require.Equal(t, expectedRule.Title, result.Data.RuleGroups[0].Rules[0].Name) }) + + t.Run("should only return rules with given rule_uid list", func(t *testing.T) { + expectedRuleInGroup1 := rulesInGroup1[0] + expectedRuleInGroup3 := rulesInGroup3[1] + + r, err := http.NewRequest("GET", fmt.Sprintf("/api/v1/rules?rule_uid=%s&rule_uid=%s", expectedRuleInGroup1.UID, expectedRuleInGroup3.UID), nil) + require.NoError(t, err) + + c.Context = &web.Context{Req: r} + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + result := &apimodels.RuleResponse{} + require.NoError(t, json.Unmarshal(resp.Body(), result)) + + require.Len(t, result.Data.RuleGroups, 2) + require.True(t, slices.ContainsFunc(result.Data.RuleGroups, func(rg apimodels.RuleGroup) bool { + return rg.Name == "rule-group-1" + })) + require.True(t, slices.ContainsFunc(result.Data.RuleGroups, func(rg apimodels.RuleGroup) bool { + return rg.Name == "rule-group-3" + })) + require.Len(t, result.Data.RuleGroups[0].Rules, 1) + require.Len(t, result.Data.RuleGroups[1].Rules, 1) + + if result.Data.RuleGroups[0].Name == "rule-group-1" { + require.Equal(t, expectedRuleInGroup1.UID, result.Data.RuleGroups[0].Rules[0].UID) + require.Equal(t, expectedRuleInGroup3.UID, result.Data.RuleGroups[1].Rules[0].UID) + } else { + require.Equal(t, expectedRuleInGroup1.UID, result.Data.RuleGroups[1].Rules[0].UID) + require.Equal(t, expectedRuleInGroup3.UID, result.Data.RuleGroups[0].Rules[0].UID) + } + }) }) t.Run("when requesting rules with pagination", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index 99cb0d95530..c835fb2acdd 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -477,6 +477,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt } ruleGroups := opts.Query["rule_group"] + ruleUIDs := opts.Query["rule_uid"] receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") @@ -515,6 +516,7 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt ListAlertRulesQuery: ngmodels.ListAlertRulesQuery{ OrgID: opts.OrgID, NamespaceUIDs: namespaceUIDs, + RuleUIDs: ruleUIDs, DashboardUID: dashboardUID, PanelID: panelID, RuleGroups: ruleGroups, @@ -650,6 +652,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru } ruleGroups := opts.Query["rule_group"] + ruleUIDs := opts.Query["rule_uid"] receiverName := opts.Query.Get("receiver_name") title := opts.Query.Get("search.rule_name") @@ -658,6 +661,7 @@ func PrepareRuleGroupStatuses(log log.Logger, store ListAlertRulesStore, opts Ru alertRuleQuery := ngmodels.ListAlertRulesQuery{ OrgID: opts.OrgID, NamespaceUIDs: namespaceUIDs, + RuleUIDs: ruleUIDs, DashboardUID: dashboardUID, PanelID: panelID, RuleGroups: ruleGroups, From 62b8caec9b18f86ed715bbab9dfb943236203a19 Mon Sep 17 00:00:00 2001 From: Juan Cabanas Date: Wed, 19 Nov 2025 16:26:34 -0300 Subject: [PATCH 086/125] DashboardLibrary: Add template dashboard tests (#114179) * dashboard template tests * command plaette restored * command plaette restored --- .../AppChrome/QuickAdd/QuickAdd.test.tsx | 36 ++++- .../BrowseDashboardsPage.test.tsx | 61 +++++++- .../components/CreateNewButton.test.tsx | 66 ++++++--- .../TemplateDashboardModal.test.tsx | 135 ++++++++++++++++++ 4 files changed, 278 insertions(+), 20 deletions(-) create mode 100644 public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.test.tsx diff --git a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx index 82636ed6f91..9fed0105c54 100644 --- a/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx +++ b/public/app/core/components/AppChrome/QuickAdd/QuickAdd.test.tsx @@ -3,7 +3,7 @@ import userEvent from '@testing-library/user-event'; import { TestProvider } from 'test/helpers/TestProvider'; import { NavModelItem } from '@grafana/data'; -import { reportInteraction } from '@grafana/runtime'; +import { config, reportInteraction } from '@grafana/runtime'; import { QuickAdd } from './QuickAdd'; @@ -11,6 +11,13 @@ jest.mock('@grafana/runtime', () => { return { ...jest.requireActual('@grafana/runtime'), reportInteraction: jest.fn(), + getDataSourceSrv: () => ({ + getList: jest + .fn() + .mockReturnValue([ + { name: 'Test Data Source', uid: 'test-data-source-uid', type: 'grafana-testdata-datasource' }, + ]), + }), }; }); @@ -74,4 +81,31 @@ describe('QuickAdd', () => { from: 'quickadd', }); }); + + describe('Dashboard from template button', () => { + beforeEach(() => { + config.featureToggles.dashboardTemplates = true; + }); + + it('shows a `Dashboard from template` button when the feature flag is enabled', async () => { + setup(); + await userEvent.click(screen.getByRole('button', { name: 'New' })); + expect(screen.getByRole('link', { name: 'Dashboard from template' })).toBeInTheDocument(); + }); + + it('does not show a `Dashboard from template` button when the feature flag is disabled', async () => { + config.featureToggles.dashboardTemplates = false; + setup(); + await userEvent.click(screen.getByRole('button', { name: 'New' })); + expect(screen.queryByRole('link', { name: 'Dashboard from template' })).not.toBeInTheDocument(); + }); + + it('redirects the user to the dashboard from template page when the button is clicked', async () => { + setup(); + + await userEvent.click(screen.getByRole('button', { name: 'New' })); + const link = screen.getByRole('link', { name: 'Dashboard from template' }); + expect(link).toHaveAttribute('href', '/dashboards?templateDashboards=true&source=quickAdd'); + }); + }); }); diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index 6ac3b775d0e..67888b34e14 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -1,3 +1,4 @@ +import { http, HttpResponse } from 'msw'; import { ComponentProps } from 'react'; import { useParams } from 'react-router-dom-v5-compat'; import AutoSizer from 'react-virtualized-auto-sizer'; @@ -5,7 +6,7 @@ import { render as testRender, screen, waitFor } from 'test/test-utils'; import { selectors } from '@grafana/e2e-selectors'; import { config, setBackendSrv } from '@grafana/runtime'; -import { setupMockServer } from '@grafana/test-utils/server'; +import server, { setupMockServer } from '@grafana/test-utils/server'; import { getFolderFixtures } from '@grafana/test-utils/unstable'; import { backendSrv } from 'app/core/services/backend_srv'; import { contextSrv } from 'app/core/services/context_srv'; @@ -41,9 +42,25 @@ jest.mock('react-router-dom-v5-compat', () => ({ useParams: jest.fn().mockReturnValue({}), })); -function render(ui: Parameters[0]) { +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: jest + .fn() + .mockReturnValue([ + { name: 'Test Data Source', uid: 'test-data-source-uid', type: 'grafana-testdata-datasource' }, + ]), + }), + }; +}); + +function render(ui: Parameters[0], options: Parameters[1] = {}) { return testRender(ui, { - preloadedState: { navIndex: { 'dashboards/browse': { text: 'Dashboards', id: 'dashboards/browse' } } }, + preloadedState: { + navIndex: { 'dashboards/browse': { text: 'Dashboards', id: 'dashboards/browse' } }, + }, + ...options, }); } @@ -324,4 +341,42 @@ describe('browse-dashboards BrowseDashboardsPage', () => { expect(checkbox).toBeInTheDocument(); }); }); + + describe('Template dashboard modal', () => { + beforeEach(() => { + config.featureToggles.dashboardTemplates = true; + server.use( + http.get('/api/gnet/dashboards', () => { + return HttpResponse.json({ + page: 1, + pages: 1, + items: [ + { + id: 1, + name: 'Test Template Dashboard', + description: 'A test template dashboard', + downloads: 100, + datasource: 'grafana-testdata-datasource', + }, + ], + }); + }) + ); + }); + + it('should show TemplateDashboard modal when the feature flag is enabled', async () => { + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + expect(await screen.findByRole('dialog', { name: 'Start a dashboard from a template' })).toBeInTheDocument(); + }); + + it('should not show TemplateDashboard modal when the feature flag is disabled', async () => { + config.featureToggles.dashboardTemplates = false; + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + expect(screen.queryByRole('dialog', { name: 'Start a dashboard from a template' })).not.toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx b/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx index 11d83f9e35f..ff0b2d83c93 100644 --- a/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx +++ b/public/app/features/browse-dashboards/components/CreateNewButton.test.tsx @@ -1,7 +1,7 @@ -import { render as rtlRender, screen, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { TestProvider } from 'test/helpers/TestProvider'; +import { screen, within } from '@testing-library/react'; +import { render } from 'test/test-utils'; +import { config } from '@grafana/runtime'; import { ManagerKind } from 'app/features/apiserver/types'; import { useIsProvisionedInstance } from 'app/features/provisioning/hooks/useIsProvisionedInstance'; import { FolderDTO } from 'app/types/folders'; @@ -14,18 +14,29 @@ jest.mock('app/features/provisioning/hooks/useIsProvisionedInstance', () => ({ useIsProvisionedInstance: jest.fn(), })); +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: jest + .fn() + .mockReturnValue([ + { name: 'Test Data Source', uid: 'test-data-source-uid', type: 'grafana-testdata-datasource' }, + ]), + }), + }; +}); + const mockUseIsProvisionedInstance = useIsProvisionedInstance as jest.MockedFunction; const mockParentFolder = mockFolderDTO(); -function render(...[ui, options]: Parameters) { - rtlRender({ui}, options); -} - async function renderAndOpen(folder?: FolderDTO) { - render(); + const { user } = render( + + ); const newButton = screen.getByText('New'); - await userEvent.click(newButton); + await user.click(newButton); } describe('NewActionsButton', () => { @@ -53,13 +64,13 @@ describe('NewActionsButton', () => { }); it('clicking the "New folder" button opens the drawer', async () => { - render( + const { user } = render( ); const newButton = screen.getByText('New'); - await userEvent.click(newButton); - await userEvent.click(screen.getByText('New folder')); + await user.click(newButton); + await user.click(screen.getByText('New folder')); const drawer = screen.getByRole('dialog', { name: 'Drawer title New folder' }); expect(drawer).toBeInTheDocument(); @@ -68,9 +79,9 @@ describe('NewActionsButton', () => { }); it('should only render dashboard items when folder creation is disabled', async () => { - render(); + const { user } = render(); const newButton = screen.getByText('New'); - await userEvent.click(newButton); + await user.click(newButton); expect(screen.getByRole('link', { name: 'New dashboard' })).toBeInTheDocument(); expect(screen.getByText('Import')).toBeInTheDocument(); @@ -78,9 +89,9 @@ describe('NewActionsButton', () => { }); it('should only render folder item when dashboard creation is disabled', async () => { - render(); + const { user } = render(); const newButton = screen.getByText('New'); - await userEvent.click(newButton); + await user.click(newButton); expect(screen.queryByText('New dashboard')).not.toBeInTheDocument(); expect(screen.queryByText('Import')).not.toBeInTheDocument(); @@ -124,4 +135,27 @@ describe('NewActionsButton', () => { expect(screen.getByText('New folder')).toBeInTheDocument(); expect(screen.queryByText('Import')).not.toBeInTheDocument(); }); + + describe('Dashboard from template button', () => { + beforeEach(() => { + config.featureToggles.dashboardTemplates = true; + }); + + it('should show a `Dashboard from template` button when the feature flag is enabled', async () => { + await renderAndOpen(); + expect(screen.getByRole('link', { name: 'Dashboard from template' })).toBeInTheDocument(); + }); + + it('should not show a `Dashboard from template` button when the feature flag is disabled', async () => { + config.featureToggles.dashboardTemplates = false; + await renderAndOpen(); + expect(screen.queryByRole('link', { name: 'Dashboard from template' })).not.toBeInTheDocument(); + }); + + it('should redirect the user to the dashboard from template page when the button is clicked', async () => { + await renderAndOpen(); + const link = screen.getByRole('link', { name: 'Dashboard from template' }); + expect(link).toHaveAttribute('href', '/dashboards?templateDashboards=true&source=createNewButton'); + }); + }); }); diff --git a/public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.test.tsx b/public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.test.tsx new file mode 100644 index 00000000000..47ad435cbf5 --- /dev/null +++ b/public/app/features/dashboard/dashgrid/DashboardLibrary/TemplateDashboardModal.test.tsx @@ -0,0 +1,135 @@ +import { http, HttpResponse } from 'msw'; +import { render, screen, waitFor } from 'test/test-utils'; + +import { setBackendSrv } from '@grafana/runtime'; +import server, { setupMockServer } from '@grafana/test-utils/server'; +import { backendSrv } from 'app/core/services/backend_srv'; + +import { TemplateDashboardModal } from './TemplateDashboardModal'; + +setBackendSrv(backendSrv); +setupMockServer(); + +const mockGetList = jest + .fn() + .mockReturnValue([{ name: 'Test Data Source', uid: 'test-data-source-uid', type: 'grafana-testdata-datasource' }]); + +jest.mock('@grafana/runtime', () => { + return { + ...jest.requireActual('@grafana/runtime'), + getDataSourceSrv: () => ({ + getList: mockGetList, + }), + }; +}); + +describe('TemplateDashboardModal', () => { + beforeEach(() => { + mockGetList.mockReturnValue([ + { name: 'Test Data Source', uid: 'test-data-source-uid', type: 'grafana-testdata-datasource' }, + ]); + server.use( + http.get('/api/gnet/dashboards', () => { + return HttpResponse.json({ + page: 1, + pages: 1, + items: [ + { + id: 1, + name: 'Test Template Dashboard', + description: 'A test template dashboard', + downloads: 100, + datasource: 'grafana-testdata-datasource', + }, + { + id: 2, + name: 'Test Template Dashboard 2', + description: 'A test template dashboard 2', + downloads: 100, + datasource: 'grafana-testdata-datasource', + }, + ], + }); + }) + ); + }); + + describe('Render conditions', () => { + it('should show TemplateDashboard modal when query param is present, test data source is available and there are template dashboards', async () => { + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + expect(await screen.findByRole('dialog', { name: 'Start a dashboard from a template' })).toBeInTheDocument(); + }); + + it('should not show TemplateDashboard modal when query param is present but test data source is not available', async () => { + mockGetList.mockReturnValueOnce([]); + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + expect(screen.queryByRole('dialog', { name: 'Start a dashboard from a template' })).not.toBeInTheDocument(); + }); + + it('should not show TemplateDashboard modal when query param is present but there are no template dashboards', async () => { + server.use( + http.get('/api/gnet/dashboards', () => { + return HttpResponse.json({ + page: 1, + pages: 1, + items: [], + }); + }) + ); + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Start a dashboard from a template' })).not.toBeInTheDocument(); + }); + }); + + it('should not show TemplateDashboard modal when query param is not present', async () => { + render(); + await waitFor(() => { + expect(screen.queryByRole('dialog', { name: 'Start a dashboard from a template' })).not.toBeInTheDocument(); + }); + }); + }); + + describe('Render content', () => { + it('should render title and description', async () => { + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + + await waitFor(() => { + expect(screen.getByText('Start a dashboard from a template')).toBeInTheDocument(); + }); + + expect( + screen.getByText( + 'Get started with Grafana templates using sample data. Connect your data to power them with real metrics.' + ) + ).toBeInTheDocument(); + }); + it('should show template dashboard cards', async () => { + render(, { + historyOptions: { initialEntries: [`/dashboards?templateDashboards=true`] }, + }); + + await waitFor(() => { + // Assert DashboardCard components are rendered by checking for their headings + expect(screen.getByRole('heading', { name: 'Test Template Dashboard' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Test Template Dashboard 2' })).toBeInTheDocument(); + + // Assert DashboardCard components are rendered by checking for "Use template" buttons + const useTemplateButtons = screen.getAllByRole('button', { name: 'Use template' }); + expect(useTemplateButtons).toHaveLength(2); + + // Assert text content (descriptions) + expect(screen.getByText('A test template dashboard')).toBeInTheDocument(); + expect(screen.getByText('A test template dashboard 2')).toBeInTheDocument(); + }); + }); + }); +}); From 84d6f54daac5119e6c26413e13d11d6b188f2be6 Mon Sep 17 00:00:00 2001 From: Ieva Date: Wed, 19 Nov 2025 19:40:54 +0000 Subject: [PATCH 087/125] Chore: Remove references to `editors_can_admin` (#114168) remove references to editors_can_admin --- conf/defaults.ini | 4 ---- conf/sample.ini | 4 ---- docs/sources/setup-grafana/configure-grafana/_index.md | 9 --------- 3 files changed, 17 deletions(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 620e009fe92..77bc39c9feb 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -581,10 +581,6 @@ external_manage_info = # Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. viewers_can_edit = false -# Deprecated: Assign your editors to admins. -# Editors can administrate dashboard, folders and teams they create -editors_can_admin = false - # The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 24h (24 hours). The minimum supported duration is 15m (15 minutes). user_invite_max_lifetime_duration = 24h diff --git a/conf/sample.ini b/conf/sample.ini index 6d41d750b8a..eea46ccaacb 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -568,10 +568,6 @@ # Viewers can edit/inspect dashboard settings in the browser. But not save the dashboard. ;viewers_can_edit = false -# Deprecated: Assign your editors to admins. -# Editors can administrate dashboard, folders and teams they create -;editors_can_admin = false - # The duration in time a user invitation remains valid before expiring. This setting should be expressed as a duration. Examples: 6h (hours), 2d (days), 1w (week). Default is 24h (24 hours). The minimum supported duration is 15m (15 minutes). ;user_invite_max_lifetime_duration = 24h diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index c08056b0f26..c81d24d0758 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -987,15 +987,6 @@ This option is deprecated - assign your viewers as editors, if you are using RBA Viewers can access and use [Explore](../../explore/) and perform temporary edits on panels in dashboards they have access to. They cannot save their changes. Default is `false`. -#### `editors_can_admin` - -{{< admonition type="note" >}} -This option is deprecated - assign your editors as admins, if you are using RBAC assign the team creator role to your users. -{{< /admonition >}} - -Editors can administrate dashboards, folders and teams they create. -Default is `false`. - #### `user_invite_max_lifetime_duration` The duration in time a user invitation remains valid before expiring. From 72648030166aacd4eec7c54dfb3fa85330af267a Mon Sep 17 00:00:00 2001 From: Dave Henderson Date: Wed, 19 Nov 2025 15:20:32 -0500 Subject: [PATCH 088/125] chore(deps): Switch to maintained gopkg.in/yaml fork (#114131) Signed-off-by: Dave Henderson --- .golangci.yml | 4 +++- apps/secret/go.mod | 4 ++-- .../pkg/apis/secret/v1beta1/exposed_secure_value.go | 2 +- .../pkg/apis/secret/v1beta1/exposed_secure_value_test.go | 2 +- devenv/scopes/scopes.go | 2 +- go.mod | 8 ++++---- pkg/api/response/response.go | 2 +- pkg/apimachinery/apis/common/v0alpha1/secure_values.go | 2 +- .../apis/common/v0alpha1/secure_values_test.go | 2 +- pkg/apimachinery/go.mod | 7 +++---- pkg/registry/apis/provisioning/resources/parser.go | 3 ++- pkg/services/ngalert/api/api_convert_prometheus.go | 2 +- pkg/services/ngalert/api/api_convert_prometheus_test.go | 2 +- pkg/services/ngalert/api/lotex_am.go | 2 +- pkg/services/ngalert/api/lotex_ruler.go | 2 +- pkg/services/ngalert/api/prometheus_conversion.go | 2 +- .../ngalert/api/tooling/definitions/alertmanager.go | 2 +- .../ngalert/api/tooling/definitions/alertmanager_test.go | 2 +- .../api/tooling/definitions/alertmanager_validation.go | 2 +- .../ngalert/api/tooling/definitions/cortex-ruler_test.go | 2 +- .../api/tooling/definitions/provisioning_policies_test.go | 2 +- pkg/services/ngalert/api/util.go | 2 +- pkg/services/ngalert/models/alert_rule_test.go | 2 +- pkg/services/ngalert/prom/convert.go | 2 +- pkg/services/ngalert/prom/convert_test.go | 2 +- pkg/services/ngalert/prom/models_test.go | 2 +- pkg/services/ngalert/remote/alertmanager.go | 2 +- pkg/services/ngalert/remote/alertmanager_test.go | 2 +- pkg/services/ngalert/sender/notifier.go | 2 +- pkg/services/ngalert/sender/notifier_test.go | 2 +- pkg/services/provisioning/alerting/config_reader.go | 2 +- .../provisioning/alerting/contact_point_types_test.go | 2 +- .../alerting/notification_policy_types_test.go | 2 +- pkg/services/provisioning/alerting/rules_types_test.go | 2 +- pkg/services/provisioning/dashboards/config_reader.go | 2 +- pkg/services/provisioning/datasources/config_reader.go | 2 +- pkg/services/provisioning/plugins/config_reader.go | 2 +- pkg/services/provisioning/values/values_test.go | 2 +- .../alerting/api_convert_prometheus_alertmanager_test.go | 2 +- pkg/tests/api/alerting/api_provisioning_test.go | 2 +- pkg/tests/api/alerting/testing.go | 2 +- 41 files changed, 50 insertions(+), 48 deletions(-) diff --git a/.golangci.yml b/.golangci.yml index 33b2b9e8a19..3b7871662e2 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -181,7 +181,9 @@ linters: - pkg: io/ioutil desc: 'Deprecated: As of Go 1.16, the same functionality is now provided by package io or package os, and those implementations should be preferred in new code. See the specific function documentation for details.' - pkg: gopkg.in/yaml.v2 - desc: Grafana packages are not allowed to depend on gopkg.in/yaml.v2 as gopkg.in/yaml.v3 is now available + desc: use go.yaml.in/yaml/v3 instead + - pkg: gopkg.in/yaml.v3 + desc: use go.yaml.in/yaml/v3 instead - pkg: github.com/pkg/errors desc: 'Deprecated: Go 1.13 supports the functionality provided by pkg/errors in the standard library.' - pkg: github.com/xorcare/pointer diff --git a/apps/secret/go.mod b/apps/secret/go.mod index c64f6ad2a2e..09d3e5545f3 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -6,9 +6,9 @@ require ( github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250710134100-1f3dc0533caf github.com/stretchr/testify v1.11.1 + go.yaml.in/yaml/v3 v3.0.4 google.golang.org/grpc v1.76.0 google.golang.org/protobuf v1.36.10 - gopkg.in/yaml.v3 v3.0.1 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 @@ -54,7 +54,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/oauth2 v0.33.0 // indirect golang.org/x/sys v0.38.0 // indirect @@ -63,6 +62,7 @@ require ( golang.org/x/time v0.14.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/client-go v0.34.2 // indirect k8s.io/klog/v2 v2.130.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect diff --git a/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value.go b/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value.go index a8738c3093a..0a3d230f50a 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value.go +++ b/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value.go @@ -9,7 +9,7 @@ import ( "fmt" "strconv" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) const redacted = "[REDACTED]" diff --git a/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value_test.go b/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value_test.go index 90d53bdf7ae..d9bede562f8 100644 --- a/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value_test.go +++ b/apps/secret/pkg/apis/secret/v1beta1/exposed_secure_value_test.go @@ -11,7 +11,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) func TestExposedSecureValue(t *testing.T) { diff --git a/devenv/scopes/scopes.go b/devenv/scopes/scopes.go index 9e712aedc4d..1f8a698ccda 100644 --- a/devenv/scopes/scopes.go +++ b/devenv/scopes/scopes.go @@ -13,7 +13,7 @@ import ( "os" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "github.com/grafana/grafana/apps/scope/pkg/apis/scope/v0alpha1" diff --git a/go.mod b/go.mod index 0139b0ae433..65c896fc313 100644 --- a/go.mod +++ b/go.mod @@ -197,6 +197,8 @@ require ( go.uber.org/goleak v1.3.0 // @grafana/grafana-search-and-storage go.uber.org/mock v0.6.0 // @grafana/grafana-operator-experience-squad go.uber.org/zap v1.27.0 // @grafana/identity-access-team + go.yaml.in/yaml/v2 v2.4.3 // @grafana/alerting-backend + go.yaml.in/yaml/v3 v3.0.4 // @grafana/alerting-backend gocloud.dev v0.43.0 // @grafana/grafana-app-platform-squad gocloud.dev/secrets/hashivault v0.43.0 // @grafana/grafana-operator-experience-squad golang.org/x/crypto v0.44.0 // @grafana/grafana-backend-group @@ -214,8 +216,6 @@ require ( google.golang.org/protobuf v1.36.10 // @grafana/plugins-platform-backend gopkg.in/ini.v1 v1.67.0 // @grafana/alerting-backend gopkg.in/mail.v2 v2.3.1 // @grafana/grafana-backend-group - gopkg.in/yaml.v2 v2.4.0 // @grafana/alerting-backend - gopkg.in/yaml.v3 v3.0.1 // @grafana/alerting-backend k8s.io/api v0.34.2 // @grafana/grafana-app-platform-squad k8s.io/apimachinery v0.34.2 // @grafana/grafana-app-platform-squad k8s.io/apiserver v0.34.2 // @grafana/grafana-app-platform-squad @@ -628,8 +628,6 @@ require ( go.opentelemetry.io/otel/sdk/metric v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.uber.org/multierr v1.11.0 // indirect - go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect go4.org/netipx v0.0.0-20230125063823-8449b0a6169f // indirect golang.org/x/sys v0.38.0 // indirect golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect @@ -647,6 +645,8 @@ require ( gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/src-d/go-errors.v1 v1.0.0 // indirect gopkg.in/telebot.v3 v3.3.8 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiextensions-apiserver v0.34.2 // indirect k8s.io/kms v0.34.2 // indirect modernc.org/libc v1.66.10 // indirect diff --git a/pkg/api/response/response.go b/pkg/api/response/response.go index 3d5b23a8fc9..72cd28de511 100644 --- a/pkg/api/response/response.go +++ b/pkg/api/response/response.go @@ -10,7 +10,7 @@ import ( "reflect" jsoniter "github.com/json-iterator/go" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/infra/tracing" diff --git a/pkg/apimachinery/apis/common/v0alpha1/secure_values.go b/pkg/apimachinery/apis/common/v0alpha1/secure_values.go index fd10d9c32a7..1eff978fe8f 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/secure_values.go +++ b/pkg/apimachinery/apis/common/v0alpha1/secure_values.go @@ -5,7 +5,7 @@ import ( "fmt" "strconv" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" openapi "k8s.io/kube-openapi/pkg/common" spec "k8s.io/kube-openapi/pkg/validation/spec" ptr "k8s.io/utils/ptr" diff --git a/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go b/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go index 83dd8ec6762..fad96ec27ae 100644 --- a/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go +++ b/pkg/apimachinery/apis/common/v0alpha1/secure_values_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "k8s.io/kube-openapi/pkg/validation/strfmt" "k8s.io/kube-openapi/pkg/validation/validate" diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index 9543bd29c67..9f94733b8d0 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -3,18 +3,17 @@ module github.com/grafana/grafana/pkg/apimachinery go 1.25.3 require ( + github.com/go-jose/go-jose/v4 v4.1.2 github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // @grafana/identity-access-team github.com/stretchr/testify v1.11.1 - gopkg.in/yaml.v3 v3.0.1 + go.yaml.in/yaml/v3 v3.0.4 k8s.io/apimachinery v0.34.2 k8s.io/apiserver v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 ) -require github.com/go-jose/go-jose/v4 v4.1.2 - require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect @@ -44,7 +43,6 @@ require ( go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect - go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.44.0 // indirect golang.org/x/net v0.47.0 // indirect golang.org/x/sync v0.18.0 // indirect @@ -54,6 +52,7 @@ require ( google.golang.org/grpc v1.76.0 // indirect google.golang.org/protobuf v1.36.10 // indirect gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/klog/v2 v2.130.1 // indirect sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect sigs.k8s.io/randfill v1.0.0 // indirect diff --git a/pkg/registry/apis/provisioning/resources/parser.go b/pkg/registry/apis/provisioning/resources/parser.go index c8c65baf656..a684e4cffc5 100644 --- a/pkg/registry/apis/provisioning/resources/parser.go +++ b/pkg/registry/apis/provisioning/resources/parser.go @@ -8,7 +8,7 @@ import ( "path" "go.opentelemetry.io/otel/attribute" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" @@ -16,6 +16,7 @@ import ( "k8s.io/client-go/dynamic" "github.com/grafana/grafana-app-sdk/logging" + dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" provisioning "github.com/grafana/grafana/apps/provisioning/pkg/apis/provisioning/v0alpha1" "github.com/grafana/grafana/apps/provisioning/pkg/repository" diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index e59f912c972..7e999010795 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -15,7 +15,7 @@ import ( amconfig "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" prommodel "github.com/prometheus/common/model" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/errutil" diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index 7ad8ee3c0b4..9371ca6a230 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -15,7 +15,7 @@ import ( prommodel "github.com/prometheus/common/model" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" diff --git a/pkg/services/ngalert/api/lotex_am.go b/pkg/services/ngalert/api/lotex_am.go index 3d1745f376f..47900d336fb 100644 --- a/pkg/services/ngalert/api/lotex_am.go +++ b/pkg/services/ngalert/api/lotex_am.go @@ -8,7 +8,7 @@ import ( "io" "net/http" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" diff --git a/pkg/services/ngalert/api/lotex_ruler.go b/pkg/services/ngalert/api/lotex_ruler.go index d3dedfe2866..c058a554570 100644 --- a/pkg/services/ngalert/api/lotex_ruler.go +++ b/pkg/services/ngalert/api/lotex_ruler.go @@ -7,7 +7,7 @@ import ( "net/http" "net/url" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/infra/log" diff --git a/pkg/services/ngalert/api/prometheus_conversion.go b/pkg/services/ngalert/api/prometheus_conversion.go index 4006dcb0e10..588ea22bd11 100644 --- a/pkg/services/ngalert/api/prometheus_conversion.go +++ b/pkg/services/ngalert/api/prometheus_conversion.go @@ -5,7 +5,7 @@ import ( "io" "mime" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/errutil" diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go index ecd19849ea4..4dad304af75 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager.go @@ -13,7 +13,7 @@ import ( "github.com/prometheus/alertmanager/config" "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/common/model" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/alerting/definition" alertingmodels "github.com/grafana/alerting/models" diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go index 8c4b7efa509..87098ebbd30 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_test.go @@ -14,7 +14,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) //go:embed test-data/*.* diff --git a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go index 46f64d096d1..c3fce7cb1b4 100644 --- a/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go +++ b/pkg/services/ngalert/api/tooling/definitions/alertmanager_validation.go @@ -6,7 +6,7 @@ import ( "strings" "github.com/grafana/alerting/templates" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) func (t *NotificationTemplate) Validate() error { diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler_test.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler_test.go index 781d02dc8b1..019cdb99e23 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler_test.go @@ -6,7 +6,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) func Test_Rule_Marshaling(t *testing.T) { diff --git a/pkg/services/ngalert/api/tooling/definitions/provisioning_policies_test.go b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies_test.go index 0176fbd1815..9c6469f1fcb 100644 --- a/pkg/services/ngalert/api/tooling/definitions/provisioning_policies_test.go +++ b/pkg/services/ngalert/api/tooling/definitions/provisioning_policies_test.go @@ -5,7 +5,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) func TestNotificationPolicyExportMarshal(t *testing.T) { diff --git a/pkg/services/ngalert/api/util.go b/pkg/services/ngalert/api/util.go index e5840c992a7..c62f15ed7f4 100644 --- a/pkg/services/ngalert/api/util.go +++ b/pkg/services/ngalert/api/util.go @@ -13,7 +13,7 @@ import ( "strconv" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/services/accesscontrol" diff --git a/pkg/services/ngalert/models/alert_rule_test.go b/pkg/services/ngalert/models/alert_rule_test.go index d42b5ae176f..f713cc19570 100644 --- a/pkg/services/ngalert/models/alert_rule_test.go +++ b/pkg/services/ngalert/models/alert_rule_test.go @@ -15,8 +15,8 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" "golang.org/x/exp/maps" - "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/util" diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 82318bba1e6..13c95e70aa4 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -6,7 +6,7 @@ import ( "time" "github.com/google/uuid" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/services/datasources" diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index ae4cf4a73e8..503cd76dd64 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -10,7 +10,7 @@ import ( "github.com/google/uuid" prommodel "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/expr" "github.com/grafana/grafana/pkg/expr/mathexp" diff --git a/pkg/services/ngalert/prom/models_test.go b/pkg/services/ngalert/prom/models_test.go index f2184a4fffb..d10b828e936 100644 --- a/pkg/services/ngalert/prom/models_test.go +++ b/pkg/services/ngalert/prom/models_test.go @@ -6,7 +6,7 @@ import ( prommodel "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/util" ) diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index 81175e14766..bbac2a7c156 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -30,7 +30,7 @@ import ( "github.com/prometheus/alertmanager/pkg/labels" "github.com/prometheus/client_golang/prometheus" common_config "github.com/prometheus/common/config" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" diff --git a/pkg/services/ngalert/remote/alertmanager_test.go b/pkg/services/ngalert/remote/alertmanager_test.go index b183943d2ac..953ef3ef2d6 100644 --- a/pkg/services/ngalert/remote/alertmanager_test.go +++ b/pkg/services/ngalert/remote/alertmanager_test.go @@ -27,12 +27,12 @@ import ( "github.com/prometheus/client_golang/prometheus" common_config "github.com/prometheus/common/config" "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" alertingClusterPB "github.com/grafana/alerting/cluster/clusterpb" "github.com/grafana/alerting/definition" alertingModels "github.com/grafana/alerting/models" "github.com/grafana/alerting/notify" - "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/infra/db" "github.com/grafana/grafana/pkg/infra/log" diff --git a/pkg/services/ngalert/sender/notifier.go b/pkg/services/ngalert/sender/notifier.go index 2ba819a8c41..061ca7d138c 100644 --- a/pkg/services/ngalert/sender/notifier.go +++ b/pkg/services/ngalert/sender/notifier.go @@ -38,7 +38,7 @@ import ( "github.com/prometheus/common/promslog" "github.com/prometheus/common/version" "github.com/prometheus/sigv4" - "gopkg.in/yaml.v2" + "go.yaml.in/yaml/v3" "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/discovery/targetgroup" diff --git a/pkg/services/ngalert/sender/notifier_test.go b/pkg/services/ngalert/sender/notifier_test.go index 20caf7e9aea..e7e9aedec96 100644 --- a/pkg/services/ngalert/sender/notifier_test.go +++ b/pkg/services/ngalert/sender/notifier_test.go @@ -38,7 +38,7 @@ import ( "github.com/prometheus/common/promslog" "github.com/stretchr/testify/require" "go.uber.org/atomic" - "gopkg.in/yaml.v2" + "go.yaml.in/yaml/v2" "github.com/prometheus/prometheus/config" "github.com/prometheus/prometheus/discovery" diff --git a/pkg/services/provisioning/alerting/config_reader.go b/pkg/services/provisioning/alerting/config_reader.go index b6ca9d2c3dd..8073ff64348 100644 --- a/pkg/services/provisioning/alerting/config_reader.go +++ b/pkg/services/provisioning/alerting/config_reader.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/infra/log" ) diff --git a/pkg/services/provisioning/alerting/contact_point_types_test.go b/pkg/services/provisioning/alerting/contact_point_types_test.go index 57ef9acfca0..28af33c42ae 100644 --- a/pkg/services/provisioning/alerting/contact_point_types_test.go +++ b/pkg/services/provisioning/alerting/contact_point_types_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/services/provisioning/values" ) diff --git a/pkg/services/provisioning/alerting/notification_policy_types_test.go b/pkg/services/provisioning/alerting/notification_policy_types_test.go index 5ec98c8722f..72a60cb5155 100644 --- a/pkg/services/provisioning/alerting/notification_policy_types_test.go +++ b/pkg/services/provisioning/alerting/notification_policy_types_test.go @@ -4,7 +4,7 @@ import ( "testing" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" ) func TestNotificationPolicy(t *testing.T) { diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/rules_types_test.go index 9c4e819ff73..c2cff56e419 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/rules_types_test.go @@ -6,7 +6,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/provisioning/values" diff --git a/pkg/services/provisioning/dashboards/config_reader.go b/pkg/services/provisioning/dashboards/config_reader.go index 3cf08eb01c2..509665135eb 100644 --- a/pkg/services/provisioning/dashboards/config_reader.go +++ b/pkg/services/provisioning/dashboards/config_reader.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/provisioning/utils" diff --git a/pkg/services/provisioning/datasources/config_reader.go b/pkg/services/provisioning/datasources/config_reader.go index 1d5a992d1f7..cea2eb36207 100644 --- a/pkg/services/provisioning/datasources/config_reader.go +++ b/pkg/services/provisioning/datasources/config_reader.go @@ -8,7 +8,7 @@ import ( "path/filepath" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/datasources" diff --git a/pkg/services/provisioning/plugins/config_reader.go b/pkg/services/provisioning/plugins/config_reader.go index 52684de6326..36790a6e58c 100644 --- a/pkg/services/provisioning/plugins/config_reader.go +++ b/pkg/services/provisioning/plugins/config_reader.go @@ -9,7 +9,7 @@ import ( "path/filepath" "strings" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginstore" diff --git a/pkg/services/provisioning/values/values_test.go b/pkg/services/provisioning/values/values_test.go index 93776cb7a60..61378f124d7 100644 --- a/pkg/services/provisioning/values/values_test.go +++ b/pkg/services/provisioning/values/values_test.go @@ -8,8 +8,8 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.yaml.in/yaml/v3" "gopkg.in/ini.v1" - "gopkg.in/yaml.v3" "github.com/grafana/grafana/pkg/setting" ) diff --git a/pkg/tests/api/alerting/api_convert_prometheus_alertmanager_test.go b/pkg/tests/api/alerting/api_convert_prometheus_alertmanager_test.go index cdd2b51df00..427131bb90c 100644 --- a/pkg/tests/api/alerting/api_convert_prometheus_alertmanager_test.go +++ b/pkg/tests/api/alerting/api_convert_prometheus_alertmanager_test.go @@ -7,7 +7,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/services/featuremgmt" apimodels "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" diff --git a/pkg/tests/api/alerting/api_provisioning_test.go b/pkg/tests/api/alerting/api_provisioning_test.go index 41acf2819dc..20484a38bf4 100644 --- a/pkg/tests/api/alerting/api_provisioning_test.go +++ b/pkg/tests/api/alerting/api_provisioning_test.go @@ -20,7 +20,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/expr" diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index ab33cd9ddbd..b49e61d3a4e 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -17,7 +17,7 @@ import ( "github.com/prometheus/common/model" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "gopkg.in/yaml.v3" + "go.yaml.in/yaml/v3" "github.com/grafana/grafana/pkg/api" "github.com/grafana/grafana/pkg/configprovider" From 53c39ccda3760bdc3316459a8eb9ec224966bcb6 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 21:24:08 +0000 Subject: [PATCH 089/125] Alerting: Update alerting module to 3befd25883e0d17673e5590cc5c5702bbc031b16 (#114062) * [create-pull-request] automated change * fix module path for alerting notify test receivers --------- Co-authored-by: moustafab <27738648+moustafab@users.noreply.github.com> Co-authored-by: Moustafa Baiou --- apps/advisor/go.mod | 4 +-- apps/advisor/go.sum | 8 ++--- go.mod | 4 +-- go.sum | 8 ++--- go.work.sum | 9 +++++ .../ngalert/api/api_provisioning_test.go | 11 +++++- .../redacted/all-integrations.hcl | 2 ++ .../redacted/all-integrations.json | 2 ++ .../redacted/all-integrations.yaml | 2 ++ .../unredacted/all-integrations.hcl | 8 +++-- .../unredacted/all-integrations.json | 2 ++ .../unredacted/all-integrations.yaml | 2 ++ .../api/tooling/definitions/contact_points.go | 6 ++-- .../ngalert/notifier/testreceivers.go | 2 +- pkg/services/ngalert/remote/alertmanager.go | 4 +-- .../alert-notifiers-v1-snapshot.json | 36 +++++++++++++++++++ .../alert-notifiers-v2-snapshot.json | 36 +++++++++++++++++++ 17 files changed, 125 insertions(+), 21 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index fb3e8dbf3aa..1620778be0c 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -62,7 +62,7 @@ require ( github.com/apache/arrow-go/v18 v18.4.1 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/at-wat/mqtt-go v0.19.4 // indirect + github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go v1.55.7 // indirect github.com/aws/aws-sdk-go-v2 v1.39.1 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect @@ -147,7 +147,7 @@ require ( github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/google/wire v0.7.0 // indirect - github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae // indirect + github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 189664f60b8..58de28ff76f 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -166,8 +166,8 @@ github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/at-wat/mqtt-go v0.19.4 h1:R2cbCU7O5PHQ38unbe1Y51ncG3KsFEJV6QeipDoqdLQ= -github.com/at-wat/mqtt-go v0.19.4/go.mod h1:AsiWc9kqVOhqq7LzUeWT/AkKUBfx3Sw5cEe8lc06fqA= +github.com/at-wat/mqtt-go v0.19.6 h1:+jo59yGHB3oqBU8nSFYbq6KDgN4ngMpqts5Qtyq61pc= +github.com/at-wat/mqtt-go v0.19.6/go.mod h1:Y/YrYwUlLE4N53//699Zir3WJnxSdJojvLEAbZxJjgQ= github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= github.com/aws/aws-sdk-go v1.55.7 h1:UJrkFq7es5CShfBwlWAC8DA077vp8PyVbQd3lqLiztE= @@ -647,8 +647,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae h1:NLPwY3tIP0lg0g9wTRiMcypm6VRXW6W+MOLBsq8JSVA= -github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= +github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 h1:nbxKRtrbuhvOYmI2RhOYauHRJCtpR+vTNIgg1lFUCws= +github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181/go.mod h1:VtPNIFlEOJPPEc13Ax6ZTbNV3M/sAzLID72YjgzOPVA= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= diff --git a/go.mod b/go.mod index 65c896fc313..d6c78270adf 100644 --- a/go.mod +++ b/go.mod @@ -86,7 +86,7 @@ require ( github.com/googleapis/gax-go/v2 v2.15.0 // @grafana/grafana-backend-group github.com/gorilla/mux v1.8.1 // @grafana/grafana-backend-group github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad - github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics @@ -334,7 +334,7 @@ require ( github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect - github.com/at-wat/mqtt-go v0.19.4 // indirect + github.com/at-wat/mqtt-go v0.19.6 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.6.11 // indirect github.com/aws/aws-sdk-go-v2/config v1.31.10 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.18.14 // indirect diff --git a/go.sum b/go.sum index 238a14bff36..21a60a41bd1 100644 --- a/go.sum +++ b/go.sum @@ -838,8 +838,8 @@ github.com/asaskevich/govalidator v0.0.0-20180720115003-f9ffefc3facf/go.mod h1:l github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= -github.com/at-wat/mqtt-go v0.19.4 h1:R2cbCU7O5PHQ38unbe1Y51ncG3KsFEJV6QeipDoqdLQ= -github.com/at-wat/mqtt-go v0.19.4/go.mod h1:AsiWc9kqVOhqq7LzUeWT/AkKUBfx3Sw5cEe8lc06fqA= +github.com/at-wat/mqtt-go v0.19.6 h1:+jo59yGHB3oqBU8nSFYbq6KDgN4ngMpqts5Qtyq61pc= +github.com/at-wat/mqtt-go v0.19.6/go.mod h1:Y/YrYwUlLE4N53//699Zir3WJnxSdJojvLEAbZxJjgQ= github.com/aws/aws-sdk-go v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.22.4/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo= github.com/aws/aws-sdk-go v1.38.35/go.mod h1:hcU610XS61/+aQV88ixoOzUoG7v3b31pl2zKMmprdro= @@ -1611,8 +1611,8 @@ github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7Fsg github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= -github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae h1:NLPwY3tIP0lg0g9wTRiMcypm6VRXW6W+MOLBsq8JSVA= -github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae/go.mod h1:VGjS5gDwWEADPP6pF/drqLxEImgeuHlEW5u8E5EfIrM= +github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 h1:nbxKRtrbuhvOYmI2RhOYauHRJCtpR+vTNIgg1lFUCws= +github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181/go.mod h1:VtPNIFlEOJPPEc13Ax6ZTbNV3M/sAzLID72YjgzOPVA= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= diff --git a/go.work.sum b/go.work.sum index fc120825511..9dc15683534 100644 --- a/go.work.sum +++ b/go.work.sum @@ -133,6 +133,7 @@ cloud.google.com/go/gkehub v0.15.6/go.mod h1:sRT0cOPAgI1jUJrS3gzwdYCJ1NEzVVwmnMK cloud.google.com/go/gkemulticloud v1.5.3 h1:334aZmOzIt3LVBpguCof8IHaLaftcZlx+L0TGBukYkY= cloud.google.com/go/gkemulticloud v1.5.3/go.mod h1:KPFf+/RcfvmuScqwS9/2MF5exZAmXSuoSLPuaQ98Xlk= cloud.google.com/go/grafeas v0.2.0 h1:CYjC+xzdPvbV65gi6Dr4YowKcmLo045pm18L0DhdELM= +cloud.google.com/go/grafeas v0.3.15 h1:lBjwKmhpiqOAFaE0xdqF8CqO74a99s8tUT5mCkBBxPs= cloud.google.com/go/grafeas v0.3.15/go.mod h1:irwcwIQOBlLBotGdMwme8PipnloOPqILfIvMwlmu8Pk= cloud.google.com/go/gsuiteaddons v1.7.7 h1:sk0SxpCGIA7tIO//XdiiG29f2vrF6Pq/dsxxyBGiRBY= cloud.google.com/go/gsuiteaddons v1.7.7/go.mod h1:zTGmmKG/GEBCONsvMOY2ckDiEsq3FN+lzWGUiXccF9o= @@ -303,6 +304,7 @@ github.com/DmitriyVTitov/size v1.5.0 h1:/PzqxYrOyOUX1BXj6J9OuVRVGe+66VL4D9FlUaW5 github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/GoogleCloudPlatform/cloudsql-proxy v1.37.8 h1:9aTh5GPncdE8BjUn+xanuF/BT3m2BJiyvS50Mmws/fw= github.com/GoogleCloudPlatform/cloudsql-proxy v1.37.8/go.mod h1:exon/I6I+5u/ab7AHmGh0eCXGoYZO5cjqA3wHJlYFFQ= +github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3 h1:2afWGsMzkIcN8Qm4mgPJKZWyroE5QBszMiDMYEBrnfw= github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3/go.mod h1:dppbR7CwXD4pgtV9t3wD1812RaLDcBjtblcDF5f1vI0= github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.27.0/go.mod h1:yAZHSGnqScoU556rBOVkwLze6WP5N+U11RHuWaGVxwY= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.51.0/go.mod h1:BnBReJLvVYx2CS/UHOgVz2BXKXD9wsQPxZug20nZhd0= @@ -391,6 +393,7 @@ github.com/antchfx/xpath v1.3.4 h1:1ixrW1VnXd4HurCj7qnqnR0jo14g8JMe20Fshg1Vgz4= github.com/antchfx/xpath v1.3.4/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= github.com/antlr/antlr4/runtime/Go/antlr v1.4.10/go.mod h1:F7bn7fEU90QkQ3tnmaTx3LTKLEDqnwWODIYppRQ5hnY= +github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9 h1:goHVqTbFX3AIo0tzGr14pgfAW2ZfPChKO21Z9MGf/gk= github.com/antlr/antlr4/runtime/Go/antlr/v4 v4.0.0-20230512164433-5d1fd1a340c9/go.mod h1:pSwJ0fSY5KhvocuWSx4fz3BA8OrA1bQn+K1Eli3BRwM= github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g= github.com/apache/arrow-go/v18 v18.3.0/go.mod h1:eEM1DnUTHhgGAjf/ChvOAQbUQ+EPohtDrArffvUjPg8= @@ -399,6 +402,7 @@ github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40 h1:q4dksr6IC github.com/apache/arrow/go/arrow v0.0.0-20211112161151-bc219186db40/go.mod h1:Q7yQnSMnLvcXlZ8RV+jwz/6y1rQTqbX6C82SndT52Zs= github.com/apache/arrow/go/v10 v10.0.1 h1:n9dERvixoC/1JjDmBcs9FPaEryoANa2sCgVFo6ez9cI= github.com/apache/arrow/go/v11 v11.0.0 h1:hqauxvFQxww+0mEU/2XHG6LT7eZternCZq+A5Yly2uM= +github.com/apache/arrow/go/v15 v15.0.2 h1:60IliRbiyTWCWjERBCkO1W4Qun9svcYoZrSLcyOsMLE= github.com/apache/arrow/go/v15 v15.0.2/go.mod h1:DGXsR3ajT524njufqf95822i+KTh+yea1jass9YXgjA= github.com/apache/thrift v0.21.0/go.mod h1:W1H8aR/QRtYNvrPeFXBtobyRkd0/YVhTc6i07XIAgDw= github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e h1:QEF07wC0T1rKkctt1RINW/+RMTVmiwxETico2l3gxJA= @@ -508,6 +512,8 @@ github.com/bradleyjkemp/cupaloy/v2 v2.6.0 h1:knToPYa2xtfg42U3I6punFEjaGFKWQRXJwj github.com/bradleyjkemp/cupaloy/v2 v2.6.0/go.mod h1:bm7JXdkRd4BHJk9HpwqAI8BoAY1lps46Enkdqw6aRX0= github.com/bsm/sarama-cluster v2.1.13+incompatible h1:bqU3gMJbWZVxLZ9PGWVKP05yOmFXUlfw61RBwuE3PYU= github.com/bsm/sarama-cluster v2.1.13+incompatible/go.mod h1:r7ao+4tTNXvWm+VRpRJchr2kQhqxgmAp2iEX5W96gMM= +github.com/bufbuild/protovalidate-go v0.2.1 h1:pJr07sYhliyfj/STAM7hU4J3FKpVeLVKvOBmOTN8j+s= +github.com/bufbuild/protovalidate-go v0.2.1/go.mod h1:e7XXDtlxj5vlEyAgsrxpzayp4cEMKCSSb8ZCkin+MVA= github.com/bufbuild/protovalidate-go v0.9.1/go.mod h1:5jptBxfvlY51RhX32zR6875JfPBRXUsQjyZjm/NqkLQ= github.com/bwesterb/go-ristretto v1.2.3 h1:1w53tCkGhCQ5djbat3+MH0BAQ5Kfgbt56UZQ/JMzngw= github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= @@ -621,6 +627,7 @@ github.com/d4l3k/messagediff v1.2.1 h1:ZcAIMYsUg0EAp9X+tt8/enBE/Q8Yd5kzPynLyKptt github.com/d4l3k/messagediff v1.2.1/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= github.com/davecgh/go-xdr v0.0.0-20161123171359-e6a2ba005892 h1:qg9VbHo1TlL0KDM0vYvBG9EY0X0Yku5WYIPoFWt8f6o= github.com/davecgh/go-xdr v0.0.0-20161123171359-e6a2ba005892/go.mod h1:CTDl0pzVzE5DEzZhPfvhY/9sPFMQIxaJ9VAMs9AagrE= +github.com/dchest/siphash v1.2.3 h1:QXwFc8cFOR2dSa/gE6o/HokBMWtLUaNDVd+22aKHeEA= github.com/dchest/siphash v1.2.3/go.mod h1:0NvQU092bT0ipiFN++/rXm69QG9tVxLAlQHIXMPAkHc= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= @@ -628,6 +635,7 @@ github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.0-20210816181553-5444fa50b93d/go. github.com/denisenkom/go-mssqldb v0.0.0-20190515213511-eb9f6a1743f3 h1:tkum0XDgfR0jcVVXuTsYv/erY2NnEDqwRojbxR1rBYA= github.com/denisenkom/go-mssqldb v0.10.0/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= github.com/devigned/tab v0.1.1/go.mod h1:XG9mPq0dFghrYvoBF3xdRrJzSTX1b7IQrvaL9mzjeJY= +github.com/dgryski/go-ddmin v0.0.0-20210904190556-96a6d69f1034 h1:BuCyszxPxUjBrYW2HNVrimC0rBUs2U27jCJGVh0IKTM= github.com/dgryski/go-ddmin v0.0.0-20210904190556-96a6d69f1034/go.mod h1:zz4KxBkcXUWKjIcrc+uphJ1gPh/t18ymGm3PmQ+VGTk= github.com/dgryski/go-sip13 v0.0.0-20190329191031-25c5027a8c7b h1:Yqiad0+sloMPdd/0Fg22actpFx0dekpzt1xJmVNVkU0= github.com/dhui/dktest v0.3.0 h1:kwX5a7EkLcjo7VpsPQSYJcKGbXBXdjI9FGjuUj1jn6I= @@ -818,6 +826,7 @@ github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaU github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/wire v0.6.0/go.mod h1:F4QhpQ9EDIdJ1Mbop/NZBRB+5yrR6qg3BnctaoUk6NA= +github.com/googleapis/cloud-bigtable-clients-test v0.0.3 h1:afMKTvA/jc6jSTMkeHBZGFDTt8Cc+kb1ATFzqMK85hw= github.com/googleapis/cloud-bigtable-clients-test v0.0.3/go.mod h1:TWtDzrrAI70C3dNLDY+nZN3gxHtFdZIbpL9rCTFyxE0= github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w= github.com/googleapis/gnostic v0.3.0 h1:CcQijm0XKekKjP/YCz28LXVSpgguuB+nCxaSjCe09y0= diff --git a/pkg/services/ngalert/api/api_provisioning_test.go b/pkg/services/ngalert/api/api_provisioning_test.go index 830c2f83434..8eaf3ee6d93 100644 --- a/pkg/services/ngalert/api/api_provisioning_test.go +++ b/pkg/services/ngalert/api/api_provisioning_test.go @@ -2023,7 +2023,16 @@ func TestApiContactPointExportSnapshot(t *testing.T) { exportRaw, err := receiverExportResponses.ReadFile(p) require.NoError(t, err) - require.Equal(t, string(exportRaw), string(actualBody)) + switch tc.exportType { + case "json": + require.JSONEq(t, string(exportRaw), string(actualBody)) + case "yaml": + require.YAMLEq(t, string(exportRaw), string(actualBody)) + case "hcl": + require.Equal(t, string(exportRaw), string(actualBody)) + default: + t.Fatalf("unknown export type %q", tc.exportType) + } } t.Run("contact point export for all known configs", func(t *testing.T) { diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl index 2f71ea87efc..c8e64dc1a85 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl +++ b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.hcl @@ -38,6 +38,8 @@ resource "grafana_contact_point" "contact_point_2b661702215368fe" { url = "[REDACTED]" title = "test-title" message = "test-message" + hide_open_button = true + hide_version_info = true } jira { diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.json b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.json index 1857db2807f..b8894dfe865 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.json +++ b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.json @@ -101,6 +101,8 @@ "uid": "googlechat-uid", "type": "googlechat", "settings": { + "hide_open_button": true, + "hide_version_info": true, "http_config": { "oauth2": { "client_id": "test-client-id", diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.yaml b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.yaml index 83e31ec0203..bc8f6c410d0 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.yaml +++ b/pkg/services/ngalert/api/test-data/receiver-exports/redacted/all-integrations.yaml @@ -119,6 +119,8 @@ contactPoints: - uid: googlechat-uid type: googlechat settings: + hide_open_button: true + hide_version_info: true http_config: oauth2: client_id: test-client-id diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl index aebe516faaa..ef5f1374b68 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl +++ b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.hcl @@ -30,9 +30,11 @@ resource "grafana_contact_point" "contact_point_2b661702215368fe" { } googlechat { - url = "http://localhost" - title = "test-title" - message = "test-message" + url = "http://localhost" + title = "test-title" + message = "test-message" + hide_open_button = true + hide_version_info = true } jira { diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.json b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.json index 1532bdd8ac4..b75d624a85c 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.json +++ b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.json @@ -101,6 +101,8 @@ "uid": "googlechat-uid", "type": "googlechat", "settings": { + "hide_open_button": true, + "hide_version_info": true, "http_config": { "oauth2": { "client_id": "test-client-id", diff --git a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.yaml b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.yaml index a6af53fcb55..9cfc6e7c385 100644 --- a/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.yaml +++ b/pkg/services/ngalert/api/test-data/receiver-exports/unredacted/all-integrations.yaml @@ -119,6 +119,8 @@ contactPoints: - uid: googlechat-uid type: googlechat settings: + hide_open_button: true + hide_version_info: true http_config: oauth2: client_id: test-client-id diff --git a/pkg/services/ngalert/api/tooling/definitions/contact_points.go b/pkg/services/ngalert/api/tooling/definitions/contact_points.go index eda7c545b06..9980736ede6 100644 --- a/pkg/services/ngalert/api/tooling/definitions/contact_points.go +++ b/pkg/services/ngalert/api/tooling/definitions/contact_points.go @@ -58,8 +58,10 @@ type GooglechatIntegration struct { URL Secret `json:"url" yaml:"url" hcl:"url"` - Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"` - Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` + Title *string `json:"title,omitempty" yaml:"title,omitempty" hcl:"title"` + Message *string `json:"message,omitempty" yaml:"message,omitempty" hcl:"message"` + HideOpenButton *bool `json:"hide_open_button,omitempty" yaml:"hide_open_button,omitempty" hcl:"hide_open_button"` + HideVersionInfo *bool `json:"hide_version_info,omitempty" yaml:"hide_version_info,omitempty" hcl:"hide_version_info"` } type JiraIntegration struct { diff --git a/pkg/services/ngalert/notifier/testreceivers.go b/pkg/services/ngalert/notifier/testreceivers.go index 41bd2a181c7..851521c61b5 100644 --- a/pkg/services/ngalert/notifier/testreceivers.go +++ b/pkg/services/ngalert/notifier/testreceivers.go @@ -44,7 +44,7 @@ func (am *alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei } AddDefaultLabelsAndAnnotations(a) return am.Base.TestReceivers(ctx, alertingNotify.TestReceiversConfigBodyParams{ - Alert: &alertingNotify.TestReceiversConfigAlertParams{ + Alert: &models.TestReceiversConfigAlertParams{ Annotations: v2.APILabelSetToModelLabelSet(a.Annotations), Labels: v2.APILabelSetToModelLabelSet(a.Labels), }, diff --git a/pkg/services/ngalert/remote/alertmanager.go b/pkg/services/ngalert/remote/alertmanager.go index bbac2a7c156..a7c99f8518c 100644 --- a/pkg/services/ngalert/remote/alertmanager.go +++ b/pkg/services/ngalert/remote/alertmanager.go @@ -625,9 +625,9 @@ func (am *Alertmanager) TestReceivers(ctx context.Context, c apimodels.TestRecei } apiReceivers := alertingNotify.PostableAPIReceiversToAPIReceivers(decryptedReceivers) - var alert *alertingNotify.TestReceiversConfigAlertParams + var alert *alertingModels.TestReceiversConfigAlertParams if c.Alert != nil { - alert = &alertingNotify.TestReceiversConfigAlertParams{Annotations: c.Alert.Annotations, Labels: c.Alert.Labels} + alert = &alertingModels.TestReceiversConfigAlertParams{Annotations: c.Alert.Annotations, Labels: c.Alert.Labels} } return am.mimirClient.TestReceivers(ctx, alertingNotify.TestReceiversConfigBodyParams{ diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json index 59324e97edd..7bf5ff10535 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v1-snapshot.json @@ -392,6 +392,42 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "HideOpenButton", + "description": "Hide open URL button", + "placeholder": "", + "propertyName": "hide_open_button", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "HideVersionInfo", + "description": "Hide version info", + "placeholder": "", + "propertyName": "hide_version_info", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] }, diff --git a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json index cd5ed8b8f96..d942144ef9c 100644 --- a/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json +++ b/pkg/tests/api/alerting/test-data/alert-notifiers-v2-snapshot.json @@ -1340,6 +1340,42 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "HideOpenButton", + "description": "Hide open URL button", + "placeholder": "", + "propertyName": "hide_open_button", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null + }, + { + "element": "checkbox", + "inputType": "", + "label": "HideVersionInfo", + "description": "Hide version info", + "placeholder": "", + "propertyName": "hide_version_info", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] } From fabb4a603de8ad7c19cb6f5486f6e21ed4fbe6b9 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Wed, 19 Nov 2025 16:27:11 -0500 Subject: [PATCH 090/125] Docs: Add panel time zoom with mouse (#113721) Co-authored-by: Jesse David Peterson Co-authored-by: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> --- .../shared/visualizations/panel-zoom.md | 19 ++++++++ .../panel-overview/index.md | 46 +++++++++++++++++++ .../visualizations/candlestick/index.md | 4 ++ .../visualizations/heatmap/index.md | 4 ++ .../visualizations/state-timeline/index.md | 4 ++ .../visualizations/status-history/index.md | 4 ++ .../visualizations/time-series/index.md | 4 ++ 7 files changed, 85 insertions(+) create mode 100644 docs/sources/shared/visualizations/panel-zoom.md diff --git a/docs/sources/shared/visualizations/panel-zoom.md b/docs/sources/shared/visualizations/panel-zoom.md new file mode 100644 index 00000000000..281f138fc63 --- /dev/null +++ b/docs/sources/shared/visualizations/panel-zoom.md @@ -0,0 +1,19 @@ +--- +title: Panel mouse time zoom +comments: | + This file is used in the following visualizations: candlestick, heatmap, state timeline, status history, time series. +--- + +You can zoom the panel time range in and out, which in turn, changes the dashboard time range. + +**Zoom in** - Click and drag on the panel to zoom in on a particular time range. + +**Zoom out** - Double-click anywhere on the panel to zoom out the time range. + +When you zoom out, the range doubles with each double-click, adding equal time to each side of the range. +For example, if the original time range is from 9:00 to 9:59, the time range changes as follow with each double-click: + +- Next range: 8:30 - 10:29 +- Next range: 7:30 - 11:29 + +For screen recordings showing these interactions, refer to the [Panel overview documentation](https://grafana.com/docs/grafana//visualizations/panels-visualizations/panel-overview/#zoom-panel-time-range). diff --git a/docs/sources/visualizations/panels-visualizations/panel-overview/index.md b/docs/sources/visualizations/panels-visualizations/panel-overview/index.md index 299f3963e30..8f6708492a3 100644 --- a/docs/sources/visualizations/panels-visualizations/panel-overview/index.md +++ b/docs/sources/visualizations/panels-visualizations/panel-overview/index.md @@ -175,6 +175,52 @@ By hovering over a panel with the mouse you can use some shortcuts that will tar - `pl`: Hide or show legend - `pr`: Remove Panel +## Zoom panel time range + +You can zoom the panel time range in and out, which in turn, changes the dashboard time range. + +This feature is supported for the following visualizations: + +- Candlestick +- Heatmap +- State timeline +- Status history +- Time series + +### Zoom in + +Click and drag on the panel to zoom in on a particular time range. + +The following screen recordings show this interaction in the time series and x visualizations: + +Time series + +{{< video-embed src="/media/docs/grafana/panels-visualizations/recording-ts-time-zoom-in-mouse.mp4" >}} + +Candlestick + +{{< video-embed src="/media/docs/grafana/panels-visualizations/recording-candle-panel-time-zoom-in-mouse.mp4" >}} + +### Zoom out + +Double-click anywhere on the panel to zoom out the time range. + +The range doubles with each double-click, adding equal time to each side of the range. +For example, if the original time range is from 9:00 to 9:59, the time range changes as follows with each double-click: + +- Next range: 8:30 - 10:29 +- Next range: 7:30 - 11:29 + +The following screen recordings demonstrate the preceding example in the time series and x visualizations: + +Time series + +{{< video-embed src="/media/docs/grafana/panels-visualizations/recording-ts-time-zoom-out-mouse.mp4" >}} + +Heatmap + +{{< video-embed src="/media/docs/grafana/panels-visualizations/recording-heatmap-panel-time-zoom-out-mouse.mp4" >}} + ## Add a panel To add a panel in a new dashboard click **+ Add visualization** in the middle of the dashboard: diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md index bc07d805000..e5760e78199 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/candlestick/index.md @@ -92,6 +92,10 @@ The data is converted as follows: {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-candles-volume-v11.6.png" max-width="750px" alt="A candlestick visualization showing the price movements of specific asset." >}} +## Zoom panel time range + +{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} + ## Configuration options {{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md index 31ffb8295dc..56c91db0e0d 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/heatmap/index.md @@ -79,6 +79,10 @@ The data is converted as follows: {{< figure src="/static/img/docs/heatmap-panel/heatmap.png" max-width="1025px" alt="A heatmap visualization showing the random walk distribution over time" >}} +## Zoom panel time range + +{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} + ## Configuration options {{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md index c5840be810d..ef4066c2b6c 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/state-timeline/index.md @@ -93,6 +93,10 @@ You can also create a state timeline visualization using time series data. To do ![State timeline with time series](/media/docs/grafana/panels-visualizations/screenshot-state-timeline-time-series-v11.4.png) +## Zoom panel time range + +{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} + ## Configuration options {{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md index 4b30df76171..834f50ca733 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/status-history/index.md @@ -85,6 +85,10 @@ The data is converted as follows: {{< figure src="/static/img/docs/status-history-panel/status_history.png" max-width="1025px" alt="A status history panel with two time columns showing the status of two servers" >}} +## Zoom panel time range + +{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} + ## Configuration options {{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md index e7c51fb74cf..a171c88259a 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/time-series/index.md @@ -167,6 +167,10 @@ The following example shows three series: Min, Max, and Value. The Min and Max s {{< docs/shared lookup="visualizations/multiple-y-axes.md" source="grafana" version="" leveloffset="+2" >}} +## Zoom panel time range + +{{< docs/shared lookup="visualizations/panel-zoom.md" source="grafana" version="" >}} + ## Configuration options {{< docs/shared lookup="visualizations/config-options-intro.md" source="grafana" version="" >}} From a4e1db66d39be095a1e3d925df3938fa21824d59 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Wed, 19 Nov 2025 21:40:54 +0000 Subject: [PATCH 091/125] Chore: Add html_handler_requests metric (#113991) * Chore: Add html_handler_requests metric for tracking requests handled by index.go * make a member of HttpServer * make it a histogram instead * update test --- pkg/api/http_server.go | 50 ++++++++++++-------- pkg/api/index.go | 12 +++++ pkg/api/login.go | 6 +++ pkg/api/login_test.go | 104 +++++++++++++++++++++++++++-------------- 4 files changed, 116 insertions(+), 56 deletions(-) diff --git a/pkg/api/http_server.go b/pkg/api/http_server.go index 2d3bd11ab37..fa0b407aa2b 100644 --- a/pkg/api/http_server.go +++ b/pkg/api/http_server.go @@ -36,6 +36,7 @@ import ( "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/infra/localcache" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/remotecache" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/login/social" @@ -202,26 +203,27 @@ type HTTPServer struct { pluginsCDNService *pluginscdn.Service managedPluginsService managedplugins.Manager - userService user.Service - tempUserService tempUser.Service - loginAttemptService loginAttempt.Service - orgService org.Service - orgDeletionService org.DeletionService - TeamService team.Service - accesscontrolService accesscontrol.Service - annotationsRepo annotations.Repository - tagService tag.Service - oauthTokenService oauthtoken.OAuthTokenService - statsService stats.Service - authnService authn.Service - starApi *starApi.API - promRegister prometheus.Registerer - promGatherer prometheus.Gatherer - clientConfigProvider grafanaapiserver.DirectRestConfigProvider - namespacer request.NamespaceMapper - anonService anonymous.Service - userVerifier user.Verifier - tlsCerts TLSCerts + userService user.Service + tempUserService tempUser.Service + loginAttemptService loginAttempt.Service + orgService org.Service + orgDeletionService org.DeletionService + TeamService team.Service + accesscontrolService accesscontrol.Service + annotationsRepo annotations.Repository + tagService tag.Service + oauthTokenService oauthtoken.OAuthTokenService + statsService stats.Service + authnService authn.Service + starApi *starApi.API + promRegister prometheus.Registerer + promGatherer prometheus.Gatherer + clientConfigProvider grafanaapiserver.DirectRestConfigProvider + namespacer request.NamespaceMapper + anonService anonymous.Service + userVerifier user.Verifier + tlsCerts TLSCerts + htmlHandlerRequestsDuration *prometheus.HistogramVec } type TLSCerts struct { @@ -375,7 +377,15 @@ func ProvideHTTPServer(opts ServerOptions, cfg *setting.Cfg, routeRegister routi namespacer: request.GetNamespaceMapper(cfg), anonService: anonService, userVerifier: userVerifier, + htmlHandlerRequestsDuration: metricutil.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "grafana", + Name: "html_handler_requests_duration_seconds", + Help: "Duration of requests handled by the index.go HTML handler", + }, []string{"handler"}), } + + promRegister.MustRegister(hs.htmlHandlerRequestsDuration) + if hs.Listener != nil { hs.log.Debug("Using provided listener") } diff --git a/pkg/api/index.go b/pkg/api/index.go index 1f85f2b0dbe..afbebb3b013 100644 --- a/pkg/api/index.go +++ b/pkg/api/index.go @@ -8,11 +8,13 @@ import ( "html/template" "net/http" "strings" + "time" claims "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/api/dtos" "github.com/grafana/grafana/pkg/api/webassets" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/middleware" ac "github.com/grafana/grafana/pkg/services/accesscontrol" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" @@ -272,6 +274,11 @@ func (hs *HTTPServer) Index(c *contextmodel.ReqContext) { c, span := hs.injectSpan(c, "api.Index") defer span.End() + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.htmlHandlerRequestsDuration.WithLabelValues("index"), time.Since(start).Seconds()) + }() + data, err := hs.setIndexViewData(c) if err != nil { c.Handle(hs.Cfg, http.StatusInternalServerError, "Failed to get settings", err) @@ -286,6 +293,11 @@ func (hs *HTTPServer) NotFoundHandler(c *contextmodel.ReqContext) { return } + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.htmlHandlerRequestsDuration.WithLabelValues("not_found"), time.Since(start).Seconds()) + }() + data, err := hs.setIndexViewData(c) if err != nil { c.Handle(hs.Cfg, http.StatusInternalServerError, "Failed to get settings", err) diff --git a/pkg/api/login.go b/pkg/api/login.go index 60b894d0c49..89f452cd103 100644 --- a/pkg/api/login.go +++ b/pkg/api/login.go @@ -9,11 +9,13 @@ import ( "net/url" "regexp" "strings" + "time" "github.com/grafana/grafana/pkg/api/response" "github.com/grafana/grafana/pkg/apimachinery/errutil" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/metrics" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/infra/network" "github.com/grafana/grafana/pkg/middleware" "github.com/grafana/grafana/pkg/middleware/cookies" @@ -101,6 +103,10 @@ func (hs *HTTPServer) LoginView(c *contextmodel.ReqContext) { return } + start := time.Now() + defer func() { + metricutil.ObserveWithExemplar(c.Req.Context(), hs.htmlHandlerRequestsDuration.WithLabelValues("login"), time.Since(start).Seconds()) + }() viewData, err := setIndexViewData(hs, c) if err != nil { c.Handle(hs.Cfg, http.StatusInternalServerError, "Failed to get settings", err) diff --git a/pkg/api/login_test.go b/pkg/api/login_test.go index 2407876995e..59a539608a7 100644 --- a/pkg/api/login_test.go +++ b/pkg/api/login_test.go @@ -12,6 +12,7 @@ import ( "strings" "testing" + "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,6 +23,7 @@ import ( "github.com/grafana/grafana/pkg/api/routing" "github.com/grafana/grafana/pkg/components/simplejson" "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/infra/metrics/metricutil" "github.com/grafana/grafana/pkg/login/social" "github.com/grafana/grafana/pkg/models/usertoken" "github.com/grafana/grafana/pkg/services/auth/authtest" @@ -44,6 +46,18 @@ import ( const loginCookieName = "grafana_session" +// setupHTMLHandlerMetrics creates and registers the prometheus metrics needed for HTTPServer tests +// that call methods using htmlHandlerRequestsDuration (LoginView, Index, NotFoundHandler) +func setupHTMLHandlerMetrics() (prometheus.Registerer, *prometheus.HistogramVec) { + promRegister := prometheus.NewRegistry() + htmlHandlerRequestsDuration := metricutil.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: "grafana", + Name: "html_handler_requests_duration_seconds", + }, []string{"handler"}) + promRegister.MustRegister(htmlHandlerRequestsDuration) + return promRegister, htmlHandlerRequestsDuration +} + func fakeSetIndexViewData(t *testing.T) { origSetIndexViewData := setIndexViewData t.Cleanup(func() { @@ -109,13 +123,16 @@ func TestLoginErrorCookieAPIEndpoint(t *testing.T) { sc := setupScenarioContext(t, "/login") cfg := setting.NewCfg() secretsService := secretsManager.SetupTestService(t, fakes.NewFakeSecretsStore()) + promRegister, htmlHandlerRequestsDuration := setupHTMLHandlerMetrics() hs := &HTTPServer{ - Cfg: cfg, - SettingsProvider: &setting.OSSImpl{Cfg: cfg}, - License: &licensing.OSSLicensingService{}, - SocialService: &mockSocialService{}, - SecretsService: secretsService, - Features: featuremgmt.WithFeatures(), + Cfg: cfg, + SettingsProvider: &setting.OSSImpl{Cfg: cfg}, + License: &licensing.OSSLicensingService{}, + SocialService: &mockSocialService{}, + SecretsService: secretsService, + Features: featuremgmt.WithFeatures(), + promRegister: promRegister, + htmlHandlerRequestsDuration: htmlHandlerRequestsDuration, } sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { @@ -156,13 +173,16 @@ func TestLoginViewRedirect(t *testing.T) { fakeViewIndex(t) sc := setupScenarioContext(t, "/login") cfg := setting.NewCfg() + promRegister, htmlHandlerRequestsDuration := setupHTMLHandlerMetrics() hs := &HTTPServer{ - Cfg: cfg, - SettingsProvider: &setting.OSSImpl{Cfg: cfg}, - License: &licensing.OSSLicensingService{}, - SocialService: &mockSocialService{}, - Features: featuremgmt.WithFeatures(), - log: log.NewNopLogger(), + Cfg: cfg, + SettingsProvider: &setting.OSSImpl{Cfg: cfg}, + License: &licensing.OSSLicensingService{}, + SocialService: &mockSocialService{}, + Features: featuremgmt.WithFeatures(), + log: log.NewNopLogger(), + promRegister: promRegister, + htmlHandlerRequestsDuration: htmlHandlerRequestsDuration, } hs.Cfg.CookieSecure = true @@ -492,13 +512,16 @@ func TestLoginOAuthRedirect(t *testing.T) { }, oAuthInfos: oAuthInfos, } + promRegister, htmlHandlerRequestsDuration := setupHTMLHandlerMetrics() hs := &HTTPServer{ - authnService: &authntest.FakeService{}, - Cfg: cfg, - SettingsProvider: &setting.OSSImpl{Cfg: cfg}, - License: &licensing.OSSLicensingService{}, - SocialService: mock, - Features: featuremgmt.WithFeatures(), + authnService: &authntest.FakeService{}, + Cfg: cfg, + SettingsProvider: &setting.OSSImpl{Cfg: cfg}, + License: &licensing.OSSLicensingService{}, + SocialService: mock, + Features: featuremgmt.WithFeatures(), + promRegister: promRegister, + htmlHandlerRequestsDuration: htmlHandlerRequestsDuration, } sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { @@ -521,11 +544,14 @@ func TestLoginInternal(t *testing.T) { fakeViewIndex(t) sc := setupScenarioContext(t, "/login") + promRegister, htmlHandlerRequestsDuration := setupHTMLHandlerMetrics() hs := &HTTPServer{ - Cfg: setting.NewCfg(), - License: &licensing.OSSLicensingService{}, - log: log.New("test"), - Features: featuremgmt.WithFeatures(), + Cfg: setting.NewCfg(), + License: &licensing.OSSLicensingService{}, + log: log.New("test"), + Features: featuremgmt.WithFeatures(), + promRegister: promRegister, + htmlHandlerRequestsDuration: htmlHandlerRequestsDuration, } sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { @@ -583,14 +609,17 @@ func TestAuthProxyLoginWithEnableLoginTokenAndEnabledOauthAutoLogin(t *testing.T sc := setupScenarioContext(t, "/login") sc.cfg.LoginCookieName = loginCookieName sc.cfg.OAuthAutoLogin = true + promRegister, htmlHandlerRequestsDuration := setupHTMLHandlerMetrics() hs := &HTTPServer{ - Cfg: sc.cfg, - SettingsProvider: &setting.OSSImpl{Cfg: sc.cfg}, - License: &licensing.OSSLicensingService{}, - AuthTokenService: authtest.NewFakeUserAuthTokenService(), - log: log.New("hello"), - SocialService: mock, - Features: featuremgmt.WithFeatures(), + Cfg: sc.cfg, + SettingsProvider: &setting.OSSImpl{Cfg: sc.cfg}, + License: &licensing.OSSLicensingService{}, + AuthTokenService: authtest.NewFakeUserAuthTokenService(), + log: log.New("hello"), + SocialService: mock, + Features: featuremgmt.WithFeatures(), + promRegister: promRegister, + htmlHandlerRequestsDuration: htmlHandlerRequestsDuration, } sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { @@ -623,14 +652,17 @@ func setupAuthProxyLoginTest(t *testing.T, enableLoginToken bool) *scenarioConte sc := setupScenarioContext(t, "/login") sc.cfg.LoginCookieName = loginCookieName + promRegister, htmlHandlerRequestsDuration := setupHTMLHandlerMetrics() hs := &HTTPServer{ - Cfg: sc.cfg, - SettingsProvider: &setting.OSSImpl{Cfg: sc.cfg}, - License: &licensing.OSSLicensingService{}, - AuthTokenService: authtest.NewFakeUserAuthTokenService(), - log: log.New("hello"), - SocialService: &mockSocialService{}, - Features: featuremgmt.WithFeatures(), + Cfg: sc.cfg, + SettingsProvider: &setting.OSSImpl{Cfg: sc.cfg}, + License: &licensing.OSSLicensingService{}, + AuthTokenService: authtest.NewFakeUserAuthTokenService(), + log: log.New("hello"), + SocialService: &mockSocialService{}, + Features: featuremgmt.WithFeatures(), + promRegister: promRegister, + htmlHandlerRequestsDuration: htmlHandlerRequestsDuration, } sc.defaultHandler = routing.Wrap(func(c *contextmodel.ReqContext) response.Response { From 5754d7fbadec43b288bd2ec7c785736373364f9a Mon Sep 17 00:00:00 2001 From: maicon Date: Wed, 19 Nov 2025 21:05:20 -0300 Subject: [PATCH 092/125] chore: bump authlib/types to v0.0.0-20251119142549-be091cf2f4d4 (#114177) * chore: bump authlib/types to v0.0.0-20251119142549-be091cf2f4d4 Signed-off-by: Maicon Costa * Update Go Workspace Signed-off-by: Maicon Costa * Stop supporting deprecated namespace format in TestExtendedJWT_Authenticate Signed-off-by: Maicon Costa * Update go mod Signed-off-by: Maicon Costa --------- Signed-off-by: Maicon Costa --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 +-- apps/dashboard/go.mod | 2 +- apps/dashboard/go.sum | 4 +-- apps/example/go.mod | 2 +- apps/example/go.sum | 4 +-- apps/iam/go.mod | 30 ++++++++++---------- apps/iam/go.sum | 23 ++++++++++++++++ apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 +-- apps/provisioning/go.mod | 2 +- apps/provisioning/go.sum | 4 +-- apps/shorturl/go.mod | 2 +- apps/shorturl/go.sum | 4 +-- go.mod | 2 +- go.sum | 4 +-- pkg/apimachinery/go.mod | 2 +- pkg/apimachinery/go.sum | 4 +-- pkg/apiserver/go.mod | 2 +- pkg/apiserver/go.sum | 4 +-- pkg/services/authn/clients/ext_jwt_test.go | 32 ++-------------------- 21 files changed, 68 insertions(+), 71 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 1620778be0c..1cffeb06464 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -6,7 +6,7 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 github.com/google/go-cmp v0.7.0 github.com/google/go-github/v70 v70.0.0 - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 github.com/grafana/grafana v0.0.0-00010101000000-000000000000 github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 58de28ff76f..c681c3c257d 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -651,8 +651,8 @@ github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 h1:nbxKRtrbuhvOYm github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181/go.mod h1:VtPNIFlEOJPPEc13Ax6ZTbNV3M/sAzLID72YjgzOPVA= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dataplane/examples v0.0.1 h1:K9M5glueWyLoL4//H+EtTQq16lXuHLmOhb6DjSCahzA= github.com/grafana/dataplane/examples v0.0.1/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg= github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6kE/MWfg7s= diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index a175b696c57..5ed404c7c5d 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -4,7 +4,7 @@ go 1.25.3 require ( cuelang.org/go v0.11.1 - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 github.com/grafana/grafana-plugin-sdk-go v0.283.0 diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 867bf7e2697..8d7185e4d78 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -81,8 +81,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= diff --git a/apps/example/go.mod b/apps/example/go.mod index 143b915615a..a0d1dc37319 100644 --- a/apps/example/go.mod +++ b/apps/example/go.mod @@ -35,7 +35,7 @@ require ( github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // indirect + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/apps/example/go.sum b/apps/example/go.sum index 2fe30a1cd49..5a7f0692b24 100644 --- a/apps/example/go.sum +++ b/apps/example/go.sum @@ -52,8 +52,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 8db0429adcd..adb3755c58f 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -46,7 +46,7 @@ replace github.com/prometheus/alertmanager => github.com/grafana/prometheus-aler require ( github.com/grafana/grafana v0.0.0-00010101000000-000000000000 - github.com/grafana/grafana-app-sdk v0.48.1 + github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 github.com/grafana/grafana/apps/folder v0.0.0 github.com/grafana/grafana/pkg/apimachinery v0.0.0 @@ -231,12 +231,12 @@ require ( github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/alerting v0.0.0-20251009192429-9427c24835ae // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // indirect + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grafana/grafana-aws-sdk v1.3.0 // indirect github.com/grafana/grafana-azure-sdk-go/v2 v2.3.1 // indirect - github.com/grafana/grafana-plugin-sdk-go v0.281.0 // indirect + github.com/grafana/grafana-plugin-sdk-go v0.283.0 // indirect github.com/grafana/grafana/apps/dashboard v0.0.0 // indirect github.com/grafana/grafana/apps/plugins v0.0.0 // indirect github.com/grafana/grafana/apps/provisioning v0.0.0 // indirect @@ -251,7 +251,7 @@ require ( github.com/grafana/sqlds/v4 v4.2.7 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 // indirect - github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect + github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/consul/api v1.31.2 // indirect @@ -349,7 +349,7 @@ require ( github.com/pressly/goose/v3 v3.25.0 // indirect github.com/prometheus/alertmanager v0.28.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.1 // indirect + github.com/prometheus/common v0.67.2 // indirect github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect @@ -425,18 +425,18 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect gocloud.dev v0.43.0 // indirect - golang.org/x/crypto v0.43.0 // indirect + golang.org/x/crypto v0.44.0 // indirect golang.org/x/exp v0.0.0-20251002181428-27f1f14c8bb9 // indirect - golang.org/x/mod v0.29.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/oauth2 v0.32.0 // indirect - golang.org/x/sync v0.17.0 // indirect - golang.org/x/sys v0.37.0 // indirect - golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 // indirect - golang.org/x/term v0.36.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/mod v0.30.0 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.33.0 // indirect + golang.org/x/sync v0.18.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.38.0 // indirect + golang.org/x/tools v0.39.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect gonum.org/v1/gonum v0.16.0 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 31148a98766..b23eb7f0e4d 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -839,6 +839,8 @@ github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZ github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dataplane/examples v0.0.1 h1:K9M5glueWyLoL4//H+EtTQq16lXuHLmOhb6DjSCahzA= github.com/grafana/dataplane/examples v0.0.1/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg= github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6kE/MWfg7s= @@ -853,6 +855,7 @@ github.com/grafana/gomemcache v0.0.0-20250828162811-a96f6acee2fe h1:q+QaVANzNZxv github.com/grafana/gomemcache v0.0.0-20250828162811-a96f6acee2fe/go.mod h1:j/s0jkda4UXTemDs7Pgw/vMT06alWc42CHisvYac0qw= github.com/grafana/grafana-app-sdk v0.48.1 h1:bKJadWH18WCpJ+Zk8AezRFXCcZgGredRv+fRS+8zkek= github.com/grafana/grafana-app-sdk v0.48.1/go.mod h1:5LljCz+wvmGfkQ8ZKTOfserhtXNEF0cSFthoWShvN6c= +github.com/grafana/grafana-app-sdk v0.48.2/go.mod h1:LDOvQ7OOyHLcXdSa0InATCa5OMoYAd6E1+rGLrMgHuk= github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= github.com/grafana/grafana-app-sdk/logging v0.48.1/go.mod h1:Gh/nBWnspK3oDNWtiM5qUF/fardHzOIEez+SPI3JeHA= github.com/grafana/grafana-aws-sdk v1.3.0 h1:/bfJzP93rCel1GbWoRSq0oUo424MZXt8jAp2BK9w8tM= @@ -867,6 +870,7 @@ github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79/go.mod h1:wc6Hbh3K2TgCUSfBC/BOzabItujtHMESZeFk5ZhdxhQ= github.com/grafana/grafana-plugin-sdk-go v0.281.0 h1:V8dGyatzcOLQeivFhBV2JWMwTSZH/clDnpfKG9p3dTA= github.com/grafana/grafana-plugin-sdk-go v0.281.0/go.mod h1:3I0g+v6jAwVmrt6BEjDUP4V6pkhGP5QKY5NkXY4Ayr4= +github.com/grafana/grafana-plugin-sdk-go v0.283.0/go.mod h1:20qhoYxIgbZRmwCEO1KMP8q2yq/Kge5+xE/99/hLEk0= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b h1:6Bo65etvjQ4tStkaA5+N3A3ENbO4UAWj53TxF6g2Hdk= github.com/grafana/grafana/apps/example v0.0.0-20251027162426-edef69fdc82b/go.mod h1:6+wASOCN8LWt6FJ8dc0oODUBIEY5XHaE6ABi8g0mR+k= github.com/grafana/grafana/pkg/promlib v0.0.8 h1:VUWsqttdf0wMI4j9OX9oNrykguQpZcruudDAFpJJVw0= @@ -901,6 +905,7 @@ github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0 h1:QGLs github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 h1:uGoIog/wiQHI9GAxXO5TJbT0wWKH3O9HhOJW1F9c3fY= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340/go.mod h1:3bDW6wMZJB7tiONtC/1Xpicra6Wp5GgbTbQWCbI5fkc= @@ -1374,6 +1379,7 @@ github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9 github.com/prometheus/common v0.29.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI= github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q= +github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= @@ -1730,6 +1736,7 @@ golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= golang.org/x/exp v0.0.0-20180321215751-8460e604b9de/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20180807140117-3d87b88a115f/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -1773,6 +1780,7 @@ golang.org/x/mod v0.4.1/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -1829,6 +1837,7 @@ golang.org/x/net v0.0.0-20220425223048-2871e0cb64e4/go.mod h1:CfG3xpIq0wQ8r1q4Su golang.org/x/net v0.0.0-20220520000938-2e3eb7b945c2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20181106182150-f42d05182288/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -1853,6 +1862,7 @@ golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a/go.mod h1:DAh4E804XQdzx2j golang.org/x/oauth2 v0.0.0-20220411215720-9780585627b5/go.mod h1:DAh4E804XQdzx2j+YRIaUnCqCV2RuMz24cGBJ5QYIrc= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -1867,6 +1877,7 @@ golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20220513210516-0976fa681c29/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -1961,12 +1972,15 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8 h1:LvzTn0GQhWuvKH/kVRS3R3bVAsdQWI7hvfLHGgh9+lU= golang.org/x/telemetry v0.0.0-20251008203120-078029d740a8/go.mod h1:Pi4ztBfryZoJEkyFTI5/Ocsu2jXyDr6iSdgJiYE/uwE= +golang.org/x/telemetry v0.0.0-20251111182119-bc8e575c7b54/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -1978,6 +1992,7 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -2046,6 +2061,7 @@ golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ= golang.org/x/tools/godoc v0.1.0-deprecated h1:o+aZ1BOj6Hsx/GBdJO/s815sqftjSnrZZwyYTHODvtk= golang.org/x/tools/godoc v0.1.0-deprecated/go.mod h1:qM63CriJ961IHWmnWa9CjZnBndniPt4a3CK0PVB9bIg= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -2305,20 +2321,27 @@ honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9 honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= k8s.io/api v0.34.1 h1:jC+153630BMdlFukegoEL8E/yT7aLyQkIVuwhmwDgJM= k8s.io/api v0.34.1/go.mod h1:SB80FxFtXn5/gwzCoN6QCtPD7Vbu5w2n1S0J5gFfTYk= +k8s.io/api v0.34.2/go.mod h1:MMBPaWlED2a8w4RSeanD76f7opUoypY8TFYkSM+3XHw= k8s.io/apiextensions-apiserver v0.34.1 h1:NNPBva8FNAPt1iSVwIE0FsdrVriRXMsaWFMqJbII2CI= k8s.io/apiextensions-apiserver v0.34.1/go.mod h1:hP9Rld3zF5Ay2Of3BeEpLAToP+l4s5UlxiHfqRaRcMc= +k8s.io/apiextensions-apiserver v0.34.2/go.mod h1:398CJrsgXF1wytdaanynDpJ67zG4Xq7yj91GrmYN2SE= k8s.io/apimachinery v0.34.1 h1:dTlxFls/eikpJxmAC7MVE8oOeP1zryV7iRyIjB0gky4= k8s.io/apimachinery v0.34.1/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= +k8s.io/apimachinery v0.34.2/go.mod h1:/GwIlEcWuTX9zKIg2mbw0LRFIsXwrfoVxn+ef0X13lw= k8s.io/apiserver v0.34.1 h1:U3JBGdgANK3dfFcyknWde1G6X1F4bg7PXuvlqt8lITA= k8s.io/apiserver v0.34.1/go.mod h1:eOOc9nrVqlBI1AFCvVzsob0OxtPZUCPiUJL45JOTBG0= +k8s.io/apiserver v0.34.2/go.mod h1:gqJQy2yDOB50R3JUReHSFr+cwJnL8G1dzTA0YLEqAPI= k8s.io/client-go v0.34.1 h1:ZUPJKgXsnKwVwmKKdPfw4tB58+7/Ik3CrjOEhsiZ7mY= k8s.io/client-go v0.34.1/go.mod h1:kA8v0FP+tk6sZA0yKLRG67LWjqufAoSHA2xVGKw9Of8= +k8s.io/client-go v0.34.2/go.mod h1:2VYDl1XXJsdcAxw7BenFslRQX28Dxz91U9MWKjX97fE= k8s.io/component-base v0.34.1 h1:v7xFgG+ONhytZNFpIz5/kecwD+sUhVE6HU7qQUiRM4A= k8s.io/component-base v0.34.1/go.mod h1:mknCpLlTSKHzAQJJnnHVKqjxR7gBeHRv0rPXA7gdtQ0= +k8s.io/component-base v0.34.2/go.mod h1:9xw2FHJavUHBFpiGkZoKuYZ5pdtLKe97DEByaA+hHbM= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kms v0.34.1 h1:iCFOvewDPzWM9fMTfyIPO+4MeuZ0tcZbugxLNSHFG4w= k8s.io/kms v0.34.1/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM= +k8s.io/kms v0.34.2/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM= k8s.io/kube-aggregator v0.34.1 h1:WNLV0dVNoFKmuyvdWLd92iDSyD/TSTjqwaPj0U9XAEU= k8s.io/kube-aggregator v0.34.1/go.mod h1:RU8j+5ERfp0h+gIvWtxRPfsa5nK7rboDm8RST8BJfYQ= k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index ca743f0f56a..9a73a11cc62 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -37,7 +37,7 @@ require ( github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // indirect + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 96d8dce605d..55fa4dc09fc 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -52,8 +52,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index 5a00cb7031b..06fa5976b01 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -42,7 +42,7 @@ require ( github.com/google/go-github/v64 v64.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // indirect + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grafana/grafana-app-sdk v0.48.2 // indirect github.com/josharian/intern v1.0.0 // indirect diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index 6a63c2cd85e..9a2b854aa09 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -58,8 +58,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= diff --git a/apps/shorturl/go.mod b/apps/shorturl/go.mod index 7f853adb219..6071ce04924 100644 --- a/apps/shorturl/go.mod +++ b/apps/shorturl/go.mod @@ -35,7 +35,7 @@ require ( github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // indirect + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect diff --git a/apps/shorturl/go.sum b/apps/shorturl/go.sum index a19635cc757..f10e228919a 100644 --- a/apps/shorturl/go.sum +++ b/apps/shorturl/go.sum @@ -52,8 +52,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk v0.48.2 h1:CQQDhwo1fWaXQVKvxxOcK6azbuY3E2TgJHNAZlYYn7U= diff --git a/go.mod b/go.mod index d6c78270adf..9e558b61411 100644 --- a/go.mod +++ b/go.mod @@ -88,7 +88,7 @@ require ( github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 // @grafana/grafana-app-platform-squad github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // @grafana/identity-access-team + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/grafana/dataplane/examples v0.0.1 // @grafana/observability-metrics github.com/grafana/dataplane/sdata v0.0.9 // @grafana/observability-metrics github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 // @grafana/grafana-backend-group diff --git a/go.sum b/go.sum index 21a60a41bd1..4cc52d323c4 100644 --- a/go.sum +++ b/go.sum @@ -1615,8 +1615,8 @@ github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181 h1:nbxKRtrbuhvOYm github.com/grafana/alerting v0.0.0-20251119204204-77fa75125181/go.mod h1:VtPNIFlEOJPPEc13Ax6ZTbNV3M/sAzLID72YjgzOPVA= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dataplane/examples v0.0.1 h1:K9M5glueWyLoL4//H+EtTQq16lXuHLmOhb6DjSCahzA= github.com/grafana/dataplane/examples v0.0.1/go.mod h1:h5YwY8s407/17XF5/dS8XrUtsTVV2RnuW8+m1Mp46mg= github.com/grafana/dataplane/sdata v0.0.9 h1:AGL1LZnCUG4MnQtnWpBPbQ8ZpptaZs14w6kE/MWfg7s= diff --git a/pkg/apimachinery/go.mod b/pkg/apimachinery/go.mod index 9f94733b8d0..df34d602434 100644 --- a/pkg/apimachinery/go.mod +++ b/pkg/apimachinery/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/go-jose/go-jose/v4 v4.1.2 github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @grafana/identity-access-team - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 // @grafana/identity-access-team + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // @grafana/identity-access-team github.com/stretchr/testify v1.11.1 go.yaml.in/yaml/v3 v3.0.4 k8s.io/apimachinery v0.34.2 diff --git a/pkg/apimachinery/go.sum b/pkg/apimachinery/go.sum index 1e3875d7db4..f5123c3038a 100644 --- a/pkg/apimachinery/go.sum +++ b/pkg/apimachinery/go.sum @@ -34,8 +34,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index b1dba553ed5..8e2ae9ae59b 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -4,7 +4,7 @@ go 1.25.3 require ( github.com/google/go-cmp v0.7.0 - github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 + github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 github.com/grafana/grafana-app-sdk/logging v0.48.1 github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250514132646-acbc7b54ed9e github.com/prometheus/client_golang v1.23.2 diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 4070cf8e5f8..7a0b4d77cd7 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -67,8 +67,8 @@ github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5T github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f h1:Cbm6OKkOcJ+7CSZsGsEJzktC/SIa5bxVeYKQLuYK86o= github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f/go.mod h1:axY0cdOg3q0TZHwpHnIz5x16xZ8ZBxJHShsSHHXcHQg= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37 h1:qEwZ+7MbPjzRvTi31iT9w7NBhKIpKwZrFbYmOZLqkwA= -github.com/grafana/authlib/types v0.0.0-20250926065801-df98203cff37/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= +github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4/go.mod h1:qeWYbnWzaYGl88JlL9+DsP1GT2Cudm58rLtx13fKZdw= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 h1:jSojuc7njleS3UOz223WDlXOinmuLAIPI0z2vtq8EgI= github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4/go.mod h1:VahT+GtfQIM+o8ht2StR6J9g+Ef+C2Vokh5uuSmOD/4= github.com/grafana/grafana-app-sdk/logging v0.48.1 h1:veM0X5LAPyN3KsDLglWjIofndbGuf7MqnrDuDN+F/Ng= diff --git a/pkg/services/authn/clients/ext_jwt_test.go b/pkg/services/authn/clients/ext_jwt_test.go index 48612981a45..88cf11fb83a 100644 --- a/pkg/services/authn/clients/ext_jwt_test.go +++ b/pkg/services/authn/clients/ext_jwt_test.go @@ -373,23 +373,10 @@ func TestExtendedJWT_Authenticate(t *testing.T) { ExpectIssuer: "http://localhost:3000", }, }, - want: &authn.Identity{ - ID: "this-uid", - UID: "this-uid", - Name: "this-uid", - Type: claims.TypeAccessPolicy, - OrgID: 1, - AccessTokenClaims: &validAccessTokenClaimsWithDeprecatedStackClaimSet, - Namespace: "stack-1234", - AuthenticatedBy: "extendedjwt", - AuthID: "access-policy:this-uid", - ClientParams: authn.ClientParams{ - SyncPermissions: true, - }, - }, + wantErr: errExtJWTDisallowedNamespaceClaim, }, { - name: "should authenticate as user using specific deprecated namespace claim in access and id tokens", + name: "should NOT authenticate as user using specific deprecated namespace claim in access and id tokens", accessToken: &validAccessTokenClaimsWithDeprecatedStackClaimSet, idToken: &validIDTokenClaimsWithDeprecatedStackClaimSet, orgID: 1, @@ -401,20 +388,7 @@ func TestExtendedJWT_Authenticate(t *testing.T) { ExpectIssuer: "http://localhost:3000", }, }, - want: &authn.Identity{ - ID: "2", - Type: claims.TypeUser, - OrgID: 1, - AccessTokenClaims: &validAccessTokenClaimsWithDeprecatedStackClaimSet, - IDTokenClaims: &validIDTokenClaimsWithDeprecatedStackClaimSet, - Namespace: "stack-1234", - AuthenticatedBy: "extendedjwt", - AuthID: "access-policy:this-uid", - ClientParams: authn.ClientParams{ - SyncPermissions: true, - FetchSyncedUser: true, - }, - }, + wantErr: errExtJWTDisallowedNamespaceClaim, }, { name: "should authenticate as user using wildcard namespace for access token, setting allowed namespace to specific", From 946e6d96412de5b138b7d0e04620af7e05fc7e2d Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Wed, 19 Nov 2025 19:38:06 -0500 Subject: [PATCH 093/125] Table: Improve JSON rendering in inspect for JSON strings (#114189) --- .../Table/TableNG/__snapshots__/utils.test.ts.snap | 11 ++++++++++- .../src/components/Table/TableNG/utils.test.ts | 6 ++++-- .../grafana-ui/src/components/Table/TableNG/utils.ts | 10 +++++++++- 3 files changed, 23 insertions(+), 4 deletions(-) diff --git a/packages/grafana-ui/src/components/Table/TableNG/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/Table/TableNG/__snapshots__/utils.test.ts.snap index 922b7514309..17707f4aa6c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/__snapshots__/utils.test.ts.snap +++ b/packages/grafana-ui/src/components/Table/TableNG/__snapshots__/utils.test.ts.snap @@ -94,9 +94,18 @@ exports[`TableNG utils buildInspectValue should handle string 1`] = ` ] `; +exports[`TableNG utils buildInspectValue should handle string w/ JSON (invalid JSON) 1`] = ` +[ + ""{\\"invalid\\": \\"json"", + "code", +] +`; + exports[`TableNG utils buildInspectValue should handle string w/ JSON 1`] = ` [ - ""baz"", + "{ + "valid": "json" +}", "code", ] `; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index 02527df8540..7d8c81961db 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -1619,6 +1619,7 @@ describe('TableNG utils', () => { }; const jsonStringField: Field = { ...stringField, + values: ['{"valid": "json"}', '{"invalid": "json', null, '{"another": "one"}'], config: { custom: { cellOptions: { type: TableCellDisplayMode.JSONView } } }, }; const booleanField: Field = { @@ -1675,11 +1676,12 @@ describe('TableNG utils', () => { it.each([ { name: 'numbers', input: { valueIdx: 0, field: numberFieldWithNulls } }, { name: 'string', input: { valueIdx: 0, field: stringField } }, - { name: 'string w/ JSON', input: { valueIdx: 2, field: jsonStringField } }, + { name: 'string w/ JSON', input: { valueIdx: 0, field: jsonStringField } }, + { name: 'string w/ JSON (invalid JSON)', input: { valueIdx: 1, field: jsonStringField } }, { name: 'boolean', input: { valueIdx: 0, field: booleanField } }, { name: 'NaN', input: { valueIdx: 4, field: numberFieldWithNulls } }, { name: 'null', input: { valueIdx: 3, field: numberFieldWithNulls } }, - { name: 'null w/ JSON', input: { valueIdx: 3, field: jsonStringField } }, + { name: 'null w/ JSON', input: { valueIdx: 2, field: jsonStringField } }, { name: 'undefined', input: { valueIdx: 6, field: numberFieldWithNulls } }, { name: 'sparkline', input: { valueIdx: 0, field: sparklineField } }, { name: 'sparkline (no x)', input: { valueIdx: 0, field: sparklineFieldNoX } }, diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index eb608ce2548..0226f8b6463 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -1049,7 +1049,15 @@ export function buildInspectValue(value: unknown, field: Field): [string, TableC inspectValue += ']'; mode = TableCellInspectorMode.code; } else if (cellOptions.type === TableCellDisplayMode.JSONView || Array.isArray(value) || isPlainObject(value)) { - inspectValue = JSON.stringify(value, null, ' '); + let toStringify = value; + if (typeof value === 'string') { + try { + toStringify = JSON.parse(value); + } catch { + // do nothing, toStringify will stay as the raw string + } + } + inspectValue = JSON.stringify(toStringify, null, ' '); mode = TableCellInspectorMode.code; } else { inspectValue = String(value ?? ''); From b0f3e531b171caf86008681f1143fc71134403de Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Thu, 20 Nov 2025 00:40:29 +0000 Subject: [PATCH 094/125] I18n: Download translations from Crowdin (#114206) New Crowdin translations by GitHub Action Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- public/locales/cs-CZ/grafana.json | 11 +++++++---- public/locales/de-DE/grafana.json | 11 +++++++---- public/locales/es-ES/grafana.json | 11 +++++++---- public/locales/fr-FR/grafana.json | 11 +++++++---- public/locales/hu-HU/grafana.json | 11 +++++++---- public/locales/id-ID/grafana.json | 11 +++++++---- public/locales/it-IT/grafana.json | 11 +++++++---- public/locales/ja-JP/grafana.json | 11 +++++++---- public/locales/ko-KR/grafana.json | 11 +++++++---- public/locales/nl-NL/grafana.json | 11 +++++++---- public/locales/pl-PL/grafana.json | 11 +++++++---- public/locales/pt-BR/grafana.json | 11 +++++++---- public/locales/pt-PT/grafana.json | 11 +++++++---- public/locales/ru-RU/grafana.json | 11 +++++++---- public/locales/sv-SE/grafana.json | 11 +++++++---- public/locales/tr-TR/grafana.json | 11 +++++++---- public/locales/zh-Hans/grafana.json | 11 +++++++---- public/locales/zh-Hant/grafana.json | 11 +++++++---- 18 files changed, 126 insertions(+), 72 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index d446bbc5710..70cca883b8b 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -2622,8 +2622,7 @@ "firing": "Spuštěno", "normal": "Normální", "pending": "Nevyřízeno", - "recovering": "Probíhá obnovení", - "unknown": "Neznámé" + "recovering": "Probíhá obnovení" }, "type": { "alert": "Pravidlo výstrahy", @@ -2647,12 +2646,14 @@ "placeholder-search-input": "Hledejte podle názvu nebo zadejte dotaz na filtr…" }, "grafana-folder": "Složka Grafana", + "group-autocomplete-unavailable": "", "health": "Kondice", "label": { "hide": "Skrýt", "show": "Zobrazit" }, "manage-alerts": "V těchto zdrojích dat můžete prostřednictvím uživatelského rozhraní upozorňování vybrat možnost Správa upozorňování. Odtud můžete spravovat tato pravidla výstrah v uživatelském rozhraní Grafana i ve zdroji dat, kde byla nakonfigurována.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Všechny zdroje dat", "placeholder-contact-point": "Vyberte kontaktní bod", "placeholder-data-sources": "Vyberte zdroje dat", @@ -3564,6 +3565,7 @@ "delete-modal-invalid-title": "Složku nelze odstranit", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Tato akce odstraní následující obsah:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Odstranit", "delete-provisioned-folder": "Odstranit poskytnutou složku", "deleting": "Probíhá odstranění…", @@ -3572,6 +3574,7 @@ "move-modal-alert": "Přesunutí této položky může změnit oprávnění.", "move-modal-field-label": "Název složky", "move-modal-text": "Tato akce přesune následující obsah:", + "move-modal-text-one-folder": "", "move-modal-title": "Přesunout", "move-provisioned-folder": "", "moving": "Probíhá přesouvání…", @@ -3669,11 +3672,11 @@ "title-folder": "Tento složka zatím nemá žádné nástěnky" }, "folder-actions-button": { - "delete": "Odstranit", + "delete": "", "delete-folder-error": "Chyba při odstraňování složky. Zkuste to znovu později.", "folder-actions": "Akce složky", "manage-permissions": "Spravovat oprávnění", - "move": "Přesunout" + "move": "" }, "folder-picker": { "accessible-label": "Vybrat složku: {{ label }} aktuálně vybraná", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 7a59b2181a7..d491c81700b 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Auslösung", "normal": "Normal", "pending": "Ausstehend", - "recovering": "Wird wiederhergestellt", - "unknown": "Unbekannt" + "recovering": "Wird wiederhergestellt" }, "type": { "alert": "Alertregel", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Nach Name suchen oder Filterabfrage eingeben …" }, "grafana-folder": "Grafana-Ordner", + "group-autocomplete-unavailable": "", "health": "Zustand", "label": { "hide": "Ausblenden", "show": "Anzeigen" }, "manage-alerts": "In diesen Datenquellen können Sie über die Alerting-Benutzeroberfläche „Warnungen verwalten“ auswählen, um diese Warnregeln sowohl in der Grafana-Benutzeroberfläche als auch in der Datenquelle, in der sie konfiguriert wurden, verwalten zu können.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Alle Datenquellen", "placeholder-contact-point": "Kontaktpunkt auswählen", "placeholder-data-sources": "Datenquellen auswählen", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Ordner kann nicht gelöscht werden", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Diese Aktion wird folgenden Inhalt löschen:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Löschen", "delete-provisioned-folder": "Bereitgestellten Ordner löschen", "deleting": "Löschen...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Das Verschieben dieses Elements kann seine Berechtigungen ändern.", "move-modal-field-label": "Ordnername", "move-modal-text": "Diese Aktion wird folgenden Inhalt verschieben:", + "move-modal-text-one-folder": "", "move-modal-title": "Verschieben", "move-provisioned-folder": "", "moving": "Verschieben...", @@ -3635,11 +3638,11 @@ "title-folder": "Dieser Ordner enthält noch keine Dashboards" }, "folder-actions-button": { - "delete": "Löschen", + "delete": "", "delete-folder-error": "Beim Löschen des Ordners ist ein Fehler aufgetreten. Bitte versuchen Sie es später erneut.", "folder-actions": "Ordneraktionen", "manage-permissions": "Berechtigungen verwalten", - "move": "Verschieben" + "move": "" }, "folder-picker": { "accessible-label": "Ordner auswählen: {{ label }} aktuell ausgewählt", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index c9bf819dcc7..adae8f28bf6 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Activada", "normal": "Normal", "pending": "Pendiente", - "recovering": "En recuperación", - "unknown": "Desconocido" + "recovering": "En recuperación" }, "type": { "alert": "Regla de alerta", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Buscar por nombre o introducir consulta de filtro..." }, "grafana-folder": "Carpeta de Grafana", + "group-autocomplete-unavailable": "", "health": "Salud", "label": { "hide": "Ocultar", "show": "Mostrar" }, "manage-alerts": "En estas fuentes de datos, puedes seleccionar Gestionar alertas a través de la interfaz de usuario de Alerting para poder administrar estas reglas de alerta en la interfaz de usuario de Grafana, así como en la fuente de datos donde se configuraron.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Todas las fuentes de datos", "placeholder-contact-point": "Seleccionar punto de contacto", "placeholder-data-sources": "Seleccionar fuentes de datos", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "No se puede eliminar la carpeta", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Esta acción eliminará el siguiente contenido:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Eliminar", "delete-provisioned-folder": "Eliminar carpeta aprovisionada", "deleting": "Eliminando...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Al mover este elemento puede cambiar sus permisos.", "move-modal-field-label": "Nombre de la carpeta", "move-modal-text": "Esta acción moverá el siguiente contenido:", + "move-modal-text-one-folder": "", "move-modal-title": "Mover", "move-provisioned-folder": "", "moving": "Moviendo...", @@ -3635,11 +3638,11 @@ "title-folder": "Esta carpeta aún no contiene ningún panel de control" }, "folder-actions-button": { - "delete": "Eliminar", + "delete": "", "delete-folder-error": "Error al eliminar la carpeta. Inténtalo de nuevo más tarde.", "folder-actions": "Acciones de la carpeta", "manage-permissions": "Gestionar permisos", - "move": "Mover" + "move": "" }, "folder-picker": { "accessible-label": "Seleccionar carpeta: {{ label }} seleccionada actualmente", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 909effb75e1..e0648accd63 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Déclenché", "normal": "Normal", "pending": "En attente", - "recovering": "Récupération en cours", - "unknown": "Inconnu" + "recovering": "Récupération en cours" }, "type": { "alert": "Règle d’alerte", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Rechercher par nom ou saisir une requête de filtre..." }, "grafana-folder": "Dossier Grafana", + "group-autocomplete-unavailable": "", "health": "Santé", "label": { "hide": "Masquer", "show": "Afficher" }, "manage-alerts": "Dans ces sources de données, vous pouvez sélectionner Gérer les alertes via l’interface utilisateur d’alerte pour pouvoir gérer ces règles d’alerte dans l’interface utilisateur Grafana, ainsi que dans la source de données où elles ont été configurées.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Toutes les sources de données", "placeholder-contact-point": "Sélectionner un point de contact", "placeholder-data-sources": "Sélectionner des sources de données", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Impossible de supprimer le dossier", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Cette action supprimera le contenu suivant :", + "delete-modal-text-one-folder": "", "delete-modal-title": "Supprimer", "delete-provisioned-folder": "Supprimer le dossier provisionné", "deleting": "Suppression…", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Le déplacement de cet élément peut modifier ses permissions.", "move-modal-field-label": "Nom du dossier", "move-modal-text": "Cette action déplacera le contenu suivant :", + "move-modal-text-one-folder": "", "move-modal-title": "Déplacer", "move-provisioned-folder": "", "moving": "Déplacement...", @@ -3635,11 +3638,11 @@ "title-folder": "Ce dossier ne contient pas encore de tableaux de bord" }, "folder-actions-button": { - "delete": "Supprimer", + "delete": "", "delete-folder-error": "Erreur lors de la suppression du dossier. Veuillez réessayer plus tard.", "folder-actions": "Actions sur le dossier", "manage-permissions": "Gérer les autorisations", - "move": "Déplacer" + "move": "" }, "folder-picker": { "accessible-label": "Sélectionnez un dossier : {{ label }} est sélectionné actuellement", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index faddf11c310..80e9b74c7a7 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Aktív", "normal": "Normál", "pending": "Függőben", - "recovering": "Helyreállítás", - "unknown": "Ismeretlen" + "recovering": "Helyreállítás" }, "type": { "alert": "Riasztási szabály", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Keresés név alapján, vagy szűrőlekérdezés megadása..." }, "grafana-folder": "Grafana mappa", + "group-autocomplete-unavailable": "", "health": "Állapot", "label": { "hide": "Elrejtés", "show": "Megjelenítés" }, "manage-alerts": "Ezekben az adatforrásokban kiválaszthatja a Riasztások kezelése az Alerting felületén keresztül lehetőséget, hogy kezelhesse ezeket a riasztási szabályokat a Grafana kezelőfelületén, valamint abban az adatforrásban, ahol konfigurálták őket.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Összes adatforrás", "placeholder-contact-point": "Kapcsolattartási pont kiválasztása", "placeholder-data-sources": "Adatforrások kiválasztása", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Nem lehet törölni a mappát", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Ez a művelet törli a következő tartalmat:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Törlés", "delete-provisioned-folder": "Kiépített mappa törlése", "deleting": "Törlés...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Az elem áthelyezésével megváltozhatnak az engedélyei.", "move-modal-field-label": "Mappa neve", "move-modal-text": "Ez a művelet áthelyezi a következő tartalmat:", + "move-modal-text-one-folder": "", "move-modal-title": "Áthelyezés", "move-provisioned-folder": "", "moving": "Áthelyezés...", @@ -3635,11 +3638,11 @@ "title-folder": "Ehhez a mappához még nem tartoznak irányítópultok" }, "folder-actions-button": { - "delete": "Törlés", + "delete": "", "delete-folder-error": "Hiba történt a mappa törlése során. Próbálja meg később.", "folder-actions": "Mappaműveletek", "manage-permissions": "Engedélyek kezelése", - "move": "Áthelyezés" + "move": "" }, "folder-picker": { "accessible-label": "Mappa kijelölése: {{ label }} jelenleg kijelölve", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index ff85c103ce3..1d0d348f979 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -2586,8 +2586,7 @@ "firing": "Dipicu", "normal": "Normal", "pending": "Tertunda", - "recovering": "Memulihkan", - "unknown": "Tidak diketahui" + "recovering": "Memulihkan" }, "type": { "alert": "Aturan peringatan", @@ -2611,12 +2610,14 @@ "placeholder-search-input": "Cari berdasarkan nama atau masukkan kueri filter..." }, "grafana-folder": "Folder Grafana", + "group-autocomplete-unavailable": "", "health": "Kesehatan", "label": { "hide": "Sembunyikan", "show": "Tampilkan" }, "manage-alerts": "Dalam sumber data ini, Anda dapat memilih Kelola peringatan melalui UI Alerting untuk dapat mengelola aturan peringatan ini di UI Grafana serta di sumber data tempat mereka dikonfigurasi.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Semua sumber data", "placeholder-contact-point": "Pilih titik kontak", "placeholder-data-sources": "Pilih sumber data", @@ -3528,6 +3529,7 @@ "delete-modal-invalid-title": "Tidak dapat menghapus folder", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Tindakan ini akan menghapus konten berikut:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Hapus", "delete-provisioned-folder": "Hapus folder yang disediakan", "deleting": "Menghapus...", @@ -3536,6 +3538,7 @@ "move-modal-alert": "Memindahkan item ini dapat mengubah izinnya.", "move-modal-field-label": "Nama folder", "move-modal-text": "Tindakan ini akan memindahkan konten berikut:", + "move-modal-text-one-folder": "", "move-modal-title": "Pindahkan", "move-provisioned-folder": "", "moving": "Memindahkan...", @@ -3618,11 +3621,11 @@ "title-folder": "Folder ini belum memiliki dasbor" }, "folder-actions-button": { - "delete": "Hapus", + "delete": "", "delete-folder-error": "Terjadi kesalahan saat menghapus folder. Coba lagi nanti.", "folder-actions": "Tindakan folder", "manage-permissions": "Kelola izin", - "move": "Pindahkan" + "move": "" }, "folder-picker": { "accessible-label": "Pilih folder: {{ label }} saat ini dipilih", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 2032908b4e0..31e179978f2 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -2598,8 +2598,7 @@ "firing": "In attivazione", "normal": "Normale", "pending": "In sospeso", - "recovering": "In fase di recupero", - "unknown": "Sconosciuto" + "recovering": "In fase di recupero" }, "type": { "alert": "Regola avviso", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Cerca per nome o inserisci una query di filtro..." }, "grafana-folder": "Cartella Grafana", + "group-autocomplete-unavailable": "", "health": "Stato", "label": { "hide": "Nascondi", "show": "Mostra" }, "manage-alerts": "In queste origini dei dati, è possibile selezionare Gestisci avvisi tramite l'interfaccia utente di avviso per poter gestire queste regole di avviso nell'interfaccia utente di Grafana e nell'origine dei dati in cui sono state configurate.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Tutte le fonti dei dati", "placeholder-contact-point": "Seleziona punto di contatto", "placeholder-data-sources": "Seleziona origini dati", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Impossibile eliminare la cartella", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Questa azione eliminerà il seguente contenuto:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Elimina", "delete-provisioned-folder": "Elimina cartella fornita", "deleting": "Eliminazione in corso...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Lo spostamento di questo elemento potrebbe modificarne le autorizzazioni.", "move-modal-field-label": "Nome cartella", "move-modal-text": "Questa azione sposterà il seguente contenuto:", + "move-modal-text-one-folder": "", "move-modal-title": "Sposta", "move-provisioned-folder": "", "moving": "Spostamento in corso...", @@ -3635,11 +3638,11 @@ "title-folder": "Questa cartella non contiene ancora nessun dashboard" }, "folder-actions-button": { - "delete": "Elimina", + "delete": "", "delete-folder-error": "Errore durante l'eliminazione della cartella. Riprova più tardi.", "folder-actions": "Azioni cartella", "manage-permissions": "Gestisci autorizzazioni", - "move": "Sposta" + "move": "" }, "folder-picker": { "accessible-label": "Seleziona cartella: {{ label }} attualmente selezionata", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index d0219ba1ecc..22a4029f62d 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -2586,8 +2586,7 @@ "firing": "発生中", "normal": "通常", "pending": "保留中", - "recovering": "復旧中", - "unknown": "不明" + "recovering": "復旧中" }, "type": { "alert": "アラートルール", @@ -2611,12 +2610,14 @@ "placeholder-search-input": "名前で検索するか、フィルタークエリを入力してください…" }, "grafana-folder": "Grafanaフォルダ", + "group-autocomplete-unavailable": "", "health": "健康", "label": { "hide": "非表示", "show": "表示" }, "manage-alerts": "これらのデータソースでは、「アラートUI経由で管理」を選択すると、Grafana UIとアラートルールが設定されたデータソースの両方でこれらのアラートルールを管理できます。", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "すべてのデータソース", "placeholder-contact-point": "コンタクトポイントを選択してください", "placeholder-data-sources": "データソースを選択してください", @@ -3528,6 +3529,7 @@ "delete-modal-invalid-title": "フォルダを削除できません", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "この操作により、次のコンテンツが削除されます:", + "delete-modal-text-one-folder": "", "delete-modal-title": "削除", "delete-provisioned-folder": "プロビジョニングされたフォルダを削除", "deleting": "削除中…", @@ -3536,6 +3538,7 @@ "move-modal-alert": "このアイテムを移動すると、そのアクセス許可が変更される可能性があります。", "move-modal-field-label": "フォルダ名", "move-modal-text": "この操作により、次のコンテンツが移動されます。", + "move-modal-text-one-folder": "", "move-modal-title": "移動", "move-provisioned-folder": "", "moving": "移動中…", @@ -3618,11 +3621,11 @@ "title-folder": "このフォルダにはまだダッシュボードがありません" }, "folder-actions-button": { - "delete": "削除", + "delete": "", "delete-folder-error": "フォルダの削除中にエラーが発生しました。後でもう一度お試しください。", "folder-actions": "フォルダの操作", "manage-permissions": "権限を管理する", - "move": "移動" + "move": "" }, "folder-picker": { "accessible-label": "フォルダを選択:{{ label }}が現在選択されています", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index faea085221a..3174cc0be90 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -2586,8 +2586,7 @@ "firing": "발생 중", "normal": "정상", "pending": "보류 중", - "recovering": "복구 중", - "unknown": "알 수 없음" + "recovering": "복구 중" }, "type": { "alert": "경고 규칙", @@ -2611,12 +2610,14 @@ "placeholder-search-input": "이름으로 검색하거나 필터 쿼리를 입력하세요..." }, "grafana-folder": "Grafana 폴더", + "group-autocomplete-unavailable": "", "health": "상태", "label": { "hide": "숨기기", "show": "표시" }, "manage-alerts": "이러한 데이터 소스에서 경고 UI를 통해 '경고 관리'를 선택하면 Grafana UI와 경고 규칙이 구성된 데이터 소스에서 이러한 경고 규칙을 관리할 수 있습니다.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "모든 데이터 소스", "placeholder-contact-point": "연락처 선택", "placeholder-data-sources": "데이터 소스 선택", @@ -3528,6 +3529,7 @@ "delete-modal-invalid-title": "폴더를 삭제할 수 없음", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "이 작업을 수행하면 다음 콘텐츠가 삭제됩니다.", + "delete-modal-text-one-folder": "", "delete-modal-title": "삭제", "delete-provisioned-folder": "프로비저닝된 폴더 삭제", "deleting": "삭제 중…", @@ -3536,6 +3538,7 @@ "move-modal-alert": "이 항목을 이동하면 권한이 변경될 수 있습니다.", "move-modal-field-label": "폴더 이름", "move-modal-text": "이 작업을 수행하면 다음 콘텐츠가 이동됩니다.", + "move-modal-text-one-folder": "", "move-modal-title": "이동", "move-provisioned-folder": "", "moving": "이동 중...", @@ -3618,11 +3621,11 @@ "title-folder": "이 폴더에는 아직 대시보드가 없습니다." }, "folder-actions-button": { - "delete": "삭제", + "delete": "", "delete-folder-error": "폴더 삭제 도중 오류가 발생했습니다. 나중에 다시 시도해 주세요.", "folder-actions": "폴더 작업", "manage-permissions": "권한 관리", - "move": "이동" + "move": "" }, "folder-picker": { "accessible-label": "폴더 선택: 현재 {{ label }} 선택함", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index ff870d106ed..ebef81b6199 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Geactiveerd", "normal": "Normaal", "pending": "In afwachting", - "recovering": "Herstellen", - "unknown": "Onbekend" + "recovering": "Herstellen" }, "type": { "alert": "Waarschuwingsregel", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Zoeken op naam of filterquery invoeren..." }, "grafana-folder": "Grafana-map", + "group-autocomplete-unavailable": "", "health": "Gezondheid", "label": { "hide": "Verbergen", "show": "Tonen" }, "manage-alerts": "In deze gegevensbronnen kun je waarschuwingen beheren via de waarschuwingsinterface om deze waarschuwingsregels te kunnen beheren in de Grafana-gebruikersinterface en in de gegevensbron waar ze zijn geconfigureerd.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Alle gegevensbronnen", "placeholder-contact-point": "Contactpunt selecteren", "placeholder-data-sources": "Gegevensbronnen selecteren", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Kan de map niet verwijderen", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Met deze actie wordt de volgende inhoud verwijderd:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Verwijderen", "delete-provisioned-folder": "Geprovisioneerde map verwijderen", "deleting": "Bezig met verwijderen ...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Het verplaatsen van dit item kan de toestemmingen wijzigen.", "move-modal-field-label": "Mapnaam", "move-modal-text": "Met deze actie wordt de volgende inhoud verplaatst:", + "move-modal-text-one-folder": "", "move-modal-title": "Verplaatsen", "move-provisioned-folder": "", "moving": "Bezig met verplaatsen ...", @@ -3635,11 +3638,11 @@ "title-folder": "Deze map heeft nog geen dashboards" }, "folder-actions-button": { - "delete": "Verwijderen", + "delete": "", "delete-folder-error": "Fout bij verwijderen van map. Probeer het later opnieuw.", "folder-actions": "Mapacties", "manage-permissions": "Toestemmingen beheren", - "move": "Verplaatsen" + "move": "" }, "folder-picker": { "accessible-label": "Map selecteren: {{ label }} is momenteel geselecteerd", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 9d14b81af74..4ea5e15c281 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -2622,8 +2622,7 @@ "firing": "Uruchamianie", "normal": "Normalny", "pending": "Oczekujący", - "recovering": "Przywracanie", - "unknown": "Nieznany" + "recovering": "Przywracanie" }, "type": { "alert": "Reguła alertu", @@ -2647,12 +2646,14 @@ "placeholder-search-input": "Szukaj według nazwy lub wprowadź wyrażenie do filtrowania…" }, "grafana-folder": "Folder Grafana", + "group-autocomplete-unavailable": "", "health": "Zdrowie", "label": { "hide": "Ukryj", "show": "Pokaż" }, "manage-alerts": "W tych źródłach danych możesz wybrać „Zarządzaj alertami za pośrednictwem interfejsu alertów”, aby móc zarządzać tymi regułami alertów w interfejsie Grafany, a także w źródle danych, w którym zostały skonfigurowane.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Wszystkie źródła danych", "placeholder-contact-point": "Wybierz punkt kontaktowy", "placeholder-data-sources": "Wybierz źródła danych", @@ -3564,6 +3565,7 @@ "delete-modal-invalid-title": "Nie można usunąć folderu", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "To działanie spowoduje usunięcie następującej zawartości:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Usuń", "delete-provisioned-folder": "Usuń przydzielony folder", "deleting": "Usuwanie…", @@ -3572,6 +3574,7 @@ "move-modal-alert": "Przeniesienie tego elementu może zmienić jego uprawnienia.", "move-modal-field-label": "Nazwa folderu", "move-modal-text": "To działanie spowoduje przeniesienie następującej zawartości:", + "move-modal-text-one-folder": "", "move-modal-title": "Przenieś", "move-provisioned-folder": "", "moving": "Przenoszenie…", @@ -3669,11 +3672,11 @@ "title-folder": "Ten folder nie zawiera jeszcze żadnych pulpitów" }, "folder-actions-button": { - "delete": "Usuń", + "delete": "", "delete-folder-error": "Błąd podczas usuwania folderu. Spróbuj ponownie później.", "folder-actions": "Działania w obrębie folderu", "manage-permissions": "Zarządzaj uprawnieniami", - "move": "Przenieś" + "move": "" }, "folder-picker": { "accessible-label": "Wybierz folder: aktualnie wybrano {{ label }}", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 0e3a51255e4..c6b347e785f 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Ativo", "normal": "Normal", "pending": "Pendente", - "recovering": "Em recuperação", - "unknown": "Desconhecido" + "recovering": "Em recuperação" }, "type": { "alert": "Regra de alerta", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Pesquise por nome ou insira uma consulta de filtro…" }, "grafana-folder": "Pasta do Grafana", + "group-autocomplete-unavailable": "", "health": "Integridade", "label": { "hide": "Ocultar", "show": "Exibir" }, "manage-alerts": "Nessas fontes de dados, você pode selecionar Gerenciar alertas via Interface de Envio de Alertas para gerenciar essas regras de alerta na interface da Grafana, bem como na fonte de dados onde foram configuradas.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Todas as fontes de dados", "placeholder-contact-point": "Selecionar ponto de contato", "placeholder-data-sources": "Selecionar fontes de dados", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Não é possível excluir pasta", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Esta ação apagará o seguinte conteúdo:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Excluir", "delete-provisioned-folder": "Excluir pasta provisionada", "deleting": "Excluindo...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Mover este item pode alterar suas permissões.", "move-modal-field-label": "Nome da pasta", "move-modal-text": "Esta ação moverá o seguinte conteúdo:", + "move-modal-text-one-folder": "", "move-modal-title": "Mover", "move-provisioned-folder": "", "moving": "Movendo...", @@ -3635,11 +3638,11 @@ "title-folder": "Esta pasta ainda não contém nenhum painel de controle" }, "folder-actions-button": { - "delete": "Excluir", + "delete": "", "delete-folder-error": "Erro ao excluir a pasta. Tente novamente mais tarde.", "folder-actions": "Ações da pasta", "manage-permissions": "Gerenciar permissões", - "move": "Mover" + "move": "" }, "folder-picker": { "accessible-label": "Selecionar pasta: {{ label }} atualmente selecionada", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index a201eb08d24..26db09f82dd 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Ativado", "normal": "Normal", "pending": "Pendente", - "recovering": "A recuperar", - "unknown": "Desconhecido" + "recovering": "A recuperar" }, "type": { "alert": "Regra de alerta", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Pesquise por nome ou introduza uma consulta de filtro..." }, "grafana-folder": "Pasta Grafana", + "group-autocomplete-unavailable": "", "health": "Saúde", "label": { "hide": "Ocultar", "show": "Mostrar" }, "manage-alerts": "Nestas origens de dados, pode selecionar Gerir alertas através da interface do utilizador Alerting para poder gerir estas regras de alerta na interface do utilizador Grafana, bem como na origem de dados onde foram configuradas.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Todas as origens de dados", "placeholder-contact-point": "Selecionar ponto de contacto", "placeholder-data-sources": "Selecionar origens de dados", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Não é possível eliminar a pasta", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Esta ação eliminará o seguinte conteúdo:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Eliminar", "delete-provisioned-folder": "Eliminar a pasta disponibilizada", "deleting": "A eliminar...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Mover este elemento pode alterar as suas permissões.", "move-modal-field-label": "Nome da pasta", "move-modal-text": "Esta ação moverá o seguinte conteúdo:", + "move-modal-text-one-folder": "", "move-modal-title": "Mover", "move-provisioned-folder": "", "moving": "A mover...", @@ -3635,11 +3638,11 @@ "title-folder": "Esta pasta ainda não tem qualquer painel de controlo" }, "folder-actions-button": { - "delete": "Eliminar", + "delete": "", "delete-folder-error": "Erro ao eliminar a pasta. Tente novamente mais tarde.", "folder-actions": "Ações da pasta", "manage-permissions": "Gerir permissões", - "move": "Mover" + "move": "" }, "folder-picker": { "accessible-label": "Selecionar a pasta: {{ label }} selecionada atualmente", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 1c80cf4d118..dec4add29ca 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -2622,8 +2622,7 @@ "firing": "Активное", "normal": "Нормальное", "pending": "В ожидании", - "recovering": "Восстановление", - "unknown": "Неизвестное" + "recovering": "Восстановление" }, "type": { "alert": "Правило оповещения", @@ -2647,12 +2646,14 @@ "placeholder-search-input": "Выполните поиск по имени или введите запрос фильтра..." }, "grafana-folder": "Папка Grafana", + "group-autocomplete-unavailable": "", "health": "Работоспособность", "label": { "hide": "Скрыть", "show": "Показать" }, "manage-alerts": "В этих источниках данных можно выбрать параметр «Управление оповещениями через пользовательский интерфейс Alerting», чтобы иметь возможность управлять этими правилами оповещения в пользовательском интерфейсе Grafana, а также в источнике данных, где они были установлены.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Все источники данных", "placeholder-contact-point": "Выбрать точку контакта", "placeholder-data-sources": "Выбрать источники данных", @@ -3564,6 +3565,7 @@ "delete-modal-invalid-title": "Невозможно удалить папку", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Это действие приведет к удалению следующего контента:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Удаление", "delete-provisioned-folder": "Удалить подготовленную папку", "deleting": "Удаление...", @@ -3572,6 +3574,7 @@ "move-modal-alert": "Перемещение этого элемента может привести к изменению его разрешений.", "move-modal-field-label": "Имя папки", "move-modal-text": "Это действие приведет к перемещению следующего контента:", + "move-modal-text-one-folder": "", "move-modal-title": "Перемещение", "move-provisioned-folder": "", "moving": "Перемещение...", @@ -3669,11 +3672,11 @@ "title-folder": "В этой папке еще нет дашбордов" }, "folder-actions-button": { - "delete": "Удалить", + "delete": "", "delete-folder-error": "Ошибка при удалении папки. Повторите попытку позже.", "folder-actions": "Действия с папками", "manage-permissions": "Управление разрешениями", - "move": "Переместить" + "move": "" }, "folder-picker": { "accessible-label": "Выбрать папку: в данный момент выбрана {{ label }}", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 35b5c4b7cc7..65178bb1cc1 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -2598,8 +2598,7 @@ "firing": "Utlöst", "normal": "Normal", "pending": "Väntande", - "recovering": "Återställer", - "unknown": "Okänt" + "recovering": "Återställer" }, "type": { "alert": "Larmregel", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "Sök efter namn eller ange filterfråga …" }, "grafana-folder": "Grafana-mapp", + "group-autocomplete-unavailable": "", "health": "Hälsa", "label": { "hide": "Dölj", "show": "Visa" }, "manage-alerts": "I dessa datakällor kan du välja Hantera larm via larmgränssnittet för att kunna hantera dessa larmregler i Grafana-gränssnittet samt i datakällan där de konfigurerades.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Alla datakällor", "placeholder-contact-point": "Välj kontaktpunkt", "placeholder-data-sources": "Välj datakällor", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Det går inte att ta bort mapp", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Denna åtgärd raderar följande innehåll:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Ta bort", "delete-provisioned-folder": "Ta bort provisionerad mapp", "deleting": "Tar bort ...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Om du flyttar det här objektet kan dess behörigheter ändras.", "move-modal-field-label": "Mappnamn", "move-modal-text": "Denna åtgärd flyttar följande innehåll:", + "move-modal-text-one-folder": "", "move-modal-title": "Flytta", "move-provisioned-folder": "", "moving": "Flyttar ...", @@ -3635,11 +3638,11 @@ "title-folder": "Den här mappen har inga instrumentpaneler ännu" }, "folder-actions-button": { - "delete": "Ta bort", + "delete": "", "delete-folder-error": "Fel vid radering av mapp. Försök igen senare.", "folder-actions": "Mappåtgärder", "manage-permissions": "Hantera behörigheter", - "move": "Flytta" + "move": "" }, "folder-picker": { "accessible-label": "Välj mapp: {{ label }} för närvarande vald", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 2fd50dc5856..9674e80d4de 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -2598,8 +2598,7 @@ "firing": "", "normal": "", "pending": "", - "recovering": "", - "unknown": "" + "recovering": "" }, "type": { "alert": "", @@ -2623,12 +2622,14 @@ "placeholder-search-input": "" }, "grafana-folder": "", + "group-autocomplete-unavailable": "", "health": "Sağlık", "label": { "hide": "Gizle", "show": "Göster" }, "manage-alerts": "Bu veri kaynaklarında hem Grafana arayüzünde hem de yapılandırıldıkları veri kaynağında bu uyarı kurallarını yönetebilmek için \"Uyarı Yönetimi\" arayüzü üzerinden yönetimi seçebilirsiniz.", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "Tüm veri kaynakları", "placeholder-contact-point": "", "placeholder-data-sources": "", @@ -3540,6 +3541,7 @@ "delete-modal-invalid-title": "Klasör silinemiyor", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "Bu işlem aşağıdaki içerikleri silecektir:", + "delete-modal-text-one-folder": "", "delete-modal-title": "Sil", "delete-provisioned-folder": "Sağlanan klasörü sil", "deleting": "Siliniyor...", @@ -3548,6 +3550,7 @@ "move-modal-alert": "Bu ögenin taşınması izinlerini değiştirebilir.", "move-modal-field-label": "Klasör adı", "move-modal-text": "Bu işlem aşağıdaki içerikleri taşıyacaktır:", + "move-modal-text-one-folder": "", "move-modal-title": "Taşı", "move-provisioned-folder": "", "moving": "Taşınıyor...", @@ -3635,11 +3638,11 @@ "title-folder": "Bu klasörde henüz bir pano yok" }, "folder-actions-button": { - "delete": "Sil", + "delete": "", "delete-folder-error": "", "folder-actions": "Klasör işlemleri", "manage-permissions": "İzinleri yönet", - "move": "Taşı" + "move": "" }, "folder-picker": { "accessible-label": "Klasör seçin: {{ label }} şu anda seçili", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index e139cf56610..d0302660f99 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -2586,8 +2586,7 @@ "firing": "触发中", "normal": "正常", "pending": "待处理", - "recovering": "恢复中", - "unknown": "未知" + "recovering": "恢复中" }, "type": { "alert": "警报规则", @@ -2611,12 +2610,14 @@ "placeholder-search-input": "按名称搜索或输入筛选条件查询……" }, "grafana-folder": "Grafana 文件夹", + "group-autocomplete-unavailable": "", "health": "健康", "label": { "hide": "隐藏", "show": "显示" }, "manage-alerts": "在这些数据源中,您可以选择通过警报用户界面管理警报,以便能够在 Grafana 用户界面以及在其配置所在的数据源中管理这些警报规则。", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "所有数据源", "placeholder-contact-point": "选择联络点", "placeholder-data-sources": "选择数据源", @@ -3528,6 +3529,7 @@ "delete-modal-invalid-title": "无法删除文件夹", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "此操作将删除以下内容:", + "delete-modal-text-one-folder": "", "delete-modal-title": "删除", "delete-provisioned-folder": "删除已预置的文件夹", "deleting": "正在删除...", @@ -3536,6 +3538,7 @@ "move-modal-alert": "移动此项目可能会改变其权限。", "move-modal-field-label": "文件夹名称", "move-modal-text": "此操作将移动以下内容:", + "move-modal-text-one-folder": "", "move-modal-title": "移动", "move-provisioned-folder": "", "moving": "正在移动...", @@ -3618,11 +3621,11 @@ "title-folder": "此文件夹尚无任何数据面板" }, "folder-actions-button": { - "delete": "删除", + "delete": "", "delete-folder-error": "删除文件夹时出错。请稍后重试。", "folder-actions": "文件夹操作", "manage-permissions": "管理权限", - "move": "移动" + "move": "" }, "folder-picker": { "accessible-label": "选择文件夹:{{ label }} 当前已选择", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 80360c56f8b..ad6c511d1f8 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -2586,8 +2586,7 @@ "firing": "觸發", "normal": "一般", "pending": "待處理", - "recovering": "正在還原", - "unknown": "未知" + "recovering": "正在還原" }, "type": { "alert": "警報規則", @@ -2611,12 +2610,14 @@ "placeholder-search-input": "按名稱搜尋或輸入篩選條件查詢…" }, "grafana-folder": "Grafana 資料夾", + "group-autocomplete-unavailable": "", "health": "使用情況", "label": { "hide": "隱藏", "show": "顯示" }, "manage-alerts": "在這些資料來源中,您可以選擇透過警報 UI 管理警報,以便在 Grafana UI 以及在其設定的資料來源中管理這些警報規則。", + "namespace-autocomplete-unavailable": "", "placeholder-all-data-sources": "所有資料來源", "placeholder-contact-point": "選擇聯絡點", "placeholder-data-sources": "選擇資料來源", @@ -3528,6 +3529,7 @@ "delete-modal-invalid-title": "無法刪除資料夾", "delete-modal-restore-dashboards-text": "", "delete-modal-text": "此動作將刪除以下內容:", + "delete-modal-text-one-folder": "", "delete-modal-title": "刪除", "delete-provisioned-folder": "刪除已佈建的資料夾", "deleting": "正在刪除…", @@ -3536,6 +3538,7 @@ "move-modal-alert": "移動此項目可能會變更其權限。", "move-modal-field-label": "資料夾名稱", "move-modal-text": "此動作將移動以下內容:", + "move-modal-text-one-folder": "", "move-modal-title": "移動", "move-provisioned-folder": "", "moving": "正在移動…", @@ -3618,11 +3621,11 @@ "title-folder": "此資料夾尚無任何儀表板" }, "folder-actions-button": { - "delete": "刪除", + "delete": "", "delete-folder-error": "刪除資料夾時發生錯誤,請稍後再試。", "folder-actions": "資料夾動作", "manage-permissions": "管理權限", - "move": "移動" + "move": "" }, "folder-picker": { "accessible-label": "選取資料夾:「{{ label }}」目前已選取", From 4e24b2977c7b0334637ec38e5448c9bb068c39d1 Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 20 Nov 2025 08:38:49 +0100 Subject: [PATCH 095/125] Alerting: Fix expression queries when coming from a panel (#114095) * Add getDefaultExpressions tests * Fix expression queries when building form values from a panel * make query params empty and add some better types * update snapshots * fix tests * Revert devenv changes * update shapshot --------- Co-authored-by: Gilles De Mey --- .../PanelAlertTabContent.test.tsx.snap | 8 +- ...eEditorGrafanaRecordingRules.test.tsx.snap | 8 +- .../SimplifiedRuleEditor.test.tsx.snap | 40 +--- .../RuleEditorGrafanaRules.test.tsx.snap | 8 +- .../alerting/unified/utils/rule-form.test.ts | 103 +++++++++ .../alerting/unified/utils/rule-form.ts | 210 +++++++++++------- .../PanelDataAlertingTab.test.tsx.snap | 8 +- 7 files changed, 246 insertions(+), 139 deletions(-) diff --git a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap index 4c1b0bb8b65..e36613807ec 100644 --- a/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/PanelAlertTabContent.test.tsx.snap @@ -52,9 +52,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -90,9 +88,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], diff --git a/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap b/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap index fdd49bc80d7..7aa2e489209 100644 --- a/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap +++ b/public/app/features/alerting/unified/__snapshots__/RuleEditorGrafanaRecordingRules.test.tsx.snap @@ -78,9 +78,7 @@ exports[`RuleEditor grafana recording rules can create new grafana recording rul "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -208,9 +206,7 @@ exports[`RuleEditor grafana recording rules can create new grafana recording rul "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap index 9ffe68ad3b4..1ff6c2a0502 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/simplifiedRouting/__snapshots__/SimplifiedRuleEditor.test.tsx.snap @@ -79,9 +79,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -117,9 +115,7 @@ exports[`Can create a new grafana managed alert using simplified routing can cre "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], @@ -249,9 +245,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -287,9 +281,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], @@ -422,9 +414,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -460,9 +450,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], @@ -598,9 +586,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -636,9 +622,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], @@ -771,9 +755,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -809,9 +791,7 @@ exports[`Can create a new grafana managed alert using simplified routing switch "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], diff --git a/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap index 298f5e0542a..6dd5b9af077 100644 --- a/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap +++ b/public/app/features/alerting/unified/rule-editor/__snapshots__/RuleEditorGrafanaRules.test.tsx.snap @@ -81,9 +81,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1 "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -119,9 +117,7 @@ exports[`RuleEditor grafana managed rules can create new grafana managed alert 1 "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], diff --git a/public/app/features/alerting/unified/utils/rule-form.test.ts b/public/app/features/alerting/unified/utils/rule-form.test.ts index 7137066229e..2e9b7a5730b 100644 --- a/public/app/features/alerting/unified/utils/rule-form.test.ts +++ b/public/app/features/alerting/unified/utils/rule-form.test.ts @@ -1,4 +1,5 @@ import { PromQuery } from '@grafana/prometheus'; +import { ExpressionDatasourceUID, ExpressionQueryType } from 'app/features/expressions/types'; import { RuleWithLocation } from 'app/types/unified-alerting'; import { AlertDataQuery, @@ -8,6 +9,7 @@ import { RulerAlertingRuleDTO, } from 'app/types/unified-alerting-dto'; +import { EvalFunction } from '../../state/alertDef'; import { mockDataSource, mockRuleWithLocation, mockRulerGrafanaRecordingRule } from '../mocks'; import { getDefaultFormValues } from '../rule-editor/formDefaults'; import { setupDataSources } from '../testSetup/datasources'; @@ -21,6 +23,7 @@ import { formValuesToRulerGrafanaRuleDTO, formValuesToRulerRuleDTO, getContactPointsFromDTO, + getDefaultExpressions, getInstantFromDataQuery, getNotificationSettingsForDTO, rulerRuleToFormValues, @@ -417,3 +420,103 @@ describe('getInstantFromDataQuery', () => { expect(result).toBe(false); }); }); + +describe('getDefaultExpressions', () => { + it('should create a reduce expression as the first query', () => { + const result = getDefaultExpressions('B', 'C'); + const reduceQuery = result[0]; + const model = reduceQuery.model; + + expect(reduceQuery.refId).toBe('B'); + expect(reduceQuery.datasourceUid).toBe(ExpressionDatasourceUID); + expect(reduceQuery.queryType).toBe(''); + expect(model.type).toBe(ExpressionQueryType.reduce); + expect(model.datasource?.uid).toBe(ExpressionDatasourceUID); + expect(model.reducer).toBe('last'); + }); + + it('should create reduce expression with proper conditions structure', () => { + const result = getDefaultExpressions('B', 'C'); + const reduceQuery = result[0]; + const model = reduceQuery.model; + + expect(model.conditions).toHaveLength(1); + expect(model.expression).toBe('A'); + expect(model.conditions?.[0]).toEqual({ + type: 'query', + evaluator: { + params: [], + type: EvalFunction.IsAbove, + }, + operator: { + type: 'and', + }, + query: { + params: [], + }, + reducer: { + params: [], + type: 'last', + }, + }); + }); + + it('should create a threshold expression as the second query', () => { + const result = getDefaultExpressions('B', 'C'); + const thresholdQuery = result[1]; + const model = thresholdQuery.model; + + expect(thresholdQuery.refId).toBe('C'); + expect(thresholdQuery.datasourceUid).toBe(ExpressionDatasourceUID); + expect(thresholdQuery.queryType).toBe(''); + expect(model.type).toBe(ExpressionQueryType.threshold); + expect(model.datasource?.uid).toBe(ExpressionDatasourceUID); + }); + + it('should create threshold expression with proper conditions structure', () => { + const result = getDefaultExpressions('B', 'C'); + const thresholdQuery = result[1]; + const model = thresholdQuery.model; + + expect(model.conditions).toHaveLength(1); + expect(model.conditions?.[0]).toEqual({ + type: 'query', + evaluator: { + params: [0], + type: EvalFunction.IsAbove, + }, + operator: { + type: 'and', + }, + query: { + params: [], + }, + reducer: { + params: [], + type: 'last', + }, + }); + }); + + it('should reference the reduce expression in the threshold expression', () => { + const result = getDefaultExpressions('B', 'C'); + const thresholdQuery = result[1]; + const model = thresholdQuery.model; + + expect(model.expression).toBe('B'); + }); + + it('should properly use different refIds throughout the structure', () => { + const result = getDefaultExpressions('X', 'Y'); + const reduceModel = result[0].model; + const thresholdModel = result[1].model; + + expect(result[0].refId).toBe('X'); + expect(reduceModel.refId).toBe('X'); + expect(reduceModel.conditions?.[0].query.params).toEqual([]); + + expect(result[1].refId).toBe('Y'); + expect(thresholdModel.refId).toBe('Y'); + expect(thresholdModel.expression).toBe('X'); + }); +}); diff --git a/public/app/features/alerting/unified/utils/rule-form.ts b/public/app/features/alerting/unified/utils/rule-form.ts index 899ed22575b..3c000d6cab7 100644 --- a/public/app/features/alerting/unified/utils/rule-form.ts +++ b/public/app/features/alerting/unified/utils/rule-form.ts @@ -536,85 +536,18 @@ export const getDefaultRecordingRulesQueries = ( }, ]; }; -const getDefaultExpressions = (...refIds: [string, string]): AlertQuery[] => { + +export const getDefaultExpressions = (...refIds: [string, string]) => { const refOne = refIds[0]; const refTwo = refIds[1]; - const reduceExpression: ExpressionQuery = { - refId: refIds[0], - type: ExpressionQueryType.reduce, - datasource: { - uid: ExpressionDatasourceUID, - type: ExpressionDatasourceRef.type, - }, - conditions: [ - { - type: 'query', - evaluator: { - params: [], - type: EvalFunction.IsAbove, - }, - operator: { - type: 'and', - }, - query: { - params: [refOne], - }, - reducer: { - params: [], - type: 'last', - }, - }, - ], - reducer: 'last', - expression: 'A', - }; + const reduceQuery = getDefaultReduceExpression({ inputRefId: 'A', reduceRefId: refOne }); + const thresholdQuery = getDefaultThresholdExpression({ inputRefId: refOne, thresholdRefId: refTwo }); - const thresholdExpression: ExpressionQuery = { - refId: refTwo, - type: ExpressionQueryType.threshold, - datasource: { - uid: ExpressionDatasourceUID, - type: ExpressionDatasourceRef.type, - }, - conditions: [ - { - type: 'query', - evaluator: { - params: [0], - type: EvalFunction.IsAbove, - }, - operator: { - type: 'and', - }, - query: { - params: [refTwo], - }, - reducer: { - params: [], - type: 'last', - }, - }, - ], - expression: refOne, - }; - - return [ - { - refId: refOne, - datasourceUid: ExpressionDatasourceUID, - queryType: '', - model: reduceExpression, - }, - { - refId: refTwo, - datasourceUid: ExpressionDatasourceUID, - queryType: '', - model: thresholdExpression, - }, - ]; + return [reduceQuery, thresholdQuery] as const; }; -const getDefaultExpressionsForRecording = (refOne: string): AlertQuery[] => { + +const getDefaultExpressionsForRecording = (refOne: string): Array> => { const reduceExpression: ExpressionQuery = { refId: refOne, type: ExpressionQueryType.reduce, @@ -633,7 +566,7 @@ const getDefaultExpressionsForRecording = (refOne: string): AlertQuery[] => { type: 'and', }, query: { - params: [refOne], + params: [], }, reducer: { params: [], @@ -655,6 +588,95 @@ const getDefaultExpressionsForRecording = (refOne: string): AlertQuery[] => { ]; }; +function getDefaultReduceExpression({ + inputRefId, + reduceRefId, +}: { + inputRefId: string; + reduceRefId: string; +}): AlertQuery { + const reduceExpression: ExpressionQuery = { + refId: reduceRefId, + type: ExpressionQueryType.reduce, + datasource: { + uid: ExpressionDatasourceUID, + type: ExpressionDatasourceRef.type, + }, + conditions: [ + { + type: 'query', + evaluator: { + params: [], + type: EvalFunction.IsAbove, + }, + operator: { + type: 'and', + }, + query: { + params: [], + }, + reducer: { + params: [], + type: 'last', + }, + }, + ], + reducer: 'last', + expression: inputRefId, + }; + + return { + refId: reduceRefId, + datasourceUid: ExpressionDatasourceUID, + queryType: '', + model: reduceExpression, + }; +} + +function getDefaultThresholdExpression({ + inputRefId, + thresholdRefId, +}: { + inputRefId: string; + thresholdRefId: string; +}): AlertQuery { + const thresholdExpression: ExpressionQuery = { + refId: thresholdRefId, + type: ExpressionQueryType.threshold, + datasource: { + uid: ExpressionDatasourceUID, + type: ExpressionDatasourceRef.type, + }, + conditions: [ + { + type: 'query', + evaluator: { + params: [0], + type: EvalFunction.IsAbove, + }, + operator: { + type: 'and', + }, + query: { + params: [], + }, + reducer: { + params: [], + type: 'last', + }, + }, + ], + expression: inputRefId, + }; + + return { + refId: thresholdRefId, + datasourceUid: ExpressionDatasourceUID, + queryType: '', + model: thresholdExpression, + }; +} + const dataQueriesToGrafanaQueries = async ( queries: DataQuery[], relativeTimeRange: RelativeTimeRange, @@ -736,14 +758,23 @@ export const panelToRuleFormValues = async ( return undefined; } + const lastQuery = queries.at(-1); + if (!lastQuery) { + return undefined; + } + if (!queries.find((query) => query.datasourceUid === ExpressionDatasourceUID)) { - const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefId(queries), '-'); + const reduceExpression = getDefaultReduceExpression({ + inputRefId: lastQuery.refId, + reduceRefId: getNextRefId(queries), + }); queries.push(reduceExpression); - const [_reduceExpression, thresholdExpression] = getDefaultExpressions( - reduceExpression.refId, - getNextRefId(queries) - ); + const thresholdExpression = getDefaultThresholdExpression({ + inputRefId: reduceExpression.refId, + thresholdRefId: getNextRefId(queries), + }); + queries.push(thresholdExpression); } @@ -808,14 +839,23 @@ export const scenesPanelToRuleFormValues = async (vizPanel: VizPanel): Promise

query.datasourceUid === ExpressionDatasourceUID)) { - const [reduceExpression, _thresholdExpression] = getDefaultExpressions(getNextRefId(grafanaQueries), '-'); + const reduceExpression = getDefaultReduceExpression({ + inputRefId: lastQuery.refId, + reduceRefId: getNextRefId(grafanaQueries), + }); grafanaQueries.push(reduceExpression); - const [_reduceExpression, thresholdExpression] = getDefaultExpressions( - reduceExpression.refId, - getNextRefId(grafanaQueries) - ); + const thresholdExpression = getDefaultThresholdExpression({ + inputRefId: reduceExpression.refId, + thresholdRefId: getNextRefId(grafanaQueries), + }); + grafanaQueries.push(thresholdExpression); } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap index 8710a533fdd..858dc3812c1 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/__snapshots__/PanelDataAlertingTab.test.tsx.snap @@ -47,9 +47,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button "type": "and", }, "query": { - "params": [ - "B", - ], + "params": [], }, "reducer": { "params": [], @@ -85,9 +83,7 @@ exports[`PanelAlertTabContent Will render alerts belonging to panel and a button "type": "and", }, "query": { - "params": [ - "C", - ], + "params": [], }, "reducer": { "params": [], From 1d763df932100fc32698d176ab86cbd953548204 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 20 Nov 2025 09:50:07 +0100 Subject: [PATCH 096/125] Docs: Document font_min_text_size setting in reporting section (#114155) * Docs: Document font_min_text_size setting in reporting section * better wording --- .../configure-grafana/enterprise-configuration/index.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md b/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md index 601a258764f..7f1cf5e3cb5 100644 --- a/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md +++ b/docs/sources/setup-grafana/configure-grafana/enterprise-configuration/index.md @@ -190,6 +190,10 @@ Name of the TrueType font file with bold style. Name of the TrueType font file with italic style. Default is `DejaVuSansCondensed-Oblique.ttf`. +### font_min_text_size + +The minimum pixel size that Grafana uses when rendering fonts. Default is `4`. + ### max_retries_per_panel Maximum number of times the following reporting rendering requests are retried before returning an error: generating PDFs, generating embedded dashboard images for report emails, and generating attached CSV files. To disable the retry feature, enter `0`. This is available in public preview and requires the `reportingRetries` feature toggle. Default is `3`. From cc6e0370930c8c5b4183f50b70713953e4c20033 Mon Sep 17 00:00:00 2001 From: Steve Simpson Date: Thu, 20 Nov 2025 10:00:27 +0100 Subject: [PATCH 097/125] Alerting: Add message to alert_rule_version table (Part 1). (#114194) This adds a `message` column to the `alert_rule_version` table. This follows the pattern established for dashboards as closely as possible. A new type is introduced internally for passing the new `message` field around in a type-safe manner, but doing the same for the API types becomes very messy. In that case, a new field is added with omitempty. Note this PR is only: - The column addition - The "read" path; API for listing versions Subsequent PRs will add code to actually set the message when updating rules. --- pkg/services/ngalert/api/api_ruler.go | 14 +++++- pkg/services/ngalert/api/api_ruler_test.go | 45 ++++++++++++++++--- pkg/services/ngalert/api/persist.go | 2 +- .../api/tooling/definitions/cortex-ruler.go | 3 ++ pkg/services/ngalert/models/alert_rule.go | 7 +++ pkg/services/ngalert/store/alert_rule.go | 9 ++-- pkg/services/ngalert/store/alert_rule_test.go | 4 +- pkg/services/ngalert/store/compat.go | 13 ++++++ pkg/services/ngalert/store/models.go | 3 +- pkg/services/ngalert/tests/fakes/rules.go | 23 ++++++++-- .../sqlstore/migrations/ualert/tables.go | 3 ++ 11 files changed, 105 insertions(+), 21 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler.go b/pkg/services/ngalert/api/api_ruler.go index 8e368ac1e8e..268ed7b8c5d 100644 --- a/pkg/services/ngalert/api/api_ruler.go +++ b/pkg/services/ngalert/api/api_ruler.go @@ -385,14 +385,24 @@ func (srv RulerSrv) RouteGetRuleVersionsByUID(c *contextmodel.ReqContext, ruleUI } sort.Slice(rules, func(i, j int) bool { return rules[i].ID > rules[j].ID }) result := make(apimodels.GettableRuleVersions, 0, len(rules)) - userUIDmapping := srv.getUserUIDmapping(ctx, rules) + userUIDmapping := srv.getUserUIDmapping(ctx, alertRuleVersionsToAlertRules(rules)) for _, rule := range rules { // do not provide provenance status because we do not have historical changes for it - result = append(result, toGettableExtendedRuleNode(*rule, map[string]ngmodels.Provenance{}, userUIDmapping)) + ruleNode := toGettableExtendedRuleNode(rule.AlertRule, map[string]ngmodels.Provenance{}, userUIDmapping) + ruleNode.GrafanaManagedAlert.Message = rule.Message + result = append(result, ruleNode) } return response.JSON(http.StatusOK, result) } +func alertRuleVersionsToAlertRules(vs []*ngmodels.AlertRuleVersion) []*ngmodels.AlertRule { + result := make([]*ngmodels.AlertRule, len(vs)) + for i := range vs { + result[i] = &vs[i].AlertRule + } + return result +} + func (srv RulerSrv) RoutePostNameRulesConfig(c *contextmodel.ReqContext, ruleGroupConfig apimodels.PostableRuleGroupConfig, namespaceUID string) response.Response { var deletePermanently bool if c.QueryBool("deletePermanently") { diff --git a/pkg/services/ngalert/api/api_ruler_test.go b/pkg/services/ngalert/api/api_ruler_test.go index cb6a93b27b5..c6ddeee2a6c 100644 --- a/pkg/services/ngalert/api/api_ruler_test.go +++ b/pkg/services/ngalert/api/api_ruler_test.go @@ -560,11 +560,17 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) { ruleStore.Folders[orgID] = append(ruleStore.Folders[orgID], f) rule := gen.GenerateRef() - history := gen.With(gen.WithUID(rule.UID)).GenerateManyRef(3) + historyRules := gen.With(gen.WithUID(rule.UID)).GenerateManyRef(3) + history := make([]*models.AlertRuleVersion, len(historyRules)) // simulate order of the history rule.ID = 100 - for i, alertRule := range history { + for i, alertRule := range historyRules { alertRule.ID = rule.ID - int64(i) - 1 + + history[i] = &models.AlertRuleVersion{ + AlertRule: *alertRule, + Message: fmt.Sprintf("revision %d", i), + } } ruleStore.PutRule(context.Background(), rule) @@ -584,11 +590,22 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) { require.Len(t, result, len(history)+1) // history + current version t.Run("should be in correct order", func(t *testing.T) { - expectedHistory := append([]*models.AlertRule{rule}, history...) + expectedHistory := append([]*models.AlertRuleVersion{{AlertRule: *rule}}, history...) for i, rul := range expectedHistory { assert.Equal(t, rul.UID, result[i].GrafanaManagedAlert.UID) } }) + + t.Run("should have correct messages", func(t *testing.T) { + expectedMessages := make([]string, 0, len(history)+1) + expectedMessages = append(expectedMessages, "") + for i := range history { + expectedMessages = append(expectedMessages, history[i].Message) + } + for i := range expectedMessages { + assert.Equal(t, expectedMessages[i], result[i].GrafanaManagedAlert.Message) + } + }) }) t.Run("NotFound when rule does not exist", func(t *testing.T) { @@ -599,10 +616,17 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) { UID: "test", } guid := uuid.NewString() - history := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3) + historyRules := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3) + history := make([]*models.AlertRuleVersion, len(historyRules)) + for i, alertRule := range historyRules { + history[i] = &models.AlertRuleVersion{ + AlertRule: *alertRule, + Message: fmt.Sprintf("revision %d", i), + } + } ruleStore.History[guid] = append(ruleStore.History[guid], history...) // even if history is full of records - perms := createPermissionsForRules(history, orgID) + perms := createPermissionsForRules(historyRules, orgID) req := createRequestContextWithPerms(orgID, perms, nil) response := createService(ruleStore, nil).RouteGetRuleVersionsByUID(req, ruleKey.UID) @@ -643,10 +667,17 @@ func TestRouteGetRuleVersionsByUID(t *testing.T) { guid := uuid.NewString() rule := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey), gen.WithNamespaceUID(anotherFolder.UID)).GenerateRef() ruleStore.PutRule(context.Background(), rule) - history := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3) + historyRules := gen.With(gen.WithGUID(guid), gen.WithKey(ruleKey)).GenerateManyRef(3) + history := make([]*models.AlertRuleVersion, len(historyRules)) + for i, alertRule := range historyRules { + history[i] = &models.AlertRuleVersion{ + AlertRule: *alertRule, + Message: fmt.Sprintf("revision %d", i), + } + } ruleStore.History[guid] = history - perms := createPermissionsForRules(history, orgID) // grant permissions to all records in history but not the rule itself + perms := createPermissionsForRules(historyRules, orgID) // grant permissions to all records in history but not the rule itself req := createRequestContextWithPerms(orgID, perms, nil) response := createService(ruleStore, nil).RouteGetRuleVersionsByUID(req, ruleKey.UID) diff --git a/pkg/services/ngalert/api/persist.go b/pkg/services/ngalert/api/persist.go index 52c4ca59a14..9e7df81f48e 100644 --- a/pkg/services/ngalert/api/persist.go +++ b/pkg/services/ngalert/api/persist.go @@ -35,6 +35,6 @@ type RuleStore interface { // IncreaseVersionForAllRulesInNamespaces Increases version for all rules that have specified namespace uids IncreaseVersionForAllRulesInNamespaces(ctx context.Context, orgID int64, namespaceUIDs []string) ([]ngmodels.AlertRuleKeyWithVersion, error) - GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRule, error) + GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRuleVersion, error) accesscontrol.RuleUIDToNamespaceStore } diff --git a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go index 21e6b438ac2..15cdb16d185 100644 --- a/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go +++ b/pkg/services/ngalert/api/tooling/definitions/cortex-ruler.go @@ -617,6 +617,9 @@ type GettableGrafanaRule struct { Metadata *AlertRuleMetadata `json:"metadata,omitempty" yaml:"metadata,omitempty"` GUID string `json:"guid" yaml:"guid"` MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty" yaml:"missing_series_evals_to_resolve,omitempty"` + + // Field is only populated when listing alert rule versions. + Message string `yaml:"message,omitempty" json:"message,omitempty"` } // UserInfo represents user-related information, including a unique identifier and a name. diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index 9f899bc2af7..a4e1e8fcc04 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -369,6 +369,13 @@ type AlertRule struct { MissingSeriesEvalsToResolve *int64 } +type AlertRuleVersion struct { + AlertRule + + // Message is only stored in the alert_rule_version table. + Message string +} + type AlertRuleMetadata struct { EditorSettings EditorSettings `json:"editor_settings"` PrometheusStyleRule *PrometheusStyleRule `json:"prometheus_style_rule,omitempty"` diff --git a/pkg/services/ngalert/store/alert_rule.go b/pkg/services/ngalert/store/alert_rule.go index 9044bb369c5..b05ba01a562 100644 --- a/pkg/services/ngalert/store/alert_rule.go +++ b/pkg/services/ngalert/store/alert_rule.go @@ -192,8 +192,8 @@ func (st DBstore) GetAlertRuleByUID(ctx context.Context, query *ngmodels.GetAler return result, err } -func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRule, error) { - alertRules := make([]*ngmodels.AlertRule, 0) +func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid string) ([]*ngmodels.AlertRuleVersion, error) { + alertRules := make([]*ngmodels.AlertRuleVersion, 0) err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { rows, err := sess.Table(new(alertRuleVersion)).Where("rule_org_id = ? AND rule_guid = ?", orgID, guid).Asc("id").Rows(new(alertRuleVersion)) if err != nil { @@ -213,7 +213,7 @@ func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid st if previousVersion != nil && previousVersion.EqualSpec(*rule) { continue } - converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger) + converted, err := alertRuleVersionToModelsAlertRuleVersion(*rule, st.Logger) if err != nil { st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID) continue @@ -226,7 +226,7 @@ func (st DBstore) GetAlertRuleVersions(ctx context.Context, orgID int64, guid st if err != nil { return nil, err } - slices.SortFunc(alertRules, func(a, b *ngmodels.AlertRule) int { + slices.SortFunc(alertRules, func(a, b *ngmodels.AlertRuleVersion) int { if a.ID > b.ID { return -1 } @@ -257,6 +257,7 @@ func (st DBstore) ListDeletedRules(ctx context.Context, orgID int64) ([]*ngmodel st.Logger.Error("Invalid rule version found in DB store, ignoring it", "func", "GetAlertRuleVersions", "error", err) continue } + // Note: Message is not returned as a message cannot be set when deleting rules. converted, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(*rule), st.Logger) if err != nil { st.Logger.Error("Invalid rule found in DB store, cannot convert, ignoring it", "func", "GetAlertRuleVersions", "error", err, "version_id", rule.ID) diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 0c49528f8d6..9a491120c8c 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -1682,7 +1682,7 @@ func TestIntegrationGetRuleVersions(t *testing.T) { require.NoError(t, err) assert.Len(t, versions, 2) assert.IsDecreasing(t, versions[0].ID, versions[1].ID) - diff := versions[1].Diff(versions[0], AlertRuleFieldsToIgnoreInDiff[:]...) + diff := versions[1].Diff(&versions[0].AlertRule, AlertRuleFieldsToIgnoreInDiff[:]...) assert.ElementsMatch(t, []string{"Title", "RuleGroupIndex"}, diff.Paths()) }) @@ -1712,7 +1712,7 @@ func TestIntegrationGetRuleVersions(t *testing.T) { versions, err := store.GetAlertRuleVersions(context.Background(), ruleV3.OrgID, ruleV3.GUID) require.NoError(t, err) assert.Len(t, versions, 3) - diff := versions[0].Diff(versions[1], AlertRuleFieldsToIgnoreInDiff[:]...) + diff := versions[0].Diff(&versions[1].AlertRule, AlertRuleFieldsToIgnoreInDiff[:]...) assert.ElementsMatch(t, []string{"RuleGroup", "NamespaceUID"}, diff.Paths()) }) } diff --git a/pkg/services/ngalert/store/compat.go b/pkg/services/ngalert/store/compat.go index 709450d5ed8..4f4194facc1 100644 --- a/pkg/services/ngalert/store/compat.go +++ b/pkg/services/ngalert/store/compat.go @@ -210,6 +210,7 @@ func alertRuleToAlertRuleVersion(rule alertRule) alertRuleVersion { Version: rule.Version, Created: rule.Updated, // assuming the Updated time as the creation time CreatedBy: rule.UpdatedBy, + Message: "", // Message is set by caller when creating versions Title: rule.Title, Condition: rule.Condition, Data: rule.Data, @@ -261,3 +262,15 @@ func alertRuleVersionToAlertRule(version alertRuleVersion) alertRule { MissingSeriesEvalsToResolve: version.MissingSeriesEvalsToResolve, } } + +func alertRuleVersionToModelsAlertRuleVersion(version alertRuleVersion, l log.Logger) (models.AlertRuleVersion, error) { + result, err := alertRuleToModelsAlertRule(alertRuleVersionToAlertRule(version), l) + if err != nil { + return models.AlertRuleVersion{}, err + } + + return models.AlertRuleVersion{ + AlertRule: result, + Message: version.Message, + }, nil +} diff --git a/pkg/services/ngalert/store/models.go b/pkg/services/ngalert/store/models.go index 254ec8cb048..24167a225dd 100644 --- a/pkg/services/ngalert/store/models.go +++ b/pkg/services/ngalert/store/models.go @@ -69,10 +69,11 @@ type alertRuleVersion struct { NotificationSettings string `xorm:"notification_settings"` Metadata string `xorm:"metadata"` MissingSeriesEvalsToResolve *int64 `xorm:"missing_series_evals_to_resolve"` + Message string } // EqualSpec compares two alertRuleVersion objects for equality based on their specifications and returns true if they match. -// The comparison is very basic and can produce false-negative. Fields excluded: ID, ParentVersion, RestoredFrom, Version, Created, RuleGroupIndex and CreatedBy +// The comparison is very basic and can produce false-negative. Fields excluded: ID, ParentVersion, RestoredFrom, Version, Created, RuleGroupIndex, CreatedBy and Message func (a alertRuleVersion) EqualSpec(b alertRuleVersion) bool { return a.RuleOrgID == b.RuleOrgID && a.RuleGUID == b.RuleGUID && diff --git a/pkg/services/ngalert/tests/fakes/rules.go b/pkg/services/ngalert/tests/fakes/rules.go index a0dd60e32e5..09abeaffba3 100644 --- a/pkg/services/ngalert/tests/fakes/rules.go +++ b/pkg/services/ngalert/tests/fakes/rules.go @@ -23,7 +23,7 @@ type RuleStore struct { mtx sync.Mutex // OrgID -> RuleGroup -> Namespace -> Rules Rules map[int64][]*models.AlertRule - History map[string][]*models.AlertRule + History map[string][]*models.AlertRuleVersion Deleted map[int64][]*models.AlertRule Hook func(cmd any) error // use Hook if you need to intercept some query and return an error RecordedOps []any @@ -43,7 +43,7 @@ func NewRuleStore(t *testing.T) *RuleStore { return nil }, Folders: map[int64][]*folder.Folder{}, - History: map[string][]*models.AlertRule{}, + History: map[string][]*models.AlertRuleVersion{}, } } @@ -55,7 +55,10 @@ mainloop: for _, r := range rules { rgs := f.Rules[r.OrgID] cp := models.CopyRule(r) - f.History[r.GUID] = append(f.History[r.GUID], cp) + f.History[r.GUID] = append(f.History[r.GUID], &models.AlertRuleVersion{ + AlertRule: *cp, + Message: "", + }) for idx, rulePtr := range rgs { if rulePtr.UID == r.UID { rgs[idx] = r @@ -87,6 +90,18 @@ mainloop: } } +// AppendHistory appends to rules to the version history with the given change message. +func (f *RuleStore) AppendHistory(guid string, rules []*models.AlertRule, message string) { + versions := make([]*models.AlertRuleVersion, len(rules)) + for i := range rules { + versions[i] = &models.AlertRuleVersion{ + AlertRule: *rules[i], + Message: message, + } + } + f.History[guid] = append(f.History[guid], versions...) +} + // GetRecordedCommands filters recorded commands using predicate function. Returns the subset of the recorded commands that meet the predicate func (f *RuleStore) GetRecordedCommands(predicate func(cmd any) (any, bool)) []any { f.mtx.Lock() @@ -563,7 +578,7 @@ func (f *RuleStore) GetNamespacesByRuleUID(ctx context.Context, orgID int64, uid return namespacesMap, nil } -func (f *RuleStore) GetAlertRuleVersions(_ context.Context, orgID int64, guid string) ([]*models.AlertRule, error) { +func (f *RuleStore) GetAlertRuleVersions(_ context.Context, orgID int64, guid string) ([]*models.AlertRuleVersion, error) { f.mtx.Lock() defer f.mtx.Unlock() diff --git a/pkg/services/sqlstore/migrations/ualert/tables.go b/pkg/services/sqlstore/migrations/ualert/tables.go index d6d98ebfec9..706b8dcf026 100644 --- a/pkg/services/sqlstore/migrations/ualert/tables.go +++ b/pkg/services/sqlstore/migrations/ualert/tables.go @@ -24,6 +24,9 @@ func AddTablesMigrations(mg *migrator.Migrator) { mg.AddMigration("add last_applied column to alert_configuration_history", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_configuration_history"}, &migrator.Column{ Name: "last_applied", Type: migrator.DB_Int, Nullable: false, Default: "0", })) + mg.AddMigration("add message column to alert_rule_version", migrator.NewAddColumnMigration(migrator.Table{Name: "alert_rule_version"}, &migrator.Column{ + Name: "message", Type: migrator.DB_Text, Nullable: true, + })) // End of migration log, add new migrations above this line. } From fbe29596f10102dfa4137384823116c46271ffa1 Mon Sep 17 00:00:00 2001 From: petergreen86 <37148775+petergreen86@users.noreply.github.com> Date: Thu, 20 Nov 2025 09:20:02 +0000 Subject: [PATCH 098/125] Docs: Team Sync correction (#114043) --- .../configure-access/configure-authentication/github/index.md | 2 +- .../configure-access/configure-authentication/gitlab/index.md | 2 +- .../configure-access/configure-authentication/google/index.md | 2 +- .../configure-access/configure-authentication/keycloak/index.md | 2 +- .../configure-access/configure-authentication/okta/index.md | 2 +- .../setup-grafana/configure-access/configure-team-sync.md | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/github/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/github/index.md index 7675d739951..5924e033f90 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/github/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/github/index.md @@ -213,7 +213,7 @@ role_attribute_path = [login=='octocat'][0] && 'GrafanaAdmin' || 'Viewer' ## Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/) {{< /admonition >}} By using Team Sync, you can map teams from your GitHub organization to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/gitlab/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/gitlab/index.md index 913df8f4792..d3769a72ad8 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/gitlab/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/gitlab/index.md @@ -238,7 +238,7 @@ use_refresh_token = true ## Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/) {{< /admonition >}} By using Team Sync, you can map GitLab groups to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/google/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/google/index.md index df0cc342d38..d122bb7e0f7 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/google/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/google/index.md @@ -162,7 +162,7 @@ auto_login = true ### Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/). {{< /admonition >}} With team sync, you can easily add users to teams by utilizing their Google groups. To set up team sync for Google OAuth, refer to the following example. diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/keycloak/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/keycloak/index.md index 6a9f6488994..6ce0f7a47b2 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/keycloak/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/keycloak/index.md @@ -111,7 +111,7 @@ viewer ## Team sync {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/). {{< /admonition >}} [Team Sync](https://grafana.com/docs/grafana//setup-grafana/configure-access/configure-team-sync/) is a feature that allows you to map groups from your identity provider to Grafana teams. This is useful if you want to give your users access to specific dashboards or folders based on their group membership. diff --git a/docs/sources/setup-grafana/configure-access/configure-authentication/okta/index.md b/docs/sources/setup-grafana/configure-access/configure-authentication/okta/index.md index 6cb2d7d13c8..7bce3cb59da 100644 --- a/docs/sources/setup-grafana/configure-access/configure-authentication/okta/index.md +++ b/docs/sources/setup-grafana/configure-access/configure-authentication/okta/index.md @@ -238,7 +238,7 @@ org_mapping = ["Group 1:org_foo:Viewer", "Group 2:org_bar:Editor", "*:3:Editor"] ### Configure team synchronization {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and to customers on select Grafana Cloud plans. For pricing information, visit [pricing](https://grafana.com/pricing/) or contact our sales team. +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/products/cloud/). {{< /admonition >}} By using Team Sync, you can link your Okta groups to teams within Grafana. This will automatically assign users to the appropriate teams. diff --git a/docs/sources/setup-grafana/configure-access/configure-team-sync.md b/docs/sources/setup-grafana/configure-access/configure-team-sync.md index 1741fd33edc..a4fe4b28c48 100644 --- a/docs/sources/setup-grafana/configure-access/configure-team-sync.md +++ b/docs/sources/setup-grafana/configure-access/configure-team-sync.md @@ -18,7 +18,7 @@ weight: 600 Team sync lets you set up synchronization between your auth providers teams and teams in Grafana. This enables LDAP, OAuth, or SAML users who are members of certain teams or groups to automatically be added or removed as members of certain teams in Grafana. {{< admonition type="note" >}} -Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud Advanced](https://grafana.com/docs/grafana-cloud/). +Available in [Grafana Enterprise](https://grafana.com/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](https://grafana.com/docs/grafana-cloud/). {{< /admonition >}} Grafana keeps track of all synchronized users in teams, and you can see which users have been synchronized in the team members list, see `LDAP` label in screenshot. From bf509de89f71a0141ae1c13fc85a805245761c3b Mon Sep 17 00:00:00 2001 From: Anna Urbiztondo Date: Thu, 20 Nov 2025 10:24:47 +0100 Subject: [PATCH 099/125] Docs: IaC edits (#114086) * Removed Grizzly from table * Fixes * Review * Reviews * Edits * More edits * Prettier * Remove agent * Review, removing Agent docs * Prettier * Fixes * Prettier * Edit * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Update docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> * Feedback * Prettier * Variable style edits --------- Co-authored-by: Clayton Cornell <131809008+clayton-cornell@users.noreply.github.com> --- .../as-code/infrastructure-as-code/_index.md | 43 +- .../infrastructure-as-code/ansible/_index.md | 42 +- .../ansible/ansible-cloud-stack/index.md | 354 +++++++------- .../ansible-grafana-agent-linux/index.md | 156 ------- .../ansible/ansible-multiple-agents/index.md | 207 --------- .../manage-dashboards-argocd.md | 435 +++++++++--------- ...operator-dashboards-folders-datasources.md | 136 +++--- .../observability-as-code/schema-v2/_index.md | 2 +- 8 files changed, 531 insertions(+), 844 deletions(-) delete mode 100644 docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md delete mode 100644 docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md diff --git a/docs/sources/as-code/infrastructure-as-code/_index.md b/docs/sources/as-code/infrastructure-as-code/_index.md index 1579602467b..c976df81106 100644 --- a/docs/sources/as-code/infrastructure-as-code/_index.md +++ b/docs/sources/as-code/infrastructure-as-code/_index.md @@ -4,17 +4,23 @@ keywords: - Quickstart - Grafana Cloud menuTitle: Infrastructure as code -title: Provision Grafana Cloud with infrastructure as code +title: Provision Grafana Cloud with Infrastructure as code weight: 800 +labels: + products: + - cloud canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ --- -# Provision Grafana Cloud with infrastructure as code +# Provision Grafana Cloud with Infrastructure as code -With Grafana Cloud, you can create dashboards via configuration files in source code. This enables you to review code, reuse it, and create better workflows. +With Grafana Cloud, you can use as-code tools to create and manage resources via code, and incorporate them efficiently into your own use cases. This enables you to review code, reuse it, and create better workflows. -Via code, you can _declaratively_ manage _what_ Grafana resources to use. -The as-code tools and tutorials that follow show you what do to, to declaratively manage Grafana resources, and incorporate them efficiently into your own use cases. +{{< admonition type="note" >}} + +Most of the tools defined here can be used with one another. + +{{< /admonition >}} ## Grafana Terraform provider @@ -67,7 +73,7 @@ To get started, see the [quickstart guides for the Grafana Ansible Collection](/ ### Who is this recommended for? -Like Terraform, the Grafana Ansible collection is best suited for people already using Ansible for non-Grafana use cases. The collection only works for Grafana Cloud right now, so it makes the most sense for Grafana Cloud customers who want to manage resources declaratively using Ansible. +Like Terraform, the Grafana Ansible collection is best suited for people already using Ansible for non-Grafana use cases. The collection only works for Grafana Cloud right now, so it makes the most sense for Grafana Cloud customers who want to manage resources using Ansible. ### Known limitations @@ -75,7 +81,7 @@ The Grafana Ansible collection only works for Grafana Cloud and only supports ei ## Grafana Operator -The Grafana Operator is a Kubernetes operator that can provision, manage, and operate Grafana instances and their associated resources within Kubernetes through Custom Resources. This Kubernetes-native tool eases the administration of Grafana, offering a declarative approach to managing dashboards, data sources, and folders. It also automatically syncs the Kubernetes Custom resources and the actual resources in the Grafana Instance. It supports leveraging Grafonnet for generating Grafana dashboard definitions for seamless dashboard configuration as code. +The Grafana Operator is a Kubernetes operator that can provision, manage, and operate Grafana instances and their associated resources within Kubernetes through Custom Resources. This Kubernetes-native tool eases the administration of Grafana, including managing dashboards, data sources, and folders. It also automatically syncs the Kubernetes Custom resources and the actual resources in the Grafana Instance. It supports leveraging Grafonnet for generating Grafana dashboard definitions for seamless dashboard configuration as code. To get started, see the [quickstart guides for the Grafana Operator](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/) or check out the [Grafana Operator's documentation](https://grafana.github.io/grafana-operator/). @@ -120,7 +126,7 @@ To get started with the Grafana Crossplane provider, install Crossplane in the K kubectl crossplane install provider grafana/crossplane-provider-grafana:v0.1.0 ``` -During installation of the provider, CRDs for all the resources supported by the Terraform provider are added to the cluster so users can begin defining their Grafana resources as Kubernetes custom resources. The Crossplane provider ensures that whatever is defined in the custom resource definitions is what is visible in Grafana UI. If any changes are made directly in the UI, the changes will be discarded when the provider resyncs. This helps ensure that whatever is defined declaratively in the cluster will be the source of truth for Grafana resources. +During installation of the provider, CRDs for all the resources supported by the Terraform provider are added to the cluster so users can begin defining their Grafana resources as Kubernetes custom resources. The Crossplane provider ensures that whatever is defined in the custom resource definitions is what is visible in Grafana UI. If any changes are made directly in the UI, the changes will be discarded when the provider resyncs. This helps ensure that whatever is defined via code in the cluster will be the source of truth for Grafana resources. To get started, refer to the examples folder in the Grafana Crossplane repository. @@ -152,16 +158,15 @@ To use the Crossplane provider, you must have the Crossplane CLI and Crossplane ## Grafana as code comparison -Most of the tools defined here can be used with one another. The following chart compares the properties and tools mentioned above. -| Property/Tool | Grafana Terraform Provider | Grafana Ansible Collection | Grafana Operator | Grizzly | Grafana Crossplane Provider | -| -------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------- | -| Grafana resources supported | All major Grafana resources | Grafana Cloud stack, plugins, API keys, dashboards, data sources, and folders | Dashboards, Datasources, Folders | Synthetic Monitoring checks, dashboards, data sources, folders, and Prometheus rules | All major Grafana resources | -| Tool format | HCL/JSON | YAML | YAML | Jsonnet/YAML/JSON | YAML/JSON | -| Follows Kubernetes-style manifests | | | ✓ | ✓ | ✓ | -| Easy dashboard building process | | | ✓ | ✓ | | -| Manage resources using Kubernetes | | | ✓ | | ✓ | -| Retrieves Grafana resource information | ✓ | | | | | -| Built-in resource sync process | | | ✓ | ✓ | ✓ | -| Recommended for | Existing Terraform users | Existing Ansible users | Users looking to manage Grafana resources from within Kubernetes | Users looking to define Grafana resources in a Kubernetes-style YAML and users looking to get built-in workflow support and sync process | Users looking to manage Grafana resources from within Kubernetes | +| Property/Tool | Grafana Terraform Provider | Grafana Ansible Collection | Grafana Operator | Grafana Crossplane Provider | +| -------------------------------------- | --------------------------- | ----------------------------------------------------------------------------- | ---------------------------------------------------------------- | ---------------------------------------------------------------- | +| Grafana resources supported | All major Grafana resources | Grafana Cloud stack, plugins, API keys, dashboards, data sources, and folders | Dashboards, data sources, Folders | All major Grafana resources | +| Tool format | HCL/JSON | YAML | YAML | YAML/JSON | +| Follows Kubernetes-style manifests | | | ✓ | ✓ | +| Easy dashboard building process | | | ✓ | | +| Manage resources using Kubernetes | | | ✓ | ✓ | +| Retrieves Grafana resource information | ✓ | | | | +| Built-in resource sync process | | | ✓ | ✓ | +| Recommended for | Existing Terraform users | Existing Ansible users | Users looking to manage Grafana resources from within Kubernetes | Users looking to manage Grafana resources from within Kubernetes | diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/_index.md b/docs/sources/as-code/infrastructure-as-code/ansible/_index.md index 46d44ff1f90..2f977b0166e 100644 --- a/docs/sources/as-code/infrastructure-as-code/ansible/_index.md +++ b/docs/sources/as-code/infrastructure-as-code/ansible/_index.md @@ -8,22 +8,42 @@ menuTitle: Ansible title: Grafana Ansible collection weight: 110 canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ +aliases: + - ../../infrastructure-as-code/ansible/ansible-grafana-agent-linux + - ../../infrastructure-as-code/ansible/ansible-multiple-agents +labels: + products: + - cloud --- # Grafana Ansible collection -The [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/) provides configuration management resources for Grafana. You can use it to manage resources such as dashboards, Cloud stacks, folders, and more. +The [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/) provides configuration management resources for Grafana. You can use it to manage: -The collection also houses the [Grafana Agent role](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent) which can be used to deploy and manage Grafana Agent across various Linux machines. +- Grafana Cloud stacks +- Dashboards +- Data sources +- Folders +- Alerting contact points +- Notification policies +- API keys + +If your resources aren't currently available in the Grafana Ansible collection, you can manage them on Grafana Cloud programmatically by writing Ansible playbooks that use the [Ansible's built-in URI module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/uri_module.html) to call the [HTTP APIs](/docs/grafana/latest/developers/http_api/) to manage resources for the Grafana Cloud portal, as well as those within a stack. + +## Learn more + +Refer to [Create and manage a Grafana Cloud stack using Ansible](ansible-cloud-stack/) to learn how to create a Grafana Cloud stack and add a data source and dashboard using [Ansible](https://www.ansible.com/). + +To learn more about managing Grafana with Infrastructure as code: + +- [Grafana Ansible collection documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/) +- [Ansible playbook best practices](https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html) +- [Grafana API documentation](/docs/grafana/latest/developers/http_api/) +- [Grafana Cloud API documentation](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/) +- [Infrastructure as Code with Terraform](/docs/grafana/latest/as-code/infrastructure-as-code/terraform/) + +## Grafana Agent (deprecated) {{< docs/shared lookup="agent-deprecation.md" source="alloy" version="next" >}} -For resources currently not available in the Grafana Ansible collection, you can manage those resources on Grafana Cloud programmatically by writing Ansible playbooks that use the [Ansible's builtin uri module](https://docs.ansible.com/ansible/latest/collections/ansible/builtin/uri_module.html) to call the [HTTP APIs](/docs/grafana/latest/developers/http_api/) to manage resources for the Grafana Cloud portal, as well as those within a stack. - -Use the following guides to get started using Ansible to manage your Grafana Cloud stack: - -| Topic | Description | -| ----------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | -| [Create and manage a Grafana Cloud stack using Ansible](ansible-cloud-stack/) | Describes how to create a Grafana Cloud stack and add a data source and dashboard using [Ansible](https://www.ansible.com/). | -| [Install Grafana Agent on a Linux host using Ansible](ansible-grafana-agent-linux/) | Describes how to install the Grafana Agent on a Linux node using Ansible and use it to push logs to Grafana Cloud. | -| [Monitor multiple Linux hosts with Grafana Agent Role](ansible-multiple-agents/) | Describes how to use the Grafana Ansible collection to manage agents across multiple Linux hosts. | +The Ansible collection also houses [Grafana Agent role](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent), which is now deprecated. diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md index 5dcab277f04..55f875fd1a7 100644 --- a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md +++ b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/index.md @@ -4,64 +4,78 @@ keywords: - Quickstart - Grafana Cloud - Ansible -title: Create and manage a Grafana Cloud stack using Ansible +title: Create and manage your Grafana Cloud stack using Ansible +menuTitle: Manage stack using Ansible weight: 100 canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ansible-cloud-stack/ --- -# Create and manage a Grafana Cloud stack using Ansible +# Create and manage your Grafana Cloud stack using Ansible -Learn how to add a data source, a dashboard, and a folder to a Grafana Cloud stack using Ansible collection for Grafana. +This guide shows you how to create a Grafana Cloud stack and add a data source, dashboard, and folder using the Ansible Collection for Grafana. You'll manage your Grafana infrastructure through Ansible playbooks. ## Before you begin -Before you begin, you should have the following available: +Before you begin, make sure you have the following available: -- A Grafana Cloud account. +- A Grafana Cloud account - [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/index.html) installed on your machine +## Install the Grafana Ansible collection + +Install the Grafana Ansible collection: + +```sh +ansible-galaxy collection install grafana.grafana +``` + +This collection provides all the modules needed to manage Grafana Cloud stacks and resources. + ## Create a Cloud stack -1. Create a Grafana Cloud Access Policy and get a token. - You'll need this for the Ansible playbook to be able to create a Grafana Cloud stack. - Refer to [Create a Grafana Cloud Access Policy](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/create-access-policies/). +First, create a Grafana Cloud Access Policy and get a token. You'll need this for the Ansible playbook to be able to create a Grafana Cloud stack. Refer to [Create a Grafana Cloud Access Policy](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-policies/create-access-policies/). -1. Create an Ansible playbook file. +Next, create an Ansible playbook file. This Ansible playbook creates a Grafana Cloud stack using the [Cloud stack module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/cloud_stack_module.html#ansible-collections-grafana-grafana-cloud-stack-module). - This Ansible playbook will create a Grafana Cloud stack by using the [Cloud stack module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/cloud_stack_module.html#ansible-collections-grafana-grafana-cloud-stack-module). +To do so, create a file named `cloud-stack.yml` and add the following: - Create a file named `cloud-stack.yml` and add the following: +```yaml +- name: Create Grafana Cloud stack + connection: local + hosts: localhost - ```yaml - - name: Create Grafana Cloud stack - connection: local - hosts: localhost + vars: + grafana_cloud_api_key: '' + stack_name: '' + org_name: '' - vars: - grafana_cloud_api_key: '' - stack_name: '' - org_name: '' + tasks: + - name: Create a Grafana Cloud stack + grafana.grafana.cloud_stack: + name: '{{ stack_name }}' + stack_slug: '{{ stack_name }}' + cloud_api_key: '{{ grafana_cloud_api_key }}' + org_slug: '{{ org_name }}' + delete_protection: true + state: present + register: stack_result - tasks: - - name: Create a Grafana Cloud stack - grafana.grafana.cloud_stack: - name: '{{ stack_name }}' - stack_slug: '{{ stack_name }}' - cloud_api_key: '{{ grafana_cloud_api_key }}' - org_slug: '{{ org_name }}' - delete_protection: true - state: present - ``` + - name: Display stack URL + debug: + msg: 'Stack created at: {{ stack_result.url }}' +``` -1. Replace the following field values: - - `` with a token from the Cloud Access Policy you created in the Grafana Cloud portal. - - `` with the name of your stack. - - `` with the name of the organization in Grafana Cloud. +Replace the placeholders with your values: -## Create an API key in the Grafana stack +- _``_: Token from the Cloud Access Policy you created in the Grafana Cloud portal +- _``_: Name of your stack +- _``_: Name of the organization in Grafana Cloud -Create an API key in the Grafana stack. -You'll need this key to configure Ansible to be able to create data source, folders, and dashboards. +The playbook registers the stack creation result and displays the stack URL, which you'll need for subsequent resource management. + +## Create an API key in your Grafana stack + +Create an API key in the Grafana stack. You'll need this key to configure Ansible to create data sources, folders, and dashboards. 1. Log into your Grafana Cloud instance. 2. Click **Administration** and select **API keys**. @@ -70,171 +84,169 @@ You'll need this key to configure Ansible to be able to create data source, fold 5. In **Role**, select **Admin** or **Editor** to associate the role with this API key. 6. Click **Copy** to save it for later use. -## Add a data source +## Add resources using playbooks -This guide uses the InfluxDB data source. -The required arguments vary depending on the type of data source you select. +### Add a data source -1. Create a file named `data-source.yml` and add the following: +The following steps use the InfluxDB data source. The required arguments vary depending on the type of data source you select. - ```yaml - - name: Add/Update data source - connection: local - hosts: localhost +Create a file named `data-source.yml`: - vars: - data_sources: - [ - { - name: '', - type: 'influxdb', - url: '', - user: '', - secureJsonData: { password: '' }, - database: '', - id: , - uid: '', - access: 'proxy', - }, - ] +```yaml +- name: Add/Update data source + connection: local + hosts: localhost - grafana_api_key: '' - stack_name: '' + vars: + grafana_url: 'https://.grafana.net' + grafana_api_key: '' + data_source_config: + name: '' + type: 'influxdb' + url: '' + user: '' + secureJsonData: + password: '' + database: '' + uid: '' + access: 'proxy' - tasks: - - name: Create/Update Data sources - grafana.grafana.datasource: - datasource: '{{ item }}' - stack_slug: '{{ stack_name }}' - grafana_api_key: '{{ grafana_api_key }}' - state: present - loop: '{{ data_sources }}' - ``` + tasks: + - name: Create/Update Data source + grafana.grafana.datasource: + dataSource: '{{ data_source_config }}' + grafana_url: '{{ grafana_url }}' + grafana_api_key: '{{ grafana_api_key }}' + state: present +``` -1. Replace the following field values: - - `` with the name of the data source to be added in Grafana. - - `` with URL of your data source. - - `` with the username for authenticating with your data source. - - `` with the password for authenticating with your data source. - - `` with name of your database. - - `` with the ID for your data source in Grafana. - - `` wth the UID for your data source in Grafana. - - `` with the name of your stack. - - `` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack). +Replace the placeholders with your values: -## Add a folder +- _``_: Name of the data source to be added in Grafana +- _``_: URL of your data source +- _``_: Username for authenticating with your data source +- _``_: Password for authenticating with your data source +- _``_: Name of your database +- _``_: UID for your data source in Grafana +- _``_: Name of your stack +- _``_: API key created in the Grafana instance -This Ansible playbook creates a folder in your Grafana instance by using the [Folder module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/folder_module.html#ansible-collections-grafana-grafana-folder-module). +### Add a folder -1. Create a file named `folder.yml` and add the following: +This playbook creates a folder in your Grafana instance using the [Folder module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/folder_module.html#ansible-collections-grafana-grafana-folder-module). - ```yaml - - name: Add/Update Folders - connection: local - hosts: localhost +Create a file named `folder.yml`: - vars: - folders: [{ title: '', uid: '' }] +```yaml +- name: Add/Update Folders + connection: local + hosts: localhost - stack_name: '' - grafana_api_key: +vars: + grafana_url: 'https://.grafana.net' + grafana_api_key: '' + folders: + - title: '' + uid: '' - tasks: - - name: Create/Update a Folder in Grafana - grafana.grafana.folder: - title: '{{ item.title }}' - uid: '{{ item.uid }}' - stack_slug: '{{ stack_name }}' - grafana_api_key: '{{ grafana_api_key }}' - state: present - loop: '{{ folders }}' - ``` + tasks: + - name: Create/Update a Folder in Grafana + grafana.grafana.folder: + title: '{{ item.title }}' + uid: '{{ item.uid }}' + grafana_url: '{{ grafana_url }}' + grafana_api_key: '{{ grafana_api_key }}' + state: present + loop: '{{ folders }}' +``` -1. Replace the following field values: - - `` with the name of the folder to be added in Grafana. - - `` with the UID for your folder in Grafana. - - `` with the name of your stack. - - `` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack). +Replace the placeholders with your values: -## Add a dashboard to the folder +- _``_: Name of the folder to be added in Grafana +- _``_: UID for your folder in Grafana +- _``_: Name of your stack +- _``_: API key created in the Grafana instance -This Ansible playbook iterates through the dashboard JSON source code files in the folder referenced in `dashboards_path` and adds them in the Grafana instance by using the [Dashboard module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/dashboard_module.html#ansible-collections-grafana-grafana-dashboard-module). +### Add a dashboard to the folder -1. Create a file named `dashboard.yml` and add the following: +This playbook iterates through the dashboard JSON source code files in the folder referenced in `dashboards_path` and adds them to the Grafana instance using the [Dashboard module](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/dashboard_module.html#ansible-collections-grafana-grafana-dashboard-module). - ```yaml - - name: Add/Update Dashboards - connection: local - hosts: localhost +Create a file named `dashboard.yml`: - vars: - dashboards_path: # Example "./dashboards" - stack_name: "" - grafana_api_key: +```yaml +- name: Add/Update Dashboards + connection: local + hosts: localhost - tasks: - - name: Find dashboard files - find: - paths: "{{ dashboards_path }}" - file_type: file - recurse: Yes - patterns: "*.json" - register: files_matched - no_log: True + vars: + grafana_url: 'https://.grafana.net' + grafana_api_key: '' + dashboards_path: '' # Example "./dashboards" - - name: Create list of dashboard file names - set_fact: - dashboard_file_names: "{{ dashboard_file_names | default ([]) + [item.path] }}" - loop: "{{ files_matched.files }}" - no_log: True + tasks: + - name: Find dashboard files + find: + paths: '{{ dashboards_path }}' + file_type: file + recurse: true + patterns: '*.json' + register: files_matched + no_log: true - - name: Create/Update a dashboard - grafana.grafana.dashboard: - dashboard: "{{ lookup('ansible.builtin.file','{{ item }}' ) }}" - stack_slug: "{{ stack_name }}" - grafana_api_key: "{{ grafana_api_key }}" - state: present - loop: "{{ dashboard_file_names }}" - ``` + - name: Create list of dashboard file names + set_fact: + dashboard_file_names: '{{ dashboard_file_names | default([]) + [item.path] }}' + loop: '{{ files_matched.files }}' + no_log: true -1. Replace the following field values: - - `` with the path to the folder containing dashboard JSON source code files. - - `` with the name of your stack. - - `` with the [API key created in the Grafana instance](#create-an-api-key-in-the-grafana-stack). + - name: Create/Update a dashboard + grafana.grafana.dashboard: + dashboard: "{{ lookup('ansible.builtin.file', item) }}" + grafana_url: '{{ grafana_url }}' + grafana_api_key: '{{ grafana_api_key }}' + state: present + loop: '{{ dashboard_file_names }}' +``` + +Replace the placeholders with your values: + +- _``_: Path to the folder containing dashboard JSON source code files +- _``_: Name of your stack +- _``_: API key created in the Grafana instance ## Run the Ansible playbooks In a terminal, run the following commands from the directory where all of the Ansible playbooks are located. -1. To create the Grafana Cloud stack. +Create the Grafana Cloud stack: - ```shell - ansible-playbook cloud-stack.yml - ``` +```sh +ansible-playbook cloud-stack.yml +``` -1. To add a data source to the Grafana stack. +Add a data source to the Grafana stack: - ```shell - ansible-playbook data-source.yml - ``` +```sh +ansible-playbook data-source.yml +``` -1. To add a folder to the Grafana stack +Add a folder to the Grafana stack: - ```shell - ansible-playbook folder.yml - ``` +```sh +ansible-playbook folder.yml +``` -1. To add a dashboard to the folder in your Grafana stack. +Add a dashboard to the folder in your Grafana stack: - ```shell - ansible-playbook dashboard.yml - ``` +```sh +ansible-playbook dashboard.yml +``` -## Validation +## Validate your configuration -Once you run the Ansible playbooks, you should be able to verify the following: +After you've run the Ansible playbooks, you can verify the following: -- The new Grafana stack is created and visible in the Cloud Portal. +- The new Grafana Cloud stack is created and visible in the Cloud Portal. ![Cloud Portal](/static/img/docs/grafana-cloud/terraform/cloud_portal_tf.png) @@ -242,18 +254,22 @@ Once you run the Ansible playbooks, you should be able to verify the following: ![InfluxDB datasource](/media/docs/grafana-cloud/screenshot-influxdb_datasource_tf.png) -- A new folder in Grafana. - In the following image, a folder named `Demos` was added. +- A new folder is available in your Grafana stack. In the following image, a folder named `Demos` was added. ![Folder](/media/docs/grafana-cloud/screenshot-folder_tf.png) -- A new dashboard in the Grafana stack. - In the following image a dashboard named `InfluxDB Cloud Demos` was created inside the "Demos" folder. +- A new dashboard is visible in the Grafana stack. In the following image, a dashboard named `InfluxDB Cloud Demos` was created inside the "Demos" folder. ![InfluxDB dashboard](/static/img/docs/grafana-cloud/terraform/influxdb_dashboard_tf.png) -## Summary +## Next steps -In this guide, you created a Grafana Cloud stack along with a data source, folder, and dashboard imported from a JSON file using Ansible. +You've successfully created a Grafana Cloud stack along with a data source, a folder, and a dashboard using Ansible. Your Grafana infrastructure is now managed through code. -To learn more about managing Grafana using Ansible, refer to the [Grafana Ansible collection](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/). +To learn more about managing Grafana with Infrastructure as code: + +- [Grafana Ansible collection documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/) +- [Ansible playbook best practices](https://docs.ansible.com/ansible/latest/user_guide/playbooks_best_practices.html) +- [Grafana API documentation](/docs/grafana/latest/developers/http_api/) +- [Grafana Cloud API documentation](https://grafana.com/docs/grafana-cloud/developer-resources/api-reference/) +- [Infrastructure as Code with Terraform](/docs/grafana/latest/as-code/infrastructure-as-code/terraform/) diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md deleted file mode 100644 index 8fa3a6f8357..00000000000 --- a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/index.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -keywords: - - Infrastructure as Code - - Quickstart - - Grafana Cloud - - Ansible -title: Install Grafana Agent on a Linux host using Ansible -weight: 200 -canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ansible-grafana-agent-linux/ ---- - -# Install Grafana Agent on a Linux host using Ansible - -{{< docs/shared lookup="agent-deprecation.md" source="alloy" version="next" >}} - -This guide shows how to install Grafana Agent on a Linux host using [Ansible](https://www.ansible.com/) and to use it to push logs to Grafana Cloud. - -## Before you begin - -Before you begin, you should have the following available: - -- A Grafana Cloud account. -- A Linux machine -- Command line (terminal) access to that Linux machine with `unzip` binary installed -- Account permissions sufficient to install and use Grafana Agent on the Linux machine -- [Ansible](https://docs.ansible.com/ansible/latest/installation_guide/index.html) installed on the Linux machine - -## Choose your Grafana Agent installation method - -This guide covers two methods for installing and configuring Grafana Agent using Ansible: - -- Installing Grafana Agent in Flow mode -- Installing Grafana Agent in static mode - -Depending on your specific needs and the configuration of your environment, you may choose one method over the other for better compatibility or ease of setup. - - - -### Install Grafana Agent in flow mode using Ansible - -This Ansible playbook installs Grafana Agent in Flow mode and also creates a systemd service to manage it. - -It creates a user named `grafana-agent` on the Linux machine for running Grafana Agent. - -1. Create a file named `grafana-agent.yml` and add the following: - -```yaml -- name: Install Grafana Agent Flow - hosts: all - become: true - tasks: - - name: Install Grafana Agent Flow - ansible.builtin.include_role: - name: grafana.grafana.grafana_agent - vars: - grafana_agent_mode: flow - # Change config file on the host to .river - grafana_agent_config_filename: config.river - # Change config file to be copied - grafana_agent_provisioned_config_file: '' - # Remove default flags - grafana_agent_flags_extra: - server.http.listen-addr: '0.0.0.0:12345' -``` - -1. Replace the following field values: - - `` with the path to river configuration file on the Ansible Controller (Localhost). - -### Install Grafana Agent in static mode using Ansible - -This Ansible playbook installs Grafana Agent in static mode and also creates a systemd service to manage it. -It creates a user named `grafana-agent` on the Linux machine for running Grafana Agent. - -1. Create a file named `grafana-agent.yml` and add the following: - -```yaml -- name: Install Grafana Agent in static mode - hosts: all - become: true - - vars: - grafana_cloud_api_key: - logs_username: # Example - 411478 - loki_url: # Example - https://logs-prod-017.grafana.net/loki/api/v1/push - tasks: - - name: Install Grafana Agent in static mode - ansible.builtin.include_role: - name: grafana_agent - vars: - grafana_agent_logs_config: - configs: - - clients: - - basic_auth: - password: '{{ grafana_cloud_api_key }}' - username: '{{ logs_username }}' - url: '{{ loki_url }}' - name: default - positions: - filename: /tmp/positions.yaml - scrape_configs: - - job_name: integrations/node_exporter_direct_scrape - static_configs: - - targets: - - localhost - labels: - instance: hostname - __path__: /var/log/*.log - job: integrations/node_exporter - target_config: - sync_period: 10s -``` - -1. Replace the following field values: - - `` with a token from the Cloud Access Policy you created in the Grafana Cloud portal. - - `` with the Loki Username - - `` with the push endpoint URL of Loki Instance - -## Run the Ansible playbook on the Linux machine - -In the Linux machine's terminal, run the following command from the directory where the Ansible playbook is located. - -```shell -ansible-playbook grafana-agent.yml -``` - -## Validate - - - -1. Grafana Agent service on the Linux machine should be `active` and `running`. You should see a similar output: - - -```shell -$ sudo systemctl status grafana-agent.service - grafana-agent.service - Grafana Agent - Loaded: loaded (/etc/systemd/system/grafana-agent.service; enabled; vendor preset: enabled) - Active: active (running) since Wed 2022-07-20 09:56:15 UTC; 36s ago - Main PID: 3176 (agent-linux-amd) - Tasks: 8 (limit: 515) - Memory: 92.5M - CPU: 380ms - CGroup: /system.slice/grafana-agent.service - └─3176 /usr/local/bin/agent-linux-amd64 --config.file=/etc/grafana-cloud/agent-config.yaml -``` - -1. In a Grafana Cloud stack, click **Explore** in the left-side menu. - -1. At the top of the page, use the dropdown menu to select your Loki logs data source. In the Log Browser, run the query `{job="integrations/node_exporter"}` - - ![Loki Logs](/static/img/docs/grafana-cloud/ansible/ansible-agent-logs.png) - -## Summary - -In this guide, you installed Grafana Agent on a Linux node using Ansible and used it to pushed logs to Grafana Cloud. - -To learn more about the Grafana Ansible collection, refer to the [GitHub repository](https://github.com/grafana/grafana-ansible-collection) or its [documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/index.html). diff --git a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md b/docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md deleted file mode 100644 index c6769e5ebde..00000000000 --- a/docs/sources/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/index.md +++ /dev/null @@ -1,207 +0,0 @@ ---- -menuTitle: Monitor multiple Linux hosts with the grafana_agent role -title: Monitor multiple Linux hosts with grafana_agent role -weight: 300 -canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/ansible/ansible-multiple-agents/ ---- - -# Monitor multiple Linux hosts with the `grafana_agent` role - -{{< docs/shared lookup="agent-deprecation.md" source="alloy" version="next" >}} - -Monitoring multiple Linux hosts can be difficult. -To make it easier, you can use the `grafana_agent` role with the [Grafana Ansible collection](../). -This guide shows how to use the `grafana_agent` Ansible role to deploy and manage Grafana Agent across multiple Linux hosts so you can monitor them using Grafana Cloud. - -## Before you begin - -Before you begin, you should have: - -- Linux hosts -- SSH access to the Linux hosts -- Account permissions sufficient to install and use Grafana Agent on the Linux hosts - -## Install the Grafana Ansible collection - -The [`grafana_agent` role](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent) is available in the Grafana Ansible collection as of the 1.1.0 release. - -To install the Grafana Ansible collection, run this command: - -``` -ansible-galaxy collection install grafana.grafana -``` - -## Create an Ansible inventory file - -Next, you will set up your hosts and create an inventory file. - -1. Create your hosts and add public SSH keys to them. - - This example uses eight Linux hosts: two Ubuntu hosts, two CentOS hosts, two Fedora hosts, and two Debian hosts. - -1. Create an Ansible inventory file. - - The Ansible inventory, which resides in a file named `inventory`, looks similar to this: - - ``` - 146.190.208.216 # hostname = ubuntu-01 - 146.190.208.190 # hostname = ubuntu-02 - 137.184.155.128 # hostname = centos-01 - 146.190.216.129 # hostname = centos-02 - 198.199.82.174 # hostname = debian-01 - 198.199.77.93 # hostname = debian-02 - 143.198.182.156 # hostname = fedora-01 - 143.244.174.246 # hostname = fedora-02 - ``` - -1. Create an `ansible.cfg` file within the same directory as `inventory`, with the following values: - ``` - [defaults] - inventory = inventory # Path to the inventory file - private_key_file = ~/.ssh/id_rsa # Path to my private SSH Key - remote_user=root # username - ``` - {{< admonition type="note" >}} - If you are copying the previously listed files, remove the comments (#). - {{< /admonition >}} - -## Use the `grafana_agent` Ansible role - -Next you will create an Ansible playbook that calls the `grafana_agent` role from the `grafana.grafana` Ansible collection. - -To use the `grafana_agent` Ansible role: - -1. Create a file named `deploy-agent.yml` in the same directory as `ansible.cfg` and `inventory` and add the configuration below. - - ```yaml - - name: Install Grafana Agent - hosts: all - become: true - - vars: - grafana_cloud_api_key: - metrics_username: # Example - 825019 - logs_username: # Example - 411478 - prometheus_url: # Example - https://prometheus-us-central1.grafana.net/api/prom/push - loki_url: # Example - https://logs-prod-017.grafana.net/loki/api/v1/push - tasks: - - name: Install Grafana Agent - ansible.builtin.include_role: - name: grafana.grafana.grafana_agent - vars: - grafana_agent_metrics_config: - configs: - - name: integrations - remote_write: - - basic_auth: - password: '{{ grafana_cloud_api_key }}' - username: '{{ metrics_username }}' - url: '{{ prometheus_url }}' - - global: - scrape_interval: 60s - wal_directory: /tmp/grafana-agent-wal - grafana_agent_logs_config: - configs: - - name: default - clients: - - basic_auth: - password: '{{ grafana_cloud_api_key }}' - username: '{{ logs_username }}' - url: '{{ loki_url }}' - positions: - filename: /tmp/positions.yaml - target_config: - sync_period: 10s - scrape_configs: - - job_name: varlogs - static_configs: - - targets: [localhost] - labels: - instance: ${HOSTNAME:-default} - job: varlogs - __path__: /var/log/*log - grafana_agent_integrations_config: - node_exporter: - enabled: true - instance: ${HOSTNAME:-default} - prometheus_remote_write: - - basic_auth: - password: '{{ grafana_cloud_api_key }}' - username: '{{ metrics_username }}' - url: '{{ prometheus_url }}' - grafana_agent_env_vars: - HOSTNAME: '%H' - ``` - - The playbook calls the `grafana_agent` role from the `grafana.grafana` Ansible collection. - - The Agent configuration in this playbook send metrics and logs from the Linux hosts to Grafana Cloud along with the hostname of each instance - - Refer to the [Grafana Ansible documentation](https://github.com/grafana/grafana-ansible-collection/tree/main/roles/grafana_agent#role-variables) to understand the other variables you can pass to the `grafana_agent` role. - - When deploying the Agent across multiple instances for monitoring them, It is essential that the Agent is able to auto-detect the hostname for ease in monitoring. - Notice that the label `instance` has been set to the value `${HOSTNAME:-default}`, which is substituted by the value of the HOSTNAME environment variable in the Linux host. - - To read more about the variable substitution, refer to the Grafana Agent [node_exporter_config](/docs/grafana-cloud/send-data/agent/static/configuration/integrations/node-exporter-config/) documentation. - -1. To run the playbook, run this command: - - ``` - ansible-playbook deploy-agent.yml - ``` - -{{< admonition type="note" >}} -You can place the `deploy-agent.yml`, `ansible.cfg` and `inventory` files in different directories based on your needs. -{{< /admonition >}} - -## Check that logs and metrics are being ingested into Grafana Cloud - -Logs and metrics will soon be available in Grafana Cloud. -To test this, use the Explore feature. -Click the **Explore** icon (compass icon) in the vertical navigation bar. - -### Check logs - -To check logs: - -1. Use the drop-down menu at the top of the page to select your Loki logs data source. - -1. In the log browser, run the query `{instance="centos-01"}` where `centos-01` is the hostname of one of the Linux hosts. - - If you see log lines (shown in the example below), logs are being received. - - {{< figure alt="Grafana Explore showing a graph and log output from the preceding query" src="/static/assets/img/blog/ansible-to-manage-agent1.png" >}} - - If no log lines appear, logs aren't being collected. - -### Check metrics - -To check metrics: - -1. Use the drop-down menu at the top of the page to select your Prometheus data source. - -1. Run the query `{instance="centos-01"}` where `centos-01` is the hostname of one of the Linux hosts. - - If you see a metrics graph and table (shown in the example below), metrics are being received. - - {{< figure alt="Grafana Explore showing a graph and metrics table output from the preceding query" src="/static/assets/img/blog/ansible-to-manage-agent2.png" >}} - - If no metrics appear, metrics aren't being collected. - -### View dashboards - -Now that you have logs and metrics in Grafana, you can use dashboards to view them. -Here's an example of one of the prebuilt dashboards included with the Linux integration in Grafana Cloud: - -{{< figure alt="The Grafana Node Exporter integration dashboard showing panels of visualizations" src="/static/assets/img/blog/ansible-to-manage-agent3.png" >}} - -Using the **Instance** drop-down in the dashboard, you can select from the hostnames where you deployed Grafana Agent and start monitoring them. - -## Summary - -The `grafana_agent` Ansible role makes it easy to deploy and manage Grafana Agent across multiple machines. -This example showed Grafana Agent deployments across eight Linux hosts, but it's possible to monitor more hosts using the`grafana_agent` role. -To add monitor more Linux hosts, update the `inventory` file and re-run the Ansible playbook. - -To learn more about the Grafana Ansible collection, see its [GitHub repository](https://github.com/grafana/grafana-ansible-collection) or its [documentation](https://docs.ansible.com/ansible/latest/collections/grafana/grafana/index.html). diff --git a/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md b/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md index 3c92270e860..0531a0c803f 100644 --- a/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md +++ b/docs/sources/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd.md @@ -5,295 +5,298 @@ keywords: - Grafana Cloud - Grafana Operator - ArgoCD -title: Manage Dashboards with GitOps Using ArgoCD +title: Manage dashboards with GitOps using ArgoCD +menuTitle: Manage dashboards with ArgoCD weight: 110 canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grafana-operator/manage-dashboards-argocd/ --- -# Managing Grafana Dashboards with GitOps Using ArgoCD +# Manage Grafana dashboards with GitOps using ArgoCD -This guide will walk you through setting up a continuous deployment pipeline using ArgoCD to synchronize your Grafana dashboards with a Git repository. We'll use the Grafana Dashboard Custom Resource provided by the Grafana Operator to manage dashboard configurations declaratively. +This guide shows you how to set up a continuous deployment pipeline using ArgoCD to synchronize your Grafana dashboards with a Git repository. You'll use the Grafana Dashboard Custom Resource provided by the Grafana Operator to manage dashboard configurations declaratively. ## Prerequisites +Before you begin, make sure you have the following: + - An existing Grafana Cloud stack -- A Kubernetes cluster with Grafana Operator installed, as shown in [Grafana Operator Installation](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/#installing-the-grafana-operator). -- ArgoCD installed on your Kubernetes cluster. Refer the [Installation Guide](https://argo-cd.readthedocs.io/en/stable/getting_started/). -- Git repository to store your dashboard configurations. +- A Kubernetes cluster with Grafana Operator installed, as shown in [Grafana Operator Installation](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/#installing-the-grafana-operator) +- ArgoCD installed on your Kubernetes cluster. Refer to the [ArgoCD Installation Guide](https://argo-cd.readthedocs.io/en/stable/getting_started/) +- A Git repository to store your dashboard configurations -## Set Up Your Git Repository +## Set up your Git repository -Within the repository, create a directory structure to organize your grafana and dashboard configurations. For this tutorial, lets create a folder named `grafana`. +Create a directory structure in your repository to organize your Grafana and dashboard configurations. For this tutorial, create a folder named `grafana`. -## Grafana Operator Setup +## Set up the Grafana Operator -The Grafana Operator allows us to authenticate with the Grafana instance using the Grafana Custom Resource (CR). +The Grafana Operator allows you to authenticate with the Grafana instance using the Grafana Custom Resource (CR). -1. **Create the Grafana API Token Secret:** +### Create the Grafana API Token Secret -Store the Grafana API Token in a secret with the following content in a file named `grafana-token.yml` in the `grafana` folder in your Git repo: +Store the Grafana API Token in a secret. Create a file named `grafana-token.yml` in the `grafana` folder in your Git repository: ```yaml apiVersion: v1 kind: Secret metadata: name: grafana-cloud-credentials - namespace: + namespace: '' stringData: - GRAFANA_CLOUD_INSTANCE_TOKEN: + GRAFANA_CLOUD_INSTANCE_TOKEN: '' type: Opaque ``` -Replace the following field values: +Replace the placeholders with your values: -- `` with API key from the Grafana instance. To create an API key, refer [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/). -- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +- _``_: API key from your Grafana instance. To create an API key, refer to [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/) +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster -2. **Configure the Grafana Custom Resource:** +### Configure the Grafana Custom Resource -Set up connection to your Grafana Cloud instance by creating a file named `grafana-cloud.yml` in the `grafana` folder in your Git repo with the following contents: +Set up the connection to your Grafana Cloud instance. Create a file named `grafana-cloud.yml` in the `grafana` folder in your Git repository: ```yaml apiVersion: grafana.integreatly.org/v1beta1 kind: Grafana metadata: - name: - namespace: + name: '' + namespace: '' labels: - dashboards: + dashboards: '' spec: external: - url: https://.grafana.net/ + url: https://.grafana.net/ apiKey: name: grafana-cloud-credentials key: GRAFANA_CLOUD_INSTANCE_TOKEN ``` -Replace the following field values: +Replace the placeholders with your values: -- `` with API key from the Grafana instance. -- `` with the name of your Grafana Cloud Stack. -- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +- _``_: Name of your Grafana Cloud Stack +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster -## Add Dashboards to a Git repository +## Add dashboards to your Git repository -In your `grafana` directory, Create a sub-folder called `dashboards`. For this tutorial, we will create 3 seperate dashboards. +In your `grafana` directory, create a sub-folder called `dashboards`. -1. Under `dashboards` folder, Create a file named `simple-dashboard.yaml` with the following content for the first dashboard: +This guide shows you how to creates three separate dashboards. For all dashboard configurations, replace the placeholders with your values: - ```yaml - apiVersion: grafana.integreatly.org/v1beta1 - kind: GrafanaDashboard - metadata: - name: grafanadashboard-sample - namespace: - spec: - resyncPeriod: 30s - instanceSelector: - matchLabels: - dashboards: - json: > - { - "id": null, - "title": "Simple Dashboard", - "tags": [], - "style": "dark", - "timezone": "browser", - "editable": true, - "hideControls": false, - "graphTooltip": 1, - "panels": [], - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "time_options": [], - "refresh_intervals": [] - }, - "templating": { - "list": [] - }, - "annotations": { - "list": [] - }, - "refresh": "5s", - "schemaVersion": 17, - "version": 0, - "links": [] - } - ``` +- _``_: Name of your Grafana Cloud Stack +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster - Replace the following field values: - - `` with the name of your Grafana Cloud Stack. - - `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +### Create a simple dashboard -1. Under `dashboards` folder, Create a file named `dashboard-from-cm.yaml` with the following content for the second dashboard: +Under the `dashboards` folder, create a file named `simple-dashboard.yaml`: - ```yaml - apiVersion: v1 - kind: ConfigMap - metadata: - name: dashboard-definition - namespace: - spec: - resyncPeriod: 30s - instanceSelector: - matchLabels: - dashboards: - json: > - { - "id": null, - "title": "Simple Dashboard from ConfigMap", - "tags": [], - "style": "dark", - "timezone": "browser", - "editable": true, - "hideControls": false, - "graphTooltip": 1, - "panels": [], - "time": { - "from": "now-6h", - "to": "now" - }, - "timepicker": { - "time_options": [], - "refresh_intervals": [] - }, - "templating": { - "list": [] - }, - "annotations": { - "list": [] - }, - "refresh": "5s", - "schemaVersion": 17, - "version": 0, - "links": [] - } - --- - apiVersion: grafana.integreatly.org/v1beta1 - kind: GrafanaDashboard - metadata: - name: grafanadashboard-from-configmap - namespace: - spec: - instanceSelector: - matchLabels: - dashboards: - configMapRef: - name: dashboard-definition - key: json - ``` +```yaml +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaDashboard +metadata: + name: grafanadashboard-sample + namespace: '' +spec: + resyncPeriod: 30s + instanceSelector: + matchLabels: + dashboards: '' + json: > + { + "id": null, + "title": "Simple Dashboard", + "tags": [], + "style": "dark", + "timezone": "browser", + "editable": true, + "hideControls": false, + "graphTooltip": 1, + "panels": [], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "time_options": [], + "refresh_intervals": [] + }, + "templating": { + "list": [] + }, + "annotations": { + "list": [] + }, + "refresh": "5s", + "schemaVersion": 17, + "version": 0, + "links": [] + } +``` - Replace the following field values: - - `` with the name of your Grafana Cloud Stack. - - `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +### Create a dashboard from ConfigMap -1. Under `dashboards` folder, Create a file named `dashboard-from-id.yaml` with the following content for the third dashboard: +Under the `dashboards` folder, create a file named `dashboard-from-cm.yaml`: - ```yaml - apiVersion: grafana.integreatly.org/v1beta1 - kind: GrafanaDashboard - metadata: - name: node-exporter-latest - namespace: - spec: - instanceSelector: - matchLabels: - dashboards: - grafanaCom: - id: 1860 - ``` +```yaml +apiVersion: v1 +kind: ConfigMap +metadata: + name: dashboard-definition + namespace: +data: + json: > + { + "id": null, + "title": "Simple Dashboard from ConfigMap", + "tags": [], + "style": "dark", + "timezone": "browser", + "editable": true, + "hideControls": false, + "graphTooltip": 1, + "panels": [], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "time_options": [], + "refresh_intervals": [] + }, + "templating": { + "list": [] + }, + "annotations": { + "list": [] + }, + "refresh": "5s", + "schemaVersion": 17, + "version": 0, + "links": [] + } +--- +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaDashboard +metadata: + name: grafanadashboard-from-configmap + namespace: '' +spec: + instanceSelector: + matchLabels: + dashboards: '' + configMapRef: + name: dashboard-definition + key: json +``` - Replace the following field values: - - `` with the name of your Grafana Cloud Stack. - - `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +### Create a dashboard from Grafana.com -## Configure Argo CD to Sync the Git Repository +Under the `dashboards` folder, create a file named `dashboard-from-id.yaml`: -Once all changes are committed to Git, Log in to the Argo CD user interface or use the CLI. +```yaml +apiVersion: grafana.integreatly.org/v1beta1 +kind: GrafanaDashboard +metadata: + name: node-exporter-latest + namespace: '' +spec: + instanceSelector: + matchLabels: + dashboards: '' + grafanaCom: + id: 1860 +``` -2. Create an Argo CD application to manage the synchronization: +## Configure ArgoCD to sync the Git repository - **Using UI**: - - Navigate to 'New App' and fill out the form with your Git repository details and the path to your `grafana` folder. - - Make sure to tick mark directory Recurse. - - Set the sync policy to `Automatic`. +After you commit all changes to Git, log in to the ArgoCD user interface or use the CLI. - **Using CLI**: - - Prepare an application manifest named `argo-application.yaml` with the configuration pointing to your Git repository: +### Create an ArgoCD application - ```yaml - apiVersion: argoproj.io/v1alpha1 - kind: Application - metadata: - name: Grafana - namespace: - spec: - destination: - name: '' - namespace: '' - server: 'https://kubernetes.default.svc' - source: - path: - repoURL: '' - targetRevision: HEAD - directory: - recurse: true - sources: [] - project: default - syncPolicy: - automated: - prune: true - selfHeal: true - syncOptions: - - CreateNamespace=true - retry: - limit: 2 - backoff: - duration: 5s - maxDuration: 3m0s - factor: 2 - ``` +**Using the UI:** - Replace the following field values: - - `` with the URL of your GIT Repository. - - `` with the path to the `grafana` folder. - - `` with the namespace where ArgoCD is deployed in Kubernetes Cluster. +1. Navigate to **New App** and complete the form with your Git repository details and the path to your `grafana` folder +2. Enable **Directory Recurse** +3. Set the sync policy to **Automatic** - - Create the application in Argo CD: +**Using the CLI:** - ```shell - kubectl apply -f argo-application.yaml - ``` +Prepare an application manifest named `argo-application.yaml`: -## Verify Sync Status in Argo CD +```yaml +apiVersion: argoproj.io/v1alpha1 +kind: Application +metadata: + name: Grafana + namespace: '' +spec: + destination: + name: '' + namespace: '' + server: 'https://kubernetes.default.svc' + source: + path: '' + repoURL: '' + targetRevision: HEAD + directory: + recurse: true + sources: [] + project: default + syncPolicy: + automated: + prune: true + selfHeal: true + syncOptions: + - CreateNamespace=true + retry: + limit: 2 + backoff: + duration: 5s + maxDuration: 3m0s + factor: 2 +``` -1. Monitor the newly created Argo CD application, ensuring that it successfully syncs your dashboard configuration. +Replace the placeholders with your values: -2. Visit the Argo CD dashboard and check the sync status. If it's successful, your Grafana dashboard should be up to date with the configuration from your Git repository. +- _``_: URL of your Git repository +- _``_: Path to the `grafana` folder in your repository +- _``_: Namespace where ArgoCD is deployed in your Kubernetes cluster -## Updating the Dashboards +Create the application in ArgoCD: + +```sh +kubectl apply -f argo-application.yaml +``` + +## Verify sync status in ArgoCD + +1. Monitor the newly created ArgoCD application to ensure it successfully syncs your dashboard configuration +2. Visit the ArgoCD dashboard and check the sync status. If it's successful, your Grafana dashboards should be up to date with the configuration from your Git repository + +## Update your dashboards To update an existing dashboard: -1. Make changes to the dashboard JSON configuration in your Git repository. -2. Commit and push the changes. -3. Argo CD will detect the update and synchronize the changes to your Cutom Resource. -4. Grafana Operator will then sync changes to the Grafana Instance. +1. Make changes to the dashboard JSON configuration in your Git repository +2. Commit and push the changes +3. ArgoCD detects the update and synchronizes the changes to your Custom Resource +4. Grafana Operator then syncs changes to the Grafana instance -## Validating the Grafana Dashboard Update +### Validate your dashboard updates -Log in to your Grafana dashboard and confirm that the changes have been applied. You should see the dashboard update reflected in the Grafana UI. +Log in to your Grafana dashboard and confirm that the changes are applied. You should see the dashboard updates reflected in the Grafana UI. -## Additional Tips +## Next steps -- You can also install the Grafana Operator's Helm Chart using ArgoCD to manage your setup with GitOps. -- You can follow a similar setup for Grafana Dashboards and Folders. +You've successfully set up a GitOps workflow to manage Grafana dashboards using ArgoCD and the Grafana Operator. Your dashboards are now version-controlled and can be consistently deployed across environments. This approach provides a reliable and auditable way to manage observability dashboards and scale your operations. -## Conclusion +To learn more about managing Grafana using Grafana Operator: -You've set up a GitOps workflow to manage Grafana dashboards using Argo CD and the Grafana Operator. Your dashboards are now version-controlled and can be consistently deployed across environments. This approach provides a reliable and auditable way to manage observability dashboards and scale your operations. +- [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/) +- [Grafana dashboard provisioning](/docs/grafana/latest/administration/provisioning/#dashboards) +- [ArgoCD best practices](https://argo-cd.readthedocs.io/en/stable/user-guide/best_practices/) -To learn more about managing Grafana using Grafana Operator, see the [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/). +### Additional considerations + +- You can install the Grafana Operator's Helm Chart using ArgoCD to manage your setup with GitOps +- You can follow a similar setup for Grafana Folders and other resources diff --git a/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md b/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md index 9d8617e12c6..e5bbc1467b8 100644 --- a/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md +++ b/docs/sources/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources.md @@ -5,26 +5,27 @@ keywords: - Grafana Cloud - Grafana Operator title: Manage folders, data sources, and dashboards using Grafana Operator +menuTitle: Manage resources with the Grafana Operator weight: 100 canonical: https://grafana.com/docs/grafana/latest/as-code/infrastructure-as-code/grafana-operator/operator-dashboards-folders-datasources/ --- -# Creating and managing folders, data sources, and dashboards using the Grafana Operator +# Manage folders, data sources, and dashboards using the Grafana Operator -Learn how to manage data sources, folders and dashboard, using Grafana Operator. +This guide shows you how to manage data sources, folders, and dashboards using the Grafana Operator. You'll create these resources declaratively using Kubernetes custom resources. ## Prerequisites -Before you begin, you should have the following available: +Before you begin, make sure you have the following: -- An existing Grafana Cloud stack. -- Grafana Operator Installed in your Cluster, as shown in [Grafana Operator Installation](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/#installing-the-grafana-operator). +- An existing Grafana Cloud stack +- Grafana Operator installed in your cluster, as shown in [Grafana Operator Installation](/docs/grafana-cloud/as-code/infrastructure-as-code/grafana-operator/#installing-the-grafana-operator) -## Grafana Operator Setup +## Set up the Grafana Operator -The Grafana Operator allows us to authenticate with the Grafana instance using the Grafana Custom Resource (CR). +The Grafana Operator allows you to authenticate with your Grafana instance using the Grafana Custom Resource (CR). -1. **Create the Grafana API Token Secret:** +### Create the Grafana API Token Secret Store the Grafana API Token in a secret with the following content in a file named `grafana-token.yml`: @@ -33,61 +34,64 @@ apiVersion: v1 kind: Secret metadata: name: grafana-cloud-credentials - namespace: + namespace: '' stringData: - GRAFANA_CLOUD_INSTANCE_TOKEN: + GRAFANA_CLOUD_INSTANCE_TOKEN: '' type: Opaque ``` -Replace the following field values: +Replace the placeholders with your values: -- `` with API key from the Grafana instance. To create an API key, refer [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/). -- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +- _``_: API key from your Grafana instance. To create an API key, refer to [Grafana API Key Documentation](/docs/grafana/latest/administration/api-keys/) +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster -2. **Configure the Grafana Custom Resource:** +### Configure the Grafana Custom Resource -Set up connection to your Grafana Cloud instance by creating a file named `grafana-cloud.yml` with the following contents: +Set up connection to your Grafana Cloud instance. Create a file named `grafana-cloud.yml`: ```yaml apiVersion: grafana.integreatly.org/v1beta1 kind: Grafana metadata: - name: - namespace: + name: '' + namespace: '' labels: - dashboards: + dashboards: '' spec: external: - url: https://.grafana.net/ + url: https://.grafana.net/ apiKey: name: grafana-cloud-credentials key: GRAFANA_CLOUD_INSTANCE_TOKEN ``` -Replace the following field values: +Replace the placeholders with your values: -- `` with API key from the Grafana instance. -- `` with the name of your Grafana Cloud Stack. -- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +- _``_: Name of your Grafana Cloud stack +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster ## Add a data source -The following steps use the Prometheus data source. The required arguments vary depending on the data source you select. +{{< admonition type="note" >}} -1. **Create the Data Source Configuration:** +This example uses the Prometheus data source. Note that the required arguments vary depending on the data source you select. -Save a new YAML file `datasource.yml` with the following content: +{{< /admonition >}} + +### Create a data source configuration + +Create and save a new YAML file `datasource.yml` with your data source's configuration: ```yaml apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDatasource metadata: - name: - namespace: + name: '' + namespace: '' spec: instanceSelector: matchLabels: - dashboards: + dashboards: '' allowCrossNamespaceImport: true datasource: access: proxy @@ -95,74 +99,76 @@ spec: jsonData: timeInterval: 5s tlsSkipVerify: true - name: + name: '' type: prometheus - url: + url: '' ``` -Replace the following field values: +Replace the placeholders with your values: -- `` with the name of the data source to be added in Grafana. -- `` with URL of your data source. -- `` with the name of your Grafana Cloud Stack. -- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +- _``_: Name of the data source to be added in Grafana +- _``_: URL of your data source +- _``_: Name of your Grafana Cloud stack +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster -## Add a dashboard to a folder +### Add a dashboard to a folder -Use the following YAML definition to create a simple dashboard in the Grafana instance under a custom folder. If the folder defined under spec.folder fields doesnt not exist, The operator will create it before placing the dashboard inside the folder. +Use the following YAML definition to create a simple dashboard in the Grafana instance under a custom folder. If the folder defined under the `spec.folder` field doesn't exist, the operator creates it before placing the dashboard inside the folder. -1. **Prepare the Dashboard Configuration File:** - -In `dashboard.yml`, define the dashboard and assign it to a folder: +Prepare the dashboard configuration. In `dashboard.yml`, define the dashboard and assign it to a folder: ```yaml apiVersion: grafana.integreatly.org/v1beta1 kind: GrafanaDashboard metadata: - name: - namespace: + name: '' + namespace: '' spec: instanceSelector: matchLabels: - dashboards: - folder: "" + dashboards: '' + folder: '' json: > - { - "title": "as-code dashboard", - “uid” : “ascode” - } + { + "title": "as-code dashboard", + "uid" : "ascode" + } ``` -Replace the following field values: +Replace the placeholders with your values: -- `` with the name of the folder in which you want the Dashboard to be created. -- `` with the name of your Grafana Cloud Stack. -- `` with the namespace where the grafana-operator is deployed in Kubernetes Cluster. +- _``_: Name of the folder in which you want the dashboard to be created +- _``_: Name of your Grafana Cloud stack +- _``_: Namespace where the `grafana-operator` is deployed in your Kubernetes cluster -## Apply Kubernetes Manifests +## Apply the Kubernetes manifests In a terminal, run the following commands from the directory where all of the above Kubernetes YAML definitions are located. -1. Create Kubernetes Custom resources for all of the above configurations. +Create Kubernetes Custom resources for all of the configurations: - ```shell - kubectl apply -f grafana-token.yml grafana-cloud.yml datasource.yml dashboard.yml - ``` +```sh +kubectl apply -f grafana-token.yml grafana-cloud.yml datasource.yml dashboard.yml +``` -## Validation +## Validate your configuration -Once you apply the configurations, you should be able to verify the following: +After you apply the configurations, verify that: -- A new data source is visible in Grafana. In the following image a datasource named `InfluxDB` was created. +- A new data source is visible in Grafana. In the following image, a data source named `InfluxDB` was created. ![InfluxDB datasource](/static/img/docs/grafana-cloud/terraform/influxdb_datasource_tf.png) -- A new dashboard and folder in Grafana. In the following image a dashboard named `InfluxDB Cloud Demos` was created inside the `Demos` folder. +- A new dashboard and folder have been created in Grafana. In the following image, a dashboard named `InfluxDB Cloud Demos` was created inside the `Demos` folder. ![InfluxDB dashboard](/static/img/docs/grafana-cloud/grizzly/grizzly-folder-dashboard-datasource.png) -## Conclusion +## Next steps -In this guide, you created a data source, folder, and dashboard using the Grafana Operator. +You've successfully created a data source, folder, and dashboard using the Grafana Operator. Your Grafana resources are now managed declaratively through Kubernetes custom resources. -To learn more about managing Grafana using Grafana Operator, see the [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/). +To learn more about managing Grafana: + +- [Grafana Operator documentation](https://grafana.github.io/grafana-operator/docs/) +- [Grafana dashboard provisioning](/docs/grafana/latest/administration/provisioning/#dashboards) +- [Grafana data source provisioning](/docs/grafana/latest/administration/provisioning/#data-sources) diff --git a/docs/sources/as-code/observability-as-code/schema-v2/_index.md b/docs/sources/as-code/observability-as-code/schema-v2/_index.md index 5eb693f0f1d..9be869a8391 100644 --- a/docs/sources/as-code/observability-as-code/schema-v2/_index.md +++ b/docs/sources/as-code/observability-as-code/schema-v2/_index.md @@ -13,7 +13,7 @@ labels: - enterprise - oss title: JSON schema v2 -weight: 200 +weight: 500 canonical: https://grafana.com/docs/grafana/latest/as-code/observability-as-code/schema-v2/ aliases: - ../../observability-as-code/schema-v2/ # /docs/grafana/next/observability-as-code/schema-v2/ From 5d26bc5ee11dd73e3f1ac598bba01993c98d6048 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Thu, 20 Nov 2025 11:18:50 +0100 Subject: [PATCH 100/125] FeatureToggles: Remove feedbackButton unused toggle (#114222) --- packages/grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/services/featuremgmt/registry.go | 7 ------- pkg/services/featuremgmt/toggles_gen.csv | 1 - pkg/services/featuremgmt/toggles_gen.go | 4 ---- pkg/services/featuremgmt/toggles_gen.json | 3 ++- 5 files changed, 2 insertions(+), 17 deletions(-) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b9afd13cec0..acb3b1796de 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -748,10 +748,6 @@ export interface FeatureToggles { */ alertingNotificationsStepMode?: boolean; /** - * Enables a button to send feedback from the Grafana UI - */ - feedbackButton?: boolean; - /** * Enable unified storage search UI */ unifiedStorageSearchUI?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 68e88830be4..bc4d4c282ee 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1298,13 +1298,6 @@ var ( FrontendOnly: true, Expression: "true", }, - { - Name: "feedbackButton", - Description: "Enables a button to send feedback from the Grafana UI", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - HideFromDocs: true, - }, { Name: "unifiedStorageSearchUI", Description: "Enable unified storage search UI", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index e0f2bec13bc..8a874135d00 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -169,7 +169,6 @@ alertingEnrichmentPerRule,experimental,@grafana/alerting-squad,false,false,false alertingEnrichmentAssistantInvestigations,experimental,@grafana/alerting-squad,false,false,false alertingAIAnalyzeCentralStateHistory,experimental,@grafana/alerting-squad,false,false,false alertingNotificationsStepMode,GA,@grafana/alerting-squad,false,false,true -feedbackButton,experimental,@grafana/grafana-operator-experience-squad,false,false,false unifiedStorageSearchUI,experimental,@grafana/search-and-storage,false,false,false elasticsearchCrossClusterSearch,GA,@grafana/partner-datasources,false,false,false unifiedHistory,experimental,@grafana/grafana-search-navigate-organise,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 9fb1c573406..f6c70624e8d 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -687,10 +687,6 @@ const ( // Enables simplified step mode in the notifications section FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode" - // FlagFeedbackButton - // Enables a button to send feedback from the Grafana UI - FlagFeedbackButton = "feedbackButton" - // FlagUnifiedStorageSearchUI // Enable unified storage search UI FlagUnifiedStorageSearchUI = "unifiedStorageSearchUI" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index daf3c48c40c..44ed33ad19c 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1731,7 +1731,8 @@ "metadata": { "name": "feedbackButton", "resourceVersion": "1753448760331", - "creationTimestamp": "2024-12-02T17:08:15Z" + "creationTimestamp": "2024-12-02T17:08:15Z", + "deletionTimestamp": "2025-11-20T09:53:52Z" }, "spec": { "description": "Enables a button to send feedback from the Grafana UI", From 3c0d5745fe768369fb4ac9f07de652ad55f0f392 Mon Sep 17 00:00:00 2001 From: Charandas <542168+charandas@users.noreply.github.com> Date: Thu, 20 Nov 2025 02:23:36 -0800 Subject: [PATCH 101/125] chore: remove remaining references to singular namespace (#114208) --- pkg/apiserver/endpoints/filters/path_rewriter_test.go | 8 ++++---- .../apis/provisioning/resources/repository_test.go | 4 ++-- .../apiserver/endpoints/request/namespace_test.go | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/pkg/apiserver/endpoints/filters/path_rewriter_test.go b/pkg/apiserver/endpoints/filters/path_rewriter_test.go index 2340c3ff1f8..d8d8278ccf7 100644 --- a/pkg/apiserver/endpoints/filters/path_rewriter_test.go +++ b/pkg/apiserver/endpoints/filters/path_rewriter_test.go @@ -28,20 +28,20 @@ func Test_WithPathRewriters(t *testing.T) { handler := WithPathRewriters(mockHandler, rewriters) t.Run("should rewrite path", func(t *testing.T) { - req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stack-1234/query/blah", nil) + req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stacks-1234/query/blah", nil) assert.NoError(t, err) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "/apis/scope.grafana.app/namespaces/stack-1234/query", rr.Body.String()) + assert.Equal(t, "/apis/scope.grafana.app/namespaces/stacks-1234/query", rr.Body.String()) }) t.Run("should ignore requests that don't match", func(t *testing.T) { - req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stack-1234/scopes/1", nil) + req, err := http.NewRequest("GET", "/apis/scope.grafana.app/namespaces/stacks-1234/scopes/1", nil) assert.NoError(t, err) rr := httptest.NewRecorder() handler.ServeHTTP(rr, req) assert.Equal(t, http.StatusOK, rr.Code) - assert.Equal(t, "/apis/scope.grafana.app/namespaces/stack-1234/scopes/1", rr.Body.String()) + assert.Equal(t, "/apis/scope.grafana.app/namespaces/stacks-1234/scopes/1", rr.Body.String()) }) } diff --git a/pkg/registry/apis/provisioning/resources/repository_test.go b/pkg/registry/apis/provisioning/resources/repository_test.go index 5240d9d2758..cf845ba7440 100644 --- a/pkg/registry/apis/provisioning/resources/repository_test.go +++ b/pkg/registry/apis/provisioning/resources/repository_test.go @@ -536,7 +536,7 @@ func TestCheckResourceOwnership(t *testing.T) { "name": "test-resource", "annotations": map[string]interface{}{ utils.AnnoKeyManagerKind: "terraform", - utils.AnnoKeyManagerIdentity: "tf-stack-1", + utils.AnnoKeyManagerIdentity: "tf-stacks-1", }, }, }, @@ -546,7 +546,7 @@ func TestCheckResourceOwnership(t *testing.T) { Identity: "repo-1", }, expectError: true, - expectedMessage: "resource 'test-resource' is managed by terraform 'tf-stack-1' and cannot be modified by repo 'repo-1'", + expectedMessage: "resource 'test-resource' is managed by terraform 'tf-stacks-1' and cannot be modified by repo 'repo-1'", }, } diff --git a/pkg/services/apiserver/endpoints/request/namespace_test.go b/pkg/services/apiserver/endpoints/request/namespace_test.go index 6c565d43e19..47c75fa2e77 100644 --- a/pkg/services/apiserver/endpoints/request/namespace_test.go +++ b/pkg/services/apiserver/endpoints/request/namespace_test.go @@ -26,7 +26,7 @@ func TestNamespaceMapper(t *testing.T) { orgId: 123, expected: "org-123", }, - // an invalid use-case, but just documenting that it's handled as stack-0 + // an invalid use-case, but just documenting that it's handled as stacks-0 // this currently prevents the need to have the Mapper return (mapped, err) instead of just mapped. // err checking is avoided for now to keep the usage fluent { From 6e9a33e712606dbf73fff2d2b86bfc7169af0c0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Thu, 20 Nov 2025 12:01:21 +0100 Subject: [PATCH 102/125] Dynamic Dashboards: Add more tracking (#114098) --- .../edit-pane/VizPanelEditableElement.tsx | 8 +++++- .../scene/PanelMenuBehavior.tsx | 9 +++++++ .../scene/UnconfiguredPanel.tsx | 2 ++ .../DashboardSceneSerializer.test.ts | 7 ++++- .../serialization/DashboardSceneSerializer.ts | 8 ++++++ .../dashboard-scene/utils/interactions.ts | 26 +++++++++++++++++-- .../dashboard-scene/utils/tracking.test.ts | 5 ++++ .../dashboard-scene/utils/tracking.ts | 11 +++++++- .../SaveDashboard/useDashboardSave.tsx | 8 +++++- .../app/features/dashboard/utils/tracking.ts | 5 +++- 10 files changed, 82 insertions(+), 7 deletions(-) diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 200357fa881..39ed9beaa52 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -21,6 +21,7 @@ import { BulkActionElement } from '../scene/types/BulkActionElement'; import { isDashboardLayoutItem } from '../scene/types/DashboardLayoutItem'; import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; +import { DashboardInteractions } from '../utils/interactions'; import { getDashboardSceneFor, getPanelIdForVizPanel } from '../utils/utils'; import { MultiSelectedVizPanelsEditableElement } from './MultiSelectedVizPanelsEditableElement'; @@ -95,6 +96,7 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc public useEditPaneOptions = useEditPaneOptions.bind(this); public onDelete() { + DashboardInteractions.panelActionClicked('duplicate', getPanelIdForVizPanel(this.panel), 'edit_pane'); const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel); layout.removePanel?.(this.panel); } @@ -116,11 +118,13 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc } public onDuplicate() { + DashboardInteractions.panelActionClicked('duplicate', getPanelIdForVizPanel(this.panel), 'edit_pane'); const layout = dashboardSceneGraph.getLayoutManagerFor(this.panel); layout.duplicatePanel?.(this.panel); } public onCopy() { + DashboardInteractions.panelActionClicked('copy', getPanelIdForVizPanel(this.panel), 'edit_pane'); const dashboard = getDashboardSceneFor(this.panel); dashboard.copyPanel(this.panel); } @@ -147,7 +151,9 @@ const OpenPanelEditViz = ({ panel }: OpenPanelEditVizProps) => { + + + )} + {openPane === 'outline' && ( + + togglePane('')} /> + + )} + {openPane === 'add' && ( + + togglePane('')} /> + + )} + + togglePane('add')} + /> + + togglePane('settings')} + /> + togglePane('outline')} + /> + + + + + +

+ + ); +}; + +export const VerticalTabs: StoryFn = (args) => { + const [openPane, setOpenPane] = useState('queries'); + + const togglePane = (pane: string) => { + setOpenPane(pane); + }; + + const containerStyle = css({ + flexGrow: 1, + height: '600px', + display: 'flex', + flexDirection: 'column', + position: 'relative', + overflow: 'hidden', + gap: '16px', + }); + + const vizWrapper = css({ + height: '30%', + display: 'flex', + }); + + const contextValue = useSidebar({ + position: args.position, + tabsMode: true, + edgeMargin: 0, + }); + + return ( + +
+
{renderBox('Visualization')}
+ + {openPane === 'queries' && ( + + + + )} + {openPane === 'transformations' && ( + + + + )} + + togglePane('queries')} + /> + togglePane('transformations')} + /> + + + +
+
+ ); +}; + +function renderBox(label: string) { + return ( + + {label} + + ); +} + +export default meta; diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx new file mode 100644 index 00000000000..187fbc90c4b --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.test.tsx @@ -0,0 +1,51 @@ +import { render, screen } from '@testing-library/react'; +import React, { act } from 'react'; + +import { Sidebar, useSidebar } from './Sidebar'; + +describe('Sidebar', () => { + it('should render sidebar', async () => { + render(); + + act(() => screen.getByLabelText('Settings').click()); + + // Verify pane is open + expect(screen.getByTestId('sidebar-pane-header-title')).toBeInTheDocument(); + + act(() => screen.getByLabelText('Dock').click()); + + // Verify wrapper pushes content when docked + const wrapper = screen.getByTestId('sidebar-test-wrapper'); + expect(wrapper).toHaveStyle('padding-right: 352px'); + + // Close pane + act(() => screen.getByLabelText('Close').click()); + // Verify pane is closed + expect(screen.queryByTestId('sidebar-pane-header-title')).not.toBeInTheDocument(); + }); +}); + +function TestSetup() { + const [openPane, setOpenPane] = React.useState(''); + const contextValue = useSidebar({ + position: 'right', + hasOpenPane: openPane !== '', + }); + + return ( +
+ + {openPane === 'settings' && ( + + setOpenPane('')} /> + + )} + + setOpenPane('settings')} /> + + + + +
+ ); +} diff --git a/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx new file mode 100644 index 00000000000..2d9b407a1ad --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/Sidebar.tsx @@ -0,0 +1,167 @@ +import { css, cx } from '@emotion/css'; +import { ReactNode, useContext } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; + +import { SidebarButton } from './SidebarButton'; +import { SidebarPaneHeader } from './SidebarPaneHeader'; +import { SidebarResizer } from './SidebarResizer'; +import { SIDE_BAR_WIDTH_ICON_ONLY, SIDE_BAR_WIDTH_WITH_TEXT, SidebarContext, SidebarContextValue } from './useSidebar'; + +export interface Props { + children?: ReactNode; + contextValue: SidebarContextValue; +} + +export function SidebarComp({ children, contextValue }: Props) { + const styles = useStyles2(getStyles); + const theme = useTheme2(); + const { isDocked, position, tabsMode, hasOpenPane, edgeMargin, bottomMargin } = contextValue; + + const className = cx({ + [styles.container]: true, + [styles.undockedPaneOpen]: hasOpenPane && !isDocked, + [styles.containerLeft]: position === 'left', + [styles.containerTabsMode]: tabsMode, + }); + + const style = { [position]: theme.spacing(edgeMargin), bottom: theme.spacing(bottomMargin) }; + + return ( + +
+ {!tabsMode && } + {children} +
+
+ ); +} + +export interface SiderbarToolbarProps { + children?: ReactNode; +} + +export function SiderbarToolbar({ children }: SiderbarToolbarProps) { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('Sidebar.Toolbar must be used within a Sidebar component'); + } + + return ( +
+ {children} +
+ {context.hasOpenPane && ( + + )} +
+ ); +} + +export function SidebarDivider() { + const styles = useStyles2(getStyles); + + return
; +} + +export interface SidebarOpenPaneProps { + children?: ReactNode; +} + +export function SidebarOpenPane({ children }: SidebarOpenPaneProps) { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('Sidebar.OpenPane must be used within a Sidebar component'); + } + + const className = cx(styles.openPane, context.position === 'right' ? styles.openPaneRight : styles.openPaneLeft); + + return ( +
+ {children} +
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + container: css({ + display: 'flex', + position: 'absolute', + flexDirection: 'row', + flex: '1 1 0', + border: `1px solid ${theme.colors.border.weak}`, + background: theme.colors.background.primary, + borderRadius: theme.shape.radius.default, + zIndex: theme.zIndex.navbarFixed, + bottom: 0, + top: 0, + right: 0, + }), + containerTabsMode: css({ + position: 'relative', + }), + containerLeft: css({ + right: 'unset', + flexDirection: 'row-reverse', + left: 0, + borderRadius: theme.shape.radius.default, + }), + undockedPaneOpen: css({ + boxShadow: theme.shadows.z3, + }), + toolbar: css({ + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + padding: theme.spacing(1, 0), + flexGrow: 0, + gap: theme.spacing(1), + overflow: 'hidden', + width: theme.spacing(SIDE_BAR_WIDTH_WITH_TEXT), + }), + toolbarIconsOnly: css({ + width: theme.spacing(SIDE_BAR_WIDTH_ICON_ONLY), + }), + divider: css({ + height: '1px', + background: theme.colors.border.weak, + width: '100%', + }), + flexGrow: css({ + flexGrow: 1, + }), + openPane: css({ + width: '280px', + flexGrow: 1, + paddingBottom: theme.spacing(2), + }), + openPaneRight: css({ + borderRight: `1px solid ${theme.colors.border.weak}`, + }), + openPaneLeft: css({ + borderLeft: `1px solid ${theme.colors.border.weak}`, + }), + }; +}; + +export const Sidebar = Object.assign(SidebarComp, { + Toolbar: SiderbarToolbar, + Button: SidebarButton, + OpenPane: SidebarOpenPane, + Divider: SidebarDivider, + PaneHeader: SidebarPaneHeader, +}); + +export { type SidebarPosition, type SidebarContextValue, useSidebar } from './useSidebar'; diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx new file mode 100644 index 00000000000..f436d9630f6 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/SidebarButton.tsx @@ -0,0 +1,159 @@ +import { css, cx } from '@emotion/css'; +import { useContext } from 'react'; + +import { GrafanaTheme2, IconName, isIconName } from '@grafana/data'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { getFocusStyles, getMouseFocusStyles } from '../../themes/mixins'; +import { getActiveButtonStyles } from '../Button/Button'; +import { Icon } from '../Icon/Icon'; +import { Tooltip } from '../Tooltip/Tooltip'; + +import { SidebarContext } from './useSidebar'; + +export interface Props { + icon: IconName; + active?: boolean; + onClick?: () => void; + title: string; + tooltip?: string; +} + +export function SidebarButton({ icon, active, onClick, title, tooltip }: Props) { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + + if (!context) { + throw new Error('Sidebar.Button must be used within a Sidebar component'); + } + + const buttonClass = cx( + styles.button, + context.compact && styles.compact, + active && styles.active, + context.position === 'left' && styles.leftButton + ); + + return ( + + + + ); +} + +function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) { + if (!icon) { + return null; + } + + if (isIconName(icon)) { + return ; + } + + return icon; +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + button: css({ + label: 'toolbar-button', + position: 'relative', + display: 'flex', + flexDirection: 'column', + minHeight: theme.spacing(theme.components.height.md), + padding: theme.spacing(0, 1), + width: '100%', + overflow: 'hidden', + // borderRadius: theme.shape.radius.sm, + lineHeight: `${theme.components.height.md * theme.spacing.gridSize - 2}px`, + fontWeight: theme.typography.fontWeightMedium, + color: theme.colors.text.secondary, + background: 'transparent', + border: `none`, + + [theme.transitions.handleMotion('no-preference', 'reduce')]: { + transition: theme.transitions.create(['background-color', 'border-color', 'color'], { + duration: theme.transitions.duration.short, + }), + }, + + '&:focus, &:focus-visible': { + ...getFocusStyles(theme), + zIndex: 1, + }, + + '&:focus:not(:focus-visible)': getMouseFocusStyles(theme), + + '&[disabled], &:disabled': { + cursor: 'not-allowed', + opacity: theme.colors.action.disabledOpacity, + background: theme.colors.action.disabledBackground, + boxShadow: 'none', + + '&:hover': { + color: theme.colors.text.disabled, + background: theme.colors.action.disabledBackground, + boxShadow: 'none', + }, + }, + + '&:hover, &:focus-visible': { + color: theme.colors.text.primary, + background: theme.colors.action.hover, + }, + + '&:active': { + ...getActiveButtonStyles(theme.colors.secondary, 'solid'), + }, + }), + compact: css({ + height: theme.spacing(theme.components.height.md), + padding: theme.spacing(0, 1), + width: theme.spacing(5), + }), + active: css({ + color: theme.colors.text.primary, + background: theme.colors.action.selected, + '&::before': { + display: 'block', + content: '" "', + position: 'absolute', + right: 0, + top: 0, + height: '100%', + width: '2px', + borderRadius: theme.shape.radius.default, + backgroundImage: theme.colors.gradients.brandVertical, + }, + }), + buttonWrapper: css({ + display: 'flex', + flexDirection: 'column', + width: '100%', + whiteSpace: 'nowrap', + }), + iconWrapper: css({}), + title: css({ + fontSize: theme.typography.bodySmall.fontSize, + color: theme.colors.text.secondary, + textOverflow: 'ellipsis', + overflow: 'hidden', + textAlign: 'center', + whiteSpace: 'nowrap', + }), + titleActive: css({ + color: theme.colors.text.primary, + }), + leftButton: css({ + '&::before': { + right: 'unset', + left: 0, + top: 0, + height: '100%', + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx new file mode 100644 index 00000000000..75393777879 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/SidebarPaneHeader.tsx @@ -0,0 +1,55 @@ +import { css } from '@emotion/css'; +import { ReactNode } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { t } from '@grafana/i18n'; + +import { useStyles2 } from '../../themes/ThemeContext'; +import { IconButton } from '../IconButton/IconButton'; +import { Text } from '../Text/Text'; + +export interface Props { + children?: ReactNode; + title: string; + onClose?: () => void; +} + +export function SidebarPaneHeader({ children, onClose, title }: Props) { + const styles = useStyles2(getStyles); + + return ( +
+ {onClose && ( + + )} + + {title} + +
+ {children} +
+ ); +} + +export const getStyles = (theme: GrafanaTheme2) => { + return { + wrapper: css({ + display: 'flex', + alignItems: 'center', + padding: theme.spacing(1.5), + height: theme.spacing(6), + gap: theme.spacing(1), + borderBottom: `1px solid ${theme.colors.border.weak}`, + }), + flexGrow: css({ + flexGrow: 1, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx b/packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx new file mode 100644 index 00000000000..7a243f00b54 --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/SidebarResizer.tsx @@ -0,0 +1,93 @@ +import { css } from '@emotion/css'; +import { useCallback, useContext, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; + +import { useStyles2 } from '../../themes/ThemeContext'; + +import { SidebarContext } from './useSidebar'; + +export function SidebarResizer() { + const styles = useStyles2(getStyles); + const context = useContext(SidebarContext); + const resizerRef = useRef(null); + const dragStart = useRef(null); + + if (!context) { + throw new Error('Sidebar.Resizer must be used within a Sidebar component'); + } + + const { onResize, position } = context; + + const onPointerDown = useCallback( + (e: React.PointerEvent) => { + if (resizerRef.current === null) { + return; + } + + resizerRef.current.setPointerCapture(e.pointerId); + dragStart.current = e.clientX; + }, + [resizerRef] + ); + + const onPointerMove = useCallback( + (e: React.PointerEvent) => { + if (dragStart.current === null) { + return; + } + + const diff = e.clientX - dragStart.current; + dragStart.current = e.clientX; + + onResize(position === 'right' ? -diff : diff); + }, + [dragStart, onResize, position] + ); + + const onPointerUp = useCallback( + (e: React.PointerEvent) => { + dragStart.current = null; + }, + [dragStart] + ); + + return ( +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + right: css({ + position: 'absolute', + width: theme.spacing.gridSize, + left: -theme.spacing.gridSize, + top: theme.shape.radius.default, + bottom: theme.shape.radius.default, + cursor: 'col-resize', + zIndex: 1, + '&:hover': { + borderRight: `1px solid ${theme.colors.primary.border}`, + }, + }), + left: css({ + position: 'absolute', + width: theme.spacing.gridSize, + right: -theme.spacing.gridSize, + top: theme.shape.radius.default, + bottom: theme.shape.radius.default, + cursor: 'col-resize', + zIndex: 1, + '&:hover': { + borderLeft: `1px solid ${theme.colors.primary.border}`, + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx new file mode 100644 index 00000000000..03d04b2f81a --- /dev/null +++ b/packages/grafana-ui/src/components/Sidebar/useSidebar.tsx @@ -0,0 +1,113 @@ +import { clamp } from 'lodash'; +import React, { useCallback } from 'react'; + +import { useTheme2 } from '../../themes/ThemeContext'; + +export type SidebarPosition = 'left' | 'right'; + +export interface SidebarContextValue { + isDocked: boolean; + position: SidebarPosition; + compact: boolean; + hasOpenPane?: boolean; + tabsMode?: boolean; + outerWrapperProps: React.HTMLAttributes; + paneWidth: number; + bottomMargin: number; + edgeMargin: number; + contentMargin: number; + onDockChange: () => void; + onResize: (diff: number) => void; +} + +export const SidebarContext: React.Context = React.createContext< + SidebarContextValue | undefined +>(undefined); + +export interface UseSideBarOptions { + hasOpenPane?: boolean; + position?: SidebarPosition; + tabsMode?: boolean; + compactDefault?: boolean; + /** defaults to 2 grid units (16px) */ + bottomMargin?: number; + /** defaults to 2 grid units (16px) */ + edgeMargin?: number; + /** defaults to 2 grid units (16px) */ + contentMargin?: number; +} + +export const SIDE_BAR_WIDTH_ICON_ONLY = 5; +export const SIDE_BAR_WIDTH_WITH_TEXT = 8; + +export function useSidebar({ + hasOpenPane, + position = 'right', + tabsMode, + compactDefault = true, + bottomMargin = 2, + edgeMargin = 2, + contentMargin = 2, +}: UseSideBarOptions): SidebarContextValue { + const theme = useTheme2(); + const [isDocked, setIsDocked] = React.useState(false); + const [paneWidth, setPaneWidth] = React.useState(280); + const [compact, setCompact] = React.useState(compactDefault); + // Used to accumulate drag distance to know when to change compact mode + const [_, setCompactDrag] = React.useState(0); + + const onDockChange = useCallback(() => setIsDocked((prev) => !prev), []); + + const prop = position === 'right' ? 'paddingRight' : 'paddingLeft'; + const toolbarWidth = + ((compact ? SIDE_BAR_WIDTH_ICON_ONLY : SIDE_BAR_WIDTH_WITH_TEXT) + edgeMargin + contentMargin) * + theme.spacing.gridSize; + + const outerWrapperProps = { + style: { + [prop]: isDocked && hasOpenPane ? paneWidth + toolbarWidth : toolbarWidth, + }, + }; + + const onResize = useCallback( + (diff: number) => { + setPaneWidth((prevWidth) => { + // If no pane is open we use the resize action to toggle compact mode (button text visibility) + if (!hasOpenPane) { + setCompactDrag((prevDrag) => { + const newDrag = prevDrag + diff; + if (newDrag < -20 && !compact) { + setCompact(true); + return 0; + } else if (newDrag > 20 && compact) { + setCompact(false); + return 0; + } + + return newDrag; + }); + + return prevWidth; + } + + return clamp(prevWidth + diff, 100, 500); + }); + }, + [hasOpenPane, compact] + ); + + return { + isDocked, + onDockChange, + onResize, + outerWrapperProps, + position, + compact, + hasOpenPane, + tabsMode, + paneWidth, + edgeMargin, + bottomMargin, + contentMargin, + }; +} diff --git a/packages/grafana-ui/src/index.ts b/packages/grafana-ui/src/index.ts index b46ce1d33bb..e567e3dd561 100644 --- a/packages/grafana-ui/src/index.ts +++ b/packages/grafana-ui/src/index.ts @@ -466,6 +466,7 @@ export { RunnerPlugin } from './slate-plugins/runner'; export { SelectionShortcutsPlugin } from './slate-plugins/selection_shortcuts'; export { SlatePrism, type Token } from './slate-plugins/slate-prism'; export { SuggestionsPlugin } from './slate-plugins/suggestions'; +export { Sidebar, useSidebar, type SidebarPosition, type SidebarContextValue } from './components/Sidebar/Sidebar'; // @deprecated import from @grafana/schema export { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 4492a771832..28512178dd7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -8995,6 +8995,11 @@ "series-color-picker-popover": { "y-axis-usage": "Use right y-axis" }, + "sidebar": { + "close": "Close", + "dock": "Dock", + "undock": "Undock" + }, "slider": { "drag-handle-aria-label": "Use arrow keys to change the value" }, From e9116cd23c4fc8d83542e062033c4ada561bd534 Mon Sep 17 00:00:00 2001 From: Jay Clifford <45856600+Jayclifford345@users.noreply.github.com> Date: Thu, 20 Nov 2025 11:27:04 +0000 Subject: [PATCH 107/125] chore(testid): added missing testid to add datasource button (#114169) * added missing testid * added to pages.ts * added e2e to datasource button * fixed lint --- packages/grafana-e2e-selectors/src/selectors/pages.ts | 3 +++ .../datasources/components/DataSourceAddButton.tsx | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/grafana-e2e-selectors/src/selectors/pages.ts b/packages/grafana-e2e-selectors/src/selectors/pages.ts index 7716ccc1436..96d033d5b14 100644 --- a/packages/grafana-e2e-selectors/src/selectors/pages.ts +++ b/packages/grafana-e2e-selectors/src/selectors/pages.ts @@ -85,6 +85,9 @@ export const versionedPages = { dataSources: { [MIN_GRAFANA_VERSION]: (dataSourceName: string) => `Data source list item ${dataSourceName}`, }, + dataSourceAddButton: { + '12.4.0': 'data-testid data-source-add-button', + }, }, EditDataSource: { url: { diff --git a/public/app/features/datasources/components/DataSourceAddButton.tsx b/public/app/features/datasources/components/DataSourceAddButton.tsx index fa1da6b9953..f765188b50d 100644 --- a/public/app/features/datasources/components/DataSourceAddButton.tsx +++ b/public/app/features/datasources/components/DataSourceAddButton.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; +import { Pages } from '@grafana/e2e-selectors'; import { Trans } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { LinkButton } from '@grafana/ui'; @@ -16,7 +17,12 @@ export function DataSourceAddButton(): JSX.Element | null { }, []); return canCreateDataSource ? ( - + Add new data source ) : null; From b2f022fb5e2bab8a18f51a2bf219a4d8c394a0c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ida=20=C5=A0tambuk?= Date: Thu, 20 Nov 2025 12:37:12 +0100 Subject: [PATCH 108/125] Dynamic Dashboards: Make outline open by default (#114146) --- e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts | 2 -- .../dashboards-edit-custom-variables.spec.ts | 1 - e2e-playwright/dashboard-new-layouts/utils.ts | 1 - .../dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx | 2 +- .../features/dashboard-scene/scene/NavToolbarActions.test.tsx | 2 +- .../scene/new-toolbar/actions/EditDashboardSwitch.test.tsx | 2 +- .../new-toolbar/actions/MakeDashboardEditableButton.test.tsx | 2 +- public/app/features/dashboard-scene/utils/tracking.ts | 2 +- 8 files changed, 5 insertions(+), 9 deletions(-) diff --git a/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts index 49d55f8ca04..ef38379f006 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboard-outline.spec.ts @@ -22,8 +22,6 @@ test.describe( await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); - await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click(); - // Should be able to click Variables item in outline to see add variable button await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click(); await expect( diff --git a/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts b/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts index ce5465c1e33..24c61e5e960 100644 --- a/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts +++ b/e2e-playwright/dashboard-new-layouts/dashboards-edit-custom-variables.spec.ts @@ -199,7 +199,6 @@ test.describe( .click(); // Open the modal editor in the side pane - await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.node('Variables')).click(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('foo')).click(); await openModal(dashboardPage, selectors); diff --git a/e2e-playwright/dashboard-new-layouts/utils.ts b/e2e-playwright/dashboard-new-layouts/utils.ts index c59f782b37a..da629ac8141 100644 --- a/e2e-playwright/dashboard-new-layouts/utils.ts +++ b/e2e-playwright/dashboard-new-layouts/utils.ts @@ -48,7 +48,6 @@ export const flows = { }, async newEditPaneVariableClick(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) { await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click(); - await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.section).click(); await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click(); await dashboardPage .getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx index c2e896fce83..9ca7fbab86e 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -35,7 +35,7 @@ export function DashboardEditPaneRenderer({ editPane, isEditPaneCollapsed, onTog const isNewElement = selection?.isNewElement() ?? false; const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage( 'grafana.dashboard.edit-pane.outline.collapsed', - true + false ); const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4); diff --git a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx index e42df99d74a..e276bf80e46 100644 --- a/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx +++ b/public/app/features/dashboard-scene/scene/NavToolbarActions.test.tsx @@ -162,7 +162,7 @@ describe('NavToolbarActions', () => { await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton)); expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ dashboardUid: 'dash-1', - outlineExpanded: false, + outlineExpanded: true, }); }); diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.test.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.test.tsx index 583a3da8818..e941c98cadb 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.test.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/EditDashboardSwitch.test.tsx @@ -58,7 +58,7 @@ describe('EditDashboardSwitch', () => { it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is undefined', async () => { render(); await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton)); - expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false }); + expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: true }); }); it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is false', async () => { diff --git a/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.test.tsx b/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.test.tsx index 05ce93adde1..87788a2b187 100644 --- a/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.test.tsx +++ b/public/app/features/dashboard-scene/scene/new-toolbar/actions/MakeDashboardEditableButton.test.tsx @@ -59,7 +59,7 @@ describe('MakeDashboardEditableButton', () => { it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is undefined', async () => { render(); await userEvent.click(await screen.findByTestId(selectors.components.NavToolbar.editDashboard.editButton)); - expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: false }); + expect(DashboardInteractions.editButtonClicked).toHaveBeenCalledWith({ outlineExpanded: true }); }); it('should call DashboardInteractions.editButtonClicked with outlineExpanded:true if grafana.dashboard.edit-pane.outline.collapsed is false', async () => { diff --git a/public/app/features/dashboard-scene/utils/tracking.ts b/public/app/features/dashboard-scene/utils/tracking.ts index fde95ce2ead..638f5bd781b 100644 --- a/public/app/features/dashboard-scene/utils/tracking.ts +++ b/public/app/features/dashboard-scene/utils/tracking.ts @@ -46,7 +46,7 @@ export const trackDeleteDashboardElement = (element: EditableDashboardElementInf export const trackDashboardSceneEditButtonClicked = (dashboardUid?: string) => { DashboardInteractions.editButtonClicked({ - outlineExpanded: !store.getBool('grafana.dashboard.edit-pane.outline.collapsed', true), + outlineExpanded: !store.getBool('grafana.dashboard.edit-pane.outline.collapsed', false), dashboardUid, }); }; From fbadbf385d597308636e5109a4f01702e196db12 Mon Sep 17 00:00:00 2001 From: Mihai Doarna Date: Thu, 20 Nov 2025 13:44:15 +0200 Subject: [PATCH 109/125] IAM: Add validation for new tokens with no expiration when token_expiration_day_limit is set (#114166) * do not create sa with no expiration when day limit is set * disable No expiration option if day limit is set * make i18n-extract * run prettier * address feedback --- pkg/services/serviceaccounts/api/token.go | 4 ++ .../serviceaccounts/api/token_test.go | 63 ++++++++++++++++--- .../components/CreateTokenModal.tsx | 51 +++++++++++---- public/locales/en-US/grafana.json | 3 + 4 files changed, 100 insertions(+), 21 deletions(-) diff --git a/pkg/services/serviceaccounts/api/token.go b/pkg/services/serviceaccounts/api/token.go index dcfb82b10f5..620d278ee1e 100644 --- a/pkg/services/serviceaccounts/api/token.go +++ b/pkg/services/serviceaccounts/api/token.go @@ -157,6 +157,10 @@ func (api *ServiceAccountsAPI) CreateToken(c *contextmodel.ReqContext) response. } if api.cfg.SATokenExpirationDayLimit > 0 { + if cmd.SecondsToLive == 0 { + return response.Error(http.StatusBadRequest, "Cannot create token with no expiration date when service_accounts.token_expiration_day_limit is set", nil) + } + dayExpireLimit := time.Now().Add(time.Duration(api.cfg.SATokenExpirationDayLimit) * time.Hour * 24).Truncate(24 * time.Hour) expirationDate := time.Now().Add(time.Duration(cmd.SecondsToLive) * time.Second).Truncate(24 * time.Hour) if expirationDate.After(dayExpireLimit) { diff --git a/pkg/services/serviceaccounts/api/token_test.go b/pkg/services/serviceaccounts/api/token_test.go index 087d786b723..e6218c10638 100644 --- a/pkg/services/serviceaccounts/api/token_test.go +++ b/pkg/services/serviceaccounts/api/token_test.go @@ -60,14 +60,15 @@ func TestServiceAccountsAPI_ListTokens(t *testing.T) { func TestServiceAccountsAPI_CreateToken(t *testing.T) { type TestCase struct { - desc string - id int64 - body string - permissions []accesscontrol.Permission - tokenTTL int64 - expectedErr error - expectedAPIKey *apikey.APIKey - expectedCode int + desc string + id int64 + body string + permissions []accesscontrol.Permission + tokenTTL int64 + tokenExpirationDayLimit int + expectedErr error + expectedAPIKey *apikey.APIKey + expectedCode int } tests := []TestCase{ @@ -105,12 +106,58 @@ func TestServiceAccountsAPI_CreateToken(t *testing.T) { permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, expectedCode: http.StatusBadRequest, }, + { + desc: "should not be able to create token for service account if max ttl is configured and exceeds the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 11000}`, + tokenTTL: 10000, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedCode: http.StatusBadRequest, + }, + { + desc: "should be able to create token for service account if max ttl is configured and within the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 5000}`, + tokenTTL: 10000, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedAPIKey: &apikey.APIKey{}, + expectedCode: http.StatusOK, + }, + { + desc: "should not be able to create token for service account if token expiration day limit is configured but not set in body", + id: 1, + body: `{"name": "test"}`, + tokenTTL: -1, + tokenExpirationDayLimit: 30, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedCode: http.StatusBadRequest, + }, + { + desc: "should not be able to create token for service account if token expiration day limit is configured and exceeds the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 340000}`, // 340000 seconds is > 3 days + tokenTTL: -1, + tokenExpirationDayLimit: 3, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedCode: http.StatusBadRequest, + }, + { + desc: "should be able to create token for service account if token expiration day limit is configured and within the limit", + id: 1, + body: `{"name": "test", "secondsToLive": 250000}`, // 250000 seconds is almost 3 days + tokenTTL: -1, + tokenExpirationDayLimit: 3, + permissions: []accesscontrol.Permission{{Action: serviceaccounts.ActionWrite, Scope: "serviceaccounts:id:1"}}, + expectedAPIKey: &apikey.APIKey{}, + expectedCode: http.StatusOK, + }, } for _, tt := range tests { t.Run(tt.desc, func(t *testing.T) { server := setupTests(t, func(a *ServiceAccountsAPI) { a.cfg.ApiKeyMaxSecondsToLive = tt.tokenTTL + a.cfg.SATokenExpirationDayLimit = tt.tokenExpirationDayLimit a.service = &satests.FakeServiceAccountService{ ExpectedErr: tt.expectedErr, ExpectedAPIKey: tt.expectedAPIKey, diff --git a/public/app/features/serviceaccounts/components/CreateTokenModal.tsx b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx index a0daa852b09..fdad92e254e 100644 --- a/public/app/features/serviceaccounts/components/CreateTokenModal.tsx +++ b/public/app/features/serviceaccounts/components/CreateTokenModal.tsx @@ -16,10 +16,8 @@ import { useStyles2, } from '@grafana/ui'; -const EXPIRATION_OPTIONS = [ - { label: 'No expiration', value: false }, - { label: 'Set expiration date', value: true }, -]; +const NO_EXPIRATION_OPTION = 'no-expiration'; +const CUSTOM_EXPIRATION_OPTION = 'custom-expiration'; export type ServiceAccountToken = { name: string; @@ -44,11 +42,15 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT } else { maxExpirationDate.setDate(8640000000000000); } - const defaultExpirationDate = config.tokenExpirationDayLimit !== undefined && config.tokenExpirationDayLimit > 0; + + const isTokenExpirationDayLimitConfigured = + config.tokenExpirationDayLimit !== undefined && config.tokenExpirationDayLimit > 0; + + const defaultExpirationOption = isTokenExpirationDayLimitConfigured ? CUSTOM_EXPIRATION_OPTION : NO_EXPIRATION_OPTION; const [defaultTokenName, setDefaultTokenName] = useState(''); const [newTokenName, setNewTokenName] = useState(''); - const [isWithExpirationDate, setIsWithExpirationDate] = useState(defaultExpirationDate); + const [expirationOption, setExpirationOption] = useState(defaultExpirationOption); const [newTokenExpirationDate, setNewTokenExpirationDate] = useState(tomorrow); const [isExpirationDateValid, setIsExpirationDateValid] = useState(newTokenExpirationDate !== ''); const styles = useStyles2(getStyles); @@ -69,14 +71,15 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT const onGenerateToken = () => { onCreateToken({ name: newTokenName || defaultTokenName, - secondsToLive: isWithExpirationDate ? getSecondsToLive(newTokenExpirationDate) : undefined, + secondsToLive: + expirationOption === CUSTOM_EXPIRATION_OPTION ? getSecondsToLive(newTokenExpirationDate) : undefined, }); }; const onCloseInternal = () => { setNewTokenName(''); setDefaultTokenName(''); - setIsWithExpirationDate(defaultExpirationDate); + setExpirationOption(defaultExpirationOption); setNewTokenExpirationDate(tomorrow); setIsExpirationDateValid(newTokenExpirationDate !== ''); onClose(); @@ -84,6 +87,24 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT const modalTitle = !token ? 'Add service account token' : 'Service account token created'; + const getExpirationOptions = () => { + const noExpirationDescription = t( + 'serviceaccounts.create-token-modal.description-no-expiration-disabled', + 'Cannot create a token with no expiration date when token expiration day limit is configured' + ); + return [ + { + label: t('serviceaccounts.create-token-modal.label-no-expiration', 'No expiration'), + value: NO_EXPIRATION_OPTION, + description: isTokenExpirationDayLimitConfigured ? noExpirationDescription : undefined, + }, + { + label: t('serviceaccounts.create-token-modal.label-set-expiration-date', 'Set expiration date'), + value: CUSTOM_EXPIRATION_OPTION, + }, + ]; + }; + return ( {!token ? ( @@ -109,13 +130,14 @@ export const CreateTokenModal = ({ isOpen, token, serviceAccountLogin, onCreateT - {isWithExpirationDate && ( + {expirationOption === CUSTOM_EXPIRATION_OPTION && ( )} - diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 28512178dd7..d29fe101bd6 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -12543,11 +12543,14 @@ "copy-clipboard": "Copy to clipboard", "copy-to-clipboard-and-close": "Copy to clipboard and close", "description-name-to-easily-identify-the-token": "Name to easily identify the token", + "description-no-expiration-disabled": "Cannot create a token with no expiration date when token expiration day limit is configured", "description-token": "Copy the token now as you will not be able to see it again. Losing a token requires creating a new one.", "generate-token": "Generate token", "label-display-name": "Display name", "label-expiration": "Expiration", "label-expiration-date": "Expiration date", + "label-no-expiration": "No expiration", + "label-set-expiration-date": "Set expiration date", "label-token": "Token" }, "get-actions-cell": { From 7f52586c68355d31a304b466e50d62263265c409 Mon Sep 17 00:00:00 2001 From: Priyansh Gupta <120119805+priyansh3006@users.noreply.github.com> Date: Thu, 20 Nov 2025 17:49:54 +0530 Subject: [PATCH 110/125] Prometheus: Hide 'Kick start your query' button for existing queries (#113980) * Prometheus: Hide 'Kick start your query' button for existing queries * refactor: simplify query check logic in Prometheus editor Remove unnecessary useMemo and buildVisualQueryFromString logic. Just check query.expr directly to determine button visibility. * fix: format code with prettier --- .../components/PromQueryEditorSelector.tsx | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx index 8250bd91a68..dc0a5799259 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryEditorSelector.tsx @@ -118,16 +118,18 @@ export const PromQueryEditorSelector = memo((props) => { onAddQuery={onAddQuery} /> - + {!query.expr && ( + + )}
Date: Thu, 20 Nov 2025 12:50:38 +0000 Subject: [PATCH 111/125] deps(go): bump github.com/prometheus/common from 0.67.1 to 0.67.2 in /pkg/promlib (#113145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * deps(go): bump github.com/prometheus/common in /pkg/promlib Bumps [github.com/prometheus/common](https://github.com/prometheus/common) from 0.67.1 to 0.67.2. - [Release notes](https://github.com/prometheus/common/releases) - [Changelog](https://github.com/prometheus/common/blob/main/CHANGELOG.md) - [Commits](https://github.com/prometheus/common/compare/v0.67.1...v0.67.2) --- updated-dependencies: - dependency-name: github.com/prometheus/common dependency-version: 0.67.2 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] * deps(go): update github.com/prometheus/common to v0.67.3 --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Zoltán Bedi --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 ++-- apps/alerting/notifications/go.mod | 3 +-- apps/alerting/notifications/go.sum | 4 ++-- apps/alerting/rules/go.mod | 2 +- apps/alerting/rules/go.sum | 4 ++-- apps/annotation/go.mod | 2 +- apps/annotation/go.sum | 4 ++-- apps/collections/go.mod | 2 +- apps/collections/go.sum | 4 ++-- apps/correlations/go.mod | 2 +- apps/correlations/go.sum | 4 ++-- apps/dashboard/go.mod | 2 +- apps/dashboard/go.sum | 4 ++-- apps/example/go.mod | 2 +- apps/example/go.sum | 4 ++-- apps/folder/go.mod | 2 +- apps/folder/go.sum | 4 ++-- apps/investigations/go.mod | 2 +- apps/investigations/go.sum | 4 ++-- apps/logsdrilldown/go.mod | 2 +- apps/logsdrilldown/go.sum | 4 ++-- apps/playlist/go.mod | 2 +- apps/playlist/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- apps/preferences/go.mod | 2 +- apps/preferences/go.sum | 4 ++-- apps/provisioning/go.mod | 2 +- apps/provisioning/go.sum | 4 ++-- apps/secret/go.mod | 2 +- apps/secret/go.sum | 4 ++-- apps/shorturl/go.mod | 2 +- apps/shorturl/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- pkg/aggregator/go.mod | 3 +-- pkg/aggregator/go.sum | 4 ++-- pkg/apiserver/go.mod | 3 +-- pkg/apiserver/go.sum | 4 ++-- pkg/promlib/go.mod | 2 +- pkg/promlib/go.sum | 4 ++-- 42 files changed, 63 insertions(+), 66 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 1cffeb06464..9a3fabfd18c 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -232,7 +232,7 @@ require ( github.com/prometheus/alertmanager v0.28.0 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/common/sigv4 v0.1.0 // indirect github.com/prometheus/exporter-toolkit v0.14.0 // indirect github.com/prometheus/procfs v0.16.1 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index c681c3c257d..ef7adf779d1 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -1046,8 +1046,8 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= github.com/prometheus/common v0.29.0/go.mod h1:vu+V0TpY+O6vW9J44gczi3Ap/oXXR10b+M/gUGO4Hls= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= github.com/prometheus/exporter-toolkit v0.14.0 h1:NMlswfibpcZZ+H0sZBiTjrA3/aBFHkNZqE+iCj5EmRg= diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index 521bb0b586f..c29ea0b00a8 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -32,7 +32,6 @@ require ( github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/go-test/deep v1.1.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.7.0 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -60,7 +59,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 7a53f8de14c..b67c48676b8 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -140,8 +140,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1: github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= diff --git a/apps/alerting/rules/go.mod b/apps/alerting/rules/go.mod index c262035d973..17aa567f077 100644 --- a/apps/alerting/rules/go.mod +++ b/apps/alerting/rules/go.mod @@ -5,7 +5,7 @@ go 1.25.3 require ( github.com/grafana/grafana-app-sdk v0.48.2 github.com/grafana/grafana-app-sdk/logging v0.48.1 - github.com/prometheus/common v0.67.2 + github.com/prometheus/common v0.67.3 k8s.io/apimachinery v0.34.2 k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 ) diff --git a/apps/alerting/rules/go.sum b/apps/alerting/rules/go.sum index 26bd55d3b5d..7eadc30e628 100644 --- a/apps/alerting/rules/go.sum +++ b/apps/alerting/rules/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/annotation/go.mod b/apps/annotation/go.mod index 19ac2c8b88a..988575c7ce1 100644 --- a/apps/annotation/go.mod +++ b/apps/annotation/go.mod @@ -49,7 +49,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/annotation/go.sum b/apps/annotation/go.sum index 26bd55d3b5d..7eadc30e628 100644 --- a/apps/annotation/go.sum +++ b/apps/annotation/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/collections/go.mod b/apps/collections/go.mod index a3b927ec893..281a6579c67 100644 --- a/apps/collections/go.mod +++ b/apps/collections/go.mod @@ -41,7 +41,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect diff --git a/apps/collections/go.sum b/apps/collections/go.sum index 02827e1769d..8d576bfaa8b 100644 --- a/apps/collections/go.sum +++ b/apps/collections/go.sum @@ -85,8 +85,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/apps/correlations/go.mod b/apps/correlations/go.mod index 898b631516d..988f18b89d6 100644 --- a/apps/correlations/go.mod +++ b/apps/correlations/go.mod @@ -49,7 +49,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/correlations/go.sum b/apps/correlations/go.sum index 26bd55d3b5d..7eadc30e628 100644 --- a/apps/correlations/go.sum +++ b/apps/correlations/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/dashboard/go.mod b/apps/dashboard/go.mod index 5ed404c7c5d..9e98fe233ec 100644 --- a/apps/dashboard/go.mod +++ b/apps/dashboard/go.mod @@ -84,7 +84,7 @@ require ( github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/apps/dashboard/go.sum b/apps/dashboard/go.sum index 8d7185e4d78..b4158332679 100644 --- a/apps/dashboard/go.sum +++ b/apps/dashboard/go.sum @@ -202,8 +202,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/protocolbuffers/txtpbfmt v0.0.0-20241112170944-20d2c9ebc01d h1:HWfigq7lB31IeJL8iy7jkUmU/PG1Sr8jVGhS749dbUA= diff --git a/apps/example/go.mod b/apps/example/go.mod index a0d1dc37319..acb714107b3 100644 --- a/apps/example/go.mod +++ b/apps/example/go.mod @@ -56,7 +56,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/apps/example/go.sum b/apps/example/go.sum index 5a7f0692b24..00f1a6a2620 100644 --- a/apps/example/go.sum +++ b/apps/example/go.sum @@ -116,8 +116,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/folder/go.mod b/apps/folder/go.mod index 659e4e8a019..1c9249745a9 100644 --- a/apps/folder/go.mod +++ b/apps/folder/go.mod @@ -40,7 +40,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect diff --git a/apps/folder/go.sum b/apps/folder/go.sum index 56c5afd751f..944e0d69721 100644 --- a/apps/folder/go.sum +++ b/apps/folder/go.sum @@ -85,8 +85,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/apps/investigations/go.mod b/apps/investigations/go.mod index 51e8ea0c47b..d4f3685a2aa 100644 --- a/apps/investigations/go.mod +++ b/apps/investigations/go.mod @@ -50,7 +50,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/investigations/go.sum b/apps/investigations/go.sum index 26bd55d3b5d..7eadc30e628 100644 --- a/apps/investigations/go.sum +++ b/apps/investigations/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/logsdrilldown/go.mod b/apps/logsdrilldown/go.mod index 2056bb1f8f5..712d99405d0 100644 --- a/apps/logsdrilldown/go.mod +++ b/apps/logsdrilldown/go.mod @@ -51,7 +51,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/logsdrilldown/go.sum b/apps/logsdrilldown/go.sum index 26bd55d3b5d..7eadc30e628 100644 --- a/apps/logsdrilldown/go.sum +++ b/apps/logsdrilldown/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index 246be5289c2..8a254e980d1 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -51,7 +51,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index 26bd55d3b5d..7eadc30e628 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -104,8 +104,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 9a73a11cc62..b24c9260327 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -58,7 +58,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 55fa4dc09fc..b769fbe3916 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -116,8 +116,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/apps/preferences/go.mod b/apps/preferences/go.mod index 05292d2cdd5..cd702672de6 100644 --- a/apps/preferences/go.mod +++ b/apps/preferences/go.mod @@ -40,7 +40,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/testify v1.11.1 // indirect diff --git a/apps/preferences/go.sum b/apps/preferences/go.sum index 02827e1769d..8d576bfaa8b 100644 --- a/apps/preferences/go.sum +++ b/apps/preferences/go.sum @@ -85,8 +85,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index 06fa5976b01..9bb12d5ae4f 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -57,7 +57,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index 9a2b854aa09..78f06781b15 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -113,8 +113,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/apps/secret/go.mod b/apps/secret/go.mod index 09d3e5545f3..2ac03ea3f8b 100644 --- a/apps/secret/go.mod +++ b/apps/secret/go.mod @@ -45,7 +45,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/stretchr/objx v0.5.2 // indirect github.com/woodsbury/decimal128 v1.3.0 // indirect diff --git a/apps/secret/go.sum b/apps/secret/go.sum index 29563e7984d..74024f8df34 100644 --- a/apps/secret/go.sum +++ b/apps/secret/go.sum @@ -89,8 +89,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= diff --git a/apps/shorturl/go.mod b/apps/shorturl/go.mod index 6071ce04924..382cf8dd051 100644 --- a/apps/shorturl/go.mod +++ b/apps/shorturl/go.mod @@ -56,7 +56,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/puzpuzpuz/xsync/v2 v2.5.1 // indirect github.com/spf13/pflag v1.0.10 // indirect diff --git a/apps/shorturl/go.sum b/apps/shorturl/go.sum index f10e228919a..e31288da121 100644 --- a/apps/shorturl/go.sum +++ b/apps/shorturl/go.sum @@ -116,8 +116,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU= diff --git a/go.mod b/go.mod index 9e558b61411..23756c59ae0 100644 --- a/go.mod +++ b/go.mod @@ -157,7 +157,7 @@ require ( github.com/prometheus/alertmanager v0.28.0 // @grafana/alerting-backend github.com/prometheus/client_golang v1.23.2 // @grafana/alerting-backend github.com/prometheus/client_model v0.6.2 // @grafana/grafana-backend-group - github.com/prometheus/common v0.67.2 // @grafana/alerting-backend + github.com/prometheus/common v0.67.3 // @grafana/alerting-backend github.com/prometheus/prometheus v0.303.1 // @grafana/alerting-backend github.com/prometheus/sigv4 v0.1.2 // @grafana/alerting-backend github.com/puzpuzpuz/xsync/v4 v4.2.0 // @grafana/grafana-backend-group diff --git a/go.sum b/go.sum index 4cc52d323c4..61068fdef0b 100644 --- a/go.sum +++ b/go.sum @@ -2295,8 +2295,8 @@ github.com/prometheus/common v0.45.0/go.mod h1:YJmSTw9BoKxJplESWWxlbyttQR4uaEcGy github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc= github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/common/assets v0.2.0/go.mod h1:D17UVUE12bHbim7HzwUvtqm6gwBEaDQ0F+hIGbFbccI= github.com/prometheus/common/sigv4 v0.1.0 h1:qoVebwtwwEhS85Czm2dSROY5fTo2PAPEVdDeppTwGX4= github.com/prometheus/common/sigv4 v0.1.0/go.mod h1:2Jkxxk9yYvCkE5G1sQT7GuEXm57JrvHu9k5YwTjsNtI= diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index c1004c6a52f..e822e4b6a62 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -47,7 +47,6 @@ require ( github.com/goccy/go-json v0.10.5 // indirect github.com/gogo/googleapis v1.4.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/cel-go v0.26.1 // indirect @@ -90,7 +89,7 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/spf13/cobra v1.10.1 // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index 9be90d153dc..8ab04c12ddf 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -208,8 +208,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1: github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index 8e2ae9ae59b..6947fb20b67 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -39,7 +39,6 @@ require ( github.com/go-openapi/swag v0.23.0 // indirect github.com/go-openapi/swag/jsonname v0.25.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.3.0 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.7.0 // indirect @@ -64,7 +63,7 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.2 // indirect + github.com/prometheus/common v0.67.3 // indirect github.com/prometheus/procfs v0.16.1 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/stretchr/objx v0.5.2 // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 7a0b4d77cd7..49b4ab91e93 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -129,8 +129,8 @@ github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1: github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.0.0-20181126121408-4724e9255275/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.0.0-20181204211112-1dc9a6cbc91a/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= diff --git a/pkg/promlib/go.mod b/pkg/promlib/go.mod index 27e09f912a4..c60a20cee8d 100644 --- a/pkg/promlib/go.mod +++ b/pkg/promlib/go.mod @@ -8,7 +8,7 @@ require ( github.com/grafana/grafana/apps/scope v0.0.0-20251007093103-792853df9134 github.com/json-iterator/go v1.1.12 github.com/prometheus/client_golang v1.23.2 - github.com/prometheus/common v0.67.2 + github.com/prometheus/common v0.67.3 github.com/prometheus/prometheus v0.303.1 github.com/stretchr/testify v1.11.1 go.opentelemetry.io/otel v1.38.0 diff --git a/pkg/promlib/go.sum b/pkg/promlib/go.sum index f32307ff8b3..f8c29e54262 100644 --- a/pkg/promlib/go.sum +++ b/pkg/promlib/go.sum @@ -239,8 +239,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= -github.com/prometheus/common v0.67.2/go.mod h1:63W3KZb1JOKgcjlIr64WW/LvFGAqKPj0atm+knVGEko= +github.com/prometheus/common v0.67.3 h1:shd26MlnwTw5jksTDhC7rTQIteBxy+ZZDr3t7F2xN2Q= +github.com/prometheus/common v0.67.3/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/prometheus/prometheus v0.303.1 h1:He/2jRE6sB23Ew38AIoR1WRR3fCMgPlJA2E0obD2WSY= From 24d970e4b968c4a62da785c49141dd67a63537ff Mon Sep 17 00:00:00 2001 From: Konrad Lalik Date: Thu, 20 Nov 2025 13:58:13 +0100 Subject: [PATCH 112/125] Alerting: Remove stale filtering code (#114231) Remove the old filters file, update import paths --- .../alerting/unified/rule-list/FilterView.tsx | 2 +- .../rule-list/PaginatedDataSourceLoader.tsx | 2 +- .../rule-list/PaginatedGrafanaLoader.tsx | 2 +- .../unified/rule-list/hooks/filters.ts | 377 ------------------ .../hooks/useFilteredRulesIterator.test.ts | 85 ---- .../hooks/useFilteredRulesIterator.ts | 26 +- 6 files changed, 5 insertions(+), 489 deletions(-) delete mode 100644 public/app/features/alerting/unified/rule-list/hooks/filters.ts delete mode 100644 public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts diff --git a/public/app/features/alerting/unified/rule-list/FilterView.tsx b/public/app/features/alerting/unified/rule-list/FilterView.tsx index 8ac1e1a8c58..fb46822f0c3 100644 --- a/public/app/features/alerting/unified/rule-list/FilterView.tsx +++ b/public/app/features/alerting/unified/rule-list/FilterView.tsx @@ -16,11 +16,11 @@ import { GrafanaRuleListItem } from './GrafanaRuleListItem'; import LoadMoreHelper from './LoadMoreHelper'; import { UnknownRuleListItem } from './components/AlertRuleListItem'; import { AlertRuleListItemSkeleton } from './components/AlertRuleListItemLoader'; +import { hasClientSideFilters } from './hooks/grafanaFilter'; import { GrafanaRuleWithOrigin, PromRuleWithOrigin, RuleWithOrigin, - hasClientSideFilters, useFilteredRulesIteratorProvider, } from './hooks/useFilteredRulesIterator'; import { FRONTEND_LIST_PAGE_SIZE, getSearchApiGroupPageSize } from './paginationLimits'; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx index 2e2c2fda607..546d500ace2 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedDataSourceLoader.tsx @@ -14,7 +14,7 @@ import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; import { NoRulesFound } from './components/NoRulesFound'; -import { getDatasourceFilter } from './hooks/filters'; +import { getDatasourceFilter } from './hooks/datasourceFilter'; import { toIndividualRuleGroups, usePrometheusGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; diff --git a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx index d1835d29757..a39509608b3 100644 --- a/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx +++ b/public/app/features/alerting/unified/rule-list/PaginatedGrafanaLoader.tsx @@ -18,7 +18,7 @@ import { ListGroup } from './components/ListGroup'; import { ListSection } from './components/ListSection'; import { LoadMoreButton } from './components/LoadMoreButton'; import { NoRulesFound } from './components/NoRulesFound'; -import { getGrafanaFilter } from './hooks/filters'; +import { getGrafanaFilter } from './hooks/grafanaFilter'; import { toIndividualRuleGroups, useGrafanaGroupsGenerator } from './hooks/prometheusGroupsGenerator'; import { useLazyLoadPrometheusGroups } from './hooks/useLazyLoadPrometheusGroups'; import { FRONTED_GROUPED_PAGE_SIZE, getApiGroupPageSize } from './paginationLimits'; diff --git a/public/app/features/alerting/unified/rule-list/hooks/filters.ts b/public/app/features/alerting/unified/rule-list/hooks/filters.ts deleted file mode 100644 index 6ac4d668e9d..00000000000 --- a/public/app/features/alerting/unified/rule-list/hooks/filters.ts +++ /dev/null @@ -1,377 +0,0 @@ -import { attempt, compact, isString } from 'lodash'; -import memoize from 'micro-memoize'; - -import { Matcher } from 'app/plugins/datasource/alertmanager/types'; -import { PromRuleDTO, PromRuleGroupDTO } from 'app/types/unified-alerting-dto'; - -import { GrafanaPromRulesOptions } from '../../api/prometheusApi'; -import { shouldUseBackendFilters } from '../../featureToggles'; -import { RulesFilter } from '../../search/rulesSearchParser'; -import { labelsMatchMatchers } from '../../utils/alertmanager'; -import { Annotation } from '../../utils/constants'; -import { getDatasourceAPIUid } from '../../utils/datasource'; -import { fuzzyMatches } from '../../utils/fuzzySearch'; -import { parseMatcher } from '../../utils/matchers'; -import { isPluginProvidedRule, prometheusRuleType } from '../../utils/rules'; -import { normalizeHealth } from '../components/util'; - -type RuleFilterHandler = (rule: PromRuleDTO, filterState: RulesFilter) => boolean; -type GroupFilterHandler = ( - group: PromRuleGroupDTO, - filterState: Pick -) => boolean; - -type RuleFilterConfig = Record< - Exclude, - RuleFilterHandler | null ->; - -type GroupFilterConfig = Record, GroupFilterHandler | null>; - -export function getGrafanaFilter(filterState: RulesFilter) { - const normalizedFilterState = normalizeFilterState(filterState); - const useBackendFilters = shouldUseBackendFilters(); - - // Build title search for backend filtering - const titleSearch = buildTitleSearch(normalizedFilterState); - - const backendFilter: GrafanaPromRulesOptions = { - state: normalizedFilterState.ruleState ? [normalizedFilterState.ruleState] : [], - health: normalizedFilterState.ruleHealth ? [normalizedFilterState.ruleHealth] : [], - contactPoint: normalizedFilterState.contactPoint ?? undefined, - title: useBackendFilters ? titleSearch : undefined, - type: useBackendFilters ? normalizedFilterState.ruleType : undefined, - dashboardUid: useBackendFilters ? normalizedFilterState.dashboardUid : undefined, - searchGroupName: useBackendFilters ? normalizedFilterState.groupName : undefined, - }; - - const grafanaFilterProcessingConfig: RuleFilterConfig = { - // When backend filtering is enabled, these filters are handled by the backend - freeFormWords: useBackendFilters ? null : freeFormFilter, - ruleName: useBackendFilters ? null : ruleNameFilter, - ruleState: null, - ruleType: useBackendFilters ? null : ruleTypeFilter, - dataSourceNames: dataSourceNamesFilter, - labels: labelsFilter, - ruleHealth: null, - dashboardUid: useBackendFilters ? null : dashboardUidFilter, - plugins: pluginsFilter, - contactPoint: null, - }; - - const grafanaGroupFilterConfig: GroupFilterConfig = { - namespace: namespaceFilter, - groupName: useBackendFilters ? null : groupNameFilter, - }; - - return { - backendFilter, - frontendFilter: { - groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, grafanaGroupFilterConfig), - ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, grafanaFilterProcessingConfig), - }, - }; -} - -export function getDatasourceFilter(filterState: RulesFilter) { - const normalizedFilterState = normalizeFilterState(filterState); - - const dsRuleFilterConfig: RuleFilterConfig = { - freeFormWords: freeFormFilter, - ruleName: ruleNameFilter, - ruleState: ruleStateFilter, - ruleType: ruleTypeFilter, - dataSourceNames: dataSourceNamesFilter, - labels: labelsFilter, - ruleHealth: ruleHealthFilter, - dashboardUid: dashboardUidFilter, - plugins: pluginsFilter, - contactPoint: contactPointFilter, - }; - - const dsGroupFilterConfig: GroupFilterConfig = { - namespace: namespaceFilter, - groupName: groupNameFilter, - }; - - return { - groupMatches: (group: PromRuleGroupDTO) => groupMatches(group, normalizedFilterState, dsGroupFilterConfig), - ruleMatches: (rule: PromRuleDTO) => ruleMatches(rule, normalizedFilterState, dsRuleFilterConfig), - }; -} - -/** - * @returns True if the group matches the filter, false otherwise. Keeps rules intact - */ -function groupMatches( - group: PromRuleGroupDTO, - filterState: Pick, - filterConfig: GroupFilterConfig -): boolean { - if (filterConfig.namespace && filterConfig.namespace(group, filterState) === false) { - return false; - } - - if (filterConfig.groupName && filterConfig.groupName(group, filterState) === false) { - return false; - } - - return true; -} - -function namespaceFilter(group: PromRuleGroupDTO, filterState: Pick): boolean { - if (filterState.namespace && !fuzzyMatches(group.file, filterState.namespace)) { - return false; - } - - return true; -} - -function groupNameFilter(group: PromRuleGroupDTO, filterState: Pick): boolean { - if (filterState.groupName && !fuzzyMatches(group.name, filterState.groupName)) { - return false; - } - - return true; -} - -/** - * @returns True if the rule matches the filter, false otherwise - */ -function ruleMatches(rule: PromRuleDTO, filterState: RulesFilter, filterConfig: RuleFilterConfig) { - if (filterConfig.freeFormWords && filterConfig.freeFormWords(rule, filterState) === false) { - return false; - } - - if (filterConfig.ruleName && filterConfig.ruleName(rule, filterState) === false) { - return false; - } - - if (filterConfig.labels && filterConfig.labels(rule, filterState) === false) { - return false; - } - - if (filterConfig.ruleType && filterConfig.ruleType(rule, filterState) === false) { - return false; - } - - if (filterConfig.ruleState && filterConfig.ruleState(rule, filterState) === false) { - return false; - } - - if (filterConfig.ruleHealth && filterConfig.ruleHealth(rule, filterState) === false) { - return false; - } - - if (filterConfig.contactPoint && filterConfig.contactPoint(rule, filterState) === false) { - return false; - } - - if (filterConfig.dashboardUid && filterConfig.dashboardUid(rule, filterState) === false) { - return false; - } - - if (filterConfig.plugins && filterConfig.plugins(rule, filterState) === false) { - return false; - } - - if (filterConfig.dataSourceNames && filterConfig.dataSourceNames(rule, filterState) === false) { - return false; - } - - return true; -} - -function freeFormFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.freeFormWords.length > 0) { - const nameMatches = fuzzyMatches(rule.name, filterState.freeFormWords.join(' ')); - if (!nameMatches) { - return false; - } - } - - return true; -} - -function ruleNameFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.ruleName && !fuzzyMatches(rule.name, filterState.ruleName)) { - return false; - } - - return true; -} - -function labelsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.labels.length > 0) { - const matchers = compact(filterState.labels.map(looseParseMatcher)); - const doRuleLabelsMatchQuery = matchers.length > 0 && labelsMatchMatchers(rule.labels || {}, matchers); - - // Also check alerts if they exist - const doAlertsContainMatchingLabels = - matchers.length > 0 && - prometheusRuleType.alertingRule(rule) && - rule.alerts && - rule.alerts.some((alert) => labelsMatchMatchers(alert.labels || {}, matchers)); - - if (!doRuleLabelsMatchQuery && !doAlertsContainMatchingLabels) { - return false; - } - } - - return true; -} - -function ruleTypeFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.ruleType && rule.type !== filterState.ruleType) { - return false; - } - - return true; -} - -function ruleStateFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.ruleState) { - if (!prometheusRuleType.alertingRule(rule)) { - return false; - } - if (rule.state !== filterState.ruleState) { - return false; - } - } - - return true; -} - -function ruleHealthFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.ruleHealth && normalizeHealth(rule.health) !== filterState.ruleHealth) { - return false; - } - - return true; -} - -function contactPointFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.contactPoint) { - if (!prometheusRuleType.grafana.alertingRule(rule)) { - return false; - } - - if (!rule.notificationSettings) { - return false; - } - - if (filterState.contactPoint !== rule.notificationSettings.receiver) { - return false; - } - } - - return true; -} - -function dashboardUidFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - if (filterState.dashboardUid) { - if (!prometheusRuleType.alertingRule(rule)) { - return false; - } - - const dashboardAnnotation = rule.annotations?.[Annotation.dashboardUID]; - if (dashboardAnnotation !== filterState.dashboardUid) { - return false; - } - } - - return true; -} - -function pluginsFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - // Plugins filter - hide plugin-provided rules when set to 'hide' - if (filterState.plugins === 'hide' && isPluginProvidedRule(rule)) { - return false; - } - - return true; -} - -function dataSourceNamesFilter(rule: PromRuleDTO, filterState: RulesFilter): boolean { - // Note: We can't implement these filters from reduceGroups because they rely on rulerRule property - // which is not available in PromRuleDTO: - // - contactPoint filter - // - dataSourceNames filter - if (filterState.dataSourceNames.length > 0) { - const isGrafanaRule = prometheusRuleType.grafana.rule(rule); - if (isGrafanaRule) { - try { - const filterDatasourceUids = mapDataSourceNamesToUids(filterState.dataSourceNames); - const queriedDatasourceUids = rule.queriedDatasourceUIDs || []; - - const queryIncludesDataSource = queriedDatasourceUids.some((uid) => filterDatasourceUids.includes(uid)); - if (!queryIncludesDataSource) { - return false; - } - } catch (error) { - return false; - } - } - } - - return true; -} - -function looseParseMatcher(matcherQuery: string): Matcher | undefined { - try { - return parseMatcher(matcherQuery); - } catch { - // Try to createa a matcher than matches all values for a given key - return { name: matcherQuery, value: '', isRegex: true, isEqual: true }; - } -} - -// Memoize the function to avoid calling getDatasourceAPIUid for the filter values multiple times -const mapDataSourceNamesToUids = memoize( - (names: string[]): string[] => { - return names.map((name) => attempt(getDatasourceAPIUid, name)).filter(isString); - }, - { maxSize: 1 } -); - -/** - * Build title search parameter for backend filtering - * Combines ruleName and freeFormWords into a single search string - */ -export function buildTitleSearch(filterState: RulesFilter): string | undefined { - const titleParts: string[] = []; - - const ruleName = filterState.ruleName?.trim(); - if (ruleName) { - titleParts.push(ruleName); - } - - const freeFormSegment = filterState.freeFormWords - .map((word) => word.trim()) - .filter(Boolean) - .join(' '); - - if (freeFormSegment) { - titleParts.push(freeFormSegment); - } - - if (titleParts.length === 0) { - return undefined; - } - - return titleParts.join(' '); -} - -/** - * Normalize filter state for case-insensitive matching - * Lowercase free form words, rule name, group name and namespace - */ -function normalizeFilterState(filterState: RulesFilter): RulesFilter { - return { - ...filterState, - freeFormWords: filterState.freeFormWords.map((word) => word.toLowerCase()), - ruleName: filterState.ruleName?.toLowerCase(), - groupName: filterState.groupName?.toLowerCase(), - namespace: filterState.namespace?.toLowerCase(), - }; -} diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts deleted file mode 100644 index 1b226d50c5b..00000000000 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { config } from '@grafana/runtime'; -import { PromRuleType } from 'app/types/unified-alerting-dto'; - -import { RuleSource } from '../../search/rulesSearchParser'; -import { getFilter } from '../../utils/search'; - -import { hasClientSideFilters } from './useFilteredRulesIterator'; - -describe('hasClientSideFilters', () => { - const originalFeatureToggles = config.featureToggles; - - beforeEach(() => { - config.featureToggles = { ...originalFeatureToggles }; - }); - - afterEach(() => { - config.featureToggles = originalFeatureToggles; - }); - - describe('when alertingUIUseBackendFilters is enabled', () => { - beforeEach(() => { - config.featureToggles.alertingUIUseBackendFilters = true; - }); - - it('should return false for backend-supported filters', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(false); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(false); - expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(false); - }); - - it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true); - }); - - it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); - }); - }); - - describe('when alertingUIUseBackendFilters is disabled', () => { - beforeEach(() => { - config.featureToggles.alertingUIUseBackendFilters = false; - }); - - it('should return true for backend-supported filters when backend filtering is disabled', () => { - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true); - }); - - it('should return true for client-side only filters', () => { - expect(hasClientSideFilters(getFilter({ namespace: 'test' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dataSourceNames: ['prometheus'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ labels: ['severity=critical'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleSource: RuleSource.DataSource }))).toBe(true); - }); - - it('should return false when no filters are applied', () => { - expect(hasClientSideFilters(getFilter({}))).toBe(false); - }); - }); - - describe('when alertingUIUseBackendFilters is undefined (default)', () => { - beforeEach(() => { - config.featureToggles.alertingUIUseBackendFilters = undefined; - }); - - it('should default to client-side filtering for backward compatibility', () => { - // Default behavior should be client-side filtering - expect(hasClientSideFilters(getFilter({ freeFormWords: ['cpu'] }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleName: 'test' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ ruleType: PromRuleType.Alerting }))).toBe(true); - expect(hasClientSideFilters(getFilter({ dashboardUid: 'test-dashboard' }))).toBe(true); - expect(hasClientSideFilters(getFilter({ groupName: 'my-group' }))).toBe(true); - }); - }); -}); diff --git a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts index 86f593661ea..407426bb3e1 100644 --- a/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts +++ b/public/app/features/alerting/unified/rule-list/hooks/useFilteredRulesIterator.ts @@ -15,7 +15,6 @@ import { PromRuleGroupDTO, } from 'app/types/unified-alerting-dto'; -import { shouldUseBackendFilters } from '../../featureToggles'; import { RuleSource, RulesFilter } from '../../search/rulesSearchParser'; import { getDataSourceByUid, @@ -25,7 +24,8 @@ import { } from '../../utils/datasource'; import { RulePositionHash, createRulePositionHash } from '../rulePositionHash'; -import { getDatasourceFilter, getGrafanaFilter } from './filters'; +import { getDatasourceFilter } from './datasourceFilter'; +import { getGrafanaFilter } from './grafanaFilter'; import { useGrafanaGroupsGenerator, usePrometheusGroupsGenerator } from './prometheusGroupsGenerator'; export type RuleWithOrigin = PromRuleWithOrigin | GrafanaRuleWithOrigin; @@ -146,28 +146,6 @@ export function useFilteredRulesIteratorProvider() { return getFilteredRulesIterable; } -/** - * Determines if client-side filtering is needed for Grafana-managed rules. - */ -export function hasClientSideFilters(filterState: RulesFilter): boolean { - const useBackendFilters = shouldUseBackendFilters(); - - return ( - // When backend filters are disabled, title search, type filter, dashboard filter, and group name filter need client-side filtering - (!useBackendFilters && - (filterState.freeFormWords.length > 0 || - Boolean(filterState.ruleName) || - Boolean(filterState.ruleType) || - Boolean(filterState.dashboardUid) || - Boolean(filterState.groupName))) || - // Client-side only filters: - Boolean(filterState.namespace) || - filterState.dataSourceNames.length > 0 || - filterState.labels.length > 0 || - filterState.ruleSource === RuleSource.DataSource - ); -} - function mergeIterables(iterables: Array>): AsyncIterableX { if (iterables.length === 0) { return empty(); From f6a1d4a14ecc08c6cbad26c528f2c496e4d1010b Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 20 Nov 2025 14:02:15 +0100 Subject: [PATCH 113/125] Revert Auth: Add None role to Viewer case (#114224) --- pkg/services/apiserver/auth/authorizer/role.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pkg/services/apiserver/auth/authorizer/role.go b/pkg/services/apiserver/auth/authorizer/role.go index 6bc6e9d5474..82e80b38164 100644 --- a/pkg/services/apiserver/auth/authorizer/role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -4,9 +4,10 @@ import ( "context" "fmt" + "k8s.io/apiserver/pkg/authorization/authorizer" + "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/services/org" - "k8s.io/apiserver/pkg/authorization/authorizer" ) var _ authorizer.Authorizer = &roleAuthorizer{} @@ -34,13 +35,15 @@ func (auth roleAuthorizer) Authorize(ctx context.Context, a authorizer.Attribute default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } - case org.RoleViewer, org.RoleNone: + case org.RoleViewer: switch a.GetVerb() { case "get", "list", "watch": return authorizer.DecisionAllow, "", nil default: return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } + case org.RoleNone: + return authorizer.DecisionDeny, errorMessageForGrafanaOrgRole(orgRole, a), nil } return authorizer.DecisionDeny, "", nil } From c9801fc66ec0dff17228da702c7c545cfcb44b16 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Thu, 20 Nov 2025 16:33:44 +0300 Subject: [PATCH 114/125] FeatureFlags: Remove unused settings for admin UI (#114137) --- .../dashboardversion/dashverimpl/dashver.go | 4 +- .../featuremgmt/feature_toggle_api/types.go | 6 - pkg/services/featuremgmt/models.go | 4 - pkg/services/featuremgmt/openfeature_test.go | 10 +- pkg/services/featuremgmt/registry.go | 1168 ++++++++--------- pkg/services/featuremgmt/toggles-gitlog.csv | 152 ++- pkg/services/featuremgmt/toggles_gen.csv | 14 +- pkg/services/featuremgmt/toggles_gen.go | 304 ----- pkg/services/featuremgmt/toggles_gen.json | 186 +-- pkg/services/featuremgmt/toggles_gen_test.go | 26 +- 10 files changed, 703 insertions(+), 1171 deletions(-) diff --git a/pkg/services/dashboardversion/dashverimpl/dashver.go b/pkg/services/dashboardversion/dashverimpl/dashver.go index ef39b815fad..2a4d31f2b4d 100644 --- a/pkg/services/dashboardversion/dashverimpl/dashver.go +++ b/pkg/services/dashboardversion/dashverimpl/dashver.go @@ -170,8 +170,8 @@ func (s *Service) RestoreVersion(ctx context.Context, cmd *dashver.RestoreVersio cmd.DashboardUID = u } //nolint:staticcheck // not yet migrated to OpenFeature - if s.features.IsEnabledGlobally(featuremgmt.FlagKubernetesDashboards) || - s.features.IsEnabledGlobally(featuremgmt.FlagDashboardNewLayouts) { + if s.features.IsEnabled(ctx, featuremgmt.FlagKubernetesDashboards) || + s.features.IsEnabled(ctx, featuremgmt.FlagDashboardNewLayouts) { s.log.Debug("restoring dashboard version through k8s") res, err := s.restoreVersionThroughK8s(ctx, cmd) if err != nil { diff --git a/pkg/services/featuremgmt/feature_toggle_api/types.go b/pkg/services/featuremgmt/feature_toggle_api/types.go index 6b62447fa33..046f59c28ab 100644 --- a/pkg/services/featuremgmt/feature_toggle_api/types.go +++ b/pkg/services/featuremgmt/feature_toggle_api/types.go @@ -44,12 +44,6 @@ type FeatureSpec struct { // The flag is used at startup, so any change requires a restart RequiresRestart bool `json:"requiresRestart,omitempty"` - // Allow cloud users to set the values in UI - AllowSelfServe bool `json:"allowSelfServe,omitempty"` - - // Do not show the value in the UI - HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` - // Do not show the value in docs HideFromDocs bool `json:"hideFromDocs,omitempty"` diff --git a/pkg/services/featuremgmt/models.go b/pkg/services/featuremgmt/models.go index 557ca7ef8a2..45d3dc810f4 100644 --- a/pkg/services/featuremgmt/models.go +++ b/pkg/services/featuremgmt/models.go @@ -129,10 +129,6 @@ type FeatureFlag struct { Stage FeatureFlagStage `json:"stage,omitempty"` Owner codeowner `json:"-"` // Owner person or team that owns this feature flag - // Recommended properties - control behavior of the feature toggle management page in the UI - AllowSelfServe bool `json:"allowSelfServe,omitempty"` // allow users with the right privileges to toggle this from the UI (GeneralAvailability, PublicPreview, and Deprecated toggles only) - HideFromAdminPage bool `json:"hideFromAdminPage,omitempty"` // GA, Deprecated, and PublicPreview toggles only: don't display this feature in the UI; if this is a GA toggle, add a comment with the reasoning - // CEL-GO expression. Using the value "true" will mean this is on by default Expression string `json:"expression,omitempty"` diff --git a/pkg/services/featuremgmt/openfeature_test.go b/pkg/services/featuremgmt/openfeature_test.go index 2cb5c3542d0..788b53531d4 100644 --- a/pkg/services/featuremgmt/openfeature_test.go +++ b/pkg/services/featuremgmt/openfeature_test.go @@ -6,16 +6,16 @@ import ( "net/url" "testing" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/clientauth/middleware" - "github.com/grafana/grafana/pkg/setting" - - authlib "github.com/grafana/authlib/authn" gofeatureflag "github.com/open-feature/go-sdk-contrib/providers/go-feature-flag/pkg" "github.com/open-feature/go-sdk/openfeature" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + + authlib "github.com/grafana/authlib/authn" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/clientauth/middleware" + "github.com/grafana/grafana/pkg/setting" ) func TestCreateProvider(t *testing.T) { diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index bc4d4c282ee..e6587428f18 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -18,28 +18,24 @@ var ( // Register each toggle here standardFeatureFlags = []FeatureFlag{ { - Name: "disableEnvelopeEncryption", - Description: "Disable envelope encryption (emergency only)", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOperatorExperienceSquad, - HideFromAdminPage: true, - AllowSelfServe: false, - Expression: "false", + Name: "disableEnvelopeEncryption", + Description: "Disable envelope encryption (emergency only)", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaOperatorExperienceSquad, + Expression: "false", }, { - Name: "panelTitleSearch", - Description: "Search for dashboards using panel title", - Stage: FeatureStagePublicPreview, - Owner: grafanaSearchAndStorageSquad, - HideFromAdminPage: true, + Name: "panelTitleSearch", + Description: "Search for dashboards using panel title", + Stage: FeatureStagePublicPreview, + Owner: grafanaSearchAndStorageSquad, }, { - Name: "publicDashboardsEmailSharing", - Description: "Enables public dashboard sharing to be restricted to only allowed emails", - Stage: FeatureStagePublicPreview, - Owner: grafanaOperatorExperienceSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "publicDashboardsEmailSharing", + Description: "Enables public dashboard sharing to be restricted to only allowed emails", + Stage: FeatureStagePublicPreview, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, }, { Name: "publicDashboardsScene", @@ -56,12 +52,11 @@ var ( Owner: grafanaObservabilityLogsSquad, }, { - Name: "featureHighlights", - Description: "Highlight Grafana Enterprise features", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaOperatorExperienceSquad, - AllowSelfServe: true, - Expression: "false", + Name: "featureHighlights", + Description: "Highlight Grafana Enterprise features", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaOperatorExperienceSquad, + Expression: "false", }, { Name: "storage", @@ -70,12 +65,11 @@ var ( Owner: grafanaSearchAndStorageSquad, }, { - Name: "canvasPanelNesting", - Description: "Allow elements nesting", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaDatavizSquad, - HideFromAdminPage: true, + Name: "canvasPanelNesting", + Description: "Allow elements nesting", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaDatavizSquad, }, { Name: "logRequestsInstrumentedAsUnknown", @@ -84,19 +78,17 @@ var ( Owner: grafanaBackendGroup, }, { - Name: "grpcServer", - Description: "Run the GRPC server", - Stage: FeatureStagePublicPreview, - Owner: grafanaSearchAndStorageSquad, - HideFromAdminPage: true, + Name: "grpcServer", + Description: "Run the GRPC server", + Stage: FeatureStagePublicPreview, + Owner: grafanaSearchAndStorageSquad, }, { - Name: "cloudWatchCrossAccountQuerying", - Description: "Enables cross-account querying in CloudWatch datasources", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: awsDatasourcesSquad, - AllowSelfServe: true, + Name: "cloudWatchCrossAccountQuerying", + Description: "Enables cross-account querying in CloudWatch datasources", + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default + Owner: awsDatasourcesSquad, }, { Name: "showDashboardValidationWarnings", @@ -117,13 +109,12 @@ var ( Owner: grafanaAlertingSquad, }, { - Name: "logsContextDatasourceUi", - Description: "Allow datasource to provide custom UI for context view", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - Expression: "true", // turned on by default - AllowSelfServe: true, + Name: "logsContextDatasourceUi", + Description: "Allow datasource to provide custom UI for context view", + Stage: FeatureStageGeneralAvailability, + FrontendOnly: true, + Owner: grafanaObservabilityLogsSquad, + Expression: "true", // turned on by default }, { Name: "lokiShardSplitting", @@ -133,13 +124,12 @@ var ( Owner: grafanaObservabilityLogsSquad, }, { - Name: "lokiQuerySplitting", - Description: "Split large interval queries into subqueries with smaller time intervals", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Owner: grafanaObservabilityLogsSquad, - Expression: "true", // turned on by default - AllowSelfServe: true, + Name: "lokiQuerySplitting", + Description: "Split large interval queries into subqueries with smaller time intervals", + Stage: FeatureStageGeneralAvailability, + FrontendOnly: true, + Owner: grafanaObservabilityLogsSquad, + Expression: "true", // turned on by default }, { Name: "individualCookiePreferences", @@ -148,22 +138,20 @@ var ( Owner: grafanaBackendGroup, }, { - Name: "influxdbBackendMigration", - Description: "Query InfluxDB InfluxQL without the proxy", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: true, - Owner: grafanaPartnerPluginsSquad, - Expression: "true", // enabled by default - AllowSelfServe: false, + Name: "influxdbBackendMigration", + Description: "Query InfluxDB InfluxQL without the proxy", + Stage: FeatureStageGeneralAvailability, + FrontendOnly: true, + Owner: grafanaPartnerPluginsSquad, + Expression: "true", // enabled by default }, { - Name: "starsFromAPIServer", - Description: "populate star status from apiserver", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaFrontendSearchNavOrganise, - AllowSelfServe: false, - HideFromDocs: true, + Name: "starsFromAPIServer", + Description: "populate star status from apiserver", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaFrontendSearchNavOrganise, + HideFromDocs: true, }, { Name: "kubernetesStars", @@ -198,26 +186,23 @@ var ( Owner: grafanaDatasourcesCoreServicesSquad, }, { - Name: "unifiedRequestLog", - Description: "Writes error logs to the request logger", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaBackendGroup, - Expression: "true", - HideFromAdminPage: true, + Name: "unifiedRequestLog", + Description: "Writes error logs to the request logger", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaBackendGroup, + Expression: "true", }, { - Name: "renderAuthJWT", - Description: "Uses JWT-based auth for rendering instead of relying on remote cache", - Stage: FeatureStagePublicPreview, - Owner: grafanaOperatorExperienceSquad, - HideFromAdminPage: true, + Name: "renderAuthJWT", + Description: "Uses JWT-based auth for rendering instead of relying on remote cache", + Stage: FeatureStagePublicPreview, + Owner: grafanaOperatorExperienceSquad, }, { - Name: "refactorVariablesTimeRange", - Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained", - Stage: FeatureStagePublicPreview, - Owner: grafanaDashboardsSquad, - HideFromAdminPage: true, // Non-feature, used to test out a bug fix that impacts the performance of template variables. + Name: "refactorVariablesTimeRange", + Description: "Refactor time range variables flow to reduce number of API calls made when query variables are chained", + Stage: FeatureStagePublicPreview, + Owner: grafanaDashboardsSquad, }, { Name: "faroDatasourceSelector", @@ -229,7 +214,7 @@ var ( { Name: "enableDatagridEditing", Description: "Enables the edit functionality in the datagrid panel", - FrontendOnly: true, + FrontendOnly: false, // The set of plugins returned in frontend settings changes based on this flag Stage: FeatureStagePublicPreview, Owner: grafanaDatavizSquad, }, @@ -358,31 +343,26 @@ var ( Owner: grafanaObservabilityLogsSquad, }, { - Name: "externalServiceAccounts", - Description: "Automatic service account and token setup for plugins", - HideFromAdminPage: true, - Stage: FeatureStagePublicPreview, - Owner: identityAccessTeam, + Name: "externalServiceAccounts", + Description: "Automatic service account and token setup for plugins", + Stage: FeatureStagePublicPreview, + Owner: identityAccessTeam, }, { - Name: "enableNativeHTTPHistogram", - Description: "Enables native HTTP Histograms", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaBackendServicesSquad, - HideFromAdminPage: true, - AllowSelfServe: false, - RequiresRestart: true, + Name: "enableNativeHTTPHistogram", + Description: "Enables native HTTP Histograms", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaBackendServicesSquad, + RequiresRestart: true, }, { - Name: "disableClassicHTTPHistogram", - Description: "Disables classic HTTP Histogram (use with enableNativeHTTPHistogram)", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaBackendServicesSquad, - HideFromAdminPage: true, - AllowSelfServe: false, - RequiresRestart: true, + Name: "disableClassicHTTPHistogram", + Description: "Disables classic HTTP Histogram (use with enableNativeHTTPHistogram)", + Stage: FeatureStageExperimental, + FrontendOnly: false, + Owner: grafanaBackendServicesSquad, + RequiresRestart: true, }, { Name: "kubernetesSnapshots", @@ -403,7 +383,7 @@ var ( Description: "Use the kubernetes API in the frontend for dashboards", Stage: FeatureStageGeneralAvailability, Owner: grafanaDashboardsSquad, - FrontendOnly: true, + FrontendOnly: false, // The backend changes permission behavior based on this flag Expression: "true", // enabled by default }, { @@ -534,20 +514,18 @@ var ( Owner: grafanaAlertingSquad, }, { - Name: "alertingProvenanceLockWrites", - Description: "Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "alertingProvenanceLockWrites", + Description: "Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, }, { - Name: "alertingUIUseBackendFilters", - Description: "Enables the UI to use certain backend-side filters", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "alertingUIUseBackendFilters", + Description: "Enables the UI to use certain backend-side filters", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, }, { Name: "alertmanagerRemotePrimary", @@ -591,7 +569,7 @@ var ( Name: "dashboardNewLayouts", Description: "Enables experimental new dashboard layouts", Stage: FeatureStageExperimental, - FrontendOnly: true, + FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, { @@ -653,42 +631,35 @@ var ( Owner: grafanaDatavizSquad, }, { - Name: "kubernetesFeatureToggles", - Description: "Use the kubernetes API for feature toggle management in the frontend", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaOperatorExperienceSquad, - AllowSelfServe: false, - HideFromAdminPage: true, + Name: "kubernetesFeatureToggles", + Description: "Use the kubernetes API for feature toggle management in the frontend", + Stage: FeatureStageExperimental, + FrontendOnly: true, + Owner: grafanaOperatorExperienceSquad, }, { - Name: "cloudRBACRoles", - Description: "Enabled grafana cloud specific RBAC roles", - Stage: FeatureStagePublicPreview, - Owner: identityAccessTeam, - HideFromDocs: true, - AllowSelfServe: true, - HideFromAdminPage: true, - RequiresRestart: true, + Name: "cloudRBACRoles", + Description: "Enabled grafana cloud specific RBAC roles", + Stage: FeatureStagePublicPreview, + Owner: identityAccessTeam, + HideFromDocs: true, + RequiresRestart: true, }, { - Name: "alertingQueryOptimization", - Description: "Optimizes eligible queries in order to reduce load on datasources", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAlertingSquad, - AllowSelfServe: false, - Expression: "false", + Name: "alertingQueryOptimization", + Description: "Optimizes eligible queries in order to reduce load on datasources", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaAlertingSquad, + Expression: "false", }, { - Name: "jitterAlertRulesWithinGroups", - Description: "Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled.", - FrontendOnly: false, - Stage: FeatureStagePublicPreview, - Owner: grafanaAlertingSquad, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: false, - RequiresRestart: true, + Name: "jitterAlertRulesWithinGroups", + Description: "Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled.", + FrontendOnly: false, + Stage: FeatureStagePublicPreview, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + RequiresRestart: true, }, { Name: "onPremToCloudMigrations", @@ -725,41 +696,37 @@ var ( Expression: "true", }, { - Name: "scopeApi", - Description: "In-development feature flag for the scope api using the app platform.", - Stage: FeatureStageExperimental, - Owner: grafanaAppPlatformSquad, - HideFromAdminPage: true, - Expression: "false", + Name: "scopeApi", + Description: "In-development feature flag for the scope api using the app platform.", + Stage: FeatureStageExperimental, + Owner: grafanaAppPlatformSquad, + Expression: "false", }, { - Name: "useScopeSingleNodeEndpoint", - Description: "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - Expression: "false", - FrontendOnly: true, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "useScopeSingleNodeEndpoint", + Description: "Use the single node endpoint for the scope api. This is used to fetch the scope parent node.", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + Expression: "false", + FrontendOnly: true, + HideFromDocs: true, }, { - Name: "useMultipleScopeNodesEndpoint", - Description: "Makes the frontend use the 'names' param for fetching multiple scope nodes at once", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - Expression: "false", - FrontendOnly: true, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "useMultipleScopeNodesEndpoint", + Description: "Makes the frontend use the 'names' param for fetching multiple scope nodes at once", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + Expression: "false", + FrontendOnly: true, + HideFromDocs: true, }, { - Name: "logQLScope", - Description: "In-development feature that will allow injection of labels into loki queries.", - Stage: FeatureStagePrivatePreview, - Owner: grafanaObservabilityLogsSquad, - Expression: "false", - HideFromDocs: true, - HideFromAdminPage: true, + Name: "logQLScope", + Description: "In-development feature that will allow injection of labels into loki queries.", + Stage: FeatureStagePrivatePreview, + Owner: grafanaObservabilityLogsSquad, + Expression: "false", + HideFromDocs: true, }, { Name: "sqlExpressions", @@ -790,58 +757,49 @@ var ( RequiresRestart: true, }, { - Name: "groupByVariable", - Description: "Enable groupBy variable support in scenes dashboards", - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "groupByVariable", + Description: "Enable groupBy variable support in scenes dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromDocs: true, }, { - Name: "scopeFilters", - Description: "Enables the use of scope filters in Grafana", - FrontendOnly: false, - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - RequiresRestart: false, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "scopeFilters", + Description: "Enables the use of scope filters in Grafana", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + HideFromDocs: true, }, { - Name: "oauthRequireSubClaim", - Description: "Require that sub claims is present in oauth tokens.", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "oauthRequireSubClaim", + Description: "Require that sub claims is present in oauth tokens.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "refreshTokenRequired", - Description: "Require that refresh tokens are present in oauth tokens.", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "refreshTokenRequired", + Description: "Require that refresh tokens are present in oauth tokens.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "newDashboardWithFiltersAndGroupBy", - Description: "Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.", - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "newDashboardWithFiltersAndGroupBy", + Description: "Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromDocs: true, }, { - Name: "cloudWatchNewLabelParsing", - Description: "Updates CloudWatch label parsing to be more accurate", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: awsDatasourcesSquad, - FrontendOnly: false, - AllowSelfServe: false, + Name: "cloudWatchNewLabelParsing", + Description: "Updates CloudWatch label parsing to be more accurate", + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default + Owner: awsDatasourcesSquad, + FrontendOnly: false, }, { Name: "disableNumericMetricsSortingInExpressions", @@ -852,21 +810,18 @@ var ( RequiresRestart: true, }, { - Name: "grafanaManagedRecordingRules", - Description: "Enables Grafana-managed recording rules.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "grafanaManagedRecordingRules", + Description: "Enables Grafana-managed recording rules.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, }, { - Name: "queryLibrary", - Description: "Enables Saved queries (query library) feature", - Stage: FeatureStagePublicPreview, - Owner: grafanaSharingSquad, - FrontendOnly: false, - AllowSelfServe: false, + Name: "queryLibrary", + Description: "Enables Saved queries (query library) feature", + Stage: FeatureStagePublicPreview, + Owner: grafanaSharingSquad, + FrontendOnly: false, }, { Name: "dashboardLibrary", @@ -897,28 +852,25 @@ var ( FrontendOnly: true, }, { - Name: "alertingDisableSendAlertsExternal", - Description: "Disables the ability to send alerts to an external Alertmanager datasource.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "alertingDisableSendAlertsExternal", + Description: "Disables the ability to send alerts to an external Alertmanager datasource.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, }, { - Name: "preserveDashboardStateWhenNavigating", - Description: "Enables possibility to preserve dashboard variables and time range when navigating between dashboards", - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "preserveDashboardStateWhenNavigating", + Description: "Enables possibility to preserve dashboard variables and time range when navigating between dashboards", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromDocs: true, }, { Name: "alertingCentralAlertHistory", Description: "Enables the new central alert history.", Stage: FeatureStageExperimental, Owner: grafanaAlertingSquad, - FrontendOnly: true, + FrontendOnly: false, // changes navtree from backend }, { Name: "pluginProxyPreserveTrailingSlash", @@ -935,59 +887,51 @@ var ( Expression: "true", // enabled by default }, { - Name: "authZGRPCServer", - Description: "Enables the gRPC server for authorization", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "authZGRPCServer", + Description: "Enables the gRPC server for authorization", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { Name: "ssoSettingsLDAP", Description: "Use the new SSO Settings API to configure LDAP", Stage: FeatureStageGeneralAvailability, Owner: identityAccessTeam, - AllowSelfServe: true, RequiresRestart: true, Expression: "true", // enabled by default }, { - Name: "zanzana", - Description: "Use openFGA as authorization engine.", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "zanzana", + Description: "Use openFGA as authorization engine.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "zanzanaNoLegacyClient", - Description: "Use openFGA as main authorization engine and disable legacy RBAC clietn.", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "zanzanaNoLegacyClient", + Description: "Use openFGA as main authorization engine and disable legacy RBAC clietn.", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "reloadDashboardsOnParamsChange", - Description: "Enables reload of dashboards on scopes, time range and variables changes", - FrontendOnly: false, - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - RequiresRestart: false, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "reloadDashboardsOnParamsChange", + Description: "Enables reload of dashboards on scopes, time range and variables changes", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + HideFromDocs: true, }, { - Name: "enableScopesInMetricsExplore", - Description: "Enables the scopes usage in Metrics Explore", - FrontendOnly: false, - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - RequiresRestart: false, - AllowSelfServe: false, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "enableScopesInMetricsExplore", + Description: "Enables the scopes usage in Metrics Explore", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + HideFromDocs: true, }, { Name: "cloudWatchRoundUpEndTime", @@ -1024,13 +968,12 @@ var ( Expression: "true", // enabled by default }, { - Name: "vizActionsAuth", - Description: "Allows authenticated API calls in actions", - Stage: FeatureStagePublicPreview, - Owner: grafanaDatavizSquad, - FrontendOnly: true, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "vizActionsAuth", + Description: "Allows authenticated API calls in actions", + Stage: FeatureStagePublicPreview, + Owner: grafanaDatavizSquad, + FrontendOnly: true, + HideFromDocs: true, }, { Name: "alertingPrometheusRulesPrimary", @@ -1061,12 +1004,11 @@ var ( Owner: grafanaObservabilityLogsSquad, }, { - Name: "appPlatformGrpcClientAuth", - Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "appPlatformGrpcClientAuth", + Description: "Enables the gRPC client to authenticate with the App Platform by using ID & access tokens", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { Name: "groupAttributeSync", @@ -1084,12 +1026,11 @@ var ( Expression: "true", }, { - Name: "improvedExternalSessionHandling", - Description: "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: identityAccessTeam, - AllowSelfServe: true, + Name: "improvedExternalSessionHandling", + Description: "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.", + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default + Owner: identityAccessTeam, }, { Name: "useSessionStorageForRedirection", @@ -1105,28 +1046,25 @@ var ( Owner: identityAccessTeam, }, { - Name: "unifiedStorageSearch", - Description: "Enable unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "unifiedStorageSearch", + Description: "Enable unified storage search", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, }, { - Name: "unifiedStorageSearchSprinkles", - Description: "Enable sprinkles on unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "unifiedStorageSearchSprinkles", + Description: "Enable sprinkles on unified storage search", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, }, { - Name: "managedDualWriter", - Description: "Pick the dual write mode from database configs", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "managedDualWriter", + Description: "Pick the dual write mode from database configs", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, }, { Name: "pluginsSriChecks", @@ -1169,13 +1107,11 @@ var ( RequiresRestart: true, }, { - Name: "passwordlessMagicLinkAuthentication", - Description: "Enable passwordless login via magic link authentication", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromDocs: true, - HideFromAdminPage: true, - AllowSelfServe: false, + Name: "passwordlessMagicLinkAuthentication", + Description: "Enable passwordless login via magic link authentication", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { Name: "exploreMetricsRelatedLogs", @@ -1228,67 +1164,60 @@ var ( Expression: "true", // Enabled by default for now }, { - Name: "alertingAIGenAlertRules", - Description: "Enable AI-generated alert rules.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingAIGenAlertRules", + Description: "Enable AI-generated alert rules.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingAIFeedback", - Description: "Enable AI-generated feedback from the Grafana UI.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingAIFeedback", + Description: "Enable AI-generated feedback from the Grafana UI.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingAIImproveAlertRules", - Description: "Enable AI-improve alert rules labels and annotations.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingAIImproveAlertRules", + Description: "Enable AI-improve alert rules labels and annotations.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingAIGenTemplates", - Description: "Enable AI-generated alerting templates.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingAIGenTemplates", + Description: "Enable AI-generated alerting templates.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingEnrichmentPerRule", - Description: "Enable enrichment per rule in the alerting UI.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingEnrichmentPerRule", + Description: "Enable enrichment per rule in the alerting UI.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingEnrichmentAssistantInvestigations", - Description: "Enable Assistant Investigations enrichment type.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingEnrichmentAssistantInvestigations", + Description: "Enable Assistant Investigations enrichment type.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingAIAnalyzeCentralStateHistory", - Description: "Enable AI-analyze central state history.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingAIAnalyzeCentralStateHistory", + Description: "Enable AI-analyze central state history.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { Name: "alertingNotificationsStepMode", @@ -1299,12 +1228,11 @@ var ( Expression: "true", }, { - Name: "unifiedStorageSearchUI", - Description: "Enable unified storage search UI", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "unifiedStorageSearchUI", + Description: "Enable unified storage search UI", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, }, { Name: "elasticsearchCrossClusterSearch", @@ -1351,12 +1279,11 @@ var ( Expression: "false", }, { - Name: "improvedExternalSessionHandlingSAML", - Description: "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.", - Stage: FeatureStageGeneralAvailability, - Expression: "true", // enabled by default - Owner: identityAccessTeam, - AllowSelfServe: true, + Name: "improvedExternalSessionHandlingSAML", + Description: "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.", + Stage: FeatureStageGeneralAvailability, + Expression: "true", // enabled by default + Owner: identityAccessTeam, }, { Name: "teamHttpHeadersTempo", @@ -1386,12 +1313,11 @@ var ( FrontendOnly: true, }, { - Name: "fetchRulesUsingPost", - Description: "Use a POST request to list rules by passing down the namespaces user has access to", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "fetchRulesUsingPost", + Description: "Use a POST request to list rules by passing down the namespaces user has access to", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, }, { Name: "newLogsPanel", @@ -1402,14 +1328,13 @@ var ( Expression: "true", }, { - Name: "grafanaconThemes", - Description: "Enables the temporary themes for GrafanaCon", - Stage: FeatureStageGeneralAvailability, - Owner: grafanaFrontendPlatformSquad, - HideFromAdminPage: true, - HideFromDocs: true, - RequiresRestart: true, - Expression: "true", + Name: "grafanaconThemes", + Description: "Enables the temporary themes for GrafanaCon", + Stage: FeatureStageGeneralAvailability, + Owner: grafanaFrontendPlatformSquad, + HideFromDocs: true, + RequiresRestart: true, + Expression: "true", }, { Name: "alertingJiraIntegration", @@ -1420,58 +1345,52 @@ var ( HideFromDocs: true, }, { - Name: "alertingUseNewSimplifiedRoutingHashAlgorithm", - Description: "", - Stage: FeatureStagePublicPreview, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - RequiresRestart: true, - Expression: "true", + Name: "alertingUseNewSimplifiedRoutingHashAlgorithm", + Description: "", + Stage: FeatureStagePublicPreview, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + RequiresRestart: true, + Expression: "true", }, { - Name: "useScopesNavigationEndpoint", - Description: "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - FrontendOnly: true, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "useScopesNavigationEndpoint", + Description: "Use the scopes navigation endpoint instead of the dashboardbindings endpoint", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + FrontendOnly: true, + HideFromDocs: true, }, { - Name: "scopeSearchAllLevels", - Description: "Enable scope search to include all levels of the scope node tree", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - HideFromDocs: true, - HideFromAdminPage: true, + Name: "scopeSearchAllLevels", + Description: "Enable scope search to include all levels of the scope node tree", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, }, { - Name: "alertingRuleVersionHistoryRestore", - Description: "Enables the alert rule version history restore feature", - FrontendOnly: true, - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "true", // enabled by default + Name: "alertingRuleVersionHistoryRestore", + Description: "Enables the alert rule version history restore feature", + FrontendOnly: true, + Stage: FeatureStageGeneralAvailability, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "true", // enabled by default }, { - Name: "newShareReportDrawer", - Description: "Enables the report creation drawer in a dashboard", - Stage: FeatureStagePublicPreview, - Owner: grafanaOperatorExperienceSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "newShareReportDrawer", + Description: "Enables the report creation drawer in a dashboard", + Stage: FeatureStagePublicPreview, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, }, { - Name: "rendererDisableAppPluginsPreload", - Description: "Disable pre-loading app plugins when the request is coming from the renderer", - Stage: FeatureStageExperimental, - Owner: grafanaOperatorExperienceSquad, - HideFromAdminPage: true, - HideFromDocs: true, - FrontendOnly: true, + Name: "rendererDisableAppPluginsPreload", + Description: "Disable pre-loading app plugins when the request is coming from the renderer", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, + FrontendOnly: true, }, { Name: "assetSriChecks", @@ -1524,32 +1443,29 @@ var ( Owner: grafanaFrontendPlatformSquad, }, { - Name: "unifiedStorageGrpcConnectionPool", - Description: "Enables the unified storage grpc connection pool", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "unifiedStorageGrpcConnectionPool", + Description: "Enables the unified storage grpc connection pool", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, }, { - Name: "alertingRulePermanentlyDelete", - Description: "Enables UI functionality to permanently delete alert rules", - FrontendOnly: true, - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "true", // enabled by default + Name: "alertingRulePermanentlyDelete", + Description: "Enables UI functionality to permanently delete alert rules", + FrontendOnly: true, + Stage: FeatureStageGeneralAvailability, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "true", // enabled by default }, { - Name: "alertingRuleRecoverDeleted", - Description: "Enables the UI functionality to recover and view deleted alert rules", - FrontendOnly: true, - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "true", // enabled by default + Name: "alertingRuleRecoverDeleted", + Description: "Enables the UI functionality to recover and view deleted alert rules", + FrontendOnly: false, // changes navtree from the backend + Stage: FeatureStageGeneralAvailability, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "true", // enabled by default }, { Name: "multiTenantTempCredentials", @@ -1596,11 +1512,10 @@ var ( Owner: grafanaOSSBigTent, }, { - Name: "tempoAlerting", - Description: "Enables creating alerts from Tempo data source", - Stage: FeatureStageExperimental, - Owner: grafanaObservabilityTracesAndProfilingSquad, - FrontendOnly: true, + Name: "tempoAlerting", + Description: "Enables creating alerts from Tempo data source", + Stage: FeatureStageExperimental, + Owner: grafanaObservabilityTracesAndProfilingSquad, }, { Name: "pluginsAutoUpdate", @@ -1624,107 +1539,95 @@ var ( Expression: "false", }, { - Name: "alertingBulkActionsInUI", - Description: "Enables the alerting bulk actions in the UI", - FrontendOnly: true, - Stage: FeatureStageGeneralAvailability, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "true", // enabled by default + Name: "alertingBulkActionsInUI", + Description: "Enables the alerting bulk actions in the UI", + FrontendOnly: true, + Stage: FeatureStageGeneralAvailability, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "true", // enabled by default }, { - Name: "kubernetesAuthzApis", - Description: "Registers AuthZ /apis endpoint", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "kubernetesAuthzApis", + Description: "Registers AuthZ /apis endpoint", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "kubernetesAuthZHandlerRedirect", - Description: "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "kubernetesAuthZHandlerRedirect", + Description: "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "kubernetesAuthzResourcePermissionApis", - Description: "Registers AuthZ resource permission /apis endpoints", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "kubernetesAuthzResourcePermissionApis", + Description: "Registers AuthZ resource permission /apis endpoints", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "kubernetesAuthzZanzanaSync", - Description: "Enable sync of Zanzana authorization store on AuthZ CRD mutations", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "kubernetesAuthzZanzanaSync", + Description: "Enable sync of Zanzana authorization store on AuthZ CRD mutations", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "kubernetesAuthnMutation", - Description: "Enables create, delete, and update mutations for resources owned by IAM identity", - Stage: FeatureStageExperimental, - Owner: identityAccessTeam, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "kubernetesAuthnMutation", + Description: "Enables create, delete, and update mutations for resources owned by IAM identity", + Stage: FeatureStageExperimental, + Owner: identityAccessTeam, + HideFromDocs: true, }, { - Name: "restoreDashboards", - Description: "Enables restore deleted dashboards feature", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendSearchNavOrganise, - HideFromAdminPage: true, - Expression: "false", + Name: "restoreDashboards", + Description: "Enables restore deleted dashboards feature", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendSearchNavOrganise, + Expression: "false", }, { - Name: "alertEnrichment", - Description: "Enable configuration of alert enrichments in Grafana Cloud.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertEnrichment", + Description: "Enable configuration of alert enrichments in Grafana Cloud.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertEnrichmentMultiStep", - Description: "Allow multiple steps per enrichment.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertEnrichmentMultiStep", + Description: "Allow multiple steps per enrichment.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertEnrichmentConditional", - Description: "Enable conditional alert enrichment steps.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertEnrichmentConditional", + Description: "Enable conditional alert enrichment steps.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingImportAlertmanagerAPI", - Description: "Enables the API to import Alertmanager configuration", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingImportAlertmanagerAPI", + Description: "Enables the API to import Alertmanager configuration", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "alertingImportAlertmanagerUI", - Description: "Enables the UI to see imported Alertmanager configuration", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingImportAlertmanagerUI", + Description: "Enables the UI to see imported Alertmanager configuration", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { Name: "sharingDashboardImage", @@ -1757,34 +1660,31 @@ var ( Expression: "false", }, { - Name: "enableAppChromeExtensions", - Description: "Set this to true to enable all app chrome extensions registered by plugins.", - Stage: FeatureStageExperimental, - Owner: grafanaPluginsPlatformSquad, - HideFromAdminPage: true, - HideFromDocs: true, - FrontendOnly: true, - Expression: "false", // extensions will be disabled by default + Name: "enableAppChromeExtensions", + Description: "Set this to true to enable all app chrome extensions registered by plugins.", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", // extensions will be disabled by default }, { - Name: "enableDashboardEmptyExtensions", - Description: "Set this to true to enable all dashboard empty state extensions registered by plugins.", - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - HideFromAdminPage: true, - HideFromDocs: true, - FrontendOnly: true, - Expression: "false", // extensions will be disabled by default + Name: "enableDashboardEmptyExtensions", + Description: "Set this to true to enable all dashboard empty state extensions registered by plugins.", + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", // extensions will be disabled by default }, { - Name: "foldersAppPlatformAPI", - Description: "Enables use of app platform API for folders", - Stage: FeatureStageExperimental, - Owner: grafanaFrontendSearchNavOrganise, - HideFromAdminPage: true, - HideFromDocs: true, - FrontendOnly: true, - Expression: "false", + Name: "foldersAppPlatformAPI", + Description: "Enables use of app platform API for folders", + Stage: FeatureStageExperimental, + Owner: grafanaFrontendSearchNavOrganise, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", }, { Name: "otelLogsFormatting", @@ -1794,21 +1694,19 @@ var ( Owner: grafanaObservabilityLogsSquad, }, { - Name: "alertingNotificationHistory", - Description: "Enables the notification history feature", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, - Expression: "false", + Name: "alertingNotificationHistory", + Description: "Enables the notification history feature", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { - Name: "unifiedStorageSearchDualReaderEnabled", - Description: "Enable dual reader for unified storage search", - Stage: FeatureStageExperimental, - Owner: grafanaSearchAndStorageSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "unifiedStorageSearchDualReaderEnabled", + Description: "Enable dual reader for unified storage search", + Stage: FeatureStageExperimental, + Owner: grafanaSearchAndStorageSquad, + HideFromDocs: true, }, { Name: "dashboardLevelTimeMacros", @@ -1818,22 +1716,20 @@ var ( FrontendOnly: true, }, { - Name: "alertmanagerRemoteSecondaryWithRemoteState", - Description: "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", - Stage: FeatureStageExperimental, - Owner: grafanaAlertingSquad, - HideFromAdminPage: true, - HideFromDocs: true, + Name: "alertmanagerRemoteSecondaryWithRemoteState", + Description: "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", + Stage: FeatureStageExperimental, + Owner: grafanaAlertingSquad, + HideFromDocs: true, }, { - Name: "restrictedPluginApis", - Description: "Enables sharing a list of APIs with a list of plugins", - Stage: FeatureStageExperimental, - Owner: grafanaPluginsPlatformSquad, - HideFromAdminPage: true, - HideFromDocs: true, - FrontendOnly: true, - Expression: "false", + Name: "restrictedPluginApis", + Description: "Enables sharing a list of APIs with a list of plugins", + Stage: FeatureStageExperimental, + Owner: grafanaPluginsPlatformSquad, + HideFromDocs: true, + FrontendOnly: true, + Expression: "false", }, { Name: "favoriteDatasources", @@ -1872,14 +1768,13 @@ var ( Owner: grafanaPathfinderSquad, }, { - Name: "alertingTriage", - Description: "Enables the alerting triage feature", - Stage: FeatureStageExperimental, - FrontendOnly: true, - Owner: grafanaAlertingSquad, - HideFromDocs: true, - HideFromAdminPage: true, - Expression: "false", + Name: "alertingTriage", + Description: "Enables the alerting triage feature", + Stage: FeatureStageExperimental, + FrontendOnly: false, // changes navtree in backend + Owner: grafanaAlertingSquad, + HideFromDocs: true, + Expression: "false", }, { Name: "graphiteBackendMode", @@ -1992,25 +1887,22 @@ var ( Expression: "false", }, { - Name: "onlyStoreActionSets", - Description: "When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission", - Stage: FeatureStageGeneralAvailability, - FrontendOnly: false, - HideFromDocs: true, - HideFromAdminPage: true, // this should not be a user facing change - Owner: identityAccessTeam, - Expression: "true", + Name: "onlyStoreActionSets", + Description: "When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission", + Stage: FeatureStageGeneralAvailability, + FrontendOnly: false, + HideFromDocs: true, + Owner: identityAccessTeam, + Expression: "true", }, { - Name: "panelTimeSettings", - Description: "Enables a new panel time settings drawer", - FrontendOnly: false, - Stage: FeatureStageExperimental, - Owner: grafanaDashboardsSquad, - RequiresRestart: false, - AllowSelfServe: false, - HideFromDocs: false, - HideFromAdminPage: false, + Name: "panelTimeSettings", + Description: "Enables a new panel time settings drawer", + FrontendOnly: false, + Stage: FeatureStageExperimental, + Owner: grafanaDashboardsSquad, + RequiresRestart: false, + HideFromDocs: false, }, { Name: "dashboardTemplates", @@ -2048,16 +1940,14 @@ var ( Owner: grafanaPluginsPlatformSquad, }, { - Name: "rudderstackUpgrade", - Description: "Enables the new version of rudderstack", - FrontendOnly: true, - Stage: FeatureStageExperimental, - Owner: grafanaFrontendPlatformSquad, - Expression: "false", - RequiresRestart: false, - AllowSelfServe: false, - HideFromDocs: false, - HideFromAdminPage: false, + Name: "rudderstackUpgrade", + Description: "Enables the new version of rudderstack", + FrontendOnly: true, + Stage: FeatureStageExperimental, + Owner: grafanaFrontendPlatformSquad, + Expression: "false", + RequiresRestart: false, + HideFromDocs: false, }, } ) diff --git a/pkg/services/featuremgmt/toggles-gitlog.csv b/pkg/services/featuremgmt/toggles-gitlog.csv index d8fbbaf3924..3f34ca9edc9 100644 --- a/pkg/services/featuremgmt/toggles-gitlog.csv +++ b/pkg/services/featuremgmt/toggles-gitlog.csv @@ -75,7 +75,7 @@ traceqlEditor,2022-08-24T16:57:59Z,2022-12-13T13:27:45Z,c8f2148f7504b393b8f9b32f athenaAsyncQueryDataSupport,2022-09-05T15:39:45Z,2024-03-20T14:14:21Z,34fe7a1119c0f649d3e9df866353438ff2177fdc,Kevin Yu redshiftAsyncQueryDataSupport,2022-09-05T15:39:45Z,2024-03-20T14:14:21Z,34fe7a1119c0f649d3e9df866353438ff2177fdc,Kevin Yu increaseInMemDatabaseQueryCache,2022-09-12T07:50:54Z,2023-02-06T21:01:04Z,72ae4a5aa368d18793f778683e92ae4a3ac7a1db,Carl Bergquist -correlations,2022-09-16T13:14:27Z,,9b4cdfe6526a290e5708a8c0760d50efda607d5c,Piotr Jamróz +correlations,2022-09-16T13:14:27Z,2025-11-13T09:21:46Z,9b4cdfe6526a290e5708a8c0760d50efda607d5c,Piotr Jamróz grpcServer,2022-09-26T20:25:34Z,,55aae797441d2beadc911e390f14c04bb3f101da,Alexander Emelin alertingBigTransactions,2022-10-06T06:22:58Z,2023-04-06T16:06:25Z,b476ae62fba9b94dd272409c521812108750bf50,Joe Blubaugh lokiMonacoEditor,2022-10-06T14:35:30Z,2023-02-06T10:18:01Z,729ce8bb72a5db53fbeb1b5856557652bb175c7a,Matias Chomicki @@ -88,7 +88,7 @@ showDashboardValidationWarnings,2022-10-14T13:51:05Z,,2e16d5499e1cf29e67db5031fe interFont,2022-10-15T14:22:33Z,2022-12-01T11:59:37Z,9f5e691994c9db15f5984b196ab55fd7213b7f72,Torkel Ödegaard accessControlOnCall,2022-10-19T16:10:09Z,2025-02-25T12:44:40Z,717bd4a6c051d1447c9e35385a4ba176dd391c65,Gabriel MABILLE newDBLibrary,2022-10-26T01:20:41Z,2023-11-14T14:51:35Z,a3acfb1a48126119fd4913efbb572de162264e92,Ryan McKinley -nestedFolders,2022-10-26T14:15:14Z,2025-08-06T07:07:23Z,b346ae03105af606521bbefec16722d590378646,Kristin Laemmert +nestedFolders,2022-10-26T14:15:14Z,2025-07-31T22:56:50Z,b346ae03105af606521bbefec16722d590378646,Kristin Laemmert datasourceLogger,2022-11-02T13:51:51Z,2023-02-07T11:49:16Z,06705a49e236dc3ab3b35db51b7bad8625e4866f,Carl Bergquist promQueryBuilder,2022-11-03T17:34:01Z,2022-12-19T13:52:06Z,857e545c5ac71722c8b2d3d27621dfb453dbb8ff,Ryan McKinley elasticsearchBackendMigration,2022-11-10T15:35:15Z,2023-04-12T12:20:43Z,261d620f1c46eb43282cc444ffb4633b77af4283,Ivana Huckova @@ -108,7 +108,7 @@ supportBundles,2022-12-20T10:13:37Z,2023-02-10T09:12:04Z,2c7410c87d81b8a644e738f publicDashboardsEmailSharing,2023-01-03T19:45:15Z,,f0ee3ac80ae2af695c56e709af435c155a2fb33d,owensmallwood alertingNoNormalState,2023-01-13T23:29:29Z,2025-02-03T15:32:50Z,9d57b1c72eb817c5fe1b43ad7bc9706a264b2da0,Yuri Tseretyan azureMultipleResourcePicker,2023-01-20T15:29:23Z,2023-01-30T16:19:03Z,b1efd911c1f7bfabe1b8231b059470c72dce4712,Andres Martinez Gotor -editPanelCSVDragAndDrop,2023-01-24T09:43:44Z,,4167214e3535f41c85004c6aca86fcb9ae8956dd,Zoltán Bedi +editPanelCSVDragAndDrop,2023-01-24T09:43:44Z,2025-11-12T14:47:44Z,4167214e3535f41c85004c6aca86fcb9ae8956dd,Zoltán Bedi topNavCommandPalette,2023-01-24T12:41:09Z,2023-02-24T12:14:53Z,88347caf5fcc99c21c8a07367b9e846947367636,Josh Hunt k8sDashboards,2023-01-25T19:10:16Z,2023-02-09T17:54:00Z,4965cf2edae6eae8e341e6c93c3a1aa56417efb3,Ryan McKinley logsContextDatasourceUi,2023-01-27T14:12:01Z,,7c02d9bb8a864aab35c676f1a44c6c5be9eb6c09,Sven Grossmann @@ -155,11 +155,11 @@ extraThemes,2023-05-10T13:37:04Z,2025-05-20T08:18:08Z,f8cf67347f069310b8cc6a2d9f dataSourcePageHeader,2023-05-23T13:18:00Z,2023-11-06T20:21:15Z,7f84e83ffee53446b4c458c29f7c1385dac09ce3,Taewoo K alertingNotificationsPoliciesMatchingInstances,2023-05-30T13:15:22Z,2023-11-09T17:35:03Z,2f0728ac677c26b291947354058253d2e8a50bb7,Konrad Lalik lokiPredefinedOperations,2023-06-02T10:52:36Z,2025-06-30T14:08:36Z,06003c98c855fcb7c1c718c3f0e5f190b644656a,Ivana Huckova -pluginsFrontendSandbox,2023-06-05T08:51:36Z,,1ed4c0382b3812eecf9722596d8a3ac922f3543d,Esteban Beltran +pluginsFrontendSandbox,2023-06-05T08:51:36Z,2025-11-06T10:06:53Z,1ed4c0382b3812eecf9722596d8a3ac922f3543d,Esteban Beltran refactorVariablesTimeRange,2023-06-06T13:12:09Z,,07dd90b5a8866273813c6b4a5e7087cea529f908,Alexa V -sqlDatasourceDatabaseSelection,2023-06-06T16:28:52Z,2025-08-12T13:22:30Z,c0a1fc2cbdc1ceab4fdcb43270fd3720efe4494a,Jev Forsberg +sqlDatasourceDatabaseSelection,2023-06-06T16:28:52Z,2025-07-31T22:56:50Z,c0a1fc2cbdc1ceab4fdcb43270fd3720efe4494a,Jev Forsberg cloudWatchLogsMonacoEditor,2023-06-12T13:49:52Z,2024-03-18T12:56:57Z,5a831d877ac3305e7dfdb54057069b41b36b40fa,Isabella Siu -recordedQueriesMulti,2023-06-14T12:34:22Z,,db75f20e53e717503fe3bf27217fedc40f4a12e1,Kyle Brandt +recordedQueriesMulti,2023-06-14T12:34:22Z,2025-11-10T20:31:43Z,db75f20e53e717503fe3bf27217fedc40f4a12e1,Kyle Brandt exploreScrollableLogsContainer,2023-06-15T11:25:34Z,2024-03-12T14:53:13Z,cda10fae525d5cf784368791e1aaa1de2ce88f4b,Gareth Dawson alertingLokiRangeToInstant,2023-06-16T17:55:49Z,2023-09-13T11:52:40Z,934ba1aaa1fa4c1b9f1dcc0d6753c67a23c4f466,Jean-Philippe Quéméner lokiExperimentalStreaming,2023-06-19T10:03:51Z,,271cdb4baa6d5b021e013b463350f227173c4e95,Gábor Farkas @@ -178,7 +178,7 @@ lokiLogsDataplane,2023-07-13T07:58:00Z,,e045860fbf449feb101d187730a46bb1cdab787a mlExpressions,2023-07-13T17:37:50Z,,541bfe636daa55705a130df4004aa417c99adc09,Yuri Tseretyan disableTraceQLStreaming,2023-07-14T14:10:46Z,2023-07-26T13:33:16Z,c1709c93013a3fa5797898e81c046a2a53ae7ed4,Andre Pereira grafanaAPIServer,2023-07-14T19:22:10Z,2024-01-23T16:27:28Z,52121b7165b6ffcdf10dd7a62fdf04043ef0c00a,Todd Treece -featureToggleAdminPage,2023-07-18T20:43:32Z,,600f623610b00547400e00fd0b2ee7dc4bab5849,Ibrahim +featureToggleAdminPage,2023-07-18T20:43:32Z,2025-08-29T14:46:39Z,600f623610b00547400e00fd0b2ee7dc4bab5849,Ibrahim lokiFormatQuery,2023-07-21T12:03:56Z,2024-06-05T09:46:28Z,4e42f9b6198d39ffca82c3c3d32a16d54c66090b,Gareth Dawson splitScopes,2023-07-21T14:23:01Z,2024-02-23T15:53:37Z,cfa1a2c55feea58bd7f82e4e03df2ea712ef34a7,Ieva awsAsyncQueryCaching,2023-07-21T15:34:07Z,,56913fbd9578c4666599b2ab46423361d6c9261f,Isabella Siu @@ -215,7 +215,7 @@ enableNativeHTTPHistogram,2023-10-03T18:23:55Z,,0fc403d116b9cbd0b93e497cff0ddded transformationsVariableSupport,2023-10-04T14:28:46Z,2025-02-17T12:07:11Z,40cdb3033697e8d8e43d0ee0342f0d16fac7d633,Oscar Kilhed kubernetesPlaylists,2023-10-05T19:00:36Z,2024-08-13T08:03:28Z,664ebf771e2a1d2f94bc14d3dababb634c4ac40f,Todd Treece grafanaAPIServerWithExperimentalAPIs,2023-10-06T18:55:22Z,,717a9dd6160e352ff7c184bfcddf2410eed9d908,Ryan McKinley -panelMonitoring,2023-10-09T05:19:08Z,,ef82767dabea7d19cd8efac0c36ed590d4d767f4,Victor Marin +panelMonitoring,2023-10-09T05:19:08Z,2025-11-07T19:04:42Z,ef82767dabea7d19cd8efac0c36ed590d4d767f4,Victor Marin navAdminSubsections,2023-10-10T10:50:44Z,2023-11-17T10:04:34Z,f56cc6fdc01dbf2efb802f22650cb2bef0c40179,Ashley Harrison recoveryThreshold,2023-10-10T14:51:50Z,2025-04-24T15:58:17Z,810fbc3327841da6d21f945e75cad9daf040e625,Yuri Tseretyan libraryPanelRBAC,2023-10-11T23:30:50Z,2025-06-27T08:09:02Z,a12cb8cbf3a9b33841b2f2cb1522be11de78c86a,kay delaney @@ -223,7 +223,7 @@ awsDatasourcesNewFormStyling,2023-10-12T08:59:10Z,2024-07-22T12:48:17Z,2771fb940 cachingOptimizeSerializationMemoryUsage,2023-10-12T16:56:49Z,,94ce87571ddfcede0fb7a229a65502b385d5bca3,Michael Mandrus panelTitleSearchInV1,2023-10-13T12:04:24Z,2025-01-21T09:59:32Z,bf2f2540da7a4e4b8d80e1fa4ae3d05868cf7b69,Arati R exploreContentOutline,2023-10-13T16:57:13Z,2024-06-24T15:45:42Z,4ec54bc2c39ba43843c693fdb2a4529b6a4703f2,Haris Rozajac -formatString,2023-10-13T18:17:12Z,2025-11-12T00:00:00Z,889576ac1d9278b1c6e3e278e8195968646a2db0,Sol +formatString,2023-10-13T18:17:12Z,2025-11-17T13:06:30Z,889576ac1d9278b1c6e3e278e8195968646a2db0,Sol pluginsInstrumentationStatusSource,2023-10-17T08:27:45Z,2024-02-21T11:57:40Z,f5076d1868caa14ce44a70e812315541b4199d9f,Giuseppe Guerra teamHttpHeaders,2023-10-17T10:23:54Z,2025-02-20T10:26:46Z,be5ba6813209b5b24e955e0f761032cb5826b578,Eric Leijonmarck costManagementUi,2023-10-17T16:15:51Z,2024-01-08T14:25:11Z,de1ed216f4bbf6f341aa22b144fa66d583a63981,Adam Bannach @@ -237,10 +237,10 @@ alertmanagerRemoteSecondary,2023-10-30T16:27:08Z,,363830883cb1f5de30f7015df5cba4 annotationPermissionUpdate,2023-10-31T13:30:13Z,,c51c51458e4dd103aa0c099aa48b0d41f9375f86,Ieva kubernetesPlaylistsAPI,2023-10-31T17:26:39Z,2023-11-08T19:14:05Z,dd773e74f120ba908cafd596a6875c2d7fa199bf,Ryan McKinley traceToProfiles,2023-11-01T10:14:24Z,2024-01-22T14:21:14Z,c39e9a8f527b79881b95f64fd1c413b4bff42983,Joey -extractFieldsNameDeduplication,2023-11-02T15:47:42Z,,0eda368d32dbd7f57635cabd6b7d6870ca7b58ee,Oscar Kilhed +extractFieldsNameDeduplication,2023-11-02T15:47:42Z,2025-11-12T10:08:13Z,0eda368d32dbd7f57635cabd6b7d6870ca7b58ee,Oscar Kilhed dashboardSceneForViewers,2023-11-02T19:02:25Z,,6e80a3d59b14d9af235431d6acdce95835e2a792,Dominik Prokop panelFilterVariable,2023-11-03T12:15:54Z,,6bf4d0cbc6756ec8e8fb64b3a9d7b76f4c4194f5,Jacob Zelek -addFieldFromCalculationStatFunctions,2023-11-03T14:39:58Z,,61d63d3034d7eed4837c63e1f5cb2f6b7114758d,Victor Marin +addFieldFromCalculationStatFunctions,2023-11-03T14:39:58Z,2025-11-17T15:58:43Z,61d63d3034d7eed4837c63e1f5cb2f6b7114758d,Victor Marin pdfTables,2023-11-06T13:39:22Z,,95b48339f89c7d267bce7d894404d26ccd75d0e3,Agnès Toulet newVizTooltips,2023-11-06T16:35:59Z,2024-04-03T00:32:01Z,6b4b7127544865b78f712d907e6f1719595f4232,Adela Almasan ssoSettingsApi,2023-11-08T09:50:01Z,2025-07-03T08:53:33Z,5285e9503be5702680acb2b52a6bda0632f4603d,Misi @@ -250,10 +250,11 @@ alertingDetailsViewV2,2023-11-09T17:35:03Z,2024-03-14T14:18:01Z,323ee7c38ceb18b8 alertingSimplifiedRouting,2023-11-10T13:14:39Z,2025-05-09T13:30:56Z,68e37c3925080cf64a5e7570eabb042f19ca2dbf,Sonia Aguilar dashboardScene,2023-11-13T08:51:21Z,,4bc322ca1d6ed63d7e79eecb1ed09f3043f9aedb,Torkel Ödegaard datatrails,2023-11-15T11:28:29Z,2024-04-09T18:15:18Z,1f1d348e1700735683dc9b80fce106b8a5bc4cee,Torkel Ödegaard -pluginsSkipHostEnvVars,2023-11-15T17:09:14Z,,cb0a88a02770eaee2561fd434d8339cab268f02e,Giuseppe Guerra +pluginsSkipHostEnvVars,2023-11-15T17:09:14Z,2025-11-13T15:31:57Z,cb0a88a02770eaee2561fd434d8339cab268f02e,Giuseppe Guerra logRowsPopoverMenu,2023-11-16T09:48:10Z,,9cb303c3f701169e8651267744a4b5fe1695a066,Matias Chomicki lokiStructuredMetadata,2023-11-16T16:06:14Z,2025-06-30T14:09:44Z,a01f8c5b42bb7937139b8d52e9723cc37e5f7ae6,Sven Grossmann tracesEmbeddedFlameGraph,2023-11-23T13:36:53Z,2024-01-22T14:21:14Z,4f46fb412ca4e96b0b0ef3f462aead3fc146389e,Joey +regressionTransformation,2023-11-24T14:49:16Z,2025-07-01T13:59:22Z,ab982e7bd36b86c94093bddcc31008f5dc49660e,Oscar Kilhed displayAnonymousStats,2023-11-29T16:58:41Z,2024-02-23T15:53:37Z,59bdff0280d52ca5d8918157d7697b9279b25501,Eric Leijonmarck influxqlStreamingParser,2023-11-29T17:29:35Z,,5845f140758473ab5ffe789bec4077032fd22839,ismail simsek kubernetesSnapshots,2023-12-05T22:31:49Z,,439edebcd605a1b63bf3a9b0ab5c2b83341cd5cd,Ryan McKinley @@ -274,12 +275,12 @@ jitterAlertRules,2024-01-18T18:48:11Z,2024-02-09T21:53:58Z,00a260effab802edc8f72 jitterAlertRulesWithinGroups,2024-01-18T18:48:11Z,,00a260effab802edc8f72df50bfb6447aac343f0,Alexander Weaver onPremToCloudMigrations,2024-01-22T16:09:08Z,,cf13cb9f70c2230f17450667ce59440304fb023c,Michael Mandrus alertingSaveStatePeriodic,2024-01-23T16:03:30Z,,aa25776f813926cb4f1947d4ae5a014f4e7728ff,Jean-Philippe Quéméner -promQLScope,2024-01-29T20:22:17Z,,43d0664340f3e3af219d7b5c747f486a073f5ce3,Kyle Brandt +promQLScope,2024-01-29T20:22:17Z,2025-10-10T14:53:18Z,43d0664340f3e3af219d7b5c747f486a073f5ce3,Kyle Brandt slateAutocomplete,2024-01-31T10:01:20Z,2024-02-01T13:03:11Z,39057552dc2ddfe8ee770c67a71cf55159aca741,Ashley Harrison nodeGraphDotLayout,2024-01-31T16:26:12Z,2025-04-08T14:37:17Z,cb945aa5df3453ab2a851ef7ec8bee244e3eeac4,Andrej Ocenas kubernetesQueryServiceRewrite,2024-01-31T18:36:51Z,2024-04-19T09:26:21Z,e013cd427cb0457177e11f19ebd30bc523b36c76,Ryan McKinley influxdbRunQueriesInParallel,2024-02-01T10:58:24Z,,536153c3362782ae7f79e06755e8c7e0cd3b3acd,ismail simsek -groupToNestedTableTransformation,2024-02-07T14:28:26Z,,756cd3c28b0648f55a6c0158f5faf63b632ebe70,Kyle Cunningham +groupToNestedTableTransformation,2024-02-07T14:28:26Z,2025-11-17T11:57:22Z,756cd3c28b0648f55a6c0158f5faf63b632ebe70,Kyle Cunningham newPDFRendering,2024-02-08T12:09:34Z,,28e66b4ad82ecebb551374325f0be4332412341c,Agnès Toulet autoMigrateGraphPanel,2024-02-08T22:00:48Z,2025-04-04T09:31:35Z,829672759c12b27f849c92c3a2aee4a6b0037920,Nathan Marrs dashboardSceneSolo,2024-02-11T08:08:47Z,,fe6d1460b09b403fc74fd20e95f61513eede2555,Torkel Ödegaard @@ -290,7 +291,7 @@ autoMigrateTablePanel,2024-02-14T16:06:25Z,2025-04-04T09:31:35Z,ce750e06187599da autoMigrateWorldmapPanel,2024-02-14T16:06:25Z,2025-04-04T09:31:35Z,ce750e06187599da6b9c0a91ef95c7a62fe0d069,Nathan Marrs groupByVariable,2024-02-14T17:18:04Z,,f016f95298fe490a865612864520f3622f8e804a,Dominik Prokop alertingUpgradeDryrunOnStart,2024-02-16T16:29:54Z,2024-03-14T14:36:35Z,dfaf6d1e2e13b2bd11dc8f0cd4432bfcad819aa9,Matthew Jacobson -expressionParser,2024-02-17T00:59:11Z,2025-08-26T13:21:24Z,f23f50f58d7ab5cb1fd88b42b6c58ec09c1a159d,Ryan McKinley +expressionParser,2024-02-17T00:59:11Z,2025-07-31T22:56:50Z,f23f50f58d7ab5cb1fd88b42b6c58ec09c1a159d,Ryan McKinley sqlExpressions,2024-02-27T21:16:00Z,,70009201d44c2d0ab39cc77081808a69a6c4fd63,Scott Lepper aiGeneratedDashboardChanges,2024-03-05T12:01:31Z,,a7c06d26f14b2a9fa8faa929a6c9a0c355018429,Ivan Ortega Alba scopeFilters,2024-03-05T15:41:19Z,,b3efb4217e48656f24aacdffd5737595d7361afe,Carl Bergquist @@ -303,7 +304,7 @@ usePrometheusFrontendPackage,2024-03-23T00:47:53Z,2024-04-15T21:45:23Z,d08459521 oauthRequireSubClaim,2024-03-25T13:22:24Z,,2f3a01f79fda7f6c465dd64adf5cf5c7227f9d19,Karl Persson authAPIAccessTokenAuth,2024-04-02T15:45:15Z,2025-02-04T15:31:24Z,5340a6e548b1e5fcd3af66695ca34d7e943fd068,Jo newDashboardWithFiltersAndGroupBy,2024-04-04T11:25:21Z,,32b6ef9d153dc7def2c17b81b02cfb1c412eb315,Dominik Prokop -prometheusCodeModeMetricNamesSearch,2024-04-04T20:38:23Z,2025-08-27T13:11:58Z,559fab9dc6a6c882e7d9621ba013c3a53347176a,Nick Richmond +prometheusCodeModeMetricNamesSearch,2024-04-04T20:38:23Z,2025-07-31T22:56:50Z,559fab9dc6a6c882e7d9621ba013c3a53347176a,Nick Richmond cloudWatchNewLabelParsing,2024-04-05T15:57:56Z,,58f32150c262605d188874b88f44a7dece4b9388,Isabella Siu exploreMetrics,2024-04-09T18:15:18Z,2025-04-11T20:45:14Z,66c0fd4dcc3202e11f41b302d27894dc162fb288,Darren Janeczek accessActionSets,2024-04-12T16:19:25Z,2025-03-13T15:18:23Z,56f4664875047d6861ea3facbc94cd921e263950,Ieva @@ -314,8 +315,8 @@ queryServiceRewrite,2024-04-19T09:26:21Z,,5a8384a2455bbd3c0ba5ec67e5f5e3cc4a8369 grafanaManagedRecordingRules,2024-04-22T17:53:16Z,2025-05-19T10:15:49Z,c32953e52cf9fd46a462711bc23fde15a5c3b6bf,Alexander Weaver logsExploreTableDefaultVisualization,2024-05-02T15:28:15Z,,840aeddbd1957117d9b62074c155e87bb4afd4b4,Galen Kistler autofixDSUID,2024-05-03T11:32:07Z,2024-06-20T10:56:39Z,b6f899d953a0924760dc5fd5a3d40669c86d475d,Andres Martinez Gotor -newDashboardSharingComponent,2024-05-03T15:02:18Z,,d1434fad3a68bc1d2b49725be31e45526e6ad349,Juan Cabanas -tlsMemcached,2024-05-09T19:12:08Z,,b009536329d110afd807ef2f27f2b7dcc7d310ba,lean.dev +newDashboardSharingComponent,2024-05-03T15:02:18Z,2025-08-29T14:46:39Z,d1434fad3a68bc1d2b49725be31e45526e6ad349,Juan Cabanas +tlsMemcached,2024-05-09T19:12:08Z,2025-11-12T15:49:28Z,b009536329d110afd807ef2f27f2b7dcc7d310ba,lean.dev notificationBanner,2024-05-13T09:32:34Z,2025-01-10T10:18:43Z,f3953b4955c218cc4678e842faa3d3380fd0f8f7,Alex Khomenko dualWritePlaylistsMode2,2024-05-14T12:11:56Z,2024-05-31T18:18:09Z,6836bfe1ea1bf62f4eb66dc1328a456183874f81,Arati R dualWritePlaylistsMode3,2024-05-14T12:11:56Z,2024-05-31T18:18:09Z,6836bfe1ea1bf62f4eb66dc1328a456183874f81,Arati R @@ -329,7 +330,7 @@ alertingCentralAlertHistory,2024-05-29T15:01:38Z,,289ce6185574df99acea37b86c2e08 pluginProxyPreserveTrailingSlash,2024-06-05T11:36:14Z,,fe3e5917f1bc83ab29b6a57578316e054718aa4a,Marcus Efraimsson kubernetesDashboards,2024-06-05T14:34:23Z,,41e0430f83bf7db50c4caaa1472afa3bf3d5c2dc,Ryan McKinley azureMonitorPrometheusExemplars,2024-06-06T16:53:17Z,,c9778c3332aa93e5dd8bc3b894264e1955f0d593,Andreas Christou -pinNavItems,2024-06-10T11:40:03Z,,84b638fb26cecf856374bb3d09b123061b4b8a6b,Laura Fernández +pinNavItems,2024-06-10T11:40:03Z,2025-11-17T12:12:47Z,84b638fb26cecf856374bb3d09b123061b4b8a6b,Laura Fernández authZGRPCServer,2024-06-13T09:41:35Z,,afcb5a855c26e985e43861bff6fab36b1b008109,Gabriel MABILLE openSearchBackendFlowEnabled,2024-06-17T09:41:50Z,2025-02-03T13:24:49Z,ab2af9b8f75cd13595f4d487c1168e849768a518,Ida Štambuk ssoSettingsLDAP,2024-06-18T11:31:27Z,,d074cc7892b96a1333bd07011baff146ea71e21d,Mihai Doarna @@ -388,7 +389,7 @@ enableExtensionsAdminPage,2024-11-05T15:55:10Z,,c7a7f7dce5d93dc5044c4be140433ee2 exploreMetricsRelatedLogs,2024-11-05T16:28:43Z,,82ac9e2bb67a665c3b56e3a80c47d8aea7f7d044,ismail simsek enableScopesInMetricsExplore,2024-11-06T13:11:33Z,,a80517d6fecf245cb9366b5662ab6d8760200389,Bogdan Matei zipkinBackendMigration,2024-11-07T09:35:53Z,2025-01-28T14:11:10Z,c151021b162d99da2cb0f09485f90a2b5d38c11c,Ivana Huckova -preinstallAutoUpdate,2024-11-07T12:14:25Z,,a415c0b83184ad473b3f8e10c0319c46d9e5c515,Andres Martinez Gotor +preinstallAutoUpdate,2024-11-07T12:14:25Z,2025-11-10T14:06:30Z,a415c0b83184ad473b3f8e10c0319c46d9e5c515,Andres Martinez Gotor enableSCIM,2024-11-07T14:38:46Z,,3d51ca7377eff46b39750a8e0311ead7b75fd359,linoman logQLScope,2024-11-11T11:53:24Z,,c7b6822a5e9cba703e521536947682aaf801203e,Carl Bergquist userStorageAPI,2024-11-12T11:56:41Z,2025-03-27T12:40:00Z,c3494614e39638f6d78b79397cf6032cbcde4b9f,Andres Martinez Gotor @@ -417,7 +418,7 @@ k8SFolderCounts,2024-12-27T17:10:44Z,,df36e77cd31d2ad77e3d708748d040367a0c8c9c,L k8SFolderMove,2024-12-27T17:10:44Z,,df36e77cd31d2ad77e3d708748d040367a0c8c9c,Leonor Oliveira kubernetesRestore,2025-01-03T14:48:47Z,2025-03-20T21:38:32Z,5429512779bd5f25b88ff728ea91efdef7dfafa0,Stephanie Hingtgen improvedExternalSessionHandlingSAML,2025-01-09T17:02:49Z,,c52ec21c75ab72c2f7d28259bac0364edae560d0,Misi -teamHttpHeadersMimir,2025-01-13T10:42:47Z,2025-08-07T09:04:46Z,04acbcdef23f673bd6bbfdbbece29c9769ce155a,Eric Leijonmarck +teamHttpHeadersMimir,2025-01-13T10:42:47Z,2025-07-31T22:56:50Z,04acbcdef23f673bd6bbfdbbece29c9769ce155a,Eric Leijonmarck ABTestFeatureToggleA,2025-01-13T21:13:13Z,2025-05-27T19:18:23Z,009d7f42b3d09b3a6be1f00f07314e2b25af7ebc,Nathan Marrs ABTestFeatureToggleB,2025-01-13T21:13:13Z,2025-05-27T19:18:23Z,009d7f42b3d09b3a6be1f00f07314e2b25af7ebc,Nathan Marrs kubernetesFoldersServiceV2,2025-01-13T21:15:35Z,2025-02-18T23:11:26Z,766d645d827f5e6e0872ae30e5fe23226ae85785,maicon @@ -425,10 +426,10 @@ queryLibraryDashboards,2025-01-14T11:01:15Z,2025-02-14T16:39:22Z,740cd22fe51a354 elasticsearchImprovedParsing,2025-01-15T17:05:54Z,,bab55a4cb84f2ba57838f96a492ab9aa7f307957,Adam Yeats grafanaAdvisor,2025-01-20T10:08:00Z,,c1364d6be6f552203ba786f17a89664304b89247,Andres Martinez Gotor datasourceConnectionsTab,2025-01-21T17:39:48Z,,97d8f68b705f9949493079d1833abfe80e7b48f3,Syerikjan Kh -unifiedStorageSearchPermissionFiltering,2025-01-22T11:38:37Z,2025-08-06T08:04:32Z,dd483fc17fa4a2931848e3574cfc31ea6f6530d9,owensmallwood +unifiedStorageSearchPermissionFiltering,2025-01-22T11:38:37Z,2025-07-31T22:56:50Z,dd483fc17fa4a2931848e3574cfc31ea6f6530d9,owensmallwood alertingSaveStateCompressed,2025-01-27T17:47:33Z,,cb43f4b6962fca18655b3ba634adeb4d59dc89df,Alexander Akhmetov fetchRulesUsingPost,2025-01-29T12:17:44Z,,1444051b65af0de6c412a12132083135c7730414,Fayzal Ghantiwala -templateVariablesUsesCombobox,2025-01-31T09:53:13Z,,7190bfb0ca675fc1b3b5d7ddf7e0ee9d1c9ca3d7,Tobias Skarhed +templateVariablesUsesCombobox,2025-01-31T09:53:13Z,2025-11-13T03:31:18Z,7190bfb0ca675fc1b3b5d7ddf7e0ee9d1c9ca3d7,Tobias Skarhed alertingAlertmanagerExtraDedupStage,2025-01-31T16:12:38Z,2025-03-26T19:15:10Z,0be6e1bb86613b6466e531119fe1cfec67d35aac,Yuri Tseretyan alertingAlertmanagerExtraDedupStageStopPipeline,2025-01-31T16:12:38Z,2025-03-26T19:15:10Z,0be6e1bb86613b6466e531119fe1cfec67d35aac,Yuri Tseretyan exploreMetricsUseExternalAppPlugin,2025-02-03T20:46:54Z,2025-04-11T20:45:14Z,29fa6dfc8de0a758f9f37f000c923f0806c3b629,Nick Richmond @@ -445,24 +446,24 @@ rendererDisableAppPluginsPreload,2025-02-24T14:43:06Z,,608d974585c696253ac629f3c assetSriChecks,2025-03-04T10:56:35Z,,bbfeb8d220cc67c329aa2b5d6ed693ae1cb54325,Jack Westbrook alertRuleRestore,2025-03-05T14:15:26Z,,374380d1f6ab05e98bc00b0b0fdaa076d1d30ffa,Yuri Tseretyan grafanaManagedRecordingRulesDatasources,2025-03-07T13:30:40Z,2025-06-02T08:56:05Z,14ebec527ccf22c15321ac8394a4f9c3909dd4e1,Steve Simpson -inviteUserExperimental,2025-03-07T19:09:59Z,,5e21b9e2d1ebad0dcb5fce5fa1bc48b74cea4b45,Juan Cabanas +inviteUserExperimental,2025-03-07T19:09:59Z,2025-11-14T15:33:26Z,5e21b9e2d1ebad0dcb5fce5fa1bc48b74cea4b45,Juan Cabanas extraLanguages,2025-03-11T10:07:16Z,2025-03-27T12:58:52Z,210c886bb7b913e4ca0b77ff142a72185a82a385,Josh Hunt infinityRunQueriesInParallel,2025-03-14T12:54:04Z,,d72b6649810ffacebe73ef67c6b5bd6e46dfab47,ismail simsek noBackdropBlur,2025-03-14T15:21:35Z,2025-04-08T10:58:19Z,bf172dfd296d15c02473904646c19d2bb663cc3f,Josh Hunt alertingMigrationUI,2025-03-14T16:40:05Z,,ef9dca9ea369997116cd7880c238597fc18ca682,Tom Ratcliffe -unifiedStorageHistoryPruner,2025-03-17T10:36:38Z,,1700a8aa9f2a103f953a385544c2ce200e6fcf9d,Jean-Philippe Quéméner +unifiedStorageHistoryPruner,2025-03-17T10:36:38Z,2025-11-17T19:47:37Z,1700a8aa9f2a103f953a385544c2ce200e6fcf9d,Jean-Philippe Quéméner secretsManagementAppPlatform,2025-03-19T09:25:14Z,,ac4b2a320071323f0d2849b844f7e8c3afbff21d,Matheus Macabu unifiedStorageGrpcConnectionPool,2025-03-21T13:24:54Z,,ba3e8014b3363762369c434c0c67d871a311a033,Jean-Philippe Quéméner -tableNextGen,2025-03-26T03:57:57Z,2025-08-26T21:25:16Z,03d6d8f854ac06701bba2d5d10bedd8a227bea4e,Drew Slobodnjak +tableNextGen,2025-03-26T03:57:57Z,2025-07-31T22:56:50Z,03d6d8f854ac06701bba2d5d10bedd8a227bea4e,Drew Slobodnjak alertingRuleRecoverDeleted,2025-03-27T14:39:26Z,,f9471ac10b65e08343b08d82e1209f1ae7006955,Sonia Aguilar -localizationForPlugins,2025-03-31T04:38:38Z,,18ae5d7f0c599bf417dc68a67fa6852b6cf54600,Hugo Häggmark +localizationForPlugins,2025-03-31T04:38:38Z,2025-08-29T14:46:39Z,18ae5d7f0c599bf417dc68a67fa6852b6cf54600,Hugo Häggmark localeFormatPreference,2025-03-31T13:59:07Z,,4ad0492d3dc8ee81b388384e3e6c329746f46df7,Laura Fernández useScopesNavigationEndpoint,2025-03-31T15:20:00Z,,c321afdeb710db302558ac63a8f0569c4706d003,Tobias Skarhed xrayApplicationSignals,2025-04-01T14:42:02Z,2025-06-13T18:26:14Z,1aea65f6d5aae0ea9d54a6d943b27e1004f4ef85,Isabella Siu queryServiceFromExplore,2025-04-02T10:00:33Z,,135fbf6258d85382a3c80b696b4e410fd6efe809,Gábor Farkas azureMonitorLogsBuilderEditor,2025-04-02T14:15:25Z,,3b73ebb21096f69f4dcb287896e2cbfec65e48d7,Alyssa (Bull) Joyner multiTenantTempCredentials,2025-04-02T20:25:50Z,,6a699b69bace2aecfe3631a5e050cda69180b97f,Isabella Siu -extensionSidebar,2025-04-03T10:16:35Z,2025-09-01T10:14:17Z,f27790268254294be7c09832e9a73676fd9a629c,Sven Grossmann +extensionSidebar,2025-04-03T10:16:35Z,2025-07-31T22:56:50Z,f27790268254294be7c09832e9a73676fd9a629c,Sven Grossmann alertingRulePermanentlyDelete,2025-04-03T11:18:25Z,,3450d243b95acd5bf86fbbf3b183cba4b2eb8301,Sonia Aguilar logsPanelControls,2025-04-07T14:38:55Z,,e2a6f9a84928cfb2f958522b1f50fdf3051f51de,Matias Chomicki metricsFromProfiles,2025-04-09T10:55:28Z,,ceed8243784500557f2686bb478695cc125a0e19,Piotr Jamróz @@ -475,14 +476,14 @@ pluginsAutoUpdate,2025-04-16T11:44:39Z,,c947732e0de99395a6ee4e0161e27c3e884168ac alertingListViewV2PreviewToggle,2025-04-22T08:50:34Z,,512df0091a1df6c7d19243e288ec458250b49813,Konrad Lalik alertRuleUseFiredAtForStartsAt,2025-04-22T11:16:38Z,,3a054d5e00abc212a741fc1aad8cf266a188de1f,Fayzal Ghantiwala alertingBulkActionsInUI,2025-04-24T14:49:59Z,,674fdd1d323ef041dbed93b44ae58afaaf1a8b64,Sonia Aguilar -multiTenantFrontend,2025-04-25T09:24:25Z,,7b492d7e1610da99460cb121ec542c5b755fe6d1,Ryan McKinley +multiTenantFrontend,2025-04-25T09:24:25Z,2025-07-31T22:56:50Z,7b492d7e1610da99460cb121ec542c5b755fe6d1,Ryan McKinley extensionsReadOnlyProxy,2025-05-06T04:55:23Z,2025-07-01T04:10:57Z,bcb2a7e36f7ae5190ab45669f4a0d04660313842,Levente Balogh kubernetesAggregatorCapTokenAuth,2025-05-15T18:14:23Z,,aa2cf8e398ee05353cc488ca22e3c8ee65dd5c53,Charandas alertingImportYAMLUI,2025-05-21T15:59:41Z,,fc5472615fc8b823f1c18c4eae2b6ce3ddcc76f9,Sonia Aguilar teamHttpHeadersTempo,2025-05-22T19:13:31Z,,249e2f3d34f5175e62a17629646994d3116e64f6,Cory Forseth restoreDashboards,2025-05-23T14:35:54Z,,0cb6f9584bf77a7dbdfcc6ce1032db2b4bdc15e2,Alex Khomenko postgresDSUsePGX,2025-05-26T06:54:18Z,2025-06-03T12:45:07Z,1e383b0c1e98734ad4bf974f0435512d10c33246,Zoltán Bedi -skipTokenRotationIfRecent,2025-06-03T06:59:40Z,,86f2bf294044129e802ddeab6f4ac8e9f79dee9e,xavi +skipTokenRotationIfRecent,2025-06-03T06:59:40Z,2025-10-23T08:02:41Z,86f2bf294044129e802ddeab6f4ac8e9f79dee9e,xavi alertEnrichment,2025-06-06T12:16:07Z,,f81031f945b55b644a3ad6a6bcf99d445beb4ad4,Steve Simpson alertingImportAlertmanagerAPI,2025-06-10T08:32:50Z,,f14ed750f53899d72153defc6c787200c22816ca,Alexander Akhmetov preferLibraryPanelTitle,2025-06-17T11:21:21Z,,e90134bb6f2a9de0ce520f4a00427de2d80c6667,Oscar Kilhed @@ -494,42 +495,93 @@ kubernetesLibraryPanels,2025-06-25T22:21:56Z,,79fe8a9902335c7a28af30e467b904a4cc enableAppChromeExtensions,2025-06-30T04:32:08Z,,15293a2ceb083108c0f15490933db523aa915c7d,Hugo Häggmark foldersAppPlatformAPI,2025-07-03T14:15:23Z,,e76f470b444499f70825e9e5eb6b1775ff086c3c,Andrej Ocenas tempoAlerting,2025-07-15T13:36:36Z,,68b9a5f57c30976a1356a0bdb381821e2643384f,Piotr Jamróz -provisioningSecretsService,2025-07-15T13:43:17Z,2025-08-22T16:38:28Z,d39a47a89b9bfc2940f27ca8469e576a659bfc63,Stephanie Hingtgen +provisioningSecretsService,2025-07-15T13:43:17Z,2025-07-31T22:56:50Z,d39a47a89b9bfc2940f27ca8469e576a659bfc63,Stephanie Hingtgen sharingDashboardImage,2025-07-15T21:07:39Z,,b691b3288d5d18366e405ad4e881e7e9d9d6de96,Nathan Marrs -enablePluginImporter,2025-07-16T04:42:28Z,,5b82e056972f959908cd42c9934ba56e8adbf143,Hugo Häggmark +enablePluginImporter,2025-07-16T04:42:28Z,2025-10-23T04:18:23Z,5b82e056972f959908cd42c9934ba56e8adbf143,Hugo Häggmark otelLogsFormatting,2025-07-16T15:42:14Z,,974103c6fa20701c5467c67fb24f173aea9113fb,Matias Chomicki alertingAIAnalyzeCentralStateHistory,2025-07-16T16:42:42Z,,9c15662cf6337b37f2932cf7b20fe5e05d310153,Sonia Aguilar alertingAIGenAlertRules,2025-07-16T16:42:42Z,,9c15662cf6337b37f2932cf7b20fe5e05d310153,Sonia Aguilar alertingAIGenTemplates,2025-07-16T16:42:42Z,,9c15662cf6337b37f2932cf7b20fe5e05d310153,Sonia Aguilar alertingAIImproveAlertRules,2025-07-16T16:42:42Z,,9c15662cf6337b37f2932cf7b20fe5e05d310153,Sonia Aguilar alertingNotificationHistory,2025-07-17T13:26:26Z,,bccc980b902987fc9a2707deca766412b47bbe2a,Vadim Stepanov -pluginAssetProvider,2025-07-17T15:20:35Z,,f6ed9e6ff0e799f3e69532809a7e444efb3c610e,Will Browne +pluginAssetProvider,2025-07-17T15:20:35Z,2025-10-10T09:35:22Z,f6ed9e6ff0e799f3e69532809a7e444efb3c610e,Will Browne unifiedStorageSearchDualReaderEnabled,2025-07-18T12:43:56Z,,2dba473015a6e7e75a5d117bf766a6c24d404d38,maicon kubernetesLibraryPanelConnections,2025-07-21T12:53:46Z,2025-07-30T16:46:19Z,5ec3a2b758dfd0cc63cc42759d6f01a88dfbb6bc,Stephanie Hingtgen -dashboardDsAdHocFiltering,2025-07-23T08:12:25Z,,aedd7b6e3408a04a84ef02c535439fd17aa2e0e6,Sam Jewell +dashboardDsAdHocFiltering,2025-07-23T08:12:25Z,2025-11-10T17:17:49Z,aedd7b6e3408a04a84ef02c535439fd17aa2e0e6,Sam Jewell alertingAIFeedback,2025-07-23T12:38:09Z,,7c872f0e8aa6d05061f546da98d94fe223192fa2,Sonia Aguilar alertingProvenanceLockWrites,2025-07-23T18:16:06Z,,7c43c061a827e4802f40d74f451a15a24db6abd6,Alexander Akhmetov sqlExpressionsColumnAutoComplete,2025-07-23T21:49:58Z,,5bfed408edcc6f74dd8b0780b4f87bccfea02b01,Kristina timeComparison,2025-07-24T20:07:28Z,,219672722662938633ffae3f6e8bfa89f8f29661,Drew Slobodnjak kubernetesAuthnMutation,2025-07-25T15:05:32Z,,5f6fc38430494e3cb642a09e32415ae62a92ad56,Victor Cinaglia alertmanagerRemoteSecondaryWithRemoteState,2025-07-25T15:06:59Z,,dcb965b7dcf1ec47d0847968fa362d2b308328f8,Santiago -adhocFiltersInTooltips,2025-07-29T17:53:43Z,,2174a84b36b9a086018972654c5ec661c19937a1,Sam Jewell +adhocFiltersInTooltips,2025-07-29T17:53:43Z,2025-11-12T10:05:30Z,2174a84b36b9a086018972654c5ec661c19937a1,Sam Jewell scanRowInvalidDashboardParseFallbackEnabled,2025-07-30T14:18:38Z,,98e37f2ca9b4fa1a6cac0c3a2ae355a5ff60dbd9,Mustafa Sencer Özcan dashboardLevelTimeMacros,2025-07-31T09:49:07Z,,e7cfe0c0237394f6c87d01ad4a5e6f29954212fb,Oscar Kilhed useScopeSingleNodeEndpoint,2025-07-31T14:32:41Z,,972e2f31e5f3dfd8d9f8152b3bb1fe7224e5d1e1,Tobias Skarhed -newLogContext,2025-08-01T11:30:17Z,,3f90c85c4eea9657bac14b49c925ba32d208c54e,Matias Chomicki -kubernetesShortURLs,2025-08-04T12:12:12Z,,e88b54e9d3412508342809fd0bb85f836cd50d73,Ezequiel Victorero -newClickhouseConfigPageDesign,2025-08-05T13:37:28Z,,23b801470889c9c99a02f32e819b4a45d5d67189,Alyssa Joyner -favoriteDatasources,2025-08-08T13:28:17Z,,463e544db9812ef6bf02dfe764585b21a00c172c,Andres Martinez Gotor -kubernetesAuthzResourcePermissionApis,2025-08-11T08:54:36Z,,58c4305d64a8e1d33d4f10f0cb2e4e2c318c2fac,Ieva -unifiedStorageSearchAfterWriteExperimentalAPI,2025-08-13T14:05:15Z,,b9b34223a7e8cd40a496c0d4a2c8386f46136ca7,Will Assis -alertingImportAlertmanagerUI,2025-08-13T15:28:43Z,,587f52cf5b8c480cfe63490a1e77907faa1a263a,Alexander Akhmetov -teamFolders,2025-08-13T16:41:00Z,,5564f699ca45fcbffcf67ce2575ff40a34e263cd,Tom Ratcliffe -grafanaAssistantInProfilesDrilldown,2025-08-19T07:54:00Z,,bbf01a638345e42c0f48dfcc743efbfbb7fedaeb,Piotr Jamróz -savedQueries,2025-08-25T21:22:09Z,,649e9aa8ca9e8f7f16e9198b3858aaf12714a6a8,Ezequiel Victorero -alertingEnrichmentPerRule,2025-08-28T08:30:28Z,,98bd10965b4be99d1b1306a2ddf87b1099df74dd,Sonia Aguilar +alertEnrichmentConditional,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +alertEnrichmentMultiStep,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +alertingEnrichmentPerRule,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +alertingImportAlertmanagerUI,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +alertingTriage,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +azureResourcePickerUpdates,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +dskitBackgroundServices,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +favoriteDatasources,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +grafanaAssistantInProfilesDrilldown,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +graphiteBackendMode,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +kubernetesAlertingRules,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +kubernetesAuthzResourcePermissionApis,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +kubernetesShortURLs,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +newClickhouseConfigPageDesign,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +newLogContext,2025-07-31T22:56:50Z,,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +pluginContainers,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +prometheusTypeMigration,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +restrictedPluginApis,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +savedQueries,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +secretsManagementAppPlatformUI,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +teamFolders,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +unifiedStorageSearchAfterWriteExperimentalAPI,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou +vizActionsAuth,2025-07-31T22:56:50Z,2025-08-01T11:30:17Z,ca8324e62a3ed855e9e68c7a5e358a16d48edc8f,Moustafa Baiou queryServiceWithConnections,2025-08-28T19:28:26Z,2025-08-29T12:49:57Z,eda94a6434efc84862f9907c40463c02460c400d,Ryan McKinley -alertingTriage,2025-09-01T09:33:33Z,,31114fb47ced7afbd559afe00a49f40914cd7acb,Konrad Lalik -restrictedPluginApis,2025-09-01T09:57:00Z,,d31e682345c5a4a7b3e055a481996d406329e1fa,Levente Balogh -graphiteBackendMode,2025-09-01T15:13:47Z,,0dc283b303a4b0b992e39b9d72c71f1f3a1ea597,Andreas Christou -azureResourcePickerUpdates,2025-09-02T10:02:01Z,,1a8d25375a6cd8adc3f64558b08ca9fd2ae9782d,Andreas Christou +alertingEnrichmentAssistantInvestigations,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +alertingUseNewSimplifiedRoutingHashAlgorithm,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +cdnPluginsLoadFirst,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +cdnPluginsUrls,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +dashboardLibrary,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +dashboardUndoRedo,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +filterOutBotsFromFrontendLogs,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +kubernetesAuthZHandlerRedirect,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +kubernetesCorrelations,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +kubernetesStars,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +queryCacheRequestDeduplication,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +starsFromAPIServer,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +tempoSearchBackendMigration,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +unifiedStorageUseFullNgram,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +useKubernetesShortURLsAPI,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +useMultipleScopeNodesEndpoint,2025-08-29T14:46:39Z,2025-09-01T09:33:33Z,c3f34efb41749d50b76ff0d765739e8c7b5c4da0,beejeebus +alertingGenerateSimplifiedRoutingWithOldHashes,2025-10-01T16:45:36Z,2025-10-01T19:21:33Z,0da9d4989652218d73ea9aa176ed300dba9aa884,Alexander Akhmetov +unlimitedLayoutsNesting,2025-10-10T12:15:54Z,,b5613b37249f42e18ef62f987202ba43858e5992,Sergej-Vlasov +enableDashboardEmptyExtensions,2025-10-13T07:03:13Z,,85174e3313952f522e1325b17a4e34c898d8617e,Matt Cowley +kubernetesAuthzZanzanaSync,2025-10-13T19:37:13Z,,0e341643292d903bc5fc882aa3332966284958ff,Gabriel MABILLE +grafanaPathfinder,2025-10-14T10:40:40Z,2025-10-22T08:06:21Z,c9f402c764d41a7ff85ef26979f8b4fcbe1b4b81,Jay Clifford +kubernetesLogsDrilldown,2025-10-16T21:31:42Z,,b3f9dad044f65479f99bb174af5725a8863ec915,Liza Detrick +preventPanelChromeOverflow,2025-10-17T14:40:08Z,,4cf11b721aeade73b37551382f5202ecef94d49a,Ashley Harrison +pluginStoreServiceLoading,2025-10-17T20:01:43Z,,69628baa9ddde2851dcccfe6c3c7d8c3e8b3197b,Todd Treece +onlyStoreActionSets,2025-10-20T15:02:56Z,,0a0311a2b230991a55d5b2d393d1cef0326e26f9,Ieva +kubernetesQueryCaching,2025-10-20T16:11:25Z,,17771e0e1df9d70ef8a2f90a7468a1652c462aa1,Lucy Chen +newGauge,2025-10-20T16:33:19Z,,7df537c9fc84ad6d277cee249e87db08bc6a81b7,Torkel Ödegaard +zanzanaNoLegacyClient,2025-10-21T14:03:17Z,,adf1224e82d83277e169646db477462a9a4db8e2,Alexander Zobnin +interactiveLearning,2025-10-22T08:06:21Z,,1abb5aa0f9c77d18775377a7459c091a9adddaa3,Jack Baldry +pluginInstallAPISync,2025-10-24T12:09:26Z,,dc77da11cf4cb3c4e705477cf2fb97e6004daf2f,Todd Treece +dashboardTemplates,2025-10-28T20:05:32Z,,8263803e81f26bdce676c5676b7cc4838cdf9042,Nathan Marrs +panelTimeSettings,2025-10-29T08:06:23Z,,5a031b370f4eeb873ed305c11a0235017fd54307,Torkel Ödegaard +jaegerEnableGrpcEndpoint,2025-10-31T18:19:16Z,,d0ea82633f757dcaa2562e94b2de0b40f9c0e229,Jocelyn Collado-Kuri +timeRangePan,2025-11-05T01:39:46Z,,e067b1de98f564045dfbb726546ef06957eeb3a6,Jesse David Peterson +kubernetesAnnotations,2025-11-06T18:22:20Z,,95ea7584752ddeb2aa224d2d8d9aba2de81f5aa3,Serge Zaitsev +suggestedDashboards,2025-11-07T13:38:59Z,,e5ed003fb219d4b8ffa2c2a44abda4e6ff54d91a,Alexa Vargas +grafanaAdvisorAppInstaller,2025-11-12T14:32:21Z,2025-11-14T11:25:30Z,d83c35fd71f435dc0fdc83b6af43dd7bc3682d22,Andres Martinez Gotor +newPanelPadding,2025-11-12T15:40:46Z,,1f558b1e066ed124c408ed52e95ce60bccfc135b,Torkel Ödegaard +awsDatasourcesHttpProxy,2025-11-12T18:51:23Z,,ec9f39d54a0489ca36a9059ad1b31ab7a58ca20a,Isabella Siu +newVizSuggestions,2025-11-12T19:26:29Z,,a194219365400e6dd5929453b17d212d1efd386c,Adela Almasan +alertingUIUseBackendFilters,2025-11-13T14:52:14Z,,44a92d252b18a571f5858aa7a7519f365d89ad7b,Alexander Akhmetov +transformationsEmptyPlaceholder,2025-11-17T13:57:05Z,,c4f2f3f6f4038a7428157a3629b428c340c08b52,Natalia Bernarte Oses +ttlPluginInstanceManager,2025-11-18T11:17:23Z,,e00eb854f54c972286a39d8ed101f1a423084f06,Will Browne diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 8a874135d00..24f9769ea4f 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -28,7 +28,7 @@ unifiedRequestLog,GA,@grafana/grafana-backend-group,false,false,false renderAuthJWT,preview,@grafana/grafana-operator-experience-squad,false,false,false refactorVariablesTimeRange,preview,@grafana/dashboards-squad,false,false,false faroDatasourceSelector,preview,@grafana/app-o11y,false,false,true -enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,true +enableDatagridEditing,preview,@grafana/dataviz-squad,false,false,false extraThemes,experimental,@grafana/grafana-frontend-platform,false,false,true logsExploreTableVisualisation,GA,@grafana/observability-logs,false,false,true awsDatasourcesTempCredentials,GA,@grafana/aws-datasources,false,false,false @@ -51,7 +51,7 @@ enableNativeHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,f disableClassicHTTPHistogram,experimental,@grafana/grafana-backend-services-squad,false,true,false kubernetesSnapshots,experimental,@grafana/grafana-app-platform-squad,false,true,false kubernetesLibraryPanels,experimental,@grafana/grafana-app-platform-squad,false,true,false -kubernetesDashboards,GA,@grafana/dashboards-squad,false,false,true +kubernetesDashboards,GA,@grafana/dashboards-squad,false,false,false kubernetesShortURLs,experimental,@grafana/grafana-app-platform-squad,false,true,false useKubernetesShortURLsAPI,experimental,@grafana/sharing-squad,false,false,true kubernetesAlertingRules,experimental,@grafana/alerting-squad,false,true,false @@ -78,7 +78,7 @@ annotationPermissionUpdate,GA,@grafana/identity-access-team,false,false,false 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,true +dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true panelFilterVariable,experimental,@grafana/dashboards-squad,false,false,true @@ -119,7 +119,7 @@ logsExploreTableDefaultVisualization,experimental,@grafana/observability-logs,fa alertingListViewV2,privatePreview,@grafana/alerting-squad,false,false,true alertingDisableSendAlertsExternal,experimental,@grafana/alerting-squad,false,false,false preserveDashboardStateWhenNavigating,experimental,@grafana/dashboards-squad,false,false,false -alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,true +alertingCentralAlertHistory,experimental,@grafana/alerting-squad,false,false,false pluginProxyPreserveTrailingSlash,GA,@grafana/plugins-platform-backend,false,false,false azureMonitorPrometheusExemplars,GA,@grafana/partner-datasources,false,false,false authZGRPCServer,experimental,@grafana/identity-access-team,false,false,false @@ -200,14 +200,14 @@ azureMonitorLogsBuilderEditor,preview,@grafana/partner-datasources,false,false,f localeFormatPreference,preview,@grafana/grafana-frontend-platform,false,false,false unifiedStorageGrpcConnectionPool,experimental,@grafana/search-and-storage,false,false,false alertingRulePermanentlyDelete,GA,@grafana/alerting-squad,false,false,true -alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,true +alertingRuleRecoverDeleted,GA,@grafana/alerting-squad,false,false,false multiTenantTempCredentials,experimental,@grafana/aws-datasources,false,false,false unifiedNavbars,GA,@grafana/plugins-platform-backend,false,false,true logsPanelControls,preview,@grafana/observability-logs,false,false,true metricsFromProfiles,experimental,@grafana/observability-traces-and-profiling,false,false,true grafanaAssistantInProfilesDrilldown,GA,@grafana/observability-traces-and-profiling,false,false,true postgresDSUsePGX,experimental,@grafana/oss-big-tent,false,false,false -tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,true +tempoAlerting,experimental,@grafana/observability-traces-and-profiling,false,false,false pluginsAutoUpdate,experimental,@grafana/plugins-platform-backend,false,false,false alertingListViewV2PreviewToggle,privatePreview,@grafana/alerting-squad,false,false,true alertRuleUseFiredAtForStartsAt,experimental,@grafana/alerting-squad,false,false,false @@ -241,7 +241,7 @@ newLogContext,experimental,@grafana/observability-logs,false,false,true newClickhouseConfigPageDesign,privatePreview,@grafana/partner-datasources,false,false,false teamFolders,experimental,@grafana/grafana-search-navigate-organise,false,false,false interactiveLearning,preview,@grafana/pathfinder,false,false,false -alertingTriage,experimental,@grafana/alerting-squad,false,false,true +alertingTriage,experimental,@grafana/alerting-squad,false,false,false graphiteBackendMode,privatePreview,@grafana/partner-datasources,false,false,false azureResourcePickerUpdates,preview,@grafana/partner-datasources,false,false,true prometheusTypeMigration,experimental,@grafana/partner-datasources,false,true,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index f6c70624e8d..fdc48861894 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -19,10 +19,6 @@ const ( // Enables public dashboard sharing to be restricted to only allowed emails FlagPublicDashboardsEmailSharing = "publicDashboardsEmailSharing" - // FlagPublicDashboardsScene - // Enables public dashboard rendering using scenes - FlagPublicDashboardsScene = "publicDashboardsScene" - // FlagLokiExperimentalStreaming // Support new streaming approach for loki (prototype, needs special loki build) FlagLokiExperimentalStreaming = "lokiExperimentalStreaming" @@ -35,10 +31,6 @@ const ( // Configurable storage for dashboards, datasources, and resources FlagStorage = "storage" - // FlagCanvasPanelNesting - // Allow elements nesting - FlagCanvasPanelNesting = "canvasPanelNesting" - // FlagLogRequestsInstrumentedAsUnknown // Logs the path for requests that are instrumented as unknown FlagLogRequestsInstrumentedAsUnknown = "logRequestsInstrumentedAsUnknown" @@ -63,30 +55,10 @@ const ( // Rule backtesting API for alerting FlagAlertingBacktesting = "alertingBacktesting" - // FlagLogsContextDatasourceUi - // Allow datasource to provide custom UI for context view - FlagLogsContextDatasourceUi = "logsContextDatasourceUi" - - // FlagLokiShardSplitting - // Use stream shards to split queries into smaller subqueries - FlagLokiShardSplitting = "lokiShardSplitting" - - // FlagLokiQuerySplitting - // Split large interval queries into subqueries with smaller time intervals - FlagLokiQuerySplitting = "lokiQuerySplitting" - // FlagIndividualCookiePreferences // Support overriding cookie preferences per user FlagIndividualCookiePreferences = "individualCookiePreferences" - // FlagInfluxdbBackendMigration - // Query InfluxDB InfluxQL without the proxy - FlagInfluxdbBackendMigration = "influxdbBackendMigration" - - // FlagStarsFromAPIServer - // populate star status from apiserver - FlagStarsFromAPIServer = "starsFromAPIServer" - // FlagKubernetesStars // Routes stars requests from /api to the /apis endpoint FlagKubernetesStars = "kubernetesStars" @@ -119,22 +91,10 @@ const ( // Refactor time range variables flow to reduce number of API calls made when query variables are chained FlagRefactorVariablesTimeRange = "refactorVariablesTimeRange" - // FlagFaroDatasourceSelector - // Enable the data source selector within the Frontend Apps section of the Frontend Observability - FlagFaroDatasourceSelector = "faroDatasourceSelector" - // FlagEnableDatagridEditing // Enables the edit functionality in the datagrid panel FlagEnableDatagridEditing = "enableDatagridEditing" - // FlagExtraThemes - // Enables extra themes - FlagExtraThemes = "extraThemes" - - // FlagLogsExploreTableVisualisation - // A table visualisation for logs in Explore - FlagLogsExploreTableVisualisation = "logsExploreTableVisualisation" - // FlagAwsDatasourcesTempCredentials // Support temporary security credentials in AWS plugins for Grafana Cloud customers FlagAwsDatasourcesTempCredentials = "awsDatasourcesTempCredentials" @@ -175,14 +135,6 @@ const ( // Enable changing the scheduler base interval via configuration option unified_alerting.scheduler_tick_interval FlagConfigurableSchedulerTick = "configurableSchedulerTick" - // FlagDashgpt - // Enable AI powered features in dashboards - FlagDashgpt = "dashgpt" - - // FlagAiGeneratedDashboardChanges - // Enable AI powered features for dashboards to auto-summary changes when saving - FlagAiGeneratedDashboardChanges = "aiGeneratedDashboardChanges" - // FlagReportingRetries // Enables rendering retries for the reporting feature FlagReportingRetries = "reportingRetries" @@ -223,10 +175,6 @@ const ( // Enables k8s short url api and uses it under the hood when handling legacy /api FlagKubernetesShortURLs = "kubernetesShortURLs" - // FlagUseKubernetesShortURLsAPI - // Routes short url requests from /api to the /apis endpoint in the frontend. Depends on kubernetesShortURLs - FlagUseKubernetesShortURLsAPI = "useKubernetesShortURLsAPI" - // FlagKubernetesAlertingRules // Adds support for Kubernetes alerting and recording rules FlagKubernetesAlertingRules = "kubernetesAlertingRules" @@ -275,14 +223,6 @@ const ( // Rewrite requests targeting /ds/query to the query service FlagQueryServiceRewrite = "queryServiceRewrite" - // FlagQueryServiceFromUI - // Routes requests to the new query service - FlagQueryServiceFromUI = "queryServiceFromUI" - - // FlagQueryServiceFromExplore - // Routes explore requests to the new query service - FlagQueryServiceFromExplore = "queryServiceFromExplore" - // FlagCloudWatchBatchQueries // Runs CloudWatch metrics queries as separate batches FlagCloudWatchBatchQueries = "cloudWatchBatchQueries" @@ -311,58 +251,14 @@ const ( // Change the way annotation permissions work by scoping them to folders and dashboards. FlagAnnotationPermissionUpdate = "annotationPermissionUpdate" - // FlagDashboardSceneForViewers - // Enables dashboard rendering using Scenes for viewer roles - FlagDashboardSceneForViewers = "dashboardSceneForViewers" - - // FlagDashboardSceneSolo - // Enables rendering dashboards using scenes for solo panels - FlagDashboardSceneSolo = "dashboardSceneSolo" - - // FlagDashboardScene - // Enables dashboard rendering using scenes for all roles - FlagDashboardScene = "dashboardScene" - // FlagDashboardNewLayouts // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" - // FlagDashboardUndoRedo - // Enables undo/redo in dynamic dashboards - FlagDashboardUndoRedo = "dashboardUndoRedo" - - // FlagUnlimitedLayoutsNesting - // Enables unlimited dashboard panel grouping - FlagUnlimitedLayoutsNesting = "unlimitedLayoutsNesting" - - // FlagPanelFilterVariable - // Enables use of the `systemPanelFilterVar` variable to filter panels in a dashboard - FlagPanelFilterVariable = "panelFilterVariable" - // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" - // FlagCanvasPanelPanZoom - // Allow pan and zoom in canvas panel - FlagCanvasPanelPanZoom = "canvasPanelPanZoom" - - // FlagTimeComparison - // Enables time comparison option in supported panels - FlagTimeComparison = "timeComparison" - - // FlagLogsInfiniteScrolling - // Enables infinite scrolling for the Logs panel in Explore and Dashboards - FlagLogsInfiniteScrolling = "logsInfiniteScrolling" - - // FlagTableSharedCrosshair - // Enables shared crosshair in table panel - FlagTableSharedCrosshair = "tableSharedCrosshair" - - // FlagKubernetesFeatureToggles - // Use the kubernetes API for feature toggle management in the frontend - FlagKubernetesFeatureToggles = "kubernetesFeatureToggles" - // FlagCloudRBACRoles // Enabled grafana cloud specific RBAC roles FlagCloudRBACRoles = "cloudRBACRoles" @@ -399,14 +295,6 @@ const ( // In-development feature flag for the scope api using the app platform. FlagScopeApi = "scopeApi" - // FlagUseScopeSingleNodeEndpoint - // Use the single node endpoint for the scope api. This is used to fetch the scope parent node. - FlagUseScopeSingleNodeEndpoint = "useScopeSingleNodeEndpoint" - - // FlagUseMultipleScopeNodesEndpoint - // Makes the frontend use the 'names' param for fetching multiple scope nodes at once - FlagUseMultipleScopeNodesEndpoint = "useMultipleScopeNodesEndpoint" - // FlagLogQLScope // In-development feature that will allow injection of labels into loki queries. FlagLogQLScope = "logQLScope" @@ -415,10 +303,6 @@ const ( // Enables SQL Expressions, which can execute SQL queries against data source results. FlagSqlExpressions = "sqlExpressions" - // FlagSqlExpressionsColumnAutoComplete - // Enables column autocomplete for SQL Expressions - FlagSqlExpressionsColumnAutoComplete = "sqlExpressionsColumnAutoComplete" - // FlagKubernetesAggregator // Enable grafana's embedded kube-aggregator FlagKubernetesAggregator = "kubernetesAggregator" @@ -471,14 +355,6 @@ const ( // Enable suggested dashboards when creating new dashboards FlagSuggestedDashboards = "suggestedDashboards" - // FlagLogsExploreTableDefaultVisualization - // Sets the logs table as default visualisation in logs explore - FlagLogsExploreTableDefaultVisualization = "logsExploreTableDefaultVisualization" - - // FlagAlertingListViewV2 - // Enables the new alert list view design - FlagAlertingListViewV2 = "alertingListViewV2" - // FlagAlertingDisableSendAlertsExternal // Disables the ability to send alerts to an external Alertmanager datasource. FlagAlertingDisableSendAlertsExternal = "alertingDisableSendAlertsExternal" @@ -543,26 +419,6 @@ const ( // Enables new combobox style UI for the Ad hoc filters variable in scenes architecture FlagNewFiltersUI = "newFiltersUI" - // FlagVizActionsAuth - // Allows authenticated API calls in actions - FlagVizActionsAuth = "vizActionsAuth" - - // FlagAlertingPrometheusRulesPrimary - // Uses Prometheus rules as the primary source of truth for ruler-enabled data sources - FlagAlertingPrometheusRulesPrimary = "alertingPrometheusRulesPrimary" - - // FlagExploreLogsShardSplitting - // Used in Logs Drilldown to split queries into multiple queries based on the number of shards - FlagExploreLogsShardSplitting = "exploreLogsShardSplitting" - - // FlagExploreLogsAggregatedMetrics - // Used in Logs Drilldown to query by aggregated metrics - FlagExploreLogsAggregatedMetrics = "exploreLogsAggregatedMetrics" - - // FlagExploreLogsLimitedTimeRange - // Used in Logs Drilldown to limit the time range - FlagExploreLogsLimitedTimeRange = "exploreLogsLimitedTimeRange" - // FlagAppPlatformGrpcClientAuth // Enables the gRPC client to authenticate with the App Platform by using ID & access tokens FlagAppPlatformGrpcClientAuth = "appPlatformGrpcClientAuth" @@ -571,10 +427,6 @@ const ( // Enable the groupsync extension for managing Group Attribute Sync feature FlagGroupAttributeSync = "groupAttributeSync" - // FlagAlertingQueryAndExpressionsStepMode - // Enables step mode for alerting queries and expressions - FlagAlertingQueryAndExpressionsStepMode = "alertingQueryAndExpressionsStepMode" - // FlagImprovedExternalSessionHandling // Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves. FlagImprovedExternalSessionHandling = "improvedExternalSessionHandling" @@ -611,10 +463,6 @@ const ( // Enables time pickers sync FlagTimeRangeProvider = "timeRangeProvider" - // FlagTimeRangePan - // Enables time range panning functionality - FlagTimeRangePan = "timeRangePan" - // FlagAzureMonitorDisableLogLimit // Disables the log limit restriction for Azure Monitor when true. The limit is enabled by default. FlagAzureMonitorDisableLogLimit = "azureMonitorDisableLogLimit" @@ -627,14 +475,6 @@ const ( // Enable passwordless login via magic link authentication FlagPasswordlessMagicLinkAuthentication = "passwordlessMagicLinkAuthentication" - // FlagExploreMetricsRelatedLogs - // Display Related Logs in Grafana Metrics Drilldown - FlagExploreMetricsRelatedLogs = "exploreMetricsRelatedLogs" - - // FlagPrometheusSpecialCharsInLabelValues - // Adds support for quotes and special characters in label values for Prometheus queries - FlagPrometheusSpecialCharsInLabelValues = "prometheusSpecialCharsInLabelValues" - // FlagEnableExtensionsAdminPage // Enables the extension admin page regardless of development mode FlagEnableExtensionsAdminPage = "enableExtensionsAdminPage" @@ -643,14 +483,6 @@ const ( // Enables SCIM support for user and group management FlagEnableSCIM = "enableSCIM" - // FlagCrashDetection - // Enables browser crash detection reporting to Faro. - FlagCrashDetection = "crashDetection" - - // FlagAlertingUIOptimizeReducer - // Enables removing the reducer from the alerting UI when creating a new alert rule and using instant query - FlagAlertingUIOptimizeReducer = "alertingUIOptimizeReducer" - // FlagAzureMonitorEnableUserAuth // Enables user auth for Azure Monitor datasource only FlagAzureMonitorEnableUserAuth = "azureMonitorEnableUserAuth" @@ -683,10 +515,6 @@ const ( // Enable AI-analyze central state history. FlagAlertingAIAnalyzeCentralStateHistory = "alertingAIAnalyzeCentralStateHistory" - // FlagAlertingNotificationsStepMode - // Enables simplified step mode in the notifications section - FlagAlertingNotificationsStepMode = "alertingNotificationsStepMode" - // FlagUnifiedStorageSearchUI // Enable unified storage search UI FlagUnifiedStorageSearchUI = "unifiedStorageSearchUI" @@ -695,10 +523,6 @@ const ( // Enables cross cluster search in the Elasticsearch data source FlagElasticsearchCrossClusterSearch = "elasticsearchCrossClusterSearch" - // FlagUnifiedHistory - // Displays the navigation history so the user can navigate back to previous pages - FlagUnifiedHistory = "unifiedHistory" - // FlagLokiLabelNamesQueryApi // Defaults to using the Loki `/labels` API instead of `/series` FlagLokiLabelNamesQueryApi = "lokiLabelNamesQueryApi" @@ -731,53 +555,25 @@ const ( // Enables less memory intensive Elasticsearch result parsing FlagElasticsearchImprovedParsing = "elasticsearchImprovedParsing" - // FlagDatasourceConnectionsTab - // Shows defined connections for a data source in the plugins detail page - FlagDatasourceConnectionsTab = "datasourceConnectionsTab" - // FlagFetchRulesUsingPost // Use a POST request to list rules by passing down the namespaces user has access to FlagFetchRulesUsingPost = "fetchRulesUsingPost" - // FlagNewLogsPanel - // Enables the new logs panel - FlagNewLogsPanel = "newLogsPanel" - // FlagGrafanaconThemes // Enables the temporary themes for GrafanaCon FlagGrafanaconThemes = "grafanaconThemes" - // FlagAlertingJiraIntegration - // Enables the new Jira integration for contact points in cloud alert managers. - FlagAlertingJiraIntegration = "alertingJiraIntegration" - // FlagAlertingUseNewSimplifiedRoutingHashAlgorithm FlagAlertingUseNewSimplifiedRoutingHashAlgorithm = "alertingUseNewSimplifiedRoutingHashAlgorithm" - // FlagUseScopesNavigationEndpoint - // Use the scopes navigation endpoint instead of the dashboardbindings endpoint - FlagUseScopesNavigationEndpoint = "useScopesNavigationEndpoint" - // FlagScopeSearchAllLevels // Enable scope search to include all levels of the scope node tree FlagScopeSearchAllLevels = "scopeSearchAllLevels" - // FlagAlertingRuleVersionHistoryRestore - // Enables the alert rule version history restore feature - FlagAlertingRuleVersionHistoryRestore = "alertingRuleVersionHistoryRestore" - // FlagNewShareReportDrawer // Enables the report creation drawer in a dashboard FlagNewShareReportDrawer = "newShareReportDrawer" - // FlagRendererDisableAppPluginsPreload - // Disable pre-loading app plugins when the request is coming from the renderer - FlagRendererDisableAppPluginsPreload = "rendererDisableAppPluginsPreload" - - // FlagAssetSriChecks - // Enables SRI checks for Grafana JavaScript assets - FlagAssetSriChecks = "assetSriChecks" - // FlagAlertRuleRestore // Enables the alert rule restore feature FlagAlertRuleRestore = "alertRuleRestore" @@ -786,14 +582,6 @@ const ( // Enables running Infinity queries in parallel FlagInfinityRunQueriesInParallel = "infinityRunQueriesInParallel" - // FlagAlertingMigrationUI - // Enables the alerting migration UI, to migrate data source-managed rules to Grafana-managed rules - FlagAlertingMigrationUI = "alertingMigrationUI" - - // FlagAlertingImportYAMLUI - // Enables a UI feature for importing rules from a Prometheus file to Grafana-managed rules - FlagAlertingImportYAMLUI = "alertingImportYAMLUI" - // FlagAzureMonitorLogsBuilderEditor // Enables the logs builder mode for the Azure Monitor data source FlagAzureMonitorLogsBuilderEditor = "azureMonitorLogsBuilderEditor" @@ -806,10 +594,6 @@ const ( // Enables the unified storage grpc connection pool FlagUnifiedStorageGrpcConnectionPool = "unifiedStorageGrpcConnectionPool" - // FlagAlertingRulePermanentlyDelete - // Enables UI functionality to permanently delete alert rules - FlagAlertingRulePermanentlyDelete = "alertingRulePermanentlyDelete" - // FlagAlertingRuleRecoverDeleted // Enables the UI functionality to recover and view deleted alert rules FlagAlertingRuleRecoverDeleted = "alertingRuleRecoverDeleted" @@ -818,22 +602,6 @@ const ( // use multi-tenant path for awsTempCredentials FlagMultiTenantTempCredentials = "multiTenantTempCredentials" - // FlagUnifiedNavbars - // Enables unified navbars - FlagUnifiedNavbars = "unifiedNavbars" - - // FlagLogsPanelControls - // Enables a control component for the logs panel in Explore - FlagLogsPanelControls = "logsPanelControls" - - // FlagMetricsFromProfiles - // Enables creating metrics from profiles and storing them as recording rules - FlagMetricsFromProfiles = "metricsFromProfiles" - - // FlagGrafanaAssistantInProfilesDrilldown - // Enables integration with Grafana Assistant in Profiles Drilldown - FlagGrafanaAssistantInProfilesDrilldown = "grafanaAssistantInProfilesDrilldown" - // FlagPostgresDSUsePGX // Enables using PGX instead of libpq for PostgreSQL datasource FlagPostgresDSUsePGX = "postgresDSUsePGX" @@ -846,18 +614,10 @@ const ( // Enables auto-updating of users installed plugins FlagPluginsAutoUpdate = "pluginsAutoUpdate" - // FlagAlertingListViewV2PreviewToggle - // Enables the alerting list view v2 preview toggle - FlagAlertingListViewV2PreviewToggle = "alertingListViewV2PreviewToggle" - // FlagAlertRuleUseFiredAtForStartsAt // Use FiredAt for StartsAt when sending alerts to Alertmaanger FlagAlertRuleUseFiredAtForStartsAt = "alertRuleUseFiredAtForStartsAt" - // FlagAlertingBulkActionsInUI - // Enables the alerting bulk actions in the UI - FlagAlertingBulkActionsInUI = "alertingBulkActionsInUI" - // FlagKubernetesAuthzApis // Registers AuthZ /apis endpoint FlagKubernetesAuthzApis = "kubernetesAuthzApis" @@ -902,10 +662,6 @@ const ( // Enables the UI to see imported Alertmanager configuration FlagAlertingImportAlertmanagerUI = "alertingImportAlertmanagerUI" - // FlagSharingDashboardImage - // Enables image sharing functionality for dashboards - FlagSharingDashboardImage = "sharingDashboardImage" - // FlagPreferLibraryPanelTitle // Prefer library panel title over viz panel title. FlagPreferLibraryPanelTitle = "preferLibraryPanelTitle" @@ -918,22 +674,6 @@ const ( // Enables new design for the InfluxDB data source configuration page FlagNewInfluxDSConfigPageDesign = "newInfluxDSConfigPageDesign" - // FlagEnableAppChromeExtensions - // Set this to true to enable all app chrome extensions registered by plugins. - FlagEnableAppChromeExtensions = "enableAppChromeExtensions" - - // FlagEnableDashboardEmptyExtensions - // Set this to true to enable all dashboard empty state extensions registered by plugins. - FlagEnableDashboardEmptyExtensions = "enableDashboardEmptyExtensions" - - // FlagFoldersAppPlatformAPI - // Enables use of app platform API for folders - FlagFoldersAppPlatformAPI = "foldersAppPlatformAPI" - - // FlagOtelLogsFormatting - // Applies OTel formatting templates to displayed logs - FlagOtelLogsFormatting = "otelLogsFormatting" - // FlagAlertingNotificationHistory // Enables the notification history feature FlagAlertingNotificationHistory = "alertingNotificationHistory" @@ -942,26 +682,10 @@ const ( // Enable dual reader for unified storage search FlagUnifiedStorageSearchDualReaderEnabled = "unifiedStorageSearchDualReaderEnabled" - // FlagDashboardLevelTimeMacros - // Supports __from and __to macros that always use the dashboard level time range - FlagDashboardLevelTimeMacros = "dashboardLevelTimeMacros" - // FlagAlertmanagerRemoteSecondaryWithRemoteState // Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications. FlagAlertmanagerRemoteSecondaryWithRemoteState = "alertmanagerRemoteSecondaryWithRemoteState" - // FlagRestrictedPluginApis - // Enables sharing a list of APIs with a list of plugins - FlagRestrictedPluginApis = "restrictedPluginApis" - - // FlagFavoriteDatasources - // Enable favorite datasources - FlagFavoriteDatasources = "favoriteDatasources" - - // FlagNewLogContext - // New Log Context component - FlagNewLogContext = "newLogContext" - // FlagNewClickhouseConfigPageDesign // Enables new design for the Clickhouse data source configuration page FlagNewClickhouseConfigPageDesign = "newClickhouseConfigPageDesign" @@ -982,10 +706,6 @@ const ( // Enables the Graphite data source full backend mode FlagGraphiteBackendMode = "graphiteBackendMode" - // FlagAzureResourcePickerUpdates - // Enables the updated Azure Monitor resource picker - FlagAzureResourcePickerUpdates = "azureResourcePickerUpdates" - // FlagPrometheusTypeMigration // Checks for deprecated Prometheus authentication methods (SigV4 and Azure), installs the relevant data source, and migrates the Prometheus data sources FlagPrometheusTypeMigration = "prometheusTypeMigration" @@ -1010,18 +730,6 @@ const ( // Enable syncing plugin installations to the installs API FlagPluginInstallAPISync = "pluginInstallAPISync" - // FlagNewGauge - // Enable new gauge visualization - FlagNewGauge = "newGauge" - - // FlagNewVizSuggestions - // Enable new visualization suggestions - FlagNewVizSuggestions = "newVizSuggestions" - - // FlagPreventPanelChromeOverflow - // Restrict PanelChrome contents with overflow: hidden; - FlagPreventPanelChromeOverflow = "preventPanelChromeOverflow" - // FlagJaegerEnableGrpcEndpoint // Enable querying trace data through Jaeger's gRPC endpoint (HTTP) FlagJaegerEnableGrpcEndpoint = "jaegerEnableGrpcEndpoint" @@ -1053,16 +761,4 @@ const ( // FlagAwsDatasourcesHttpProxy // Enables http proxy settings for aws datasources FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy" - - // FlagTransformationsEmptyPlaceholder - // Show transformation quick-start cards in empty transformations state - FlagTransformationsEmptyPlaceholder = "transformationsEmptyPlaceholder" - - // FlagTtlPluginInstanceManager - // Enable TTL plugin instance manager - FlagTtlPluginInstanceManager = "ttlPluginInstanceManager" - - // FlagRudderstackUpgrade - // Enables the new version of rudderstack - FlagRudderstackUpgrade = "rudderstackUpgrade" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 44ed33ad19c..48eba662c0a 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -62,7 +62,6 @@ "description": "Enable configuration of alert enrichments in Grafana Cloud.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -77,7 +76,6 @@ "description": "Enable conditional alert enrichment steps.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -92,7 +90,6 @@ "description": "Allow multiple steps per enrichment.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -133,7 +130,6 @@ "description": "Enable AI-analyze central state history.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -148,7 +144,6 @@ "description": "Enable AI-generated feedback from the Grafana UI.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -163,7 +158,6 @@ "description": "Enable AI-generated alert rules.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -178,7 +172,6 @@ "description": "Enable AI-generated alerting templates.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -193,7 +186,6 @@ "description": "Enable AI-improve alert rules labels and annotations.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -221,7 +213,6 @@ "stage": "GA", "codeowner": "@grafana/alerting-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -229,14 +220,16 @@ { "metadata": { "name": "alertingCentralAlertHistory", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-05-29T15:01:38Z" + "resourceVersion": "1763541314825", + "creationTimestamp": "2024-05-29T15:01:38Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-19 08:35:14.825756 +0000 UTC" + } }, "spec": { "description": "Enables the new central alert history.", "stage": "experimental", - "codeowner": "@grafana/alerting-squad", - "frontend": true + "codeowner": "@grafana/alerting-squad" } }, { @@ -249,7 +242,6 @@ "description": "Disables the ability to send alerts to an external Alertmanager datasource.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -263,7 +255,6 @@ "description": "Enable Assistant Investigations enrichment type.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -279,7 +270,6 @@ "description": "Enable Assistant Investigations enrichment type in the UI.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -294,7 +284,6 @@ "description": "Enable enrichment per rule in the alerting UI.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -322,7 +311,6 @@ "description": "Enables the API to import Alertmanager configuration", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -337,7 +325,6 @@ "description": "Enables the UI to see imported Alertmanager configuration", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -420,7 +407,6 @@ "description": "Enables the notification history feature", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -462,7 +448,6 @@ "description": "Enables a feature to avoid issues with concurrent writes to the alerting provenance table in MySQL", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -504,7 +489,6 @@ "description": "Enable rule notification message section extension.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -520,7 +504,6 @@ "stage": "GA", "codeowner": "@grafana/alerting-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -528,15 +511,16 @@ { "metadata": { "name": "alertingRuleRecoverDeleted", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-03-27T14:39:26Z" + "resourceVersion": "1763541314825", + "creationTimestamp": "2025-03-27T14:39:26Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-19 08:35:14.825756 +0000 UTC" + } }, "spec": { "description": "Enables the UI functionality to recover and view deleted alert rules", "stage": "GA", "codeowner": "@grafana/alerting-squad", - "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -552,7 +536,6 @@ "stage": "GA", "codeowner": "@grafana/alerting-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -588,15 +571,16 @@ { "metadata": { "name": "alertingTriage", - "resourceVersion": "1756386724059", - "creationTimestamp": "2025-09-01T09:33:33Z" + "resourceVersion": "1763541314825", + "creationTimestamp": "2025-09-01T09:33:33Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-19 08:35:14.825756 +0000 UTC" + } }, "spec": { "description": "Enables the alerting triage feature", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -625,7 +609,6 @@ "description": "Enables the UI to use certain backend-side filters", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -644,7 +627,6 @@ "stage": "preview", "codeowner": "@grafana/alerting-squad", "requiresRestart": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -664,7 +646,6 @@ "stage": "deprecated", "codeowner": "@grafana/alerting-squad", "requiresRestart": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -706,7 +687,6 @@ "description": "Starts Grafana in remote secondary mode pulling the latest state from the remote Alertmanager to avoid duplicate notifications.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -733,7 +713,6 @@ "description": "Enables the gRPC client to authenticate with the App Platform by using ID \u0026 access tokens", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -760,7 +739,6 @@ "description": "Enables the gRPC server for authorization", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -895,8 +873,7 @@ "description": "Allow elements nesting", "stage": "experimental", "codeowner": "@grafana/dataviz-squad", - "frontend": true, - "hideFromAdminPage": true + "frontend": true } }, { @@ -963,8 +940,6 @@ "stage": "preview", "codeowner": "@grafana/identity-access-team", "requiresRestart": true, - "allowSelfServe": true, - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -990,7 +965,6 @@ "description": "Enables cross-account querying in CloudWatch datasources", "stage": "GA", "codeowner": "@grafana/aws-datasources", - "allowSelfServe": true, "expression": "true" } }, @@ -1048,7 +1022,6 @@ "description": "Correlations page", "stage": "GA", "codeowner": "@grafana/datapro", - "allowSelfServe": true, "expression": "true" } }, @@ -1138,14 +1111,16 @@ { "metadata": { "name": "dashboardNewLayouts", - "resourceVersion": "1753448760331", - "creationTimestamp": "2024-10-23T08:55:45Z" + "resourceVersion": "1763533458962", + "creationTimestamp": "2024-10-23T08:55:45Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-19 06:24:18.962973 +0000 UTC" + } }, "spec": { "description": "Enables experimental new dashboard layouts", "stage": "experimental", - "codeowner": "@grafana/dashboards-squad", - "frontend": true + "codeowner": "@grafana/dashboards-squad" } }, { @@ -1266,7 +1241,6 @@ "stage": "GA", "codeowner": "@grafana/observability-metrics", "frontend": true, - "allowSelfServe": true, "expression": "true" } }, @@ -1319,8 +1293,7 @@ "description": "Disables classic HTTP Histogram (use with enableNativeHTTPHistogram)", "stage": "experimental", "codeowner": "@grafana/grafana-backend-services-squad", - "requiresRestart": true, - "hideFromAdminPage": true + "requiresRestart": true } }, { @@ -1333,7 +1306,6 @@ "description": "Disable envelope encryption (emergency only)", "stage": "GA", "codeowner": "@grafana/grafana-operator-experience-squad", - "hideFromAdminPage": true, "expression": "false" } }, @@ -1380,7 +1352,6 @@ "stage": "experimental", "codeowner": "@grafana/plugins-platform-backend", "requiresRestart": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -1441,7 +1412,6 @@ "stage": "experimental", "codeowner": "@grafana/plugins-platform-backend", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -1457,7 +1427,6 @@ "stage": "experimental", "codeowner": "@grafana/dashboards-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -1465,14 +1434,16 @@ { "metadata": { "name": "enableDatagridEditing", - "resourceVersion": "1753448760331", - "creationTimestamp": "2023-04-24T14:46:31Z" + "resourceVersion": "1763539789149", + "creationTimestamp": "2023-04-24T14:46:31Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-19 08:09:49.149669 +0000 UTC" + } }, "spec": { "description": "Enables the edit functionality in the datagrid panel", "stage": "preview", - "codeowner": "@grafana/dataviz-squad", - "frontend": true + "codeowner": "@grafana/dataviz-squad" } }, { @@ -1498,8 +1469,7 @@ "description": "Enables native HTTP Histograms", "stage": "experimental", "codeowner": "@grafana/grafana-backend-services-squad", - "requiresRestart": true, - "hideFromAdminPage": true + "requiresRestart": true } }, { @@ -1514,7 +1484,6 @@ "stage": "experimental", "codeowner": "@grafana/plugins-platform-backend", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -1541,7 +1510,6 @@ "description": "Enables the scopes usage in Metrics Explore", "stage": "experimental", "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -1634,8 +1602,7 @@ "spec": { "description": "Automatic service account and token setup for plugins", "stage": "preview", - "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true + "codeowner": "@grafana/identity-access-team" } }, { @@ -1705,7 +1672,6 @@ "description": "Highlight Grafana Enterprise features", "stage": "GA", "codeowner": "@grafana/grafana-operator-experience-squad", - "allowSelfServe": true, "expression": "false" } }, @@ -1751,7 +1717,6 @@ "description": "Use a POST request to list rules by passing down the namespaces user has access to", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -1781,7 +1746,6 @@ "stage": "experimental", "codeowner": "@grafana/grafana-search-navigate-organise", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -1886,7 +1850,6 @@ "description": "Enables Grafana-managed recording rules.", "stage": "experimental", "codeowner": "@grafana/alerting-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -1917,7 +1880,6 @@ "stage": "GA", "codeowner": "@grafana/grafana-frontend-platform", "requiresRestart": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -1958,7 +1920,6 @@ "description": "Enable groupBy variable support in scenes dashboards", "stage": "experimental", "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -1989,8 +1950,7 @@ "spec": { "description": "Run the GRPC server", "stage": "preview", - "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true + "codeowner": "@grafana/search-and-storage" } }, { @@ -2003,7 +1963,6 @@ "description": "Enables improved support for OAuth external sessions. After enabling this feature, users might need to re-authenticate themselves.", "stage": "GA", "codeowner": "@grafana/identity-access-team", - "allowSelfServe": true, "expression": "true" } }, @@ -2017,7 +1976,6 @@ "description": "Enables improved support for SAML external sessions. Ensure the NameID format is correctly configured in Grafana for SAML Single Logout to function properly.", "stage": "GA", "codeowner": "@grafana/identity-access-team", - "allowSelfServe": true, "expression": "true" } }, @@ -2121,7 +2079,6 @@ "stage": "experimental", "codeowner": "@grafana/sharing-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2239,7 +2196,6 @@ "description": "Redirects the traffic from the legacy access control endpoints to the new K8s AuthZ endpoints", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2256,7 +2212,6 @@ "description": "Enables create, delete, and update mutations for resources owned by IAM identity", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2270,7 +2225,6 @@ "description": "Registers AuthZ /apis endpoint", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2285,7 +2239,6 @@ "description": "Enables K8s AuthZ endpoints", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2299,7 +2252,6 @@ "description": "Registers AuthZ resource permission /apis endpoints", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2316,7 +2268,6 @@ "description": "Enable sync of Zanzana authorization store on AuthZ CRD mutations", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2336,17 +2287,16 @@ { "metadata": { "name": "kubernetesDashboards", - "resourceVersion": "1755157224830", + "resourceVersion": "1763533458962", "creationTimestamp": "2024-06-05T14:34:23Z", "annotations": { - "grafana.app/updatedTimestamp": "2025-08-14 07:40:24.830741 +0000 UTC" + "grafana.app/updatedTimestamp": "2025-11-19 06:24:18.962973 +0000 UTC" } }, "spec": { "description": "Use the kubernetes API in the frontend for dashboards", "stage": "GA", "codeowner": "@grafana/dashboards-squad", - "frontend": true, "expression": "true" } }, @@ -2360,8 +2310,7 @@ "description": "Use the kubernetes API for feature toggle management in the frontend", "stage": "experimental", "codeowner": "@grafana/grafana-operator-experience-squad", - "frontend": true, - "hideFromAdminPage": true + "frontend": true } }, { @@ -2459,7 +2408,6 @@ "description": "Populate Zanzana on AuthZ CRDs creation or update", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2498,7 +2446,6 @@ "description": "In-development feature that will allow injection of labels into loki queries.", "stage": "privatePreview", "codeowner": "@grafana/observability-logs", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -2541,7 +2488,6 @@ "stage": "GA", "codeowner": "@grafana/observability-logs", "frontend": true, - "allowSelfServe": true, "expression": "true" } }, @@ -2648,7 +2594,6 @@ "stage": "GA", "codeowner": "@grafana/observability-logs", "frontend": true, - "allowSelfServe": true, "expression": "true" } }, @@ -2687,7 +2632,6 @@ "description": "Pick the dual write mode from database configs", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2792,7 +2736,6 @@ "description": "Enables filters and group by variables on all new dashboards. Variables are added only if default data source supports filtering.", "stage": "experimental", "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2903,7 +2846,6 @@ "description": "Enables the report creation drawer in a dashboard", "stage": "preview", "codeowner": "@grafana/grafana-operator-experience-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2931,7 +2873,6 @@ "description": "Require that sub claims is present in oauth tokens.", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -2961,7 +2902,6 @@ "description": "When storing dashboard and folder resource permissions, only store action sets and not the full list of underlying permission", "stage": "GA", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -3043,8 +2983,7 @@ "spec": { "description": "Search for dashboards using panel title", "stage": "preview", - "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true + "codeowner": "@grafana/search-and-storage" } }, { @@ -3057,7 +2996,6 @@ "description": "Enable passwordless login via magic link authentication", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3127,7 +3065,6 @@ "stage": "experimental", "codeowner": "@grafana/plugins-platform-backend", "requiresRestart": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -3289,7 +3226,6 @@ "description": "Enables possibility to preserve dashboard variables and time range when navigating between dashboards", "stage": "experimental", "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3318,7 +3254,6 @@ "description": "In-development feature that will allow injection of labels into prometheus queries.", "stage": "GA", "codeowner": "@grafana/oss-big-tent", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -3404,7 +3339,6 @@ "description": "Enables public dashboard sharing to be restricted to only allowed emails", "stage": "preview", "codeowner": "@grafana/grafana-operator-experience-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3540,8 +3474,7 @@ "spec": { "description": "Refactor time range variables flow to reduce number of API calls made when query variables are chained", "stage": "preview", - "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true + "codeowner": "@grafana/dashboards-squad" } }, { @@ -3554,7 +3487,6 @@ "description": "Require that refresh tokens are present in oauth tokens.", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3585,7 +3517,6 @@ "description": "Enables reload of dashboards on scopes, time range and variables changes", "stage": "experimental", "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3598,8 +3529,7 @@ "spec": { "description": "Uses JWT-based auth for rendering instead of relying on remote cache", "stage": "preview", - "codeowner": "@grafana/grafana-operator-experience-squad", - "hideFromAdminPage": true + "codeowner": "@grafana/grafana-operator-experience-squad" } }, { @@ -3613,7 +3543,6 @@ "stage": "experimental", "codeowner": "@grafana/grafana-operator-experience-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3643,7 +3572,6 @@ "description": "Enables restore deleted dashboards feature", "stage": "experimental", "codeowner": "@grafana/grafana-search-navigate-organise", - "hideFromAdminPage": true, "expression": "false" } }, @@ -3661,7 +3589,6 @@ "stage": "experimental", "codeowner": "@grafana/plugins-platform-backend", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -3730,7 +3657,6 @@ "description": "In-development feature flag for the scope api using the app platform.", "stage": "experimental", "codeowner": "@grafana/grafana-app-platform-squad", - "hideFromAdminPage": true, "expression": "false" } }, @@ -3744,7 +3670,6 @@ "description": "Enables the use of scope filters in Grafana", "stage": "experimental", "codeowner": "@grafana/dashboards-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3761,7 +3686,6 @@ "description": "Enable scope search to include all levels of the scope node tree", "stage": "experimental", "codeowner": "@grafana/grafana-operator-experience-squad", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -3829,7 +3753,6 @@ "description": "Skip token rotation if it was already rotated less than 5 seconds ago", "stage": "GA", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -3845,8 +3768,7 @@ "description": "Enables previous SQL data source dataset dropdown behavior", "stage": "preview", "codeowner": "@grafana/oss-big-tent", - "frontend": true, - "hideFromAdminPage": true + "frontend": true } }, { @@ -3903,7 +3825,6 @@ "stage": "GA", "codeowner": "@grafana/identity-access-team", "requiresRestart": true, - "allowSelfServe": true, "expression": "true" } }, @@ -4012,7 +3933,6 @@ "description": "Enables LBAC for datasources for Mimir to apply LBAC filtering of metrics to the client requests for users in teams", "stage": "GA", "codeowner": "@grafana/identity-access-team", - "allowSelfServe": true, "expression": "true" } }, @@ -4059,14 +3979,16 @@ { "metadata": { "name": "tempoAlerting", - "resourceVersion": "1753448760331", - "creationTimestamp": "2025-07-15T13:36:36Z" + "resourceVersion": "1763532089512", + "creationTimestamp": "2025-07-15T13:36:36Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-19 06:01:29.512182 +0000 UTC" + } }, "spec": { "description": "Enables creating alerts from Tempo data source", "stage": "experimental", - "codeowner": "@grafana/observability-traces-and-profiling", - "frontend": true + "codeowner": "@grafana/observability-traces-and-profiling" } }, { @@ -4170,7 +4092,6 @@ "stage": "GA", "codeowner": "@grafana/observability-metrics", "frontend": true, - "allowSelfServe": true, "expression": "true" } }, @@ -4227,7 +4148,6 @@ "description": "Writes error logs to the request logger", "stage": "GA", "codeowner": "@grafana/grafana-backend-group", - "hideFromAdminPage": true, "expression": "true" } }, @@ -4253,7 +4173,6 @@ "description": "Enables the unified storage grpc connection pool", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4268,7 +4187,6 @@ "description": "Enables the unified storage history pruner", "stage": "GA", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "true" } @@ -4283,7 +4201,6 @@ "description": "Enable unified storage search", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4302,7 +4219,6 @@ "stage": "experimental", "codeowner": "@grafana/search-and-storage", "requiresRestart": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -4317,7 +4233,6 @@ "description": "Enable dual reader for unified storage search", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4331,7 +4246,6 @@ "description": "Enable sprinkles on unified storage search", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4345,7 +4259,6 @@ "description": "Enable unified storage search UI", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4360,7 +4273,6 @@ "description": "Use full n-gram indexing instead of edge n-gram for unified storage search", "stage": "experimental", "codeowner": "@grafana/search-and-storage", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4404,7 +4316,6 @@ "stage": "experimental", "codeowner": "@grafana/grafana-operator-experience-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -4420,7 +4331,6 @@ "stage": "experimental", "codeowner": "@grafana/grafana-operator-experience-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true, "expression": "false" } @@ -4439,7 +4349,6 @@ "stage": "experimental", "codeowner": "@grafana/grafana-operator-experience-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4470,7 +4379,6 @@ "stage": "preview", "codeowner": "@grafana/dataviz-squad", "frontend": true, - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4484,7 +4392,6 @@ "description": "Use openFGA as authorization engine.", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } }, @@ -4501,7 +4408,6 @@ "description": "Use openFGA as main authorization engine and disable legacy RBAC clietn.", "stage": "experimental", "codeowner": "@grafana/identity-access-team", - "hideFromAdminPage": true, "hideFromDocs": true } } diff --git a/pkg/services/featuremgmt/toggles_gen_test.go b/pkg/services/featuremgmt/toggles_gen_test.go index d6a7c66802d..57e308de4ec 100644 --- a/pkg/services/featuremgmt/toggles_gen_test.go +++ b/pkg/services/featuremgmt/toggles_gen_test.go @@ -50,17 +50,14 @@ func TestFeatureToggleFiles(t *testing.T) { lookup := map[string]featuretoggleapi.FeatureSpec{} for _, flag := range standardFeatureFlags { lookup[flag.Name] = featuretoggleapi.FeatureSpec{ - Description: flag.Description, - Stage: flag.Stage.String(), - Owner: string(flag.Owner), - RequiresDevMode: flag.RequiresDevMode, - FrontendOnly: flag.FrontendOnly, - RequiresRestart: flag.RequiresRestart, - AllowSelfServe: flag.AllowSelfServe, - HideFromAdminPage: flag.HideFromAdminPage, - HideFromDocs: flag.HideFromDocs, - Expression: flag.Expression, - // EnabledVersion: ???, + Description: flag.Description, + Stage: flag.Stage.String(), + Owner: string(flag.Owner), + RequiresDevMode: flag.RequiresDevMode, + FrontendOnly: flag.FrontendOnly, + RequiresRestart: flag.RequiresRestart, + HideFromDocs: flag.HideFromDocs, + Expression: flag.Expression, } // Replace them all @@ -187,9 +184,6 @@ func verifyFlagsConfiguration(t *testing.T) { if flag.Name != strings.TrimSpace(flag.Name) { t.Errorf("flag Name should not start/end with spaces. See: %s", flag.Name) } - if flag.AllowSelfServe && (flag.Stage != FeatureStageGeneralAvailability && flag.Stage != FeatureStagePublicPreview && flag.Stage != FeatureStageDeprecated) { - t.Errorf("only allow self-serving GA, PublicPreview and Deprecated toggles") - } if flag.Owner == "" { t.Errorf("feature %s does not have an owner. please fill the FeatureFlag.Owner property", flag.Name) } @@ -340,6 +334,10 @@ package featuremgmt const (`) for _, flag := range standardFeatureFlags { + if flag.FrontendOnly { + continue // no need to have the golang constant for frontend only flags + } + data.CamelCase = strcase.ToCamel(flag.Name) data.Flag = flag data.Ext = "" From a4cbbe10c0d36a89334707f4f3f8e4526391ade6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 20 Nov 2025 14:55:45 +0100 Subject: [PATCH 115/125] Provisioning: Add retry logic for nanogit client operations (#114216) * chore(deps): update nanogit to v0.3.0 in go.mod and go.sum files * Add retry logic for nanogit client operations - Configure retry logic in withGitContext to ensure all Git operations have retry support - Use nanogit's ExponentialBackoffRetrier with 8 attempts (~10s retry window) - Retry transient network errors and HTTP-specific server errors (5xx for GET/DELETE, 429 for all) - Rename logger function to withGitContext to better reflect its responsibilities * fix: resolve staticcheck S1008 linting issue in retry_client.go Simplify return statement to use errors.As directly instead of if-return pattern * Revert "fix: resolve staticcheck S1008 linting issue in retry_client.go" This reverts commit bd367b5629bd49d9d15db708352b9ed1805376ab. --- apps/provisioning/go.mod | 2 +- apps/provisioning/go.sum | 4 +- .../pkg/repository/git/repository.go | 80 ++++++++++++++++--- .../pkg/repository/git/repository_test.go | 10 +-- go.mod | 2 +- go.sum | 4 +- go.work.sum | 4 +- 7 files changed, 81 insertions(+), 25 deletions(-) diff --git a/apps/provisioning/go.mod b/apps/provisioning/go.mod index 9bb12d5ae4f..a72aa77fe68 100644 --- a/apps/provisioning/go.mod +++ b/apps/provisioning/go.mod @@ -10,7 +10,7 @@ require ( github.com/grafana/grafana-app-sdk/logging v0.48.1 github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 - github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b + github.com/grafana/nanogit v0.3.0 github.com/migueleliasweb/go-github-mock v1.1.0 github.com/stretchr/testify v1.11.1 golang.org/x/oauth2 v0.33.0 diff --git a/apps/provisioning/go.sum b/apps/provisioning/go.sum index 78f06781b15..92a4d53c282 100644 --- a/apps/provisioning/go.sum +++ b/apps/provisioning/go.sum @@ -70,8 +70,8 @@ github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f h1:f+Z github.com/grafana/grafana/apps/secret v0.0.0-20250902093454-b56b7add012f/go.mod h1:RA8mP8KVIwKXBx3Ssqa/uEBABib5LvUWYPVMxrNvnP0= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2 h1:X0cnaFdR+iz+sDSuoZmkryFSjOirchHe2MdKSRwBWgM= github.com/grafana/grafana/pkg/apimachinery v0.0.0-20250804150913-990f1c69ecc2/go.mod h1:RRvSjHH12/PnQaXraMO65jUhVu8n59mzvhfIMBETnV4= -github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b h1:rFjoqJFb2KxJ29K9ltuWRSsdA46SbN0GCxoQc36h5kg= -github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/nanogit v0.3.0 h1:XNEef+4Vi+465ZITJs/g/xgnDRJbWhhJ7iQrAnWZ0oQ= +github.com/grafana/nanogit v0.3.0/go.mod h1:6s6CCTpyMOHPpcUZaLGI+rgBEKdmxVbhqSGgCK13j7Y= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= diff --git a/apps/provisioning/pkg/repository/git/repository.go b/apps/provisioning/pkg/repository/git/repository.go index 4d002e4f831..82d5ca6bd1d 100644 --- a/apps/provisioning/pkg/repository/git/repository.go +++ b/apps/provisioning/pkg/repository/git/repository.go @@ -25,6 +25,7 @@ import ( "github.com/grafana/nanogit/options" "github.com/grafana/nanogit/protocol" "github.com/grafana/nanogit/protocol/hash" + "github.com/grafana/nanogit/retry" ) type RepositoryConfig struct { @@ -144,7 +145,7 @@ func isValidGitURL(gitURL string) bool { // Test implements provisioning.Repository. func (r *gitRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { - ctx, _ = r.logger(ctx, "") + ctx, _ = r.withGitContext(ctx, "") t := string(r.config.Spec.Type) @@ -219,7 +220,7 @@ func (r *gitRepository) Test(ctx context.Context) (*provisioning.TestResults, er // Read implements provisioning.Repository. func (r *gitRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) { - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) finalPath := safepath.Join(r.gitConfig.Path, filePath) // Resolve ref to commit hash @@ -271,7 +272,7 @@ func (r *gitRepository) Read(ctx context.Context, filePath, ref string) (*reposi } func (r *gitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) { - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) // Resolve ref to commit hash refHash, err := r.resolveRefToHash(ctx, ref) @@ -319,7 +320,7 @@ func (r *gitRepository) Create(ctx context.Context, path, ref string, data []byt if ref == "" { ref = r.gitConfig.Branch } - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) branchRef, err := r.ensureBranchExists(ctx, ref) if err != nil { return err @@ -364,7 +365,7 @@ func (r *gitRepository) Update(ctx context.Context, path, ref string, data []byt if ref == "" { ref = r.gitConfig.Branch } - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) // Check if trying to update a directory if safepath.IsDir(path) { @@ -411,7 +412,7 @@ func (r *gitRepository) Write(ctx context.Context, path string, ref string, data ref = r.gitConfig.Branch } - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) info, err := r.Read(ctx, path, ref) if err != nil && !(errors.Is(err, repository.ErrFileNotFound)) { return fmt.Errorf("check if file exists before writing: %w", err) @@ -431,7 +432,7 @@ func (r *gitRepository) Delete(ctx context.Context, path, ref, comment string) e if ref == "" { ref = r.gitConfig.Branch } - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) branchRef, err := r.ensureBranchExists(ctx, ref) if err != nil { @@ -454,7 +455,7 @@ func (r *gitRepository) Move(ctx context.Context, oldPath, newPath, ref, comment if ref == "" { ref = r.gitConfig.Branch } - ctx, _ = r.logger(ctx, ref) + ctx, _ = r.withGitContext(ctx, ref) branchRef, err := r.ensureBranchExists(ctx, ref) if err != nil { @@ -545,6 +546,7 @@ func (r *gitRepository) History(_ context.Context, _ string, _ string) ([]provis } func (r *gitRepository) ListRefs(ctx context.Context) ([]provisioning.RefItem, error) { + ctx, _ = r.withGitContext(ctx, "") refs, err := r.client.ListRefs(ctx) if err != nil { return nil, fmt.Errorf("list refs: %w", err) @@ -566,7 +568,7 @@ func (r *gitRepository) ListRefs(ctx context.Context) ([]provisioning.RefItem, e } func (r *gitRepository) LatestRef(ctx context.Context) (string, error) { - ctx, _ = r.logger(ctx, "") + ctx, _ = r.withGitContext(ctx, "") branchRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch)) if err != nil { return "", fmt.Errorf("get branch ref: %w", err) @@ -583,7 +585,7 @@ func (r *gitRepository) CompareFiles(ctx context.Context, base, ref string) ([]r return nil, fmt.Errorf("ref cannot be empty") } - ctx, logger := r.logger(ctx, ref) + ctx, logger := r.withGitContext(ctx, ref) // Resolve base ref to hash var baseHash hash.Hash @@ -671,11 +673,15 @@ func (r *gitRepository) CompareFiles(ctx context.Context, base, ref string) ([]r } func (r *gitRepository) Stage(ctx context.Context, opts repository.StageOptions) (repository.StagedRepository, error) { + ctx = ensureRetryContext(ctx) + ctx, _ = r.withGitContext(ctx, "") return NewStagedGitRepository(ctx, r, opts) } // resolveRefToHash resolves a ref (branch name or commit hash) to a commit hash func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.Hash, error) { + ctx, _ = r.withGitContext(ctx, ref) + // Use default branch if ref is empty if ref == "" { ref = r.gitConfig.Branch @@ -706,6 +712,8 @@ func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash. // ensureBranchExists checks if a branch exists and creates it if it doesn't, // returning the branch reference to avoid duplicate GetRef calls func (r *gitRepository) ensureBranchExists(ctx context.Context, branchName string) (nanogit.Ref, error) { + ctx, _ = r.withGitContext(ctx, branchName) + if !IsValidGitBranchName(branchName) { return nanogit.Ref{}, &apierrors.StatusError{ ErrStatus: metav1.Status{ @@ -802,7 +810,57 @@ func (r *gitRepository) commitAndPush(ctx context.Context, writer nanogit.Staged return nil } -func (r *gitRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) { +// defaultGitRetrier returns a default retrier configuration for Git operations. +// +// Retry attempts will happen when: +// - Network errors occur: connection timeouts, temporary network failures, or connection errors +// - HTTP 5xx server errors: For GET and DELETE operations (idempotent) +// - HTTP 429 Too Many Requests: For all operations (rate limiting is temporary) +// +// The retry behavior: +// - Total attempts: 8 (1 initial attempt + 7 retries) +// - Initial delay: 100ms before the first retry +// - Exponential backoff: delay doubles after each failed attempt (100ms → 200ms → 400ms → 800ms → 1.6s → 3.2s → 5s) +// - Maximum delay: capped at 5 seconds +// - Jitter: enabled to prevent thundering herd problems +// - Total retry window: approximately 10 seconds from first attempt to last retry +// +// All attempts will fail when: +// - The Git server is completely unavailable or unreachable +// - Network connectivity issues persist beyond the retry window (~10 seconds) +// - The server returns transient errors consistently for the entire retry duration +// - Context cancellation occurs before retries complete +// +// Non-transient errors (e.g., 4xx client errors except 429, authentication failures) are not retried and returned immediately. +func defaultGitRetrier() *retry.ExponentialBackoffRetrier { + return retry.NewExponentialBackoffRetrier(). + WithMaxAttempts(8). // 1 initial + 7 retries = 8 total attempts (~10s total retry window) + WithInitialDelay(100 * time.Millisecond). + WithMaxDelay(5 * time.Second). + WithMultiplier(2.0). + WithJitter() +} + +// ensureRetryContext ensures that retry logic is configured in the context. +// This function should be called at the beginning of all methods that make client calls +// to guarantee retry logic is always present, regardless of context state. +func ensureRetryContext(ctx context.Context) context.Context { + // Only add retrier if one doesn't already exist in the context + if retry.FromContext(ctx).MaxAttempts() <= 1 { + ctx = retry.ToContext(ctx, defaultGitRetrier()) + } + return ctx +} + +// withGitContext sets up the context with logging, git repository metadata, and retry logic. +// This function should be called at the beginning of all public methods to ensure: +// - Proper logging context with git repository details +// - Retry logic is configured for all Git operations +// - Context is properly prepared for nanogit client calls +func (r *gitRepository) withGitContext(ctx context.Context, ref string) (context.Context, logging.Logger) { + // Ensure retry logic is configured first, before any early returns + ctx = ensureRetryContext(ctx) + logger := logging.FromContext(ctx) type containsGit int diff --git a/apps/provisioning/pkg/repository/git/repository_test.go b/apps/provisioning/pkg/repository/git/repository_test.go index d3946b7b6d4..4958ab372ab 100644 --- a/apps/provisioning/pkg/repository/git/repository_test.go +++ b/apps/provisioning/pkg/repository/git/repository_test.go @@ -2163,7 +2163,7 @@ func TestGitRepository_commitAndPush(t *testing.T) { } } -func TestGitRepository_logger(t *testing.T) { +func TestGitRepository_withGitContext(t *testing.T) { gitRepo := &gitRepository{ config: &provisioning.Repository{ Spec: provisioning.RepositorySpec{ @@ -2179,7 +2179,7 @@ func TestGitRepository_logger(t *testing.T) { t.Run("creates new logger context", func(t *testing.T) { ctx := context.Background() - newCtx, logger := gitRepo.logger(ctx, "feature-branch") + newCtx, logger := gitRepo.withGitContext(ctx, "feature-branch") require.NotNil(t, newCtx) require.NotNil(t, logger) @@ -2188,7 +2188,7 @@ func TestGitRepository_logger(t *testing.T) { t.Run("uses default branch when ref is empty", func(t *testing.T) { ctx := context.Background() - newCtx, logger := gitRepo.logger(ctx, "") + newCtx, logger := gitRepo.withGitContext(ctx, "") require.NotNil(t, newCtx) require.NotNil(t, logger) @@ -2198,10 +2198,10 @@ func TestGitRepository_logger(t *testing.T) { ctx := context.Background() // First call creates the logger context - ctx1, logger1 := gitRepo.logger(ctx, "branch1") + ctx1, logger1 := gitRepo.withGitContext(ctx, "branch1") // Second call should return the existing logger context - ctx2, logger2 := gitRepo.logger(ctx1, "branch2") + ctx2, logger2 := gitRepo.withGitContext(ctx1, "branch2") // When logger context already exists, it should return the same context require.Equal(t, ctx1, ctx2) diff --git a/go.mod b/go.mod index 23756c59ae0..d8df0cd6bd7 100644 --- a/go.mod +++ b/go.mod @@ -106,7 +106,7 @@ require ( github.com/grafana/grafana-plugin-sdk-go v0.283.0 // @grafana/plugins-platform-backend github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 // @grafana/alerting-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs - github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b // indirect; @grafana/grafana-git-ui-sync-team + github.com/grafana/nanogit v0.3.0 // indirect; @grafana/grafana-git-ui-sync-team github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.9 // @grafana/observability-traces-and-profiling github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling diff --git a/go.sum b/go.sum index 61068fdef0b..1c294bc1fe2 100644 --- a/go.sum +++ b/go.sum @@ -1661,8 +1661,8 @@ github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000 h1:/5LKSYgLm github.com/grafana/loki/pkg/push v0.0.0-20250823105456-332df2b20000/go.mod h1:/ZklAgE1i4f3Z8uriXwESmCr1VLF8lBGaJspuaGuf78= github.com/grafana/loki/v3 v3.2.1 h1:VB7u+KHfvL5aHAxgoVBvz5wVhsdGuqKC7uuOFOOe7jw= github.com/grafana/loki/v3 v3.2.1/go.mod h1:WvdLl6wOS+yahaeQY+xhD2m2XzkHDfKr5FZaX7D/X2Y= -github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b h1:rFjoqJFb2KxJ29K9ltuWRSsdA46SbN0GCxoQc36h5kg= -github.com/grafana/nanogit v0.0.0-20251106115617-c622d3e0fc4b/go.mod h1:ToqLjIdvV3AZQa3K6e5m9hy/nsGaUByc2dWQlctB9iA= +github.com/grafana/nanogit v0.3.0 h1:XNEef+4Vi+465ZITJs/g/xgnDRJbWhhJ7iQrAnWZ0oQ= +github.com/grafana/nanogit v0.3.0/go.mod h1:6s6CCTpyMOHPpcUZaLGI+rgBEKdmxVbhqSGgCK13j7Y= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250911094103-5456b6e45604 h1:aXfUhVN/Ewfpbko2CCtL65cIiGgwStOo4lWH2b6gw2U= diff --git a/go.work.sum b/go.work.sum index 9dc15683534..bbfe799f72c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -937,7 +937,6 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2 github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.0.1/go.mod h1:lXGCsh6c22WGtjr+qGHj1otzZpV/1kwTMAqkwZsnWRU= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.1.0/go.mod h1:XKMd7iuf/RGPSMJ/U4HP0zS2Z9Fh8Ps9a+6X26m/tmI= github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.0/go.mod h1:qOchhhIlmRcqk/O9uCo/puJlyo07YINaIqdZfZG3Jkc= -github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3 h1:B+8ClL/kCQkRiU82d9xajRPKYMrB7E0MbtzWVi1K4ns= github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9Kz84aCaG7AsGZnLjhHbUqwPg= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= @@ -1323,7 +1322,6 @@ github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkq github.com/prometheus/common v0.64.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8= github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= -github.com/prometheus/common v0.67.2 h1:PcBAckGFTIHt2+L3I33uNRTlKTplNzFctXcWhPyAEN8= github.com/prometheus/common/assets v0.2.0 h1:0P5OrzoHrYBOSM1OigWL3mY8ZvV2N4zIE/5AahrSrfM= github.com/prometheus/exporter-toolkit v0.10.1-0.20230714054209-2f4150c63f97/go.mod h1:LoBCZeRh+5hX+fSULNyFnagYlQG/gBsyA/deNzROkq8= github.com/prometheus/statsd_exporter v0.21.0/go.mod h1:rbT83sZq2V+p73lHhPZfMc3MLCHmSHelCh9hSGYNLTQ= @@ -1374,6 +1372,7 @@ github.com/schollz/closestmatch v2.1.0+incompatible h1:Uel2GXEpJqOWBrlyI+oY9LTiy github.com/schollz/closestmatch v2.1.0+incompatible/go.mod h1:RtP1ddjLong6gTkbtmuhtR2uUrrJOpYzYRvbcPAid+g= github.com/schollz/progressbar/v3 v3.14.6 h1:GyjwcWBAf+GFDMLziwerKvpuS7ZF+mNTAXIB2aspiZs= github.com/schollz/progressbar/v3 v3.14.6/go.mod h1:Nrzpuw3Nl0srLY0VlTvC4V6RL50pcEymjy6qyJAaLa0= +github.com/sclevine/spec v1.4.0/go.mod h1:LvpgJaFyvQzRvc1kaDs0bulYwzC70PbiYjC4QnFHkOM= github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtrmhM= github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= github.com/segmentio/parquet-go v0.0.0-20220811205829-7efc157d28af/go.mod h1:PxYdAI6cGd+s1j4hZDQbz3VFgobF5fDA0weLeNWKTE4= @@ -1920,7 +1919,6 @@ golang.org/x/oauth2 v0.25.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/oauth2 v0.31.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.13.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= From 3999d108f7f21a072990400e8cfcf7c4dd6bcdfc Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Thu, 20 Nov 2025 14:00:33 +0000 Subject: [PATCH 116/125] Folders: Make `listFolders` call correct API and fix tags sorting (#114181) --- .../browse-dashboards/api/services.ts | 19 ++++++++++--------- public/app/features/search/service/unified.ts | 4 +++- 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts index 98be615583b..2bb1ec6d39e 100644 --- a/public/app/features/browse-dashboards/api/services.ts +++ b/public/app/features/browse-dashboards/api/services.ts @@ -1,8 +1,7 @@ -import { getBackendSrv } from '@grafana/runtime'; import { contextSrv } from 'app/core/services/context_srv'; import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { getGrafanaSearcher } from 'app/features/search/service/searcher'; -import { NestedFolderDTO } from 'app/features/search/service/types'; +import { DashboardQueryResult } from 'app/features/search/service/types'; import { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; import { AccessControlAction } from 'app/types/accessControl'; @@ -17,22 +16,24 @@ export async function listFolders( page = 1, pageSize = PAGE_SIZE ): Promise { - const backendSrv = getBackendSrv(); + const searcher = getGrafanaSearcher(); - // TODO: what to do here for unified search? - let folders: NestedFolderDTO[] = []; + let folders: DashboardQueryResult[] = []; if (contextSrv.hasPermission(AccessControlAction.FoldersRead)) { - folders = await backendSrv.get('/api/folders', { - parentUid: parentUID, - page, + const foldersResults = await searcher.search({ + kind: ['folder'], + location: parentUID || 'general', + from: (page - 1) * pageSize, // our pages are 1-indexed, so we need to -1 to convert that to correct value to skip limit: pageSize, + offset: (page - 1) * pageSize, }); + folders = foldersResults.view.toArray(); } return folders.map((item) => ({ kind: 'folder', uid: item.uid, - title: item.title, + title: item.name, parentTitle, parentUID, managedBy: item.managedBy, diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 4ab551997a3..8d1af58f9d9 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -386,7 +386,9 @@ export function toDashboardResults(rsp: SearchAPIResponse, sort: string): DataFr ...hit, uid: hit.name, url: toURL(hit.resource, hit.name, hit.title), - tags: hit.tags || [], + // Sort tags so we aren't reliant on the backend having done this for us + // Sorting order can be different between APIs/search implementations + tags: (hit.tags || []).sort(), folder: hit.folder || 'general', location, name: hit.title, // 🤯 FIXME hit.name is k8s name, eg grafana dashboards UID From 7d179120b6387c679d66b54fbbe41d7d15cc25c6 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Thu, 20 Nov 2025 14:03:24 +0000 Subject: [PATCH 117/125] Plugins: Replace CDN class with FS CDN type (#113968) replace cdn class with fs type --- apps/plugins/kinds/plugin.cue | 4 +- .../apis/plugins/v0alpha1/plugin_spec_gen.go | 1 - apps/plugins/pkg/apis/plugins_manifest.go | 2 +- apps/plugins/pkg/app/install/registrar.go | 1 - .../plugins/pkg/app/install/registrar_test.go | 13 --- pkg/api/frontendsettings_test.go | 14 +++- pkg/plugins/ifaces.go | 17 +++- pkg/plugins/localfiles.go | 6 +- .../angularinspector/angularinspector.go | 2 +- .../angularinspector/angularinspector_test.go | 7 +- pkg/plugins/manager/loader/loader.go | 34 +------- .../manager/pipeline/validation/steps.go | 7 +- pkg/plugins/manager/pluginfakes/fakes.go | 12 ++- .../manager/signature/manifest_test.go | 2 +- pkg/plugins/pluginassets/pluginassets_test.go | 9 --- pkg/plugins/plugins.go | 3 +- pkg/plugins/test_utils.go | 2 +- .../pluginsintegration/loader/loader_test.go | 8 +- .../pluginsintegration/pipeline/steps.go | 2 +- .../pluginassets/pluginassets.go | 10 +-- .../pluginassets/pluginassets_test.go | 80 ++++++++++++------- 21 files changed, 122 insertions(+), 114 deletions(-) diff --git a/apps/plugins/kinds/plugin.cue b/apps/plugins/kinds/plugin.cue index 4f381082969..50c475cd430 100644 --- a/apps/plugins/kinds/plugin.cue +++ b/apps/plugins/kinds/plugin.cue @@ -13,7 +13,7 @@ pluginV0Alpha1: { id: string version: string url?: string - class: "core" | "external" | "cdn" + class: "core" | "external" } } routes: { @@ -233,4 +233,4 @@ pluginV0Alpha1: { title?: string description?: string }] -} \ No newline at end of file +} diff --git a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go index d46a0a4aed1..9ae14bcbdc5 100644 --- a/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go +++ b/apps/plugins/pkg/apis/plugins/v0alpha1/plugin_spec_gen.go @@ -21,5 +21,4 @@ type PluginSpecClass string const ( PluginSpecClassCore PluginSpecClass = "core" PluginSpecClassExternal PluginSpecClass = "external" - PluginSpecClassCdn PluginSpecClass = "cdn" ) diff --git a/apps/plugins/pkg/apis/plugins_manifest.go b/apps/plugins/pkg/apis/plugins_manifest.go index 428971aa0b5..09c57eaa186 100644 --- a/apps/plugins/pkg/apis/plugins_manifest.go +++ b/apps/plugins/pkg/apis/plugins_manifest.go @@ -20,7 +20,7 @@ import ( ) var ( - rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"getMetaDependencies":{"type":"object","required":["grafanaDependency"],"properties":{"extensions":{"type":"object","properties":{"exposedComponents":{"description":"+listType=set","type":"array","items":{"type":"string"}}},"additionalProperties":false},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","type":"array","items":{"type":"object","required":["id","type","name"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["app","datasource","panel"]}},"additionalProperties":false}}},"additionalProperties":false},"getMetaEnterpriseFeatures":{"type":"object","properties":{"healthDiagnosticsErrors":{"description":"Allow additional properties","type":"boolean","default":false}},"additionalProperties":false},"getMetaExtensions":{"type":"object","properties":{"addedComponents":{"description":"+listType=atomic","type":"array","items":{"type":"object","required":["targets","title"],"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","type":"array","items":{"type":"string"}},"title":{"type":"string"}},"additionalProperties":false}},"addedLinks":{"description":"+listType=atomic","type":"array","items":{"type":"object","required":["targets","title"],"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","type":"array","items":{"type":"string"}},"title":{"type":"string"}},"additionalProperties":false}},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","type":"array","items":{"type":"object","required":["id"],"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"additionalProperties":false}},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","type":"array","items":{"type":"object","required":["id"],"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"getMetaIAM":{"type":"object","properties":{"permissions":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"action":{"type":"string"},"scope":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"getMetaInclude":{"type":"object","properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"type":"string","enum":["Admin","Editor","Viewer"]},"type":{"type":"string","enum":["dashboard","page","panel","datasource"]},"uid":{"type":"string"}},"additionalProperties":false},"getMetaInfo":{"type":"object","required":["keywords","logos","updated","version"],"properties":{"author":{"description":"Optional fields","type":"object","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"additionalProperties":false},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","type":"array","items":{"type":"string"}},"links":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"url":{"type":"string"}},"additionalProperties":false}},"logos":{"type":"object","required":["small","large"],"properties":{"large":{"type":"string"},"small":{"type":"string"}},"additionalProperties":false},"screenshots":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"path":{"type":"string"}},"additionalProperties":false}},"updated":{"type":"string","format":"date-time"},"version":{"type":"string"}},"additionalProperties":false},"getMetaQueryOptions":{"type":"object","properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"additionalProperties":false},"getMetaRole":{"type":"object","properties":{"grants":{"description":"+listType=set","type":"array","items":{"type":"string"}},"role":{"type":"object","properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"action":{"type":"string"},"scope":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false}},"additionalProperties":false},"getMetaRoute":{"type":"object","properties":{"body":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"headers":{"description":"+listType=atomic","type":"array","items":{"type":"string"}},"jwtTokenAuth":{"type":"object","properties":{"params":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"scopes":{"description":"+listType=set","type":"array","items":{"type":"string"}},"url":{"type":"string"}},"additionalProperties":false},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"type":"object","properties":{"params":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"scopes":{"description":"+listType=set","type":"array","items":{"type":"string"}},"url":{"type":"string"}},"additionalProperties":false},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"name":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"spec":{"additionalProperties":false,"properties":{"class":{"enum":["core","external","cdn"],"type":"string"},"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) + rawSchemaPluginv0alpha1 = []byte(`{"OperatorState":{"additionalProperties":false,"properties":{"descriptiveState":{"description":"descriptiveState is an optional more descriptive state field which has no requirements on format","type":"string"},"details":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"details contains any extra information that is operator-specific","type":"object"},"lastEvaluation":{"description":"lastEvaluation is the ResourceVersion last evaluated","type":"string"},"state":{"description":"state describes the state of the lastEvaluation.\nIt is limited to three possible states for machine evaluation.","enum":["success","in_progress","failed"],"type":"string"}},"required":["lastEvaluation","state"],"type":"object"},"Plugin":{"properties":{"spec":{"$ref":"#/components/schemas/spec"},"status":{"$ref":"#/components/schemas/status"}},"required":["spec"]},"getMetaDependencies":{"type":"object","required":["grafanaDependency"],"properties":{"extensions":{"type":"object","properties":{"exposedComponents":{"description":"+listType=set","type":"array","items":{"type":"string"}}},"additionalProperties":false},"grafanaDependency":{"description":"Required field","type":"string"},"grafanaVersion":{"description":"Optional fields","type":"string"},"plugins":{"description":"+listType=set\n+listMapKey=id","type":"array","items":{"type":"object","required":["id","type","name"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"type":{"type":"string","enum":["app","datasource","panel"]}},"additionalProperties":false}}},"additionalProperties":false},"getMetaEnterpriseFeatures":{"type":"object","properties":{"healthDiagnosticsErrors":{"description":"Allow additional properties","type":"boolean","default":false}},"additionalProperties":false},"getMetaExtensions":{"type":"object","properties":{"addedComponents":{"description":"+listType=atomic","type":"array","items":{"type":"object","required":["targets","title"],"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","type":"array","items":{"type":"string"}},"title":{"type":"string"}},"additionalProperties":false}},"addedLinks":{"description":"+listType=atomic","type":"array","items":{"type":"object","required":["targets","title"],"properties":{"description":{"type":"string"},"targets":{"description":"+listType=set","type":"array","items":{"type":"string"}},"title":{"type":"string"}},"additionalProperties":false}},"exposedComponents":{"description":"+listType=set\n+listMapKey=id","type":"array","items":{"type":"object","required":["id"],"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"additionalProperties":false}},"extensionPoints":{"description":"+listType=set\n+listMapKey=id","type":"array","items":{"type":"object","required":["id"],"properties":{"description":{"type":"string"},"id":{"type":"string"},"title":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"getMetaIAM":{"type":"object","properties":{"permissions":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"action":{"type":"string"},"scope":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"getMetaInclude":{"type":"object","properties":{"action":{"type":"string"},"addToNav":{"type":"boolean"},"component":{"type":"string"},"defaultNav":{"type":"boolean"},"icon":{"type":"string"},"name":{"type":"string"},"path":{"type":"string"},"role":{"type":"string","enum":["Admin","Editor","Viewer"]},"type":{"type":"string","enum":["dashboard","page","panel","datasource"]},"uid":{"type":"string"}},"additionalProperties":false},"getMetaInfo":{"type":"object","required":["keywords","logos","updated","version"],"properties":{"author":{"description":"Optional fields","type":"object","properties":{"email":{"type":"string"},"name":{"type":"string"},"url":{"type":"string"}},"additionalProperties":false},"description":{"type":"string"},"keywords":{"description":"Required fields\n+listType=set","type":"array","items":{"type":"string"}},"links":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"url":{"type":"string"}},"additionalProperties":false}},"logos":{"type":"object","required":["small","large"],"properties":{"large":{"type":"string"},"small":{"type":"string"}},"additionalProperties":false},"screenshots":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"path":{"type":"string"}},"additionalProperties":false}},"updated":{"type":"string","format":"date-time"},"version":{"type":"string"}},"additionalProperties":false},"getMetaQueryOptions":{"type":"object","properties":{"cacheTimeout":{"type":"boolean"},"maxDataPoints":{"type":"boolean"},"minInterval":{"type":"boolean"}},"additionalProperties":false},"getMetaRole":{"type":"object","properties":{"grants":{"description":"+listType=set","type":"array","items":{"type":"string"}},"role":{"type":"object","properties":{"description":{"type":"string"},"name":{"type":"string"},"permissions":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"action":{"type":"string"},"scope":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false}},"additionalProperties":false},"getMetaRoute":{"type":"object","properties":{"body":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"headers":{"description":"+listType=atomic","type":"array","items":{"type":"string"}},"jwtTokenAuth":{"type":"object","properties":{"params":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"scopes":{"description":"+listType=set","type":"array","items":{"type":"string"}},"url":{"type":"string"}},"additionalProperties":false},"method":{"type":"string"},"path":{"type":"string"},"reqAction":{"type":"string"},"reqRole":{"type":"string"},"reqSignedIn":{"type":"boolean"},"tokenAuth":{"type":"object","properties":{"params":{"type":"object","additionalProperties":{"type":"object","additionalProperties":{}}},"scopes":{"description":"+listType=set","type":"array","items":{"type":"string"}},"url":{"type":"string"}},"additionalProperties":false},"url":{"type":"string"},"urlParams":{"description":"+listType=atomic","type":"array","items":{"type":"object","properties":{"content":{"type":"string"},"name":{"type":"string"}},"additionalProperties":false}}},"additionalProperties":false},"spec":{"additionalProperties":false,"properties":{"class":{"enum":["core","external"],"type":"string"},"id":{"type":"string"},"url":{"type":"string"},"version":{"type":"string"}},"required":["id","version","class"],"type":"object"},"status":{"additionalProperties":false,"properties":{"additionalFields":{"additionalProperties":{"additionalProperties":{},"type":"object"},"description":"additionalFields is reserved for future use","type":"object"},"operatorStates":{"additionalProperties":{"$ref":"#/components/schemas/OperatorState"},"description":"operatorStates is a map of operator ID to operator state evaluations.\nAny operator which consumes this kind SHOULD add its state evaluation information to this field.","type":"object"}},"type":"object"}}`) versionSchemaPluginv0alpha1 app.VersionSchema _ = json.Unmarshal(rawSchemaPluginv0alpha1, &versionSchemaPluginv0alpha1) ) diff --git a/apps/plugins/pkg/app/install/registrar.go b/apps/plugins/pkg/app/install/registrar.go index fe747eb2806..3977fd88cf0 100644 --- a/apps/plugins/pkg/app/install/registrar.go +++ b/apps/plugins/pkg/app/install/registrar.go @@ -23,7 +23,6 @@ type Class = string const ( ClassCore Class = "core" ClassExternal Class = "external" - ClassCDN Class = "cdn" ) type Source = string diff --git a/apps/plugins/pkg/app/install/registrar_test.go b/apps/plugins/pkg/app/install/registrar_test.go index 36359896ae6..f1e0adbc508 100644 --- a/apps/plugins/pkg/app/install/registrar_test.go +++ b/apps/plugins/pkg/app/install/registrar_test.go @@ -446,19 +446,6 @@ func TestPluginInstall_ToPluginInstallV0Alpha1(t *testing.T) { require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCore), p.Spec.Class) }, }, - { - name: "cdn class is mapped correctly", - install: PluginInstall{ - ID: "plugin-cdn", - Version: "3.0.0", - Class: ClassCDN, - Source: SourcePluginStore, - }, - namespace: "org-3", - validate: func(t *testing.T, p *pluginsv0alpha1.Plugin) { - require.Equal(t, pluginsv0alpha1.PluginSpecClass(ClassCDN), p.Spec.Class) - }, - }, { name: "source annotation is set correctly", install: PluginInstall{ diff --git a/pkg/api/frontendsettings_test.go b/pkg/api/frontendsettings_test.go index cb09065dc85..ba67837be5d 100644 --- a/pkg/api/frontendsettings_test.go +++ b/pkg/api/frontendsettings_test.go @@ -19,6 +19,7 @@ import ( "github.com/grafana/grafana/pkg/login/social/socialimpl" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" @@ -265,6 +266,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) { Type: plugins.TypeApp, Preload: true, }, + FS: &pluginfakes.FakePluginFS{}, }, }, } @@ -302,6 +304,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) { Type: plugins.TypeApp, Preload: true, }, + FS: &pluginfakes.FakePluginFS{}, }, }, } @@ -339,6 +342,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) { Preload: true, }, Angular: plugins.AngularMeta{Detected: true}, + FS: &pluginfakes.FakePluginFS{}, }, }, } @@ -404,12 +408,12 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) { }, }, { - desc: "app plugin with CDN class", + desc: "app plugin with CDN fs", pluginStore: func() pluginstore.Store { return &pluginstore.FakePluginStore{ PluginList: []pluginstore.Plugin{ { - Class: plugins.ClassCDN, + Class: plugins.ClassExternal, Module: fmt.Sprintf("/%s/module.js", "test-app"), JSONData: plugins.JSONData{ ID: "test-app", @@ -417,6 +421,9 @@ func TestIntegrationHTTPServer_GetFrontendSettings_apps(t *testing.T) { Type: plugins.TypeApp, Preload: true, }, + FS: &pluginfakes.FakePluginFS{TypeFunc: func() plugins.FSType { + return plugins.FSTypeCDN + }}, }, }, } @@ -545,6 +552,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) { "en-US": "public/plugins/test-app/locales/en-US/test-app.json", "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", }, + FS: &pluginfakes.FakePluginFS{}, }, }, } @@ -594,6 +602,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) { "en-US": "public/plugins/test-app/locales/en-US/test-app.json", "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", }, + FS: &pluginfakes.FakePluginFS{}, }, }, } @@ -633,6 +642,7 @@ func TestIntegrationHTTPServer_GetFrontendSettings_translations(t *testing.T) { "en-US": "public/plugins/test-app/locales/en-US/test-app.json", "pt-BR": "public/plugins/test-app/locales/pt-BR/test-app.json", }, + FS: &pluginfakes.FakePluginFS{}, }, }, } diff --git a/pkg/plugins/ifaces.go b/pkg/plugins/ifaces.go index e50f059e815..9b719b8b180 100644 --- a/pkg/plugins/ifaces.go +++ b/pkg/plugins/ifaces.go @@ -72,12 +72,27 @@ type UpdateInfo struct { type FS interface { fs.FS - Type() string + Type() FSType Base() string Files() ([]string, error) Rel(string) (string, error) } +type FSType string + +const ( + FSTypeCDN FSType = "cdn" + FSTypeLocal FSType = "local" +) + +func (f FSType) CDN() bool { + return f == FSTypeCDN +} + +func (f FSType) Local() bool { + return f == FSTypeLocal +} + type FSRemover interface { Remove() error } diff --git a/pkg/plugins/localfiles.go b/pkg/plugins/localfiles.go index a6584641a55..073ab0e844d 100644 --- a/pkg/plugins/localfiles.go +++ b/pkg/plugins/localfiles.go @@ -30,8 +30,8 @@ func NewLocalFS(basePath string) LocalFS { return LocalFS{basePath: basePath} } -func (f LocalFS) Type() string { - return "local" +func (f LocalFS) Type() FSType { + return FSTypeLocal } // fileIsAllowed takes an absolute path to a file and an os.FileInfo for that file, and it checks if access to that @@ -219,7 +219,7 @@ func NewStaticFS(fs FS) (StaticFS, error) { }, nil } -func (f StaticFS) Type() string { +func (f StaticFS) Type() FSType { return f.FS.Type() } diff --git a/pkg/plugins/manager/loader/angular/angularinspector/angularinspector.go b/pkg/plugins/manager/loader/angular/angularinspector/angularinspector.go index 98840189389..0ee0f9d9ee9 100644 --- a/pkg/plugins/manager/loader/angular/angularinspector/angularinspector.go +++ b/pkg/plugins/manager/loader/angular/angularinspector/angularinspector.go @@ -36,7 +36,7 @@ func NewPatternListInspector(detectorsProvider angulardetector.DetectorsProvider func (i *PatternsListInspector) Inspect(ctx context.Context, p *plugins.Plugin) (isAngular bool, err error) { // CDN plugins are ignored because they should not be using Angular - if p.Class == plugins.ClassCDN { + if p.FS.Type().CDN() { return false, nil } diff --git a/pkg/plugins/manager/loader/angular/angularinspector/angularinspector_test.go b/pkg/plugins/manager/loader/angular/angularinspector/angularinspector_test.go index 47c5af990b9..1e29443d97d 100644 --- a/pkg/plugins/manager/loader/angular/angularinspector/angularinspector_test.go +++ b/pkg/plugins/manager/loader/angular/angularinspector/angularinspector_test.go @@ -10,6 +10,7 @@ import ( "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/manager/loader/angular/angulardetector" + "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" ) type fakeDetector struct { @@ -81,7 +82,11 @@ func TestPatternsListInspector(t *testing.T) { { name: "CDN plugins return false without calling detectors", plugin: &plugins.Plugin{ - Class: plugins.ClassCDN, + FS: &pluginfakes.FakePluginFS{ + TypeFunc: func() plugins.FSType { + return plugins.FSTypeCDN + }, + }, }, fakeDetectors: []*fakeDetector{ {returns: true}, diff --git a/pkg/plugins/manager/loader/loader.go b/pkg/plugins/manager/loader/loader.go index e6bbc257f23..d4943cee865 100644 --- a/pkg/plugins/manager/loader/loader.go +++ b/pkg/plugins/manager/loader/loader.go @@ -18,8 +18,6 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginerrs" ) -const concurrencyLimit = 32 - type Loader struct { cfg *pluginsCfg.PluginManagementCfg discovery discovery.Discoverer @@ -87,37 +85,13 @@ func (l *Loader) Load(ctx context.Context, src plugins.PluginSource) ([]*plugins st = time.Now() validatedPlugins := []*plugins.Plugin{} - type validateResult struct { - bootstrappedPlugin *plugins.Plugin - err error - } - validateResults := make(chan validateResult, len(bootstrappedPlugins)) - - var limitSize int - if src.PluginClass(ctx) == plugins.ClassCDN { - limitSize = min(len(bootstrappedPlugins), concurrencyLimit) - } else { - limitSize = 1 - } - limit := make(chan struct{}, limitSize) for _, bootstrappedPlugin := range bootstrappedPlugins { - limit <- struct{}{} - go func(p *plugins.Plugin) { - err := l.validation.Validate(ctx, p) - validateResults <- validateResult{ - bootstrappedPlugin: bootstrappedPlugin, - err: err, - } - <-limit - }(bootstrappedPlugin) - } - for i := 0; i < len(bootstrappedPlugins); i++ { - r := <-validateResults - if r.err != nil { - l.recordError(ctx, r.bootstrappedPlugin, r.err) + err := l.validation.Validate(ctx, bootstrappedPlugin) + if err != nil { + l.recordError(ctx, bootstrappedPlugin, err) continue } - validatedPlugins = append(validatedPlugins, r.bootstrappedPlugin) + validatedPlugins = append(validatedPlugins, bootstrappedPlugin) } l.log.Debug("Validated", "class", src.PluginClass(ctx), "duration", time.Since(st), "total", len(validatedPlugins)) diff --git a/pkg/plugins/manager/pipeline/validation/steps.go b/pkg/plugins/manager/pipeline/validation/steps.go index bffac88fc0b..02b8b119884 100644 --- a/pkg/plugins/manager/pipeline/validation/steps.go +++ b/pkg/plugins/manager/pipeline/validation/steps.go @@ -57,7 +57,7 @@ func newModuleJSValidator() *ModuleJSValidator { func (v *ModuleJSValidator) Validate(_ context.Context, p *plugins.Plugin) error { // CDN plugins are ignored because the module.js is guaranteed to exist - if p.Class == plugins.ClassCDN { + if p.FS.Type().CDN() { return nil } @@ -96,6 +96,11 @@ func newAngularDetector(cfg *config.PluginManagementCfg, angularInspector angula } func (a *AngularDetector) Validate(ctx context.Context, p *plugins.Plugin) error { + // CDN plugins are ignored because they should not be using Angular + if p.FS.Type().CDN() { + return nil + } + if p.IsExternalPlugin() { var err error diff --git a/pkg/plugins/manager/pluginfakes/fakes.go b/pkg/plugins/manager/pluginfakes/fakes.go index 614c95075ad..e449fd9bb2e 100644 --- a/pkg/plugins/manager/pluginfakes/fakes.go +++ b/pkg/plugins/manager/pluginfakes/fakes.go @@ -433,6 +433,7 @@ func (f *FakeActionSetRegistry) RegisterActionSets(_ context.Context, _ string, type FakePluginFS struct { OpenFunc func(name string) (fs.File, error) RemoveFunc func() error + TypeFunc func() plugins.FSType RelFunc func(string) (string, error) base string @@ -444,10 +445,6 @@ func NewFakePluginFS(base string) *FakePluginFS { } } -func (f *FakePluginFS) Type() string { - return "fake" -} - func (f *FakePluginFS) Open(name string) (fs.File, error) { if f.OpenFunc != nil { return f.OpenFunc(name) @@ -462,6 +459,13 @@ func (f *FakePluginFS) Rel(_ string) (string, error) { return "", nil } +func (f *FakePluginFS) Type() plugins.FSType { + if f.TypeFunc != nil { + return f.TypeFunc() + } + return "fake" +} + func (f *FakePluginFS) Base() string { return f.base } diff --git a/pkg/plugins/manager/signature/manifest_test.go b/pkg/plugins/manager/signature/manifest_test.go index 5b8125d15fa..d399268b464 100644 --- a/pkg/plugins/manager/signature/manifest_test.go +++ b/pkg/plugins/manager/signature/manifest_test.go @@ -345,7 +345,7 @@ func newPathSeparatorOverrideFS(sep string, ufs plugins.FS) (fsPathSeparatorFile }, nil } -func (f fsPathSeparatorFiles) Type() string { +func (f fsPathSeparatorFiles) Type() plugins.FSType { return f.FS.Type() } diff --git a/pkg/plugins/pluginassets/pluginassets_test.go b/pkg/plugins/pluginassets/pluginassets_test.go index 3dede1ce9f0..4f0520d115b 100644 --- a/pkg/plugins/pluginassets/pluginassets_test.go +++ b/pkg/plugins/pluginassets/pluginassets_test.go @@ -42,15 +42,6 @@ func TestLocalProvider_Module(t *testing.T) { }, expected: "public/plugins/external-plugin/module.js", }, - { - name: "CDN plugin should use standard path", - plugin: PluginInfo{ - JsonData: plugins.JSONData{ID: "cdn-plugin"}, - Class: plugins.ClassCDN, - FS: plugins.NewLocalFS("/cdn/plugins/cdn-plugin"), - }, - expected: "public/plugins/cdn-plugin/module.js", - }, } for _, tt := range tests { diff --git a/pkg/plugins/plugins.go b/pkg/plugins/plugins.go index 2c394dcc632..2d8241008d9 100644 --- a/pkg/plugins/plugins.go +++ b/pkg/plugins/plugins.go @@ -496,7 +496,7 @@ func (p *Plugin) IsCorePlugin() bool { } func (p *Plugin) IsExternalPlugin() bool { - return !p.IsCorePlugin() + return p.Class == ClassExternal } type Class string @@ -504,7 +504,6 @@ type Class string const ( ClassCore Class = "core" ClassExternal Class = "external" - ClassCDN Class = "cdn" ) func (c Class) String() string { diff --git a/pkg/plugins/test_utils.go b/pkg/plugins/test_utils.go index 15545bcedbd..f2a1dae3dc1 100644 --- a/pkg/plugins/test_utils.go +++ b/pkg/plugins/test_utils.go @@ -47,7 +47,7 @@ func (f inMemoryFS) Open(fn string) (fs.File, error) { return &inMemoryFile{path: fn, reader: bytes.NewReader(f.files[fn])}, nil } -func (f inMemoryFS) Type() string { +func (f inMemoryFS) Type() FSType { return "in-memory" } diff --git a/pkg/services/pluginsintegration/loader/loader_test.go b/pkg/services/pluginsintegration/loader/loader_test.go index 2ac557edaaa..60f5ac757a1 100644 --- a/pkg/services/pluginsintegration/loader/loader_test.go +++ b/pkg/services/pluginsintegration/loader/loader_test.go @@ -1076,9 +1076,9 @@ func TestLoader_AngularClass(t *testing.T) { expAngularDetectionRun: true, }, { - name: "other-class plugin should run angular detection", - class: "CDN", // (enterprise-only class) - expAngularDetectionRun: true, + name: "other class plugin should skip angular detection", + class: "foo", + expAngularDetectionRun: false, }, } { t.Run(tc.name, func(t *testing.T) { @@ -1086,7 +1086,7 @@ func TestLoader_AngularClass(t *testing.T) { PluginClassFunc: func(ctx context.Context) plugins.Class { return tc.class }, - DiscoverFunc: sources.NewLocalSource(plugins.ClassExternal, []string{filepath.Join(testDataDir(t), "valid-v2-signature")}).Discover, + DiscoverFunc: sources.NewLocalSource(tc.class, []string{filepath.Join(testDataDir(t), "valid-v2-signature")}).Discover, } // if angularDetected = true, it means that the detection has run l := newLoaderWithOpts(t, &config.PluginManagementCfg{}, loaderDepOpts{ diff --git a/pkg/services/pluginsintegration/pipeline/steps.go b/pkg/services/pluginsintegration/pipeline/steps.go index 7ed269332de..43f1dbb06e3 100644 --- a/pkg/services/pluginsintegration/pipeline/steps.go +++ b/pkg/services/pluginsintegration/pipeline/steps.go @@ -149,7 +149,7 @@ func ReportFSMetrics(_ context.Context, p *plugins.Plugin) (*plugins.Plugin, err return p, nil } - metrics.SetPluginFSInformation(p.ID, p.FS.Type()) + metrics.SetPluginFSInformation(p.ID, string(p.FS.Type())) return p, nil } diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets.go b/pkg/services/pluginsintegration/pluginassets/pluginassets.go index cecf8ea78dc..4d9a7ec1a53 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets.go @@ -70,12 +70,12 @@ func (s *Service) LoadingStrategy(_ context.Context, p pluginstore.Plugin) plugi // Since the parent plugin is not explicitly configured as script loading compatible, // If the plugin is either loaded from the CDN (via its parent) or contains Angular, we should use fetch - if s.cdnEnabled(p.Parent.ID, p.Class) || p.Angular.Detected { + if s.cdnEnabled(p.Parent.ID, p.FS) || p.Angular.Detected { return plugins.LoadingStrategyFetch } } - if !s.cdnEnabled(p.ID, p.Class) && !p.Angular.Detected { + if !s.cdnEnabled(p.ID, p.FS) && !p.Angular.Detected { return plugins.LoadingStrategyScript } @@ -142,7 +142,7 @@ func (s *Service) moduleHash(ctx context.Context, p pluginstore.Plugin, childFSB // CDN plugins have the version as part of the URL, which acts as a cache-buster. // Needed due to: https://github.com/grafana/plugin-tools/pull/1426 // FS plugins build before this change will have SRI mismatch issues. - if !s.cdnEnabled(p.ID, p.Class) { + if !s.cdnEnabled(p.ID, p.FS) { return "", nil } @@ -185,8 +185,8 @@ func (s *Service) compatibleCreatePluginVersion(ps map[string]string) bool { return false } -func (s *Service) cdnEnabled(pluginID string, class plugins.Class) bool { - return s.cdn.PluginSupported(pluginID) || class == plugins.ClassCDN +func (s *Service) cdnEnabled(pluginID string, fs plugins.FS) bool { + return s.cdn.PluginSupported(pluginID) || fs.Type().CDN() } // convertHashForSRI takes a SHA256 hash string and returns it as expected by the browser for SRI checks. diff --git a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go index a747e7e4e89..28f9d015fff 100644 --- a/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go +++ b/pkg/services/pluginsintegration/pluginassets/pluginassets_test.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" + "github.com/grafana/grafana/pkg/plugins/manager/pluginfakes" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/manager/signature/statickey" "github.com/grafana/grafana/pkg/plugins/pluginscdn" @@ -58,7 +59,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: futureVersion, }), - plugin: newPlugin(pluginID, withAngular(false)), + plugin: newPlugin(pluginID, withAngular(false), withFS(plugins.NewFakeFS())), expected: plugins.LoadingStrategyScript, }, { @@ -66,16 +67,16 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ // NOTE: cdn key is not set }), - plugin: newPlugin(pluginID, withAngular(false)), + plugin: newPlugin(pluginID, withAngular(false), withFS(plugins.NewFakeFS())), expected: plugins.LoadingStrategyScript, }, { - name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have the CDN class", + name: "Expected LoadingStrategyScript when create-plugin version is not compatible, plugin is not angular, is not configured as CDN enabled and does not have a CDN fs", pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: incompatVersion, // NOTE: cdn key is not set }), - plugin: newPlugin(pluginID, withAngular(false), withClass(plugins.ClassExternal)), + plugin: newPlugin(pluginID, withAngular(false), withClass(plugins.ClassExternal), withFS(plugins.NewFakeFS())), expected: plugins.LoadingStrategyScript, }, { @@ -107,14 +108,14 @@ func TestService_Calculate(t *testing.T) { { name: "Expected LoadingStrategyFetch when parent create-plugin version is not set, is not configured as CDN enabled and plugin is angular", pluginSettings: setting.PluginSettings{}, - plugin: newPlugin(pluginID, withAngular(true), func(p pluginstore.Plugin) pluginstore.Plugin { + plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS()), func(p pluginstore.Plugin) pluginstore.Plugin { p.Parent = &pluginstore.ParentPlugin{ID: "parent-datasource"} return p }), expected: plugins.LoadingStrategyFetch, }, { - name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, is configured as CDN enabled and does not have the CDN class", + name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular, and plugin is configured as CDN enabled", pluginSettings: newPluginSettings(pluginID, map[string]string{ "cdn": "true", CreatePluginVersionCfgKey: incompatVersion, @@ -127,7 +128,7 @@ func TestService_Calculate(t *testing.T) { pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: incompatVersion, }), - plugin: newPlugin(pluginID, withAngular(true)), + plugin: newPlugin(pluginID, withAngular(true), withFS(plugins.NewFakeFS())), expected: plugins.LoadingStrategyFetch, }, { @@ -140,19 +141,25 @@ func TestService_Calculate(t *testing.T) { expected: plugins.LoadingStrategyFetch, }, { - name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has the CDN class", + name: "Expected LoadingStrategyFetch when create-plugin version is not compatible, plugin is not angular and has a CDN fs", pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: incompatVersion, }), - plugin: newPlugin(pluginID, withAngular(false), withClass(plugins.ClassCDN)), + plugin: newPlugin(pluginID, withAngular(false), withFS( + &pluginfakes.FakePluginFS{ + TypeFunc: func() plugins.FSType { + return plugins.FSTypeCDN + }, + }, + )), expected: plugins.LoadingStrategyFetch, }, { - name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have the CDN class", + name: "Expected LoadingStrategyScript when plugin setting create-plugin version is badly formatted, plugin is not configured as CDN enabled and does not have a CDN fs", pluginSettings: newPluginSettings(pluginID, map[string]string{ CreatePluginVersionCfgKey: "invalidSemver", }), - plugin: newPlugin(pluginID, withAngular(false)), + plugin: newPlugin(pluginID, withAngular(false), withFS(plugins.NewFakeFS())), expected: plugins.LoadingStrategyScript, }, } @@ -183,9 +190,9 @@ func TestService_ModuleHash(t *testing.T) { features *config.Features store []pluginstore.Plugin - // Can be used to configure plugin's class - // cdn class = loaded from CDN with no files on disk - // external class = files on disk but served from CDN only if cdn=true + // Can be used to configure plugin's fs + // fs cdn type = loaded from CDN with no files on disk + // fs local type = files on disk but served from CDN only if cdn=true plugin pluginstore.Plugin // When true, set cdn=true in config @@ -204,7 +211,7 @@ func TestService_ModuleHash(t *testing.T) { pluginID, withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - withClass(plugins.ClassCDN), + withClass(plugins.ClassExternal), ), cdn: true, features: &config.Features{SriChecksEnabled: true}, @@ -236,7 +243,6 @@ func TestService_ModuleHash(t *testing.T) { pluginID, withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - withClass(plugins.ClassCDN), ), cdn: true, features: &config.Features{SriChecksEnabled: false}, @@ -261,7 +267,6 @@ func TestService_ModuleHash(t *testing.T) { parentPluginID, withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), - withClass(plugins.ClassCDN), ), }, plugin: newPlugin( @@ -269,7 +274,6 @@ func TestService_ModuleHash(t *testing.T) { withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "datasource"))), withParent(parentPluginID), - withClass(plugins.ClassCDN), ), cdn: true, features: &config.Features{SriChecksEnabled: true}, @@ -284,7 +288,6 @@ func TestService_ModuleHash(t *testing.T) { parentPluginID, withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), - withClass(plugins.ClassCDN), ), }, plugin: newPlugin( @@ -292,7 +295,6 @@ func TestService_ModuleHash(t *testing.T) { withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested", "panels", "one"))), withParent(parentPluginID), - withClass(plugins.ClassCDN), ), cdn: true, features: &config.Features{SriChecksEnabled: true}, @@ -308,14 +310,12 @@ func TestService_ModuleHash(t *testing.T) { "grand-parent-app", withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested"))), - withClass(plugins.ClassCDN), ), newPlugin( "parent-datasource", withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource"))), withParent("grand-parent-app"), - withClass(plugins.ClassCDN), ), }, plugin: newPlugin( @@ -323,7 +323,6 @@ func TestService_ModuleHash(t *testing.T) { withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-deeply-nested", "datasource", "panels", "one"))), withParent("parent-datasource"), - withClass(plugins.ClassCDN), ), cdn: true, features: &config.Features{SriChecksEnabled: true}, @@ -378,11 +377,17 @@ func TestService_ModuleHash(t *testing.T) { t.Run(tc.name, func(t *testing.T) { var pluginSettings setting.PluginSettings if tc.cdn { - pluginSettings = newPluginSettings(pluginID, map[string]string{ - "cdn": "true", - }) - } else { - require.NotEqual(t, plugins.ClassCDN, tc.plugin.Class, "plugin should not have the CDN class because CDN is disabled") + pluginSettings = setting.PluginSettings{ + pluginID: { + "cdn": "true", + }, + parentPluginID: map[string]string{ + "cdn": "true", + }, + "grand-parent-app": map[string]string{ + "cdn": "true", + }, + } } features := tc.features if features == nil { @@ -439,8 +444,24 @@ func TestService_ModuleHash_Cache(t *testing.T) { withInfo(plugins.Info{Version: "1.0.0"}), withSignatureStatus(plugins.SignatureStatusValid), withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid"))), - withClass(plugins.ClassCDN), ) + + pCfg = &config.PluginManagementCfg{ + PluginsCDNURLTemplate: "https://cdn.grafana.com", + PluginSettings: setting.PluginSettings{ + pluginID: { + "cdn": "true", + }, + }, + Features: config.Features{SriChecksEnabled: true}, + } + svc = ProvideService( + pCfg, + pluginscdn.ProvideService(pCfg), + signature.ProvideService(pCfg, statickey.New()), + pluginstore.NewFakePluginStore(), + ) + k := svc.moduleHashCacheKey(pV1) _, ok := svc.moduleHashCache.Load(k) @@ -461,7 +482,6 @@ func TestService_ModuleHash_Cache(t *testing.T) { withSignatureStatus(plugins.SignatureStatusValid), // different fs for different hash withFS(plugins.NewLocalFS(filepath.Join("testdata", "module-hash-valid-nested"))), - withClass(plugins.ClassCDN), ) mhV2 := svc.ModuleHash(context.Background(), pV2) require.NotEqual(t, mhV2, mhV1, "different version should have different hash") From 41276676eb62912a58cc05684e29986f5731563c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Thu, 20 Nov 2025 15:12:07 +0100 Subject: [PATCH 118/125] Provisioning: add retry logic for transient errors in Kubernetes client (#114215) * feat: add retry logic for transient errors in Kubernetes client Add retry wrapper for dynamic.ResourceInterface that automatically retries transient errors using Kubernetes' wait.ExponentialBackoff utility. - Implements retry logic with exponential backoff for all Kubernetes API operations - Detects transient errors: ServiceUnavailable, ServerTimeout, TooManyRequests, InternalError, Timeout, and network errors - Uses wait.ExponentialBackoff from k8s.io/apimachinery/pkg/util/wait - Respects context cancellation - Includes comprehensive unit tests Part of https://github.com/grafana/git-ui-sync-project/issues/634 * docs: add detailed documentation for defaultRetryBackoff Document when retry attempts will happen, what errors trigger retries, and the retry behavior (attempts, delays, exponential backoff, jitter). * feat: add logging and increase retry attempts for Kubernetes client - Add context logger to track retry attempts (Info for retries, Warn for exhaustion) - Increase retry attempts from 5 to 8 steps (~10 seconds total retry window) - Document when all retry attempts will fail: * API server completely unavailable/unreachable * Network connectivity issues persist beyond retry window * Consistent transient errors for entire retry duration * Context cancellation before retries complete * chore: update retry client documentation * fix: resolve linting issues in retry client - Replace type assertions with errors.As for wrapped errors - Remove deprecated Temporary() check (deprecated since Go 1.18) - Update tests to remove temporary error test case * fix: resolve staticcheck S1008 linting issue in retry_client.go Simplify return statement to use errors.As directly instead of if-return pattern --- .../apis/provisioning/resources/client.go | 6 +- .../provisioning/resources/retry_client.go | 296 ++++++++++ .../resources/retry_client_test.go | 507 ++++++++++++++++++ 3 files changed, 807 insertions(+), 2 deletions(-) create mode 100644 pkg/registry/apis/provisioning/resources/retry_client.go create mode 100644 pkg/registry/apis/provisioning/resources/retry_client_test.go diff --git a/pkg/registry/apis/provisioning/resources/client.go b/pkg/registry/apis/provisioning/resources/client.go index f12bcf7b053..388e5311f72 100644 --- a/pkg/registry/apis/provisioning/resources/client.go +++ b/pkg/registry/apis/provisioning/resources/client.go @@ -221,10 +221,11 @@ func (c *resourceClients) ForKind(ctx context.Context, gvk schema.GroupVersionKi return nil, schema.GroupVersionResource{}, err } } + baseClient := dynamic.Resource(gvr).Namespace(c.namespace) info = &clientInfo{ gvk: gvk, gvr: gvr, - client: dynamic.Resource(gvr).Namespace(c.namespace), + client: newRetryResourceInterface(baseClient, defaultRetryBackoff()), } c.byKind[gvk] = info c.byResource[gvr] = info @@ -274,10 +275,11 @@ func (c *resourceClients) ForResource(ctx context.Context, gvr schema.GroupVersi return nil, schema.GroupVersionKind{}, fmt.Errorf("getting kind for resource for %s: %w", gvr.String(), err) } } + baseClient := dynamic.Resource(gvr).Namespace(c.namespace) info = &clientInfo{ gvk: gvk, gvr: gvr, - client: dynamic.Resource(gvr).Namespace(c.namespace), + client: newRetryResourceInterface(baseClient, defaultRetryBackoff()), } c.byKind[gvk] = info c.byResource[gvr] = info diff --git a/pkg/registry/apis/provisioning/resources/retry_client.go b/pkg/registry/apis/provisioning/resources/retry_client.go new file mode 100644 index 00000000000..cc09443e018 --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/retry_client.go @@ -0,0 +1,296 @@ +package resources + +import ( + "context" + "errors" + "net" + "time" + + "github.com/grafana/grafana-app-sdk/logging" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" + "k8s.io/client-go/dynamic" +) + +// defaultRetryBackoff returns a default backoff configuration for retries. +// +// Retry attempts will happen when: +// - The Kubernetes API returns transient errors: ServiceUnavailable (503), ServerTimeout (504), +// TooManyRequests (429), InternalError (500), or Timeout errors +// - Network errors occur: connection timeouts, temporary network failures, or connection errors +// +// The retry behavior: +// - Total attempts: 8 (1 initial attempt + 7 retries) +// - Initial delay: 100ms before the first retry +// - Exponential backoff: delay doubles after each failed attempt (100ms → 200ms → 400ms → 800ms → 1.6s → 3.2s → 5s) +// - Maximum delay: capped at 5 seconds +// - Jitter: 10% randomization to prevent thundering herd problems +// - Total retry window: approximately 10 seconds from first attempt to last retry +// +// All attempts will fail when: +// - The Kubernetes API server is completely unavailable or unreachable +// - Network connectivity issues persist beyond the retry window (~10 seconds) +// - The API server returns transient errors consistently for the entire retry duration +// - Context cancellation occurs before retries complete +// +// Non-transient errors (e.g., NotFound, BadRequest, Forbidden) are not retried and returned immediately. +func defaultRetryBackoff() wait.Backoff { + return wait.Backoff{ + Duration: 100 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 8, // 1 initial attempt + 7 retries = 8 total attempts (~10s total retry window) + Cap: 5 * time.Second, + } +} + +// retryResourceInterface wraps a dynamic.ResourceInterface with retry logic for transient errors +type retryResourceInterface struct { + client dynamic.ResourceInterface + backoff wait.Backoff +} + +// newRetryResourceInterface creates a new ResourceInterface wrapper with retry logic +func newRetryResourceInterface(client dynamic.ResourceInterface, backoff wait.Backoff) dynamic.ResourceInterface { + return &retryResourceInterface{ + client: client, + backoff: backoff, + } +} + +// isTransientError determines if an error is transient and should be retried +func isTransientError(err error) bool { + if err == nil { + return false + } + + // Check for Kubernetes API transient errors + if apierrors.IsServiceUnavailable(err) { + return true + } + if apierrors.IsServerTimeout(err) { + return true + } + if apierrors.IsTooManyRequests(err) { + return true + } + if apierrors.IsInternalError(err) { + return true + } + if apierrors.IsTimeout(err) { + return true + } + + // Check for network errors + var netErr net.Error + if errors.As(err, &netErr) { + if netErr.Timeout() { + return true + } + } + + // Check for connection errors + var opErr *net.OpError + return errors.As(err, &opErr) +} + +// retryWithBackoff executes a function with exponential backoff retry logic using wait.ExponentialBackoff +func (r *retryResourceInterface) retryWithBackoff(ctx context.Context, fn func() error) error { + var lastErr error + attempt := 0 + logger := logging.FromContext(ctx) + + err := wait.ExponentialBackoff(r.backoff, func() (bool, error) { + attempt++ + + // Check if context is cancelled + if ctx.Err() != nil { + logger.Debug("Retry cancelled due to context cancellation", "attempt", attempt) + return false, ctx.Err() + } + + err := fn() + if err == nil { + if attempt > 1 { + logger.Debug("Operation succeeded after retry", "attempt", attempt) + } + return true, nil // success, stop retrying + } + + // If not a transient error, return immediately without retrying + if !isTransientError(err) { + logger.Debug("Non-transient error, not retrying", "attempt", attempt, "error", err) + return false, err + } + + // Transient error, retry + lastErr = err + logger.Info("Transient error encountered, retrying", "attempt", attempt, "max_attempts", r.backoff.Steps, "error", err) + return false, nil + }) + + // If wait.ExponentialBackoff returned an error, it means we exhausted retries + if err != nil { + if lastErr != nil { + logger.Warn("All retry attempts exhausted", "total_attempts", attempt, "error", lastErr) + return lastErr + } + logger.Warn("All retry attempts exhausted", "total_attempts", attempt, "error", err) + return err + } + + return nil +} + +// Create implements dynamic.ResourceInterface +func (r *retryResourceInterface) Create(ctx context.Context, obj *unstructured.Unstructured, options metav1.CreateOptions, subresources ...string) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.Create(ctx, obj, options, subresources...) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// Update implements dynamic.ResourceInterface +func (r *retryResourceInterface) Update(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions, subresources ...string) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.Update(ctx, obj, options, subresources...) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// UpdateStatus implements dynamic.ResourceInterface +func (r *retryResourceInterface) UpdateStatus(ctx context.Context, obj *unstructured.Unstructured, options metav1.UpdateOptions) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.UpdateStatus(ctx, obj, options) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// Delete implements dynamic.ResourceInterface +func (r *retryResourceInterface) Delete(ctx context.Context, name string, options metav1.DeleteOptions, subresources ...string) error { + return r.retryWithBackoff(ctx, func() error { + return r.client.Delete(ctx, name, options, subresources...) + }) +} + +// DeleteCollection implements dynamic.ResourceInterface +func (r *retryResourceInterface) DeleteCollection(ctx context.Context, options metav1.DeleteOptions, listOptions metav1.ListOptions) error { + return r.retryWithBackoff(ctx, func() error { + return r.client.DeleteCollection(ctx, options, listOptions) + }) +} + +// Get implements dynamic.ResourceInterface +func (r *retryResourceInterface) Get(ctx context.Context, name string, options metav1.GetOptions, subresources ...string) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.Get(ctx, name, options, subresources...) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// List implements dynamic.ResourceInterface +func (r *retryResourceInterface) List(ctx context.Context, opts metav1.ListOptions) (*unstructured.UnstructuredList, error) { + var result *unstructured.UnstructuredList + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.List(ctx, opts) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// Watch implements dynamic.ResourceInterface +func (r *retryResourceInterface) Watch(ctx context.Context, opts metav1.ListOptions) (watch.Interface, error) { + // Watch operations are long-lived and shouldn't be retried in the same way + // Return the watch interface directly + return r.client.Watch(ctx, opts) +} + +// Patch implements dynamic.ResourceInterface +func (r *retryResourceInterface) Patch(ctx context.Context, name string, pt types.PatchType, data []byte, options metav1.PatchOptions, subresources ...string) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.Patch(ctx, name, pt, data, options, subresources...) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// Apply implements dynamic.ResourceInterface +func (r *retryResourceInterface) Apply(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions, subresources ...string) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.Apply(ctx, name, obj, options, subresources...) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} + +// ApplyStatus implements dynamic.ResourceInterface +func (r *retryResourceInterface) ApplyStatus(ctx context.Context, name string, obj *unstructured.Unstructured, options metav1.ApplyOptions) (*unstructured.Unstructured, error) { + var result *unstructured.Unstructured + var err error + + retryErr := r.retryWithBackoff(ctx, func() error { + result, err = r.client.ApplyStatus(ctx, name, obj, options) + return err + }) + + if retryErr != nil { + return nil, retryErr + } + return result, nil +} diff --git a/pkg/registry/apis/provisioning/resources/retry_client_test.go b/pkg/registry/apis/provisioning/resources/retry_client_test.go new file mode 100644 index 00000000000..dab40bcb001 --- /dev/null +++ b/pkg/registry/apis/provisioning/resources/retry_client_test.go @@ -0,0 +1,507 @@ +package resources + +import ( + "context" + "errors" + "net" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/apimachinery/pkg/watch" +) + +func TestIsTransientError(t *testing.T) { + tests := []struct { + name string + err error + expected bool + }{ + { + name: "nil error", + err: nil, + expected: false, + }, + { + name: "service unavailable", + err: apierrors.NewServiceUnavailable("service unavailable"), + expected: true, + }, + { + name: "server timeout", + err: apierrors.NewServerTimeout(schema.GroupResource{}, "operation", 0), + expected: true, + }, + { + name: "too many requests", + err: apierrors.NewTooManyRequests("too many requests", 0), + expected: true, + }, + { + name: "internal error", + err: apierrors.NewInternalError(errors.New("internal error")), + expected: true, + }, + { + name: "network timeout error", + err: &net.DNSError{Err: "timeout", IsTimeout: true}, + expected: true, + }, + // Note: Temporary() is deprecated in Go 1.18+, so we no longer check for temporary errors + // Timeout errors are still checked and will be retried + { + name: "network op error", + err: &net.OpError{Op: "read", Err: errors.New("connection refused")}, + expected: true, + }, + { + name: "not found error", + err: apierrors.NewNotFound(schema.GroupResource{}, "resource"), + expected: false, + }, + { + name: "bad request error", + err: apierrors.NewBadRequest("bad request"), + expected: false, + }, + { + name: "forbidden error", + err: apierrors.NewForbidden(schema.GroupResource{}, "resource", errors.New("forbidden")), + expected: false, + }, + { + name: "generic error", + err: errors.New("generic error"), + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isTransientError(tt.err) + assert.Equal(t, tt.expected, result, "isTransientError(%v) = %v, want %v", tt.err, result, tt.expected) + }) + } +} + +func TestRetryResourceInterface_Create(t *testing.T) { + tests := []struct { + name string + setupMock func(*MockDynamicResourceInterface) + backoff wait.Backoff + expectedCalls int + expectError bool + }{ + { + name: "success on first attempt", + setupMock: func(m *MockDynamicResourceInterface) { + m.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything).Return(&unstructured.Unstructured{}, nil).Once() + }, + backoff: defaultRetryBackoff(), + expectedCalls: 1, + expectError: false, + }, + { + name: "success after transient errors", + setupMock: func(m *MockDynamicResourceInterface) { + m.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Twice() + m.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + }, + backoff: wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }, + expectedCalls: 3, + expectError: false, + }, + { + name: "max retries exceeded", + setupMock: func(m *MockDynamicResourceInterface) { + m.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")) + }, + backoff: wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 3, // Only 3 steps = 2 retries + 1 initial + Cap: 100 * time.Millisecond, + }, + expectedCalls: 3, + expectError: true, + }, + { + name: "non-transient error - no retry", + setupMock: func(m *MockDynamicResourceInterface) { + m.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewBadRequest("bad request")).Once() + }, + backoff: defaultRetryBackoff(), + expectedCalls: 1, + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + tt.setupMock(mockClient) + + retryClient := newRetryResourceInterface(mockClient, tt.backoff) + obj := &unstructured.Unstructured{} + _, err := retryClient.Create(context.Background(), obj, metav1.CreateOptions{}) + + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + mockClient.AssertNumberOfCalls(t, "Create", tt.expectedCalls) + }) + } +} + +func TestRetryResourceInterface_Update(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Once() + mockClient.On("Update", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + obj := &unstructured.Unstructured{} + result, err := retryClient.Update(context.Background(), obj, metav1.UpdateOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "Update", 2) +} + +func TestRetryResourceInterface_Get(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServerTimeout(schema.GroupResource{}, "operation", 0)).Twice() + mockClient.On("Get", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + result, err := retryClient.Get(context.Background(), "test-resource", metav1.GetOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "Get", 3) +} + +func TestRetryResourceInterface_Delete(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(apierrors.NewTooManyRequests("too many requests", 0)).Once() + mockClient.On("Delete", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + err := retryClient.Delete(context.Background(), "test-resource", metav1.DeleteOptions{}) + + assert.NoError(t, err) + mockClient.AssertNumberOfCalls(t, "Delete", 2) +} + +func TestRetryResourceInterface_List(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("List", mock.Anything, mock.Anything). + Return(nil, apierrors.NewInternalError(errors.New("internal error"))).Once() + mockClient.On("List", mock.Anything, mock.Anything). + Return(&unstructured.UnstructuredList{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + result, err := retryClient.List(context.Background(), metav1.ListOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "List", 2) +} + +func TestRetryResourceInterface_Patch(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("Patch", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, &net.OpError{Op: "read", Err: errors.New("connection refused")}).Once() + mockClient.On("Patch", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + result, err := retryClient.Patch(context.Background(), "test-resource", types.MergePatchType, []byte(`{}`), metav1.PatchOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "Patch", 2) +} + +func TestRetryResourceInterface_Apply(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("Apply", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Once() + mockClient.On("Apply", mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + obj := &unstructured.Unstructured{} + result, err := retryClient.Apply(context.Background(), "test-resource", obj, metav1.ApplyOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "Apply", 2) +} + +func TestRetryResourceInterface_UpdateStatus(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("UpdateStatus", mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Once() + mockClient.On("UpdateStatus", mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + obj := &unstructured.Unstructured{} + result, err := retryClient.UpdateStatus(context.Background(), obj, metav1.UpdateOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "UpdateStatus", 2) +} + +func TestRetryResourceInterface_ApplyStatus(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("ApplyStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Once() + mockClient.On("ApplyStatus", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(&unstructured.Unstructured{}, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + obj := &unstructured.Unstructured{} + result, err := retryClient.ApplyStatus(context.Background(), "test-resource", obj, metav1.ApplyOptions{}) + + assert.NoError(t, err) + assert.NotNil(t, result) + mockClient.AssertNumberOfCalls(t, "ApplyStatus", 2) +} + +func TestRetryResourceInterface_DeleteCollection(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("DeleteCollection", mock.Anything, mock.Anything, mock.Anything). + Return(apierrors.NewServiceUnavailable("service unavailable")).Once() + mockClient.On("DeleteCollection", mock.Anything, mock.Anything, mock.Anything). + Return(nil).Once() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 10 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 100 * time.Millisecond, + }) + + err := retryClient.DeleteCollection(context.Background(), metav1.DeleteOptions{}, metav1.ListOptions{}) + + assert.NoError(t, err) + mockClient.AssertNumberOfCalls(t, "DeleteCollection", 2) +} + +func TestRetryResourceInterface_Watch(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockWatch := &mockWatch{} + mockClient.On("Watch", mock.Anything, mock.Anything).Return(mockWatch, nil).Once() + + retryClient := newRetryResourceInterface(mockClient, defaultRetryBackoff()) + + watch, err := retryClient.Watch(context.Background(), metav1.ListOptions{}) + + assert.NoError(t, err) + assert.Equal(t, mockWatch, watch) + // Watch should not retry, so only one call + mockClient.AssertNumberOfCalls(t, "Watch", 1) +} + +func TestRetryResourceInterface_ContextCancellation(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + mockClient.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Maybe() + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 100 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 1 * time.Second, + }) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + obj := &unstructured.Unstructured{} + _, err := retryClient.Create(ctx, obj, metav1.CreateOptions{}) + + assert.Error(t, err) + assert.Equal(t, context.Canceled, err) + // Should not retry after context cancellation + mockClient.AssertNumberOfCalls(t, "Create", 0) +} + +func TestRetryResourceInterface_ExponentialBackoff(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + callCount := 0 + + // Set up expectations for multiple calls + mockClient.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + callCount++ + }). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")).Twice() + mockClient.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + callCount++ + }). + Return(&unstructured.Unstructured{}, nil).Once() + + start := time.Now() + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 50 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, + Cap: 1 * time.Second, + }) + + obj := &unstructured.Unstructured{} + _, err := retryClient.Create(context.Background(), obj, metav1.CreateOptions{}) + + duration := time.Since(start) + + assert.NoError(t, err) + assert.Equal(t, 3, callCount) + // Should have waited at least initialDelay + (initialDelay * multiplier) = 50ms + 100ms = 150ms + assert.GreaterOrEqual(t, duration, 100*time.Millisecond) + // But not too long (with some buffer for jitter) + assert.Less(t, duration, 1*time.Second) +} + +func TestRetryResourceInterface_MaxDelayRespected(t *testing.T) { + mockClient := &MockDynamicResourceInterface{} + callCount := 0 + + // Set up expectations for multiple calls - always return transient error + mockClient.On("Create", mock.Anything, mock.Anything, mock.Anything, mock.Anything). + Run(func(args mock.Arguments) { + callCount++ + }). + Return(nil, apierrors.NewServiceUnavailable("service unavailable")). + Maybe() // Allow unlimited calls + + retryClient := newRetryResourceInterface(mockClient, wait.Backoff{ + Duration: 50 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Steps: 5, // 5 total attempts + Cap: 200 * time.Millisecond, // Max delay is 200ms (cap should prevent exponential growth beyond this) + }) + + start := time.Now() + obj := &unstructured.Unstructured{} + _, err := retryClient.Create(context.Background(), obj, metav1.CreateOptions{}) + duration := time.Since(start) + + assert.Error(t, err) + // Should have retried multiple times + assert.GreaterOrEqual(t, callCount, 2, "should have retried at least once") + // Verify that the delay was capped - if it wasn't capped, duration would be much longer + // With cap at 200ms and Steps=5, max total time should be reasonable + // The cap ensures delays don't grow exponentially beyond 200ms + assert.Less(t, duration, 2*time.Second, "duration should be reasonable due to cap") +} + +func TestDefaultRetryBackoff(t *testing.T) { + backoff := defaultRetryBackoff() + + assert.Equal(t, 100*time.Millisecond, backoff.Duration) + assert.Equal(t, 2.0, backoff.Factor) + assert.Equal(t, 0.1, backoff.Jitter) + assert.Equal(t, 8, backoff.Steps) // Updated to 8 steps for ~10s total retry window + assert.Equal(t, 5*time.Second, backoff.Cap) +} + +// mockWatch implements watch.Interface for testing +type mockWatch struct{} + +func (m *mockWatch) Stop() {} + +func (m *mockWatch) ResultChan() <-chan watch.Event { + return make(chan watch.Event) +} + +var _ watch.Interface = (*mockWatch)(nil) From 834f1c5e98e75083e777d0cc9ed46813fa0214f1 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Thu, 20 Nov 2025 09:13:19 -0500 Subject: [PATCH 119/125] TS checker: Increase memory limit (#114236) Increase ts checker memory limit --- scripts/webpack/webpack.dev.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/webpack/webpack.dev.js b/scripts/webpack/webpack.dev.js index 050f54a2c3a..f48aada0ed7 100644 --- a/scripts/webpack/webpack.dev.js +++ b/scripts/webpack/webpack.dev.js @@ -135,7 +135,7 @@ module.exports = (env = {}) => { async: true, // don't block webpack emit typescript: { mode: 'write-references', - memoryLimit: 5096, + memoryLimit: 8192, diagnosticOptions: { semantic: true, syntactic: true, From 7db72e03b55f8293061e387aebea4ad4ce1e3712 Mon Sep 17 00:00:00 2001 From: Robby Milo Date: Thu, 20 Nov 2025 07:39:10 -0700 Subject: [PATCH 120/125] remove image rendering docs (#114217) --- .../setup-grafana/image-rendering/_index.md | 617 ------------------ .../image-rendering/monitoring/index.md | 221 ------- .../image-rendering/troubleshooting/index.md | 156 ----- 3 files changed, 994 deletions(-) delete mode 100644 docs/sources/setup-grafana/image-rendering/_index.md delete mode 100644 docs/sources/setup-grafana/image-rendering/monitoring/index.md delete mode 100644 docs/sources/setup-grafana/image-rendering/troubleshooting/index.md diff --git a/docs/sources/setup-grafana/image-rendering/_index.md b/docs/sources/setup-grafana/image-rendering/_index.md deleted file mode 100644 index 32df8dc2154..00000000000 --- a/docs/sources/setup-grafana/image-rendering/_index.md +++ /dev/null @@ -1,617 +0,0 @@ ---- -aliases: - - ../administration/image_rendering/ - - ../image-rendering/ -description: Image rendering -keywords: - - grafana - - image - - rendering - - plugin -labels: - products: - - enterprise - - oss -title: Set up image rendering -weight: 1000 ---- - -# Set up image rendering - -Grafana supports automatic rendering of panels as PNG images. This allows Grafana to automatically generate images of your panels to include in alert notifications, [PDF export](../../dashboards/create-reports/#export-dashboard-as-pdf), and [Reporting](../../dashboards/create-reports/). PDF Export and Reporting are available only in [Grafana Enterprise](../../introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud/). - -While an image is being rendered, the PNG image is temporarily written to the file system. When the image is rendered, the PNG image is temporarily written to the `png` folder in the Grafana `data` folder. - -A background job runs every 10 minutes and removes temporary images. You can configure how long an image should be stored before being removed by configuring the [temp_data_lifetime](../configure-grafana/#temp_data_lifetime) setting. - -You can also render a PNG by hovering over the panel to display the actions menu in the top-right corner, and then clicking **Share > Share link**. The **Render image** option is displayed in the link settings. - -## Alerting and render limits - -Alert notifications can include images, but rendering many images at the same time can overload the server where the renderer is running. For instructions of how to configure this, see [max_concurrent_screenshots](../configure-grafana/#max_concurrent_screenshots). - -## Install Grafana Image Renderer plugin - -{{< admonition type="caution" >}} -Starting with Grafana v12.2, the Grafana Image Renderer plugin is deprecated and is no longer maintained. - -Instead, use the Grafana Image Renderer remote rendering service. -{{< /admonition >}} - -{{< admonition type="note" >}} -All PhantomJS support has been removed. Instead, use the Grafana Image Renderer plugin or remote rendering service. -{{< /admonition >}} - -To install the plugin, refer to the [Grafana Image Renderer Installation instructions](/grafana/plugins/grafana-image-renderer/?tab=installation#installation). - -### Memory requirements - -Rendering images requires a lot of memory, mainly because Grafana creates browser instances in the background for the actual rendering. Grafana recommends a minimum of 16GB of free memory on the system rendering images. - -Rendering multiple images in parallel requires an even bigger memory footprint. You can use the remote rendering service in order to render images on a remote system, so your local system resources are not affected. - -## Configuration - -The Grafana Image Renderer plugin has a number of configuration options that are used in plugin or remote rendering modes. - -In plugin mode, you can specify them directly in the [Grafana configuration file](../configure-grafana/#plugingrafana-image-renderer). - -In remote rendering mode, you can specify them in a `.json` [configuration file](#configuration-file) or, for some of them, you can override the configuration defaults using environment variables. - -### Configuration file - -You can update your settings by using a configuration file, see [default.json](https://github.com/grafana/grafana-image-renderer/tree/master/default.json) for defaults. Note that any configured environment variable takes precedence over configuration file settings. - -You can volume mount your custom configuration file when starting the docker container: - -```bash -docker run -d --name=renderer --network=host -v /some/path/config.json:/home/nonroot/config.json grafana/grafana-image-renderer:latest -``` - -You can see a docker-compose example using a custom configuration file [here](https://github.com/grafana/grafana-image-renderer/tree/master/devenv/docker/custom-config). - -{{< admonition type="note" >}} -The configuration files were located in `/usr/src/app` up until v4.0.0 and later. -After this point, they are located in `/home/nonroot`. -{{< /admonition >}} - -### Security - -{{< admonition type="note" >}} -This feature is available in Image Renderer v3.6.1 and later. -{{< /admonition >}} - -You can restrict access to the rendering endpoint by specifying a secret token. The token should be configured in the Grafana configuration file and the renderer configuration file. This token is important when you run the plugin in remote rendering mode. - -Renderer versions v3.6.1 or later require a Grafana version with this feature. These include: - -- Grafana v9.1.2 or later -- Grafana v9.0.8 or later patch releases -- Grafana v8.5.11 or later patch releases -- Grafana v8.4.11 or later patch releases -- Grafana v8.3.11 or later patch releases - -```bash -AUTH_TOKEN=- -``` - -```json -{ - "service": { - "security": { - "authToken": "-" - } - } -} -``` - -See [Grafana configuration](../configure-grafana/#renderer_token) for how to configure the token in Grafana. - -### Rendering mode - -You can instruct how headless browser instances are created by configuring a rendering mode. Default is `default`, other supported values are `clustered` and `reusable`. - -#### Default - -Default mode will create a new browser instance on each request. When handling multiple concurrent requests, this mode increases memory usage as it will launch multiple browsers at the same time. If you want to set a maximum number of browser to open, you'll need to use the [clustered mode](#clustered). - -{{< admonition type="note" >}} -When using the `default` mode, it's recommended to not remove the default Chromium flag `--disable-gpu`. When receiving a lot of concurrent requests, not using this flag can cause Puppeteer `newPage` function to freeze, causing request timeouts and leaving browsers open. -{{< /admonition >}} - -```bash -RENDERING_MODE=default -``` - -```json -{ - "rendering": { - "mode": "default" - } -} -``` - -#### Clustered - -With the `clustered` mode, you can configure how many browser instances or incognito pages can execute concurrently. Default is `browser` and will ensure a maximum amount of browser instances can execute concurrently. Mode `context` will ensure a maximum amount of incognito pages can execute concurrently. You can also configure the maximum concurrency allowed, which per default is `5`, and the maximum duration of a rendering request, which per default is `30` seconds. - -Using a cluster of incognito pages is more performant and consumes less CPU and memory than a cluster of browsers. However, if one page crashes it can bring down the entire browser with it (making all the rendering requests happening at the same time fail). Also, each page isn't guaranteed to be totally clean (cookies and storage might bleed-through as seen [here](https://bugs.chromium.org/p/chromium/issues/detail?id=754576)). - -```bash -RENDERING_MODE=clustered -RENDERING_CLUSTERING_MODE=browser -RENDERING_CLUSTERING_MAX_CONCURRENCY=5 -RENDERING_CLUSTERING_TIMEOUT=30 -``` - -```json -{ - "rendering": { - "mode": "clustered", - "clustering": { - "mode": "browser", - "maxConcurrency": 5, - "timeout": 30 - } - } -} -``` - -#### Reusable (experimental) - -When using the rendering mode `reusable`, one browser instance will be created and reused. A new incognito page will be opened for each request. This mode is experimental since, if the browser instance crashes, it will not automatically be restarted. You can achieve a similar behavior using `clustered` mode with a high `maxConcurrency` setting. - -```bash -RENDERING_MODE=reusable -``` - -```json -{ - "rendering": { - "mode": "reusable" - } -} -``` - -#### Optimize the performance, CPU and memory usage of the image renderer - -The performance and resources consumption of the different modes depend a lot on the number of concurrent requests your service is handling. To understand how many concurrent requests your service is handling, [monitor your image renderer service](monitoring/). - -With no concurrent requests, the different modes show very similar performance and CPU / memory usage. - -When handling concurrent requests, we see the following trends: - -- To improve performance and reduce CPU and memory consumption, use [clustered](#clustered) mode with `RENDERING_CLUSTERING_MODE` set as `context`. This parallelizes incognito pages instead of browsers. -- If you use the [clustered](#clustered) mode with a `maxConcurrency` setting below your average number of concurrent requests, performance will drop as the rendering requests will need to wait for the other to finish before getting access to an incognito page / browser. - -To achieve better performance, monitor the machine on which your service is running. If you don't have enough memory and / or CPU, every rendering step will be slower than usual, increasing the duration of every rendering request. - -### Other available settings - -{{< admonition type="note" >}} -Please note that not all settings are available using environment variables. If there is no example using environment variable below, it means that you need to update the configuration file. -{{< /admonition >}} - -#### HTTP host - -Change the listening host of the HTTP server. Default is unset and will use the local host. - -```bash -HTTP_HOST=localhost -``` - -```json -{ - "service": { - "host": "localhost" - } -} -``` - -#### HTTP port - -Change the listening port of the HTTP server. Default is `8081`. Setting `0` will automatically assign a port not in use. - -```bash -HTTP_PORT=0 -``` - -```json -{ - "service": { - "port": 0 - } -} -``` - -#### HTTP protocol - -{{< admonition type="note" >}} -HTTPS protocol is supported in the image renderer v3.11.0 and later. -{{< /admonition >}} - -Change the protocol of the server, it can be `http` or `https`. Default is `http`. - -```bash -HTTP_PROTOCOL=https -``` - -```json -{ - "service": { - "protocol": "https" - } -} -``` - -#### HTTPS certificate and key file - -Path to the image renderer certificate and key file used to start an HTTPS server. - -```bash -HTTP_CERT_FILE=./path/to/cert -HTTP_CERT_KEY=./path/to/key -``` - -```json -{ - "service": { - "certFile": "./path/to/cert", - "certKey": "./path/to/key" - } -} -``` - -#### HTTPS min TLS version - -Minimum TLS version allowed. Accepted values are: `TLSv1.2`, `TLSv1.3`. Default is `TLSv1.2`. - -```bash -HTTP_MIN_TLS_VERSION=TLSv1.2 -``` - -```json -{ - "service": { - "minTLSVersion": "TLSv1.2" - } -} -``` - -#### Enable Prometheus metrics - -You can enable [Prometheus](https://prometheus.io/) metrics endpoint `/metrics` using the environment variable `ENABLE_METRICS`. Node.js and render request duration metrics are included, see [Enable Prometheus metrics endpoint](monitoring/#enable-prometheus-metrics-endpoint) for details. - -Default is `false`. - -```bash -ENABLE_METRICS=true -``` - -```json -{ - "service": { - "metrics": { - "enabled": true, - "collectDefaultMetrics": true, - "requestDurationBuckets": [1, 5, 7, 9, 11, 13, 15, 20, 30] - } - } -} -``` - -#### Enable detailed timing metrics - -With the [Prometheus metrics enabled](#enable-prometheus-metrics), you can also enable detailed metrics to get the duration of every rendering step. - -Default is `false`. - -```bash -# Available from v3.9.0+ -RENDERING_TIMING_METRICS=true -``` - -```json -{ - "rendering": { - "timingMetrics": true - } -} -``` - -#### Log level - -Change the log level. Default is `info` and will include log messages with level `error`, `warning` and `info`. - -```bash -LOG_LEVEL=debug -``` - -```json -{ - "service": { - "logging": { - "level": "debug", - "console": { - "json": false, - "colorize": true - } - } - } -} -``` - -#### Verbose logging - -Instruct headless browser instance whether to capture and log verbose information when rendering an image. Default is `false` and will only capture and log error messages. When enabled (`true`) debug messages are captured and logged as well. - -Note that you need to change log level to `debug`, see above, for the verbose information to be included in the logs. - -```bash -RENDERING_VERBOSE_LOGGING=true -``` - -```json -{ - "rendering": { - "verboseLogging": true - } -} -``` - -#### Capture browser output - -Instruct headless browser instance whether to output its debug and error messages into running process of remote rendering service. Default is `false`. -This can be useful to enable (`true`) when troubleshooting. - -```bash -RENDERING_DUMPIO=true -``` - -```json -{ - "rendering": { - "dumpio": true - } -} -``` - -#### Tracing - -{{< admonition type="note" >}} -Tracing is supported in the image renderer v3.12.6 and later. -{{< /admonition >}} - -Set the tracing URL to enable OpenTelemetry Tracing. The default is empty (disabled). -You can also configure the service name that will be set in the traces. The default is `grafana-image-renderer`. - -```bash -RENDERING_TRACING_URL="http://localhost:4318/v1/traces" -``` - -```json -{ - "rendering": { - "tracing": { - "url": "http://localhost:4318/v1/traces", - "serviceName": "grafana-renderer" - } - } -} -``` - -#### Custom Chrome/Chromium - -If you already have [Chrome](https://www.google.com/chrome/) or [Chromium](https://www.chromium.org/) -installed on your system, then you can use this instead of the pre-packaged version of Chromium. - -{{< admonition type="note" >}} -Please note that this is not recommended, since you may encounter problems if the installed version of Chrome/Chromium is not compatible with the [Grafana Image renderer plugin](/grafana/plugins/grafana-image-renderer). -{{< /admonition >}} - -You need to make sure that the Chrome/Chromium executable is available for the Grafana/image rendering service process. - -```bash -CHROME_BIN="/usr/bin/chromium-browser" -``` - -```json -{ - "rendering": { - "chromeBin": "/usr/bin/chromium-browser" - } -} -``` - -#### Start browser with additional arguments - -Additional arguments to pass to the headless browser instance. Defaults are `--no-sandbox,--disable-gpu`. The list of Chromium flags can be found [here](https://peter.sh/experiments/chromium-command-line-switches/) and the list of flags used as defaults by Puppeteer can be found [there](https://cri.dev/posts/2020-04-04-Full-list-of-Chromium-Puppeteer-flags/). Multiple arguments is separated with comma-character. - -```bash -RENDERING_ARGS=--no-sandbox,--disable-setuid-sandbox,--disable-dev-shm-usage,--disable-accelerated-2d-canvas,--disable-gpu,--window-size=1280x758 -``` - -```json -{ - "rendering": { - "args": [ - "--no-sandbox", - "--disable-setuid-sandbox", - "--disable-dev-shm-usage", - "--disable-accelerated-2d-canvas", - "--disable-gpu", - "--window-size=1280x758" - ] - } -} -``` - -#### Ignore HTTPS errors - -Instruct headless browser instance whether to ignore HTTPS errors during navigation. Per default HTTPS errors are not ignored. -Due to the security risk it's not recommended to ignore HTTPS errors. - -```bash -IGNORE_HTTPS_ERRORS=true -``` - -```json -{ - "rendering": { - "ignoresHttpsErrors": true - } -} -``` - -#### Default timezone - -Instruct headless browser instance to use a default timezone when not provided by Grafana, .e.g. when rendering panel image of alert. See [ICU’s metaZones.txt](https://cs.chromium.org/chromium/src/third_party/icu/source/data/misc/metaZones.txt?rcl=faee8bc70570192d82d2978a71e2a615788597d1) for a list of supported timezone IDs. Fallbacks to `TZ` environment variable if not set. - -```bash -BROWSER_TZ=Europe/Stockholm -``` - -```json -{ - "rendering": { - "timezone": "Europe/Stockholm" - } -} -``` - -#### Default language - -Instruct headless browser instance to use a default language when not provided by Grafana, e.g. when rendering panel image of alert. -Refer to the HTTP header Accept-Language to understand how to format this value. - -```bash -# Available from v3.9.0+ -RENDERING_LANGUAGE="fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5" -``` - -```json -{ - "rendering": { - "acceptLanguage": "fr-CH, fr;q=0.9, en;q=0.8, de;q=0.7, *;q=0.5" - } -} -``` - -#### Viewport width - -Default viewport width when width is not specified in the rendering request. Default is `1000`. - -```bash -# Available from v3.9.0+ -RENDERING_VIEWPORT_WIDTH=1000 -``` - -```json -{ - "rendering": { - "width": 1000 - } -} -``` - -#### Viewport height - -Default viewport height when height is not specified in the rendering request. Default is `500`. - -```bash -# Available from v3.9.0+ -RENDERING_VIEWPORT_HEIGHT=500 -``` - -```json -{ - "rendering": { - "height": 500 - } -} -``` - -#### Viewport maximum width - -Limit the maximum viewport width that can be requested. Default is `3000`. - -```bash -# Available from v3.9.0+ -RENDERING_VIEWPORT_MAX_WIDTH=1000 -``` - -```json -{ - "rendering": { - "maxWidth": 1000 - } -} -``` - -#### Viewport maximum height - -Limit the maximum viewport height that can be requested. Default is `3000`. - -```bash -# Available from v3.9.0+ -RENDERING_VIEWPORT_MAX_HEIGHT=500 -``` - -```json -{ - "rendering": { - "maxHeight": 500 - } -} -``` - -#### Device scale factor - -Specify default device scale factor for rendering images. `2` is enough for monitor resolutions, `4` would be better for printed material. Setting a higher value affects performance and memory. Default is `1`. -This can be overridden in the rendering request. - -```bash -# Available from v3.9.0+ -RENDERING_VIEWPORT_DEVICE_SCALE_FACTOR=2 -``` - -```json -{ - "rendering": { - "deviceScaleFactor": 2 - } -} -``` - -#### Maximum device scale factor - -Limit the maximum device scale factor that can be requested. Default is `4`. - -```bash -# Available from v3.9.0+ -RENDERING_VIEWPORT_MAX_DEVICE_SCALE_FACTOR=4 -``` - -```json -{ - "rendering": { - "maxDeviceScaleFactor": 4 - } -} -``` - -#### Page zoom level - -The following command sets a page zoom level. The default value is `1`. A value of `1.5` equals 150% zoom. - -```bash -RENDERING_VIEWPORT_PAGE_ZOOM_LEVEL=1 -``` - -```json -{ - "rendering": { - "pageZoomLevel": 1 - } -} -``` diff --git a/docs/sources/setup-grafana/image-rendering/monitoring/index.md b/docs/sources/setup-grafana/image-rendering/monitoring/index.md deleted file mode 100644 index ad4334f52e3..00000000000 --- a/docs/sources/setup-grafana/image-rendering/monitoring/index.md +++ /dev/null @@ -1,221 +0,0 @@ ---- -aliases: - - ../../image-rendering/monitoring/ -description: Image rendering monitoring -keywords: - - grafana - - image - - rendering - - plugin - - monitoring -labels: - products: - - enterprise - - oss -title: Monitor the image renderer -weight: 100 ---- - -# Monitor the image renderer - -Rendering images requires a lot of memory, mainly because Grafana creates browser instances in the background for the actual rendering. Monitoring your service can help you allocate the right amount of resources to your rendering service and set the right [rendering mode](../#rendering-mode). - -## Enable Prometheus metrics endpoint - -Configure this service to expose a Prometheus metrics endpoint. For information on how to configure and monitor this service using Prometheus as a data source, refer to [Grafana Image Rendering Service dashboard](/grafana/dashboards/12203). - -**Metrics endpoint output example:** - -``` -# HELP process_cpu_user_seconds_total Total user CPU time spent in seconds. -# TYPE process_cpu_user_seconds_total counter -process_cpu_user_seconds_total 0.536 1579444523566 - -# HELP process_cpu_system_seconds_total Total system CPU time spent in seconds. -# TYPE process_cpu_system_seconds_total counter -process_cpu_system_seconds_total 0.064 1579444523566 - -# HELP process_cpu_seconds_total Total user and system CPU time spent in seconds. -# TYPE process_cpu_seconds_total counter -process_cpu_seconds_total 0.6000000000000001 1579444523566 - -# HELP process_start_time_seconds Start time of the process since unix epoch in seconds. -# TYPE process_start_time_seconds gauge -process_start_time_seconds 1579444433 - -# HELP process_resident_memory_bytes Resident memory size in bytes. -# TYPE process_resident_memory_bytes gauge -process_resident_memory_bytes 52686848 1579444523568 - -# HELP process_virtual_memory_bytes Virtual memory size in bytes. -# TYPE process_virtual_memory_bytes gauge -process_virtual_memory_bytes 2055344128 1579444523568 - -# HELP process_heap_bytes Process heap size in bytes. -# TYPE process_heap_bytes gauge -process_heap_bytes 1996390400 1579444523568 - -# HELP process_open_fds Number of open file descriptors. -# TYPE process_open_fds gauge -process_open_fds 31 1579444523567 - -# HELP process_max_fds Maximum number of open file descriptors. -# TYPE process_max_fds gauge -process_max_fds 1573877 - -# HELP nodejs_eventloop_lag_seconds Lag of event loop in seconds. -# TYPE nodejs_eventloop_lag_seconds gauge -nodejs_eventloop_lag_seconds 0.000915922 1579444523567 - -# HELP nodejs_active_handles Number of active libuv handles grouped by handle type. Every handle type is C++ class name. -# TYPE nodejs_active_handles gauge -nodejs_active_handles{type="WriteStream"} 2 1579444523566 -nodejs_active_handles{type="Server"} 1 1579444523566 -nodejs_active_handles{type="Socket"} 9 1579444523566 -nodejs_active_handles{type="ChildProcess"} 2 1579444523566 - -# HELP nodejs_active_handles_total Total number of active handles. -# TYPE nodejs_active_handles_total gauge -nodejs_active_handles_total 14 1579444523567 - -# HELP nodejs_active_requests Number of active libuv requests grouped by request type. Every request type is C++ class name. -# TYPE nodejs_active_requests gauge -nodejs_active_requests{type="FSReqCallback"} 2 - -# HELP nodejs_active_requests_total Total number of active requests. -# TYPE nodejs_active_requests_total gauge -nodejs_active_requests_total 2 1579444523567 - -# HELP nodejs_heap_size_total_bytes Process heap size from node.js in bytes. -# TYPE nodejs_heap_size_total_bytes gauge -nodejs_heap_size_total_bytes 13725696 1579444523567 - -# HELP nodejs_heap_size_used_bytes Process heap size used from node.js in bytes. -# TYPE nodejs_heap_size_used_bytes gauge -nodejs_heap_size_used_bytes 12068008 1579444523567 - -# HELP nodejs_external_memory_bytes Nodejs external memory size in bytes. -# TYPE nodejs_external_memory_bytes gauge -nodejs_external_memory_bytes 1728962 1579444523567 - -# HELP nodejs_heap_space_size_total_bytes Process heap space size total from node.js in bytes. -# TYPE nodejs_heap_space_size_total_bytes gauge -nodejs_heap_space_size_total_bytes{space="read_only"} 262144 1579444523567 -nodejs_heap_space_size_total_bytes{space="new"} 1048576 1579444523567 -nodejs_heap_space_size_total_bytes{space="old"} 9809920 1579444523567 -nodejs_heap_space_size_total_bytes{space="code"} 425984 1579444523567 -nodejs_heap_space_size_total_bytes{space="map"} 1052672 1579444523567 -nodejs_heap_space_size_total_bytes{space="large_object"} 1077248 1579444523567 -nodejs_heap_space_size_total_bytes{space="code_large_object"} 49152 1579444523567 -nodejs_heap_space_size_total_bytes{space="new_large_object"} 0 1579444523567 - -# HELP nodejs_heap_space_size_used_bytes Process heap space size used from node.js in bytes. -# TYPE nodejs_heap_space_size_used_bytes gauge -nodejs_heap_space_size_used_bytes{space="read_only"} 32296 1579444523567 -nodejs_heap_space_size_used_bytes{space="new"} 601696 1579444523567 -nodejs_heap_space_size_used_bytes{space="old"} 9376600 1579444523567 -nodejs_heap_space_size_used_bytes{space="code"} 286688 1579444523567 -nodejs_heap_space_size_used_bytes{space="map"} 704320 1579444523567 -nodejs_heap_space_size_used_bytes{space="large_object"} 1064872 1579444523567 -nodejs_heap_space_size_used_bytes{space="code_large_object"} 3552 1579444523567 -nodejs_heap_space_size_used_bytes{space="new_large_object"} 0 1579444523567 - -# HELP nodejs_heap_space_size_available_bytes Process heap space size available from node.js in bytes. -# TYPE nodejs_heap_space_size_available_bytes gauge -nodejs_heap_space_size_available_bytes{space="read_only"} 229576 1579444523567 -nodejs_heap_space_size_available_bytes{space="new"} 445792 1579444523567 -nodejs_heap_space_size_available_bytes{space="old"} 417712 1579444523567 -nodejs_heap_space_size_available_bytes{space="code"} 20576 1579444523567 -nodejs_heap_space_size_available_bytes{space="map"} 343632 1579444523567 -nodejs_heap_space_size_available_bytes{space="large_object"} 0 1579444523567 -nodejs_heap_space_size_available_bytes{space="code_large_object"} 0 1579444523567 -nodejs_heap_space_size_available_bytes{space="new_large_object"} 1047488 1579444523567 - -# HELP nodejs_version_info Node.js version info. -# TYPE nodejs_version_info gauge -nodejs_version_info{version="v14.16.1",major="14",minor="16",patch="1"} 1 - -# HELP grafana_image_renderer_service_http_request_duration_seconds duration histogram of http responses labeled with: status_code -# TYPE grafana_image_renderer_service_http_request_duration_seconds histogram -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="1",status_code="200"} 0 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="5",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="7",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="9",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="11",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="13",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="15",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="20",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="30",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_bucket{le="+Inf",status_code="200"} 4 -grafana_image_renderer_service_http_request_duration_seconds_sum{status_code="200"} 10.492873834 -grafana_image_renderer_service_http_request_duration_seconds_count{status_code="200"} 4 - -# HELP up 1 = up, 0 = not up -# TYPE up gauge -up 1 - -# HELP grafana_image_renderer_http_request_in_flight A gauge of requests currently being served by the image renderer. -# TYPE grafana_image_renderer_http_request_in_flight gauge -grafana_image_renderer_http_request_in_flight 1 - -# HELP grafana_image_renderer_step_duration_seconds duration histogram of browser steps for rendering an image labeled with: step -# TYPE grafana_image_renderer_step_duration_seconds histogram -grafana_image_renderer_step_duration_seconds_bucket{le="0.3",step="launch"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="0.5",step="launch"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="1",step="launch"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="2",step="launch"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="3",step="launch"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="5",step="launch"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="+Inf",step="launch"} 1 -grafana_image_renderer_step_duration_seconds_sum{step="launch"} 0.7914972 -grafana_image_renderer_step_duration_seconds_count{step="launch"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.3",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.5",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="1",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="2",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="3",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="5",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="+Inf",step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_sum{step="newPage"} 0.2217868 -grafana_image_renderer_step_duration_seconds_count{step="newPage"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.3",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.5",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="1",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="2",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="3",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="5",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="+Inf",step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_sum{step="prepare"} 0.0819274 -grafana_image_renderer_step_duration_seconds_count{step="prepare"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.3",step="navigate"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="0.5",step="navigate"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="1",step="navigate"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="2",step="navigate"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="3",step="navigate"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="5",step="navigate"} 0 -grafana_image_renderer_step_duration_seconds_bucket{le="+Inf",step="navigate"} 1 -grafana_image_renderer_step_duration_seconds_sum{step="navigate"} 15.3311258 -grafana_image_renderer_step_duration_seconds_count{step="navigate"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.3",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.5",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="1",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="2",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="3",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="5",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="+Inf",step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_sum{step="panelsRendered"} 0.0205577 -grafana_image_renderer_step_duration_seconds_count{step="panelsRendered"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.3",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="0.5",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="1",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="2",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="3",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="5",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_bucket{le="+Inf",step="screenshot"} 1 -grafana_image_renderer_step_duration_seconds_sum{step="screenshot"} 0.2866623 -grafana_image_renderer_step_duration_seconds_count{step="screenshot"} 1 - -# HELP grafana_image_renderer_browser_info A metric with a constant '1 value labeled by version of the browser in use -# TYPE grafana_image_renderer_browser_info gauge -grafana_image_renderer_browser_info{version="HeadlessChrome/79.0.3945.0"} 1 -``` diff --git a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md b/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md deleted file mode 100644 index 61cd711bd50..00000000000 --- a/docs/sources/setup-grafana/image-rendering/troubleshooting/index.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -aliases: - - ../../image-rendering/troubleshooting/ -description: Image rendering troubleshooting -keywords: - - grafana - - image - - rendering - - plugin - - troubleshooting -labels: - products: - - enterprise - - oss -menuTitle: Troubleshooting -title: Troubleshoot image rendering -weight: 200 ---- - -# Troubleshoot image rendering - -In this section, you'll learn how to enable logging for the image renderer and you'll find the most common issues. - -## Enable debug logging - -To troubleshoot the image renderer, different kind of logs are available. - -You can enable debug log messages for rendering in the Grafana configuration file and inspect the Grafana server logs. - -```bash -[log] -filters = rendering:debug -``` - -You can also enable more logs in image renderer service itself by enabling [debug logging](#enable-debug-logging). - -## Missing libraries - -The plugin and rendering service uses [Chromium browser](https://www.chromium.org/) which depends on certain libraries. -If you don't have all of those libraries installed in your system you may encounter errors when trying to render an image, e.g. - -```bash -Rendering failed: Error: Failed to launch chrome!/var/lib/grafana/plugins/grafana-image-renderer/chrome-linux/chrome: -error while loading shared libraries: libX11.so.6: cannot open shared object file: No such file or directory\n\n\nTROUBLESHOOTING: https://github.com/GoogleChrome/puppeteer/blob/master/docs/troubleshooting.md -``` - -In general you can use the [`ldd`]() utility to figure out what shared libraries -are not installed in your system: - -```bash -cd -ldd chrome-headless-shell/linux-132.0.6781.0/chrome-headless-shell-linux64/chrome-headless-shell - linux-vdso.so.1 (0x00007fff1bf65000) - libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f2047945000) - libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f2047924000) - librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f204791a000) - libX11.so.6 => not found - libX11-xcb.so.1 => not found - libxcb.so.1 => not found - libXcomposite.so.1 => not found - ... -``` - -You can find a reference to all the relevant Debian packages for the service to function [in the Dockerfile](https://github.com/grafana/grafana-image-renderer/blob/master/Dockerfile). -If you are using an operating system that is not Debian 12, you should look up what each of those packages are called on your system. - -## Certificate signed by internal certificate authorities - -In many cases, Grafana runs on internal servers and uses certificates that have not been signed by a CA ([Certificate Authority](https://en.wikipedia.org/wiki/Certificate_authority)) known to Chrome, and therefore cannot be validated. Chrome internally uses NSS ([Network Security Services](https://en.wikipedia.org/wiki/Network_Security_Services)) for cryptographic operations such as the validation of certificates. - -If you are using the Grafana Image Renderer with a Grafana server that uses a certificate signed by such a custom CA (for example a company-internal CA), rendering images will fail and you will see messages like this in the Grafana log: - -``` -t=2019-12-04T12:39:22+0000 lvl=error msg="Render request failed" logger=rendering error=map[] url="https://192.168.106.101:3443/d-solo/zxDJxNaZk/graphite-metrics?orgId=1&refresh=1m&from=1575438321300&to=1575459921300&var-Host=master1&panelId=4&width=1000&height=500&tz=Europe%2FBerlin&render=1" timestamp=0001-01-01T00:00:00.000Z -t=2019-12-04T12:39:22+0000 lvl=error msg="Rendering failed." logger=context userId=1 orgId=1 uname=admin error="Rendering failed: Error: net::ERR_CERT_AUTHORITY_INVALID at https://192.168.106.101:3443/d-solo/zxDJxNaZk/graphite-metrics?orgId=1&refresh=1m&from=1575438321300&to=1575459921300&var-Host=master1&panelId=4&width=1000&height=500&tz=Europe%2FBerlin&render=1" -t=2019-12-04T12:39:22+0000 lvl=error msg="Request Completed" logger=context userId=1 orgId=1 uname=admin method=GET path=/render/d-solo/zxDJxNaZk/graphite-metrics status=500 remote_addr=192.168.106.101 time_ms=310 size=1722 referer="https://grafana.xxx-xxx/d/zxDJxNaZk/graphite-metrics?orgId=1&refresh=1m" -``` - -If this happens, then you have to add the certificate to the trust store. If you have the certificate file for the internal root CA in the file `internal-root-ca.crt.pem`, then use these commands to create a user specific NSS trust store for the Grafana user (`grafana` for the purpose of this example) and execute the following steps: - -**Linux:** - -``` -[root@server ~]# [ -d /usr/share/grafana/.pki/nssdb ] || mkdir -p /usr/share/grafana/.pki/nssdb -[root@server ~]# certutil -d sql:/usr/share/grafana/.pki/nssdb -A -n internal-root-ca -t C -i /etc/pki/tls/certs/internal-root-ca.crt.pem -[root@server ~]# chown -R grafana: /usr/share/grafana/.pki/nssdb -``` - -You may also have to use other tooling than `certutil`, such as `update-ca-certificates` and its accompanying paths. -This depends on the Linux distribution you use; distributions often have a wiki with this type of information. - -**Windows:** - -``` -certutil –addstore "Root" /internal-root-ca.crt.pem -``` - -**Container:** - -```Dockerfile -FROM grafana/grafana-image-renderer:latest - -# Elevate our permissions to access system resources. -USER root - -RUN mkdir -p /usr/local/share/ca-certificates/ -# Convert from .pem to .crt -RUN openssl x509 -inform PEM -in rootCA.pem -out /usr/local/share/ca-certificates/rootCA.crt - -# Regenerate the CA certificates in the container. -RUN update-ca-certificates --fresh - -# Reassume the nonroot user for the service execution. -USER nonroot - -# Some CA certificates also need to explicitly be included in the user's network security services database. -# certutil is shipped in v4.0.8 and onwards of the image. -RUN mkdir -p /home/nonroot/.pki/nssdb -RUN certutil -d sql:/home/nonroot/.pki/nssdb -A -n internal-root-ca -t C -i /usr/local/share/ca-certificates/rootCA.crt -``` - -{{< admonition type="note" >}} -The container image was based on Alpine until v4.0.0. -After this point, it is based on distroless Debian. -{{< /admonition >}} - -## Custom Chrome/Chromium - -As a last resort, if you already have [Chrome](https://www.google.com/chrome/) or [Chromium](https://www.chromium.org/) -installed on your system, then you can configure the Grafana Image renderer plugin to use this -instead of the pre-packaged version of Chromium. - -{{< admonition type="note" >}} -Please note that this is not recommended, since you may encounter problems if the installed version of Chrome/Chromium is not -compatible with the [Grafana Image renderer plugin](/grafana/plugins/grafana-image-renderer). -{{< /admonition >}} - -To override the path to the Chrome/Chromium executable in plugin mode, set an environment variable and make sure that it's available for the Grafana process. For example: - -```bash -export GF_PLUGIN_RENDERING_CHROME_BIN="/usr/bin/chromium-browser" -``` - -In remote rendering mode, you need to set the environment variable or update the configuration file and make sure that it's available for the image rendering service process: - -```bash -CHROME_BIN="/usr/bin/chromium-browser" -``` - -```json -{ - "rendering": { - "chromeBin": "/usr/bin/chromium-browser" - } -} -``` From 65fd15bbf988b83fabd545b5bb33c4ae1fb9f8fc Mon Sep 17 00:00:00 2001 From: Rafael Bortolon Paulovic Date: Thu, 20 Nov 2025 15:47:44 +0100 Subject: [PATCH 121/125] feat: define unified disable migrations flag (#114120) chore: add US data migrations config --- pkg/setting/setting.go | 4 +- pkg/setting/setting_unified_storage.go | 57 +++++++++++++++++++- pkg/storage/unified/migrations/migrations.go | 8 ++- 3 files changed, 64 insertions(+), 5 deletions(-) diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index fb69aa60259..a8b48c67f29 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -578,7 +578,9 @@ type Cfg struct { ShortLinkExpiration int // Unified Storage - UnifiedStorage map[string]UnifiedStorageConfig + UnifiedStorage map[string]UnifiedStorageConfig + // DisableDataMigrations will disable resources data migration to unified storage at startup + DisableDataMigrations bool MaxPageSizeBytes int IndexPath string IndexWorkers int diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 15bed66b6bb..29cb5d0270a 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -5,8 +5,15 @@ import ( "time" "github.com/grafana/grafana/pkg/apiserver/rest" + "github.com/grafana/grafana/pkg/util/osutil" ) +var migratedUnifiedResources = []string{ + //"playlists.playlist.grafana.app", + "folders.folder.grafana.app", + "dashboards.dashboard.grafana.app", +} + // read storage configs from ini file. They look like: // [unified_storage..] // = @@ -51,8 +58,14 @@ func (cfg *Cfg) setUnifiedStorageConfig() { // Set indexer config for unified storage section := cfg.Raw.Section("unified_storage") - - cfg.EnableSearch = section.Key("enable_search").MustBool(false) + // TODO: Re-enable once migrations are ready and disabled on cloud + //cfg.DisableDataMigrations = section.Key("disable_data_migrations").MustBool(false) + cfg.DisableDataMigrations = true + if !cfg.DisableDataMigrations && cfg.getUnifiedStorageType() == "unified" { + cfg.enforceMigrationToUnifiedConfigs() + } else { + cfg.EnableSearch = section.Key("enable_search").MustBool(false) + } cfg.MaxPageSizeBytes = section.Key("max_page_size_bytes").MustInt(0) cfg.IndexPath = section.Key("index_path").String() cfg.IndexWorkers = section.Key("index_workers").MustInt(10) @@ -84,3 +97,43 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0) cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("") } + +// enforceMigrationToUnifiedConfigs enforces configurations required to run migrated resources in mode 5 +// All migrated resources in MigratedUnifiedResources are set to mode 5 and unified search is enabled +func (cfg *Cfg) enforceMigrationToUnifiedConfigs() { + section := cfg.Raw.Section("unified_storage") + cfg.EnableSearch = section.Key("enable_search").MustBool(true) + if !cfg.EnableSearch { + cfg.Logger.Info("Enforcing enable_search for unified storage") + section.Key("enable_search").SetValue("true") + cfg.EnableSearch = true + } + for _, resource := range migratedUnifiedResources { + cfg.Logger.Info("Enforcing mode 5 for resource in unified storage", "resource", resource) + if oldCfg, ok := cfg.UnifiedStorage[resource]; ok { + cfg.Logger.Info("Overriding unified storage config for migrated resource", "resource", resource, "old_config", oldCfg) + } + cfg.UnifiedStorage[resource] = UnifiedStorageConfig{ + DualWriterMode: 5, + DualWriterMigrationDataSyncDisabled: true, + } + } +} + +// getUnifiedStorageType returns the configured storage type without creating or mutating keys. +// Precedence: env > ini > default ("unified"). +// Used to decide unified storage behavior early without side effects. +func (cfg *Cfg) getUnifiedStorageType() string { + const ( + grafanaAPIServerSectionName = "grafana-apiserver" + storageTypeKeyName = "storage_type" + defaultStorageType = "unified" + ) + if envStorageType := (osutil.RealEnv{}).Getenv(EnvKey(grafanaAPIServerSectionName, storageTypeKeyName)); envStorageType != "" { + return envStorageType + } + if cfg.Raw.Section(grafanaAPIServerSectionName).HasKey(storageTypeKeyName) { + return cfg.Raw.Section(grafanaAPIServerSectionName).Key(storageTypeKeyName).Value() + } + return defaultStorageType +} diff --git a/pkg/storage/unified/migrations/migrations.go b/pkg/storage/unified/migrations/migrations.go index 195b7f5d154..a30777e308a 100644 --- a/pkg/storage/unified/migrations/migrations.go +++ b/pkg/storage/unified/migrations/migrations.go @@ -17,6 +17,7 @@ import ( ) var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") +var logger = log.New("storage.unified.migrations") // UnifiedStorageMigrationProvider provides unified storage migrations as a background service type UnifiedStorageMigrationProvider interface { @@ -56,7 +57,11 @@ func (p *UnifiedStorageMigrationProviderImpl) Run(ctx context.Context) error { if os.Getenv("GRAFANA_TEST_DB") != "" { return nil } - + // skip migrations if disabled in config + if p.cfg.DisableDataMigrations { + logger.Info("Data migrations are disabled, skipping") + return nil + } // TODO: Re-enable once migrations are ready // return RegisterMigrations(p.legacyMigrator, p.cfg, p.client, p.sqlStore) return nil @@ -74,7 +79,6 @@ func RegisterMigrations( ) error { ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations") defer span.End() - logger := log.New("storage.unified.migrations.folders-dashboards") mg := migrator.NewScopedMigrator(sqlStore.GetEngine(), cfg, "unified_storage") mg.AddCreateMigration() From b5a50e7772aa0a49cb8f00f04d371792e2673f62 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Thu, 20 Nov 2025 15:51:50 +0100 Subject: [PATCH 122/125] `grafana-iam`: Use the UniStore as the default store (#113614) * `grafana-iam`: Use the UniStore as the default store * Refactor all instantiations * Remove enableDualWriter * Nit. dw is clear enough * Use the correct access control client --- pkg/registry/apis/iam/models.go | 15 ++- pkg/registry/apis/iam/register.go | 212 ++++++++++++++---------------- 2 files changed, 112 insertions(+), 115 deletions(-) diff --git a/pkg/registry/apis/iam/models.go b/pkg/registry/apis/iam/models.go index c960b7a8655..309b266ccb4 100644 --- a/pkg/registry/apis/iam/models.go +++ b/pkg/registry/apis/iam/models.go @@ -8,6 +8,10 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" + "github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount" + "github.com/grafana/grafana/pkg/registry/apis/iam/sso" + "github.com/grafana/grafana/pkg/registry/apis/iam/team" + "github.com/grafana/grafana/pkg/registry/apis/iam/teambinding" "github.com/grafana/grafana/pkg/registry/apis/iam/user" "github.com/grafana/grafana/pkg/services/apiserver/builder" "github.com/grafana/grafana/pkg/services/authz/zanzana" @@ -42,7 +46,13 @@ type ExternalGroupMappingStorageBackend interface{ resource.StorageBackend } // This is used just so wire has something unique to return type IdentityAccessManagementAPIBuilder struct { // Stores - store legacy.LegacyIdentityStore + store legacy.LegacyIdentityStore + + userLegacyStore *user.LegacyStore + saLegacyStore *serviceaccount.LegacyStore + legacyTeamStore *team.LegacyStore + teamBindingLegacyStore *teambinding.LegacyBindingStore + ssoLegacyStore *sso.LegacyStore coreRolesStorage CoreRoleStorageBackend rolesStorage RoleStorageBackend resourcePermissionsStorage resource.StorageBackend @@ -78,7 +88,4 @@ type IdentityAccessManagementAPIBuilder struct { // Toggle for enabling authz management apis features featuremgmt.FeatureToggles - - // Toggle for enabling dual writer - enableDualWriter bool } diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index cc229f2ed4b..2f214fd0630 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -31,7 +31,6 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/iam/externalgroupmapping" "github.com/grafana/grafana/pkg/registry/apis/iam/legacy" - "github.com/grafana/grafana/pkg/registry/apis/iam/noopstorage" "github.com/grafana/grafana/pkg/registry/apis/iam/resourcepermission" "github.com/grafana/grafana/pkg/registry/apis/iam/serviceaccount" "github.com/grafana/grafana/pkg/registry/apis/iam/sso" @@ -76,8 +75,16 @@ func RegisterAPIService( authorizer := newIAMAuthorizer(accessClient, legacyAccessClient) registerMetrics(reg) + //nolint:staticcheck // not yet migrated to OpenFeature + enableAuthnMutation := features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthnMutation) + builder := &IdentityAccessManagementAPIBuilder{ store: store, + userLegacyStore: user.NewLegacyStore(store, accessClient, enableAuthnMutation), + saLegacyStore: serviceaccount.NewLegacyStore(store, accessClient, enableAuthnMutation), + legacyTeamStore: team.NewLegacyStore(store, legacyAccessClient, enableAuthnMutation), + teamBindingLegacyStore: teambinding.NewLegacyBindingStore(store, enableAuthnMutation), + ssoLegacyStore: sso.NewLegacyStore(ssoService), coreRolesStorage: coreRolesStorage, rolesStorage: rolesStorage, resourcePermissionsStorage: resourcepermission.ProvideStorageBackend(dbProvider), @@ -93,7 +100,6 @@ func RegisterAPIService( reg: reg, logger: log.New("iam.apis"), features: features, - enableDualWriter: true, dual: dual, unified: unified, userSearchClient: resource.NewSearchClient(dualwrite.NewSearchAdapter(dual), iamv0.UserResourceInfo.GroupResource(), unified, user.NewUserLegacySearchClient(userService), features), @@ -113,18 +119,16 @@ func NewAPIService( store := legacy.NewLegacySQLStores(dbProvider) resourcePermissionsStorage := resourcepermission.ProvideStorageBackend(dbProvider) resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient) - noopStorage := noopstorage.ProvideStorageBackend() registerMetrics(reg) return &IdentityAccessManagementAPIBuilder{ - store: store, - display: user.NewLegacyDisplayREST(store), - resourcePermissionsStorage: resourcePermissionsStorage, - externalGroupMappingStorage: noopStorage, - logger: log.New("iam.apis"), - features: features, - zClient: zClient, - zTickets: make(chan bool, MaxConcurrentZanzanaWrites), - reg: reg, + store: store, + display: user.NewLegacyDisplayREST(store), + resourcePermissionsStorage: resourcePermissionsStorage, + logger: log.New("iam.apis"), + features: features, + zClient: zClient, + zTickets: make(chan bool, MaxConcurrentZanzanaWrites), + reg: reg, authorizer: authorizer.AuthorizerFunc( func(ctx context.Context, a authorizer.Attributes) (authorizer.Decision, string, error) { // For now only authorize resourcepermissions resource @@ -183,8 +187,7 @@ func (b *IdentityAccessManagementAPIBuilder) AllowedV0Alpha1Resources() []string func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error { storage := map[string]rest.Storage{} - //nolint:staticcheck // not yet migrated to OpenFeature - enableAuthnMutation := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthnMutation) + //nolint:staticcheck // not yet migrated to OpenFeature enableZanzanaSync := b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzZanzanaSync) @@ -197,69 +200,63 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge }) teamResource := iamv0.TeamResourceInfo - teamLegacyStore := team.NewLegacyStore(b.store, b.legacyAccessClient, enableAuthnMutation) - storage[teamResource.StoragePath()] = teamLegacyStore - storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) + teamUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamResource, opts.OptsGetter) + if err != nil { + return err + } + storage[teamResource.StoragePath()] = teamUniStore - if b.enableDualWriter { - teamStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamResource, opts.OptsGetter) + if b.legacyTeamStore != nil { + dw, err := opts.DualWriteBuilder(teamResource.GroupResource(), b.legacyTeamStore, teamUniStore) if err != nil { return err } - teamDW, err := opts.DualWriteBuilder(teamResource.GroupResource(), teamLegacyStore, teamStore) - if err != nil { - return err - } - - storage[teamResource.StoragePath()] = teamDW + storage[teamResource.StoragePath()] = dw } + storage[teamResource.StoragePath("members")] = team.NewLegacyTeamMemberREST(b.store) + teamBindingResource := iamv0.TeamBindingResourceInfo - teamBindingLegacyStore := teambinding.NewLegacyBindingStore(b.store, enableAuthnMutation) - storage[teamBindingResource.StoragePath()] = teamBindingLegacyStore + teamBindingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter) + if err != nil { + return err + } + storage[teamBindingResource.StoragePath()] = teamBindingUniStore - if b.enableDualWriter { - teamBindingStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, teamBindingResource, opts.OptsGetter) + // Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks + if enableZanzanaSync { + b.logger.Info("Enabling hooks for TeamBinding to sync to Zanzana") + teamBindingUniStore.AfterCreate = b.AfterTeamBindingCreate + teamBindingUniStore.AfterDelete = b.AfterTeamBindingDelete + teamBindingUniStore.BeginUpdate = b.BeginTeamBindingUpdate + } + + if b.teamBindingLegacyStore != nil { + dw, err := opts.DualWriteBuilder(teamBindingResource.GroupResource(), b.teamBindingLegacyStore, teamBindingUniStore) if err != nil { return err } - - teamBindingDW, err := opts.DualWriteBuilder(teamBindingResource.GroupResource(), teamBindingLegacyStore, teamBindingStore) - if err != nil { - return err - } - - // Only teamBindingStore exposes the AfterCreate, AfterDelete, and BeginUpdate hooks - if enableZanzanaSync { - b.logger.Info("Enabling hooks for TeamBinding to sync to Zanzana") - teamBindingStore.AfterCreate = b.AfterTeamBindingCreate - teamBindingStore.AfterDelete = b.AfterTeamBindingDelete - teamBindingStore.BeginUpdate = b.BeginTeamBindingUpdate - } - - storage[teamBindingResource.StoragePath()] = teamBindingDW + storage[teamBindingResource.StoragePath()] = dw } // User store registration userResource := iamv0.UserResourceInfo - legacyStore := user.NewLegacyStore(b.store, b.accessClient, enableAuthnMutation) - storage[userResource.StoragePath()] = legacyStore + userUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, userResource, opts.OptsGetter) + if err != nil { + return err + } + storage[userResource.StoragePath()] = userUniStore - if b.enableDualWriter { - store, err := grafanaregistry.NewRegistryStore(opts.Scheme, userResource, opts.OptsGetter) - if err != nil { - return err - } + if enableZanzanaSync { + b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana") + userUniStore.AfterCreate = b.AfterUserCreate + userUniStore.BeginUpdate = b.BeginUserUpdate + userUniStore.AfterDelete = b.AfterUserDelete + } - if enableZanzanaSync { - b.logger.Info("Enabling hooks for User to sync basic role assignments to Zanzana") - store.AfterCreate = b.AfterUserCreate - store.BeginUpdate = b.BeginUserUpdate - store.AfterDelete = b.AfterUserDelete - } - - dw, err := opts.DualWriteBuilder(userResource.GroupResource(), legacyStore, store) + if b.userLegacyStore != nil { + dw, err := opts.DualWriteBuilder(userResource.GroupResource(), b.userLegacyStore, userUniStore) if err != nil { return err } @@ -270,50 +267,46 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge storage[userResource.StoragePath("teams")] = user.NewLegacyTeamMemberREST(b.store) // Service Accounts store registration - serviceAccountResource := iamv0.ServiceAccountResourceInfo - saLegacyStore := serviceaccount.NewLegacyStore(b.store, b.accessClient, enableAuthnMutation) - storage[serviceAccountResource.StoragePath()] = saLegacyStore - - if b.enableDualWriter { - store, err := grafanaregistry.NewRegistryStore(opts.Scheme, serviceAccountResource, opts.OptsGetter) - if err != nil { - return err - } - - dw, err := opts.DualWriteBuilder(serviceAccountResource.GroupResource(), saLegacyStore, store) - if err != nil { - return err - } - - storage[serviceAccountResource.StoragePath()] = dw - } - - storage[serviceAccountResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) - - if b.sso != nil { - ssoResource := legacyiamv0.SSOSettingResourceInfo - storage[ssoResource.StoragePath()] = sso.NewLegacyStore(b.sso) - } - - externalGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo - externalGroupMappingLegacyStore, err := NewLocalStore(externalGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage) + saResource := iamv0.ServiceAccountResourceInfo + saUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, saResource, opts.OptsGetter) if err != nil { return err } - storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingLegacyStore + storage[saResource.StoragePath()] = saUniStore - if b.enableDualWriter { - externalGroupMappingStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, externalGroupMappingResource, opts.OptsGetter) + if b.saLegacyStore != nil { + dw, err := opts.DualWriteBuilder(saResource.GroupResource(), b.saLegacyStore, saUniStore) + if err != nil { + return err + } + storage[saResource.StoragePath()] = dw + } + + storage[saResource.StoragePath("tokens")] = serviceaccount.NewLegacyTokenREST(b.store) + + if b.ssoLegacyStore != nil { + ssoResource := legacyiamv0.SSOSettingResourceInfo + storage[ssoResource.StoragePath()] = b.ssoLegacyStore + } + + extGroupMappingResource := iamv0.ExternalGroupMappingResourceInfo + extGroupMappingUniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, extGroupMappingResource, opts.OptsGetter) + if err != nil { + return err + } + storage[extGroupMappingResource.StoragePath()] = extGroupMappingUniStore + + if b.externalGroupMappingStorage != nil { + extGroupMappingLegacyStore, err := NewLocalStore(extGroupMappingResource, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.externalGroupMappingStorage) if err != nil { return err } - externalGroupMappingDW, err := opts.DualWriteBuilder(externalGroupMappingResource.GroupResource(), externalGroupMappingLegacyStore, externalGroupMappingStore) + dw, err := opts.DualWriteBuilder(extGroupMappingResource.GroupResource(), extGroupMappingLegacyStore, extGroupMappingUniStore) if err != nil { return err } - - storage[externalGroupMappingResource.StoragePath()] = externalGroupMappingDW + storage[extGroupMappingResource.StoragePath()] = dw } //nolint:staticcheck // not yet migrated to OpenFeature @@ -351,7 +344,7 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *ge } //nolint:staticcheck // not yet migrated to OpenFeature if b.features.IsEnabledGlobally(featuremgmt.FlagKubernetesAuthzResourcePermissionApis) { - if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, b.enableDualWriter, enableZanzanaSync); err != nil { + if err := b.UpdateResourcePermissionsAPIGroup(apiGroupInfo, opts, storage, enableZanzanaSync); err != nil { return err } } @@ -364,11 +357,19 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup( apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions, storage map[string]rest.Storage, - enableDualWriter bool, enableZanzanaSync bool, ) error { - var store rest.Storage - // Create the legacy store first + uniStore, err := grafanaregistry.NewRegistryStore(opts.Scheme, iamv0.ResourcePermissionInfo, opts.OptsGetter) + if err != nil { + return err + } + storage[iamv0.ResourcePermissionInfo.StoragePath()] = uniStore + + if b.resourcePermissionsStorage == nil { + // No legacy storage configured, nothing more to do + return nil + } + legacyStore, err := NewLocalStore(iamv0.ResourcePermissionInfo, apiGroupInfo.Scheme, opts.OptsGetter, b.reg, b.accessClient, b.resourcePermissionsStorage) if err != nil { return err @@ -384,23 +385,12 @@ func (b *IdentityAccessManagementAPIBuilder) UpdateResourcePermissionsAPIGroup( legacyStore.AfterDelete = b.AfterResourcePermissionDelete } - // Set the default store to the legacy store - store = legacyStore - - if enableDualWriter { - // Create the dual write store (UniStore + LegacyStore) - uniStore, err := grafanaregistry.NewRegistryStore(apiGroupInfo.Scheme, iamv0.ResourcePermissionInfo, opts.OptsGetter) - if err != nil { - return err - } - - store, err = opts.DualWriteBuilder(iamv0.ResourcePermissionInfo.GroupResource(), legacyStore, uniStore) - if err != nil { - return err - } + dw, err := opts.DualWriteBuilder(iamv0.ResourcePermissionInfo.GroupResource(), legacyStore, uniStore) + if err != nil { + return err } - storage[iamv0.ResourcePermissionInfo.StoragePath()] = store + storage[iamv0.ResourcePermissionInfo.StoragePath()] = dw return nil } From cb06bba24354811d5e66dd4eba8f9096591f5245 Mon Sep 17 00:00:00 2001 From: Alexander Zobnin Date: Thu, 20 Nov 2025 15:54:32 +0100 Subject: [PATCH 123/125] Zanzana: Add token namespace to config (#114165) --- pkg/services/authz/zanzana.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/services/authz/zanzana.go b/pkg/services/authz/zanzana.go index 3be1b18dc1d..da77010d0eb 100644 --- a/pkg/services/authz/zanzana.go +++ b/pkg/services/authz/zanzana.go @@ -118,6 +118,7 @@ type ZanzanaClientConfig struct { Token string TokenExchangeURL string ServerCertFile string + TokenNamespace string } // NewRemoteZanzanaClient creates a new Zanzana client that connects to remote Zanzana server. From 30c04ab3fc7dbaafe7d031727daa5f3d697ff513 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Thu, 20 Nov 2025 16:40:20 +0100 Subject: [PATCH 124/125] feat: inject unified data migrations in dual writer (#114138) * feat: draft changes for on-prem unified migration * feat: further draft changes for on-prem unified migration * fix: remove some tbis * refactor: rename * fix: another approach * fix: background service related issues * fix: address comments * fix: make gen-go * fix: background service related issues * feat: refactor dual writer and legacy migrator * fix: minor issues * feat: working version in oss * fix: wire * fix: revert test data override * fix: enterprise related issues * chore: add todo * fix: revert dual writer method * fix: lint * chore: logger format * fix: reduce log level * fix: log change * fix: disable * fix: address comments * fix: return error on dual writer service * fix: merge conflict --------- Co-authored-by: Rafael Paulovic --- .../commands/datamigrations/stubs.go | 70 --- .../datamigrations/to_unified_storage.go | 41 +- .../dashboard/legacy/legacy_migrator_mock.go | 96 ---- pkg/registry/apis/dashboard/legacy/migrate.go | 460 ------------------ .../apis/dashboard/legacy/migrate_test.go | 154 ------ .../migration_dashboard_accessor_mock.go | 279 +++++++++++ .../apis/dashboard/legacy/sql_dashboards.go | 336 ++++++++++++- .../dashboard/legacy/sql_dashboards_test.go | 146 ++++++ pkg/registry/apis/dashboard/legacy/types.go | 12 +- pkg/registry/apis/dashboard/legacy_storage.go | 2 +- pkg/registry/apis/dashboard/libary_panel.go | 2 +- pkg/registry/apis/dashboard/register.go | 2 +- pkg/registry/apis/dashboard/sub_dto.go | 4 +- .../jobs/migrate/legacy_resources.go | 64 +-- .../jobs/migrate/legacy_resources_test.go | 112 +++-- pkg/registry/apis/provisioning/register.go | 19 +- .../apis/provisioning/resources/object.go | 19 +- .../backgroundsvcs/background_services.go | 3 - pkg/server/wire.go | 8 +- pkg/server/wire_gen.go | 47 +- pkg/server/wireexts_oss.go | 2 + .../apiserver/appinstaller/storage.go | 2 +- pkg/services/apiserver/builder/helper.go | 2 +- pkg/services/apiserver/service.go | 17 +- pkg/services/provisioning/stubs.go | 69 +++ .../dualwrite/dualwriter_mode1_test.go | 12 +- .../dualwrite/dualwriter_mode2_test.go | 12 +- .../dualwrite/dualwriter_mode3_test.go | 12 +- pkg/storage/legacysql/dualwrite/runtime.go | 31 -- .../legacysql/dualwrite/runtime_test.go | 6 +- pkg/storage/legacysql/dualwrite/service.go | 58 +++ .../legacysql/dualwrite/service_test.go | 4 +- pkg/storage/legacysql/dualwrite/static.go | 4 +- .../unified/migrations/contract/migrations.go | 12 + .../migrations/dashboard_folder_migration.go | 93 ---- pkg/storage/unified/migrations/migrations.go | 112 ----- pkg/storage/unified/migrations/migrator.go | 312 ++++++------ .../unified/migrations/migrator_mock.go | 98 ++++ .../unified/migrations/resource_migration.go | 275 +++++++++++ pkg/storage/unified/migrations/service.go | 118 +++++ 40 files changed, 1779 insertions(+), 1348 deletions(-) delete mode 100644 pkg/cmd/grafana-cli/commands/datamigrations/stubs.go delete mode 100644 pkg/registry/apis/dashboard/legacy/legacy_migrator_mock.go delete mode 100644 pkg/registry/apis/dashboard/legacy/migrate.go delete mode 100644 pkg/registry/apis/dashboard/legacy/migrate_test.go create mode 100644 pkg/registry/apis/dashboard/legacy/migration_dashboard_accessor_mock.go create mode 100644 pkg/services/provisioning/stubs.go create mode 100644 pkg/storage/unified/migrations/contract/migrations.go delete mode 100644 pkg/storage/unified/migrations/dashboard_folder_migration.go delete mode 100644 pkg/storage/unified/migrations/migrations.go create mode 100644 pkg/storage/unified/migrations/migrator_mock.go create mode 100644 pkg/storage/unified/migrations/resource_migration.go create mode 100644 pkg/storage/unified/migrations/service.go diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/stubs.go b/pkg/cmd/grafana-cli/commands/datamigrations/stubs.go deleted file mode 100644 index 22dc15d6fc7..00000000000 --- a/pkg/cmd/grafana-cli/commands/datamigrations/stubs.go +++ /dev/null @@ -1,70 +0,0 @@ -package datamigrations - -import ( - "context" - "path/filepath" - - "github.com/grafana/grafana/pkg/services/provisioning" - "github.com/grafana/grafana/pkg/services/provisioning/dashboards" -) - -var ( - _ provisioning.ProvisioningService = (*stubProvisioning)(nil) -) - -func newStubProvisioning(path string) (provisioning.ProvisioningService, error) { - cfgs, err := dashboards.ReadDashboardConfig(filepath.Join(path, "dashboards")) - if err != nil { - return nil, err - } - stub := &stubProvisioning{ - path: make(map[string]string), - } - for _, cfg := range cfgs { - stub.path[cfg.Name] = cfg.Options["path"].(string) - } - return &stubProvisioning{}, nil -} - -type stubProvisioning struct { - path map[string]string // name > options.path -} - -// GetAllowUIUpdatesFromConfig implements provisioning.ProvisioningService. -func (s *stubProvisioning) GetAllowUIUpdatesFromConfig(name string) bool { - return false -} - -func (s *stubProvisioning) GetDashboardProvisionerResolvedPath(name string) string { - return s.path[name] -} - -// ProvisionAlerting implements provisioning.ProvisioningService. -func (s *stubProvisioning) ProvisionAlerting(ctx context.Context) error { - panic("unimplemented") -} - -// ProvisionDashboards implements provisioning.ProvisioningService. -func (s *stubProvisioning) ProvisionDashboards(ctx context.Context) error { - panic("unimplemented") -} - -// ProvisionDatasources implements provisioning.ProvisioningService. -func (s *stubProvisioning) ProvisionDatasources(ctx context.Context) error { - panic("unimplemented") -} - -// ProvisionPlugins implements provisioning.ProvisioningService. -func (s *stubProvisioning) ProvisionPlugins(ctx context.Context) error { - panic("unimplemented") -} - -// Run implements provisioning.ProvisioningService. -func (s *stubProvisioning) Run(ctx context.Context) error { - panic("unimplemented") -} - -// RunInitProvisioners implements provisioning.ProvisioningService. -func (s *stubProvisioning) RunInitProvisioners(ctx context.Context) error { - panic("unimplemented") -} diff --git a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go index f6bdc211f15..2e8a9892ab1 100644 --- a/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go +++ b/pkg/cmd/grafana-cli/commands/datamigrations/to_unified_storage.go @@ -24,10 +24,11 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/services/accesscontrol/acimpl" "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/search/sort" + "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified" + "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/parquet" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -52,7 +53,6 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err {Group: folders.GROUP, Resource: folders.RESOURCE}, {Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE}, }, - LargeObjects: nil, // TODO... from config Progress: func(count int, msg string) { const minInterval = time.Second shouldPrint := count < 1 || time.Since(last) > minInterval @@ -69,31 +69,26 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err } featureToggles := featuremgmt.ProvideToggles(featureManager) - provisioning, err := newStubProvisioning(cfg.ProvisioningPath) + provisioning, err := provisioning.ProvideStubProvisioningService(cfg) if err != nil { return err } - migrator := legacy.NewDashboardAccess( + grpcClient, err := newUnifiedClient(cfg, sqlStore, featureToggles) + if err != nil { + return err + } + + dashboardAccess := legacy.ProvideMigratorDashboardAccessor( legacysql.NewDatabaseProvider(sqlStore), - authlib.OrgNamespaceFormatter, - nil, // no dashboards.Store provisioning, - nil, // no librarypanels.Service - sort.ProvideService(), - nil, // we don't delete during migration, and this is only need to delete permission. acimpl.ProvideAccessControl(featuremgmt.WithFeatures()), featureToggles, ) - client, err := newUnifiedClient(cfg, sqlStore, featureToggles) - if err != nil { - return err - } - if c.Bool("non-interactive") { - opts.Store = client - opts.BlobStore = client + migrator := migrations.ProvideUnifiedMigrator(dashboardAccess, grpcClient) + opts.WithHistory = true // always include history in non-interactive mode rsp, err := migrator.Migrate(ctx, opts) if exitErr := handleMigrationError(err, rsp); exitErr != nil { @@ -113,6 +108,8 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err return err } if yes { + migrator := migrations.ProvideUnifiedMigrator(dashboardAccess, nil) // no need for grpc client for counting + opts.OnlyCount = true rsp, err := migrator.Migrate(ctx, opts) if err != nil { @@ -141,12 +138,13 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err if err != nil { return err } - start = time.Now() - last = time.Now() - opts.Store, err = newParquetClient(file) + parquetClient, err := newParquetClient(file) if err != nil { return err } + migrator := migrations.ProvideUnifiedMigratorParquet(dashboardAccess, parquetClient) + start = time.Now() + last = time.Now() rsp, err := migrator.Migrate(ctx, opts) if err != nil { return err @@ -172,7 +170,7 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err req.Kinds = append(req.Kinds, fmt.Sprintf("%s/%s", r.Group, r.Resource)) } - stats, err := client.GetStats(ctx, req) + stats, err := grpcClient.GetStats(ctx, req) if err != nil { return err } @@ -188,10 +186,9 @@ func ToUnifiedStorage(c utils.CommandLine, cfg *setting.Cfg, sqlStore db.DB) err return err } if yes { + migrator := migrations.ProvideUnifiedMigrator(dashboardAccess, grpcClient) start = time.Now() last = time.Now() - opts.Store = client - opts.BlobStore = client rsp, err := migrator.Migrate(ctx, opts) if err != nil { return err diff --git a/pkg/registry/apis/dashboard/legacy/legacy_migrator_mock.go b/pkg/registry/apis/dashboard/legacy/legacy_migrator_mock.go deleted file mode 100644 index bb7231e86f6..00000000000 --- a/pkg/registry/apis/dashboard/legacy/legacy_migrator_mock.go +++ /dev/null @@ -1,96 +0,0 @@ -// Code generated by mockery v2.53.4. DO NOT EDIT. - -package legacy - -import ( - context "context" - - resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb" - mock "github.com/stretchr/testify/mock" -) - -// MockLegacyMigrator is an autogenerated mock type for the LegacyMigrator type -type MockLegacyMigrator struct { - mock.Mock -} - -type MockLegacyMigrator_Expecter struct { - mock *mock.Mock -} - -func (_m *MockLegacyMigrator) EXPECT() *MockLegacyMigrator_Expecter { - return &MockLegacyMigrator_Expecter{mock: &_m.Mock} -} - -// Migrate provides a mock function with given fields: ctx, opts -func (_m *MockLegacyMigrator) Migrate(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) { - ret := _m.Called(ctx, opts) - - if len(ret) == 0 { - panic("no return value specified for Migrate") - } - - var r0 *resourcepb.BulkResponse - var r1 error - if rf, ok := ret.Get(0).(func(context.Context, MigrateOptions) (*resourcepb.BulkResponse, error)); ok { - return rf(ctx, opts) - } - if rf, ok := ret.Get(0).(func(context.Context, MigrateOptions) *resourcepb.BulkResponse); ok { - r0 = rf(ctx, opts) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*resourcepb.BulkResponse) - } - } - - if rf, ok := ret.Get(1).(func(context.Context, MigrateOptions) error); ok { - r1 = rf(ctx, opts) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// MockLegacyMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate' -type MockLegacyMigrator_Migrate_Call struct { - *mock.Call -} - -// Migrate is a helper method to define mock.On call -// - ctx context.Context -// - opts MigrateOptions -func (_e *MockLegacyMigrator_Expecter) Migrate(ctx interface{}, opts interface{}) *MockLegacyMigrator_Migrate_Call { - return &MockLegacyMigrator_Migrate_Call{Call: _e.mock.On("Migrate", ctx, opts)} -} - -func (_c *MockLegacyMigrator_Migrate_Call) Run(run func(ctx context.Context, opts MigrateOptions)) *MockLegacyMigrator_Migrate_Call { - _c.Call.Run(func(args mock.Arguments) { - run(args[0].(context.Context), args[1].(MigrateOptions)) - }) - return _c -} - -func (_c *MockLegacyMigrator_Migrate_Call) Return(_a0 *resourcepb.BulkResponse, _a1 error) *MockLegacyMigrator_Migrate_Call { - _c.Call.Return(_a0, _a1) - return _c -} - -func (_c *MockLegacyMigrator_Migrate_Call) RunAndReturn(run func(context.Context, MigrateOptions) (*resourcepb.BulkResponse, error)) *MockLegacyMigrator_Migrate_Call { - _c.Call.Return(run) - return _c -} - -// NewMockLegacyMigrator creates a new instance of MockLegacyMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -// The first argument is typically a *testing.T value. -func NewMockLegacyMigrator(t interface { - mock.TestingT - Cleanup(func()) -}) *MockLegacyMigrator { - mock := &MockLegacyMigrator{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/pkg/registry/apis/dashboard/legacy/migrate.go b/pkg/registry/apis/dashboard/legacy/migrate.go deleted file mode 100644 index e43a0fb7629..00000000000 --- a/pkg/registry/apis/dashboard/legacy/migrate.go +++ /dev/null @@ -1,460 +0,0 @@ -package legacy - -import ( - "context" - "encoding/json" - "errors" - "fmt" - "io" - - "google.golang.org/grpc/metadata" - "k8s.io/apimachinery/pkg/runtime/schema" - - authlib "github.com/grafana/authlib/types" - - dashboard "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" - folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" - "github.com/grafana/grafana/pkg/apimachinery/utils" - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/services/accesscontrol" - "github.com/grafana/grafana/pkg/services/featuremgmt" - "github.com/grafana/grafana/pkg/services/librarypanels" - "github.com/grafana/grafana/pkg/services/provisioning" - "github.com/grafana/grafana/pkg/services/search/sort" - "github.com/grafana/grafana/pkg/services/sqlstore" - "github.com/grafana/grafana/pkg/storage/legacysql" - "github.com/grafana/grafana/pkg/storage/unified/apistore" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/storage/unified/resourcepb" -) - -type MigrateOptions struct { - Namespace string - Store resourcepb.BulkStoreClient - LargeObjects apistore.LargeObjectSupport - BlobStore resourcepb.BlobStoreClient - Resources []schema.GroupResource - WithHistory bool // only applies to dashboards - OnlyCount bool // just count the values - Progress func(count int, msg string) -} - -// Read from legacy and write into unified storage -// -//go:generate mockery --name LegacyMigrator --structname MockLegacyMigrator --inpackage --filename legacy_migrator_mock.go --with-expecter -type LegacyMigrator interface { - Migrate(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) -} - -// This can migrate Folders, Dashboards and LibraryPanels -func ProvideLegacyMigrator( - sql db.DB, // direct access to tables - provisioning provisioning.ProvisioningService, // only needed for dashboard settings - libraryPanelSvc librarypanels.Service, - dashboardPermissionSvc accesscontrol.DashboardPermissionsService, - accessControl accesscontrol.AccessControl, - features featuremgmt.FeatureToggles, -) LegacyMigrator { - dbp := legacysql.NewDatabaseProvider(sql) - return NewDashboardAccess(dbp, authlib.OrgNamespaceFormatter, nil, provisioning, libraryPanelSvc, sort.ProvideService(), dashboardPermissionSvc, accessControl, features) -} - -type BlobStoreInfo struct { - Count int64 - Size int64 -} - -// migrate function -- works for a single kind -type migratorFunc = func(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) - -func (a *dashboardSqlAccess) Migrate(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) { - info, err := authlib.ParseNamespace(opts.Namespace) - if err != nil { - return nil, err - } - if opts.Progress == nil { - opts.Progress = func(count int, msg string) {} // noop - } - - // Migrate everything - if len(opts.Resources) < 1 { - return nil, fmt.Errorf("missing resource selector") - } - - migratorFuncs := []migratorFunc{} - settings := resource.BulkSettings{ - RebuildCollection: true, - SkipValidation: true, - } - - for _, res := range opts.Resources { - switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { - case "folder.grafana.app/folders": - migratorFuncs = append(migratorFuncs, a.migrateFolders) - settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ - Namespace: opts.Namespace, - Group: folders.GROUP, - Resource: folders.RESOURCE, - }) - - case "dashboard.grafana.app/librarypanels": - migratorFuncs = append(migratorFuncs, a.migratePanels) - settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ - Namespace: opts.Namespace, - Group: dashboard.GROUP, - Resource: dashboard.LIBRARY_PANEL_RESOURCE, - }) - - case "dashboard.grafana.app/dashboards": - migratorFuncs = append(migratorFuncs, a.migrateDashboards) - settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ - Namespace: opts.Namespace, - Group: dashboard.GROUP, - Resource: dashboard.DASHBOARD_RESOURCE, - }) - default: - return nil, fmt.Errorf("unsupported resource: %s", res) - } - } - if opts.OnlyCount { - return a.countValues(ctx, opts) - } - - ctx = metadata.NewOutgoingContext(ctx, settings.ToMD()) - if md, ok := metadata.FromOutgoingContext(ctx); ok { - a.log.Debug("bulk grpc request metadata", - "metadata", md, - "collection", settings.Collection, - ) - } else { - a.log.Debug("bulk grpc request, no metadata found", - "collection", settings.Collection, - ) - } - - stream, err := opts.Store.BulkProcess(ctx) - if err != nil { - return nil, err - } - - // Now run each migration - blobStore := BlobStoreInfo{} - a.log.Info("start migrating legacy resources", "namespace", opts.Namespace, "orgId", info.OrgID, "stackId", info.StackID) - for _, m := range migratorFuncs { - blobs, err := m(ctx, info.OrgID, opts, stream) - if err != nil { - a.log.Error("error migrating legacy resources", "error", err, "namespace", opts.Namespace) - return nil, err - } - if blobs != nil { - blobStore.Count += blobs.Count - blobStore.Size += blobs.Size - } - } - a.log.Info("finished migrating legacy resources", "blobStore", blobStore) - return stream.CloseAndRecv() -} - -func (a *dashboardSqlAccess) countValues(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) { - sql, err := a.sql(ctx) - if err != nil { - return nil, err - } - ns, err := authlib.ParseNamespace(opts.Namespace) - if err != nil { - return nil, err - } - orgId := ns.OrgID - rsp := &resourcepb.BulkResponse{} - err = sql.DB.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { - for _, res := range opts.Resources { - switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { - case "folder.grafana.app/folders": - summary := &resourcepb.BulkResponse_Summary{} - summary.Group = folders.GROUP - summary.Group = folders.RESOURCE - _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ - " WHERE is_folder=TRUE AND org_id=?", orgId).Get(&summary.Count) - rsp.Summary = append(rsp.Summary, summary) - - case "dashboard.grafana.app/librarypanels": - summary := &resourcepb.BulkResponse_Summary{} - summary.Group = dashboard.GROUP - summary.Resource = dashboard.LIBRARY_PANEL_RESOURCE - _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("library_element")+ - " WHERE org_id=?", orgId).Get(&summary.Count) - rsp.Summary = append(rsp.Summary, summary) - - case "dashboard.grafana.app/dashboards": - summary := &resourcepb.BulkResponse_Summary{} - summary.Group = dashboard.GROUP - summary.Resource = dashboard.DASHBOARD_RESOURCE - rsp.Summary = append(rsp.Summary, summary) - - _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ - " WHERE is_folder=FALSE AND org_id=?", orgId).Get(&summary.Count) - if err != nil { - return err - } - - // Also count history - _, err = sess.SQL(`SELECT COUNT(*) - FROM `+sql.Table("dashboard_version")+` as dv - JOIN `+sql.Table("dashboard")+` as dd - ON dd.id = dv.dashboard_id - WHERE org_id=?`, orgId).Get(&summary.History) - } - if err != nil { - return err - } - } - return nil - }) - return rsp, nil -} - -func (a *dashboardSqlAccess) migrateDashboards(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { - query := &DashboardQuery{ - OrgID: orgId, - Limit: 100000000, - GetHistory: opts.WithHistory, // include history - AllowFallback: true, // allow fallback to dashboard table during migration - Order: "ASC", // oldest first - } - - blobs := &BlobStoreInfo{} - sql, err := a.sql(ctx) - if err != nil { - return blobs, err - } - - opts.Progress(-1, "migrating dashboards...") - rows, err := a.getRows(ctx, sql, query) - if rows != nil { - defer func() { - _ = rows.Close() - }() - } - if err != nil { - return blobs, err - } - - large := opts.LargeObjects - - // Now send each dashboard - for i := 1; rows.Next(); i++ { - dash := rows.row.Dash - if dash.APIVersion == "" { - dash.APIVersion = fmt.Sprintf("%s/v0alpha1", dashboard.GROUP) - } - dash.SetNamespace(opts.Namespace) - dash.SetResourceVersion("") // it will be filled in by the backend - - body, err := json.Marshal(dash) - if err != nil { - err = fmt.Errorf("error reading json from: %s // %w", rows.row.Dash.Name, err) - return blobs, err - } - - req := &resourcepb.BulkRequest{ - Key: &resourcepb.ResourceKey{ - Namespace: opts.Namespace, - Group: dashboard.GROUP, - Resource: dashboard.DASHBOARD_RESOURCE, - Name: rows.Name(), - }, - Value: body, - Folder: rows.row.FolderUID, - Action: resourcepb.BulkRequest_ADDED, - } - if dash.Generation > 1 { - req.Action = resourcepb.BulkRequest_MODIFIED - } else if dash.Generation < 0 { - req.Action = resourcepb.BulkRequest_DELETED - } - - // With large object support - if large != nil && len(body) > large.Threshold() { - obj, err := utils.MetaAccessor(dash) - if err != nil { - return blobs, err - } - - opts.Progress(i, fmt.Sprintf("[v:%d] %s Large object (%d)", dash.Generation, dash.Name, len(body))) - err = large.Deconstruct(ctx, req.Key, opts.BlobStore, obj, req.Value) - if err != nil { - return blobs, err - } - - // The smaller version (most of spec removed) - req.Value, err = json.Marshal(dash) - if err != nil { - return blobs, err - } - blobs.Count++ - blobs.Size += int64(len(body)) - } - - opts.Progress(i, fmt.Sprintf("[v:%2d] %s (size:%d / %d|%d)", dash.Generation, dash.Name, len(req.Value), i, rows.count)) - - err = stream.Send(req) - if err != nil { - if errors.Is(err, io.EOF) { - opts.Progress(i, fmt.Sprintf("stream EOF/cancelled. index=%d", i)) - err = nil - } - return blobs, err - } - } - - if len(rows.rejected) > 0 { - for _, row := range rows.rejected { - id := row.Dash.Labels[utils.LabelKeyDeprecatedInternalID] - a.log.Warn("rejected dashboard", - "namespace", opts.Namespace, - "dashboard", row.Dash.Name, - "uid", row.Dash.UID, - "id", id, - "version", row.Dash.Generation, - ) - opts.Progress(-2, fmt.Sprintf("rejected: id:%s, uid:%s", id, row.Dash.Name)) - } - } - - if rows.Error() != nil { - return blobs, rows.Error() - } - - opts.Progress(-2, fmt.Sprintf("finished dashboards... (%d)", rows.count)) - return blobs, err -} - -func (a *dashboardSqlAccess) migrateFolders(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { - query := &DashboardQuery{ - OrgID: orgId, - Limit: 100000000, - GetFolders: true, - Order: "ASC", - } - - sql, err := a.sql(ctx) - if err != nil { - return nil, err - } - - opts.Progress(-1, "migrating folders...") - rows, err := a.getRows(ctx, sql, query) - if rows != nil { - defer func() { - _ = rows.Close() - }() - } - if err != nil { - return nil, err - } - - // Now send each dashboard - for i := 1; rows.Next(); i++ { - dash := rows.row.Dash - dash.APIVersion = "folder.grafana.app/v1beta1" - dash.Kind = "Folder" - dash.SetNamespace(opts.Namespace) - dash.SetResourceVersion("") // it will be filled in by the backend - - spec := map[string]any{ - "title": dash.Spec.Object["title"], - } - description := dash.Spec.Object["description"] - if description != nil { - spec["description"] = description - } - dash.Spec.Object = spec - - body, err := json.Marshal(dash) - if err != nil { - return nil, err - } - - req := &resourcepb.BulkRequest{ - Key: &resourcepb.ResourceKey{ - Namespace: opts.Namespace, - Group: "folder.grafana.app", - Resource: "folders", - Name: rows.Name(), - }, - Value: body, - Folder: rows.row.FolderUID, - Action: resourcepb.BulkRequest_ADDED, - } - if dash.Generation > 1 { - req.Action = resourcepb.BulkRequest_MODIFIED - } else if dash.Generation < 0 { - req.Action = resourcepb.BulkRequest_DELETED - } - - opts.Progress(i, fmt.Sprintf("[v:%d] %s (%d)", dash.Generation, dash.Name, len(req.Value))) - - err = stream.Send(req) - if err != nil { - if errors.Is(err, io.EOF) { - err = nil - } - return nil, err - } - } - - if rows.Error() != nil { - return nil, rows.Error() - } - - opts.Progress(-2, fmt.Sprintf("finished folders... (%d)", rows.count)) - return nil, err -} - -func (a *dashboardSqlAccess) migratePanels(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { - opts.Progress(-1, "migrating library panels...") - panels, err := a.GetLibraryPanels(ctx, LibraryPanelQuery{ - OrgID: orgId, - Limit: 1000000, - }) - if err != nil { - return nil, err - } - for i, panel := range panels.Items { - meta, err := utils.MetaAccessor(&panel) - if err != nil { - return nil, err - } - body, err := json.Marshal(panel) - if err != nil { - return nil, err - } - - req := &resourcepb.BulkRequest{ - Key: &resourcepb.ResourceKey{ - Namespace: opts.Namespace, - Group: dashboard.GROUP, - Resource: dashboard.LIBRARY_PANEL_RESOURCE, - Name: panel.Name, - }, - Value: body, - Folder: meta.GetFolder(), - Action: resourcepb.BulkRequest_ADDED, - } - if panel.Generation > 1 { - req.Action = resourcepb.BulkRequest_MODIFIED - } - - opts.Progress(i, fmt.Sprintf("[v:%d] %s (%d)", i, meta.GetName(), len(req.Value))) - - err = stream.Send(req) - if err != nil { - if errors.Is(err, io.EOF) { - err = nil - } - return nil, err - } - } - opts.Progress(-2, fmt.Sprintf("finished panels... (%d)", len(panels.Items))) - return nil, nil -} diff --git a/pkg/registry/apis/dashboard/legacy/migrate_test.go b/pkg/registry/apis/dashboard/legacy/migrate_test.go deleted file mode 100644 index 5b134cb31dc..00000000000 --- a/pkg/registry/apis/dashboard/legacy/migrate_test.go +++ /dev/null @@ -1,154 +0,0 @@ -package legacy - -import ( - "testing" - - "github.com/stretchr/testify/require" - - "github.com/grafana/grafana/pkg/storage/legacysql" - "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" - "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" -) - -func TestDashboardMigrationQuery(t *testing.T) { - // Test that migration queries use AllowFallback flag correctly - nodb := &legacysql.LegacyDatabaseHelper{ - Table: func(n string) string { - return "grafana." + n - }, - } - - t.Run("Migration query should enable AllowFallback flag", func(t *testing.T) { - // Create a migration query as would be used in actual migration - migrationQuery := &DashboardQuery{ - OrgID: 1, - GetHistory: true, // Migration includes history - AllowFallback: true, // This is the key flag for migration - Order: "ASC", // Migration uses ascending order - } - - // Verify UseHistoryTable returns true (requirement for COALESCE logic) - require.True(t, migrationQuery.UseHistoryTable(), "Migration query should use history table") - - // Verify the flag is set correctly - require.True(t, migrationQuery.AllowFallback, "Migration query should allow fallback") - require.True(t, migrationQuery.GetHistory, "Migration query should get history") - require.Equal(t, "ASC", migrationQuery.Order, "Migration should use ascending order") - }) - - t.Run("Regular history query should not use AllowFallback", func(t *testing.T) { - // Regular history query without migration - historyQuery := &DashboardQuery{ - OrgID: 1, - GetHistory: true, - Order: "DESC", - } - - require.True(t, historyQuery.UseHistoryTable(), "History query should use history table") - require.False(t, historyQuery.AllowFallback, "Regular history query should not allow fallback") - require.True(t, historyQuery.GetHistory, "History query should get history") - }) - - t.Run("Migration query template produces COALESCE SQL", func(t *testing.T) { - // Test that the SQL template produces COALESCE logic for migration queries - migrationQuery := &DashboardQuery{ - OrgID: 1, - GetHistory: true, - AllowFallback: true, - Order: "ASC", - } - - req := newQueryReq(nodb, migrationQuery) - req.SQLTemplate = mocks.NewTestingSQLTemplate() - - // Execute the template to get the generated SQL - rawQuery, err := sqltemplate.Execute(sqlQueryDashboards, &req) - require.NoError(t, err) - - sql := rawQuery - - // Verify that COALESCE functions are present in the generated SQL - // These should be used when GetHistory=true AND AllowFallback=true - require.Contains(t, sql, "COALESCE(dashboard_version.created, dashboard.updated)", - "Migration SQL should contain COALESCE for updated timestamp") - require.Contains(t, sql, "COALESCE(dashboard_version.version, dashboard.version)", - "Migration SQL should contain COALESCE for version") - require.Contains(t, sql, "COALESCE(dashboard_version.data, dashboard.data)", - "Migration SQL should contain COALESCE for data") - require.Contains(t, sql, "COALESCE(dashboard_version.api_version, dashboard.api_version)", - "Migration SQL should contain COALESCE for api_version") - require.Contains(t, sql, "COALESCE(dashboard_version.message, '')", - "Migration SQL should contain COALESCE for message with empty string fallback") - - // Verify ORDER BY uses COALESCE as well - require.Contains(t, sql, "COALESCE(dashboard_version.created, dashboard.updated) ASC", - "Migration SQL should ORDER BY COALESCED created timestamp") - require.Contains(t, sql, "COALESCE(dashboard_version.version, dashboard.version) ASC", - "Migration SQL should ORDER BY COALESCED version") - - // Verify it doesn't have the strict history table filter that would exclude NULL version entries - require.NotContains(t, sql, "dashboard_version.id IS NOT NULL", - "Migration SQL should not exclude dashboards without version entries") - }) - - t.Run("Regular history query produces strict SQL", func(t *testing.T) { - // Test that regular history queries still use strict dashboard_version fields - historyQuery := &DashboardQuery{ - OrgID: 1, - GetHistory: true, - Order: "DESC", - } - - req := newQueryReq(nodb, historyQuery) - req.SQLTemplate = mocks.NewTestingSQLTemplate() - - rawQuery, err := sqltemplate.Execute(sqlQueryDashboards, &req) - require.NoError(t, err) - - sql := rawQuery - - // Verify that direct dashboard_version fields are used (no COALESCE) - require.Contains(t, sql, "dashboard_version.created as updated", - "Regular history SQL should use direct dashboard_version.created") - require.Contains(t, sql, "dashboard_version.version", - "Regular history SQL should use direct dashboard_version.version") - require.Contains(t, sql, "dashboard_version.data", - "Regular history SQL should use direct dashboard_version.data") - - // NOTE: We intentionally do NOT add dashboard_version.id IS NOT NULL filter - // to allow for cases where dashboard_version entries might be missing - - // Should not contain COALESCE functions - require.NotContains(t, sql, "COALESCE(dashboard_version.created, dashboard.updated)", - "Regular history SQL should not contain COALESCE for updated") - }) -} - -func TestMigrateDashboardsConfiguration(t *testing.T) { - // Test the actual migration function configuration - - t.Run("Migration options should configure query correctly", func(t *testing.T) { - // Test the migration configuration as used in real migration - opts := MigrateOptions{ - WithHistory: true, // Migration includes history - } - - // This simulates what happens in migrateDashboards function - expectedQuery := &DashboardQuery{ - OrgID: 1, - Limit: 100000000, - GetHistory: opts.WithHistory, // Should be true - AllowFallback: true, // Should be true for migration - Order: "ASC", // Should be ASC for migration - } - - // Verify the configuration matches what migration sets up - require.True(t, expectedQuery.GetHistory, "Migration should enable GetHistory") - require.True(t, expectedQuery.AllowFallback, "Migration should enable AllowFallback") - require.Equal(t, "ASC", expectedQuery.Order, "Migration should use ascending order") - require.Equal(t, 100000000, expectedQuery.Limit, "Migration should use large limit") - - // Verify UseHistoryTable logic - require.True(t, expectedQuery.UseHistoryTable(), "Migration query should use history table") - }) -} diff --git a/pkg/registry/apis/dashboard/legacy/migration_dashboard_accessor_mock.go b/pkg/registry/apis/dashboard/legacy/migration_dashboard_accessor_mock.go new file mode 100644 index 00000000000..43f2ce05ad1 --- /dev/null +++ b/pkg/registry/apis/dashboard/legacy/migration_dashboard_accessor_mock.go @@ -0,0 +1,279 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package legacy + +import ( + context "context" + + resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + mock "github.com/stretchr/testify/mock" +) + +// MockMigrationDashboardAccessor is an autogenerated mock type for the MigrationDashboardAccessor type +type MockMigrationDashboardAccessor struct { + mock.Mock +} + +type MockMigrationDashboardAccessor_Expecter struct { + mock *mock.Mock +} + +func (_m *MockMigrationDashboardAccessor) EXPECT() *MockMigrationDashboardAccessor_Expecter { + return &MockMigrationDashboardAccessor_Expecter{mock: &_m.Mock} +} + +// CountResources provides a mock function with given fields: ctx, opts +func (_m *MockMigrationDashboardAccessor) CountResources(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for CountResources") + } + + var r0 *resourcepb.BulkResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, MigrateOptions) (*resourcepb.BulkResponse, error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, MigrateOptions) *resourcepb.BulkResponse); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*resourcepb.BulkResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, MigrateOptions) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMigrationDashboardAccessor_CountResources_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CountResources' +type MockMigrationDashboardAccessor_CountResources_Call struct { + *mock.Call +} + +// CountResources is a helper method to define mock.On call +// - ctx context.Context +// - opts MigrateOptions +func (_e *MockMigrationDashboardAccessor_Expecter) CountResources(ctx interface{}, opts interface{}) *MockMigrationDashboardAccessor_CountResources_Call { + return &MockMigrationDashboardAccessor_CountResources_Call{Call: _e.mock.On("CountResources", ctx, opts)} +} + +func (_c *MockMigrationDashboardAccessor_CountResources_Call) Run(run func(ctx context.Context, opts MigrateOptions)) *MockMigrationDashboardAccessor_CountResources_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(MigrateOptions)) + }) + return _c +} + +func (_c *MockMigrationDashboardAccessor_CountResources_Call) Return(_a0 *resourcepb.BulkResponse, _a1 error) *MockMigrationDashboardAccessor_CountResources_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMigrationDashboardAccessor_CountResources_Call) RunAndReturn(run func(context.Context, MigrateOptions) (*resourcepb.BulkResponse, error)) *MockMigrationDashboardAccessor_CountResources_Call { + _c.Call.Return(run) + return _c +} + +// MigrateDashboards provides a mock function with given fields: ctx, orgId, opts, stream +func (_m *MockMigrationDashboardAccessor) MigrateDashboards(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { + ret := _m.Called(ctx, orgId, opts, stream) + + if len(ret) == 0 { + panic("no return value specified for MigrateDashboards") + } + + var r0 *BlobStoreInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error)); ok { + return rf(ctx, orgId, opts, stream) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) *BlobStoreInfo); ok { + r0 = rf(ctx, orgId, opts, stream) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*BlobStoreInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) error); ok { + r1 = rf(ctx, orgId, opts, stream) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMigrationDashboardAccessor_MigrateDashboards_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MigrateDashboards' +type MockMigrationDashboardAccessor_MigrateDashboards_Call struct { + *mock.Call +} + +// MigrateDashboards is a helper method to define mock.On call +// - ctx context.Context +// - orgId int64 +// - opts MigrateOptions +// - stream resourcepb.BulkStore_BulkProcessClient +func (_e *MockMigrationDashboardAccessor_Expecter) MigrateDashboards(ctx interface{}, orgId interface{}, opts interface{}, stream interface{}) *MockMigrationDashboardAccessor_MigrateDashboards_Call { + return &MockMigrationDashboardAccessor_MigrateDashboards_Call{Call: _e.mock.On("MigrateDashboards", ctx, orgId, opts, stream)} +} + +func (_c *MockMigrationDashboardAccessor_MigrateDashboards_Call) Run(run func(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient)) *MockMigrationDashboardAccessor_MigrateDashboards_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(MigrateOptions), args[3].(resourcepb.BulkStore_BulkProcessClient)) + }) + return _c +} + +func (_c *MockMigrationDashboardAccessor_MigrateDashboards_Call) Return(_a0 *BlobStoreInfo, _a1 error) *MockMigrationDashboardAccessor_MigrateDashboards_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMigrationDashboardAccessor_MigrateDashboards_Call) RunAndReturn(run func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error)) *MockMigrationDashboardAccessor_MigrateDashboards_Call { + _c.Call.Return(run) + return _c +} + +// MigrateFolders provides a mock function with given fields: ctx, orgId, opts, stream +func (_m *MockMigrationDashboardAccessor) MigrateFolders(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { + ret := _m.Called(ctx, orgId, opts, stream) + + if len(ret) == 0 { + panic("no return value specified for MigrateFolders") + } + + var r0 *BlobStoreInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error)); ok { + return rf(ctx, orgId, opts, stream) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) *BlobStoreInfo); ok { + r0 = rf(ctx, orgId, opts, stream) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*BlobStoreInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) error); ok { + r1 = rf(ctx, orgId, opts, stream) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMigrationDashboardAccessor_MigrateFolders_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MigrateFolders' +type MockMigrationDashboardAccessor_MigrateFolders_Call struct { + *mock.Call +} + +// MigrateFolders is a helper method to define mock.On call +// - ctx context.Context +// - orgId int64 +// - opts MigrateOptions +// - stream resourcepb.BulkStore_BulkProcessClient +func (_e *MockMigrationDashboardAccessor_Expecter) MigrateFolders(ctx interface{}, orgId interface{}, opts interface{}, stream interface{}) *MockMigrationDashboardAccessor_MigrateFolders_Call { + return &MockMigrationDashboardAccessor_MigrateFolders_Call{Call: _e.mock.On("MigrateFolders", ctx, orgId, opts, stream)} +} + +func (_c *MockMigrationDashboardAccessor_MigrateFolders_Call) Run(run func(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient)) *MockMigrationDashboardAccessor_MigrateFolders_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(MigrateOptions), args[3].(resourcepb.BulkStore_BulkProcessClient)) + }) + return _c +} + +func (_c *MockMigrationDashboardAccessor_MigrateFolders_Call) Return(_a0 *BlobStoreInfo, _a1 error) *MockMigrationDashboardAccessor_MigrateFolders_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMigrationDashboardAccessor_MigrateFolders_Call) RunAndReturn(run func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error)) *MockMigrationDashboardAccessor_MigrateFolders_Call { + _c.Call.Return(run) + return _c +} + +// MigrateLibraryPanels provides a mock function with given fields: ctx, orgId, opts, stream +func (_m *MockMigrationDashboardAccessor) MigrateLibraryPanels(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { + ret := _m.Called(ctx, orgId, opts, stream) + + if len(ret) == 0 { + panic("no return value specified for MigrateLibraryPanels") + } + + var r0 *BlobStoreInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error)); ok { + return rf(ctx, orgId, opts, stream) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) *BlobStoreInfo); ok { + r0 = rf(ctx, orgId, opts, stream) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*BlobStoreInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) error); ok { + r1 = rf(ctx, orgId, opts, stream) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMigrationDashboardAccessor_MigrateLibraryPanels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'MigrateLibraryPanels' +type MockMigrationDashboardAccessor_MigrateLibraryPanels_Call struct { + *mock.Call +} + +// MigrateLibraryPanels is a helper method to define mock.On call +// - ctx context.Context +// - orgId int64 +// - opts MigrateOptions +// - stream resourcepb.BulkStore_BulkProcessClient +func (_e *MockMigrationDashboardAccessor_Expecter) MigrateLibraryPanels(ctx interface{}, orgId interface{}, opts interface{}, stream interface{}) *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call { + return &MockMigrationDashboardAccessor_MigrateLibraryPanels_Call{Call: _e.mock.On("MigrateLibraryPanels", ctx, orgId, opts, stream)} +} + +func (_c *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call) Run(run func(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient)) *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(MigrateOptions), args[3].(resourcepb.BulkStore_BulkProcessClient)) + }) + return _c +} + +func (_c *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call) Return(_a0 *BlobStoreInfo, _a1 error) *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call) RunAndReturn(run func(context.Context, int64, MigrateOptions, resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error)) *MockMigrationDashboardAccessor_MigrateLibraryPanels_Call { + _c.Call.Return(run) + return _c +} + +// NewMockMigrationDashboardAccessor creates a new instance of MockMigrationDashboardAccessor. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMigrationDashboardAccessor(t interface { + mock.TestingT + Cleanup(func()) +}) *MockMigrationDashboardAccessor { + mock := &MockMigrationDashboardAccessor{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go index 3d157e09489..7c17ec8b6aa 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards.go @@ -4,7 +4,9 @@ import ( "context" "database/sql" "encoding/json" + "errors" "fmt" + "io" "strconv" "strings" "sync" @@ -13,6 +15,7 @@ import ( "go.opentelemetry.io/otel" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" claims "github.com/grafana/authlib/types" @@ -20,6 +23,7 @@ import ( dashboardV0 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v0alpha1" dashboardV1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" "github.com/grafana/grafana/apps/dashboard/pkg/migration/schemaversion" + folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1" "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/apimachinery/utils" @@ -35,16 +39,30 @@ import ( "github.com/grafana/grafana/pkg/services/librarypanels" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/search/sort" + "github.com/grafana/grafana/pkg/services/sqlstore" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified/resource" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) var ( - _ DashboardAccess = (*dashboardSqlAccess)(nil) - tracer = otel.Tracer("github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy") + tracer = otel.Tracer("github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy") ) +type MigrateOptions struct { + Namespace string + Resources []schema.GroupResource + WithHistory bool // only applies to dashboards + OnlyCount bool // just count the values + Progress func(count int, msg string) +} + +type BlobStoreInfo struct { + Count int64 + Size int64 +} + type dashboardRow struct { // The numeric version for this dashboard RV int64 @@ -63,8 +81,9 @@ type dashboardRow struct { type dashboardSqlAccess struct { sql legacysql.LegacyDatabaseProvider namespacer request.NamespaceMapper - provisioning provisioning.ProvisioningService + provisioning provisioning.StubProvisioningService + // TODO: consider enabling this by default for on-prem migrations invalidDashboardParseFallbackEnabled bool // Use for writing (not reading) @@ -73,7 +92,7 @@ type dashboardSqlAccess struct { dashboardPermissionSvc accesscontrol.DashboardPermissionsService accessControl accesscontrol.AccessControl - libraryPanelSvc librarypanels.Service + libraryPanelSvc librarypanels.Service // only used for save dashboard // Typically one... the server wrapper subscribers []chan *resource.WrittenEvent @@ -81,7 +100,27 @@ type dashboardSqlAccess struct { log log.Logger } -func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, +// ProvideMigratorDashboardAccessor creates a DashboardAccess specifically for migration purposes. +// This provider is used by Wire DI and only includes the minimal dependencies needed for migrations. +func ProvideMigratorDashboardAccessor( + sql legacysql.LegacyDatabaseProvider, + provisioning provisioning.StubProvisioningService, + accessControl accesscontrol.AccessControl, + features featuremgmt.FeatureToggles, +) MigrationDashboardAccessor { + return &dashboardSqlAccess{ + sql: sql, + namespacer: claims.OrgNamespaceFormatter, + dashStore: nil, // not needed for migration + provisioning: provisioning, + dashboardPermissionSvc: nil, // not needed for migration + libraryPanelSvc: nil, // not needed for migration + accessControl: accessControl, + invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled), + } +} + +func NewDashboardSQLAccess(sql legacysql.LegacyDatabaseProvider, namespacer request.NamespaceMapper, dashStore dashboards.Store, provisioning provisioning.ProvisioningService, @@ -90,7 +129,7 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, dashboardPermissionSvc accesscontrol.DashboardPermissionsService, accessControl accesscontrol.AccessControl, features featuremgmt.FeatureToggles, -) DashboardAccess { +) *dashboardSqlAccess { dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter) return &dashboardSqlAccess{ sql: sql, @@ -101,7 +140,6 @@ func NewDashboardAccess(sql legacysql.LegacyDatabaseProvider, dashboardPermissionSvc: dashboardPermissionSvc, libraryPanelSvc: libraryPanelSvc, accessControl: accessControl, - log: log.New("dashboard.legacysql"), invalidDashboardParseFallbackEnabled: features.IsEnabled(context.Background(), featuremgmt.FlagScanRowInvalidDashboardParseFallbackEnabled), } } @@ -149,6 +187,290 @@ func (a *dashboardSqlAccess) getRows(ctx context.Context, sql *legacysql.LegacyD }, err } +// CountResources counts resources without migrating them +func (a *dashboardSqlAccess) CountResources(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) { + sql, err := a.sql(ctx) + if err != nil { + return nil, err + } + ns, err := claims.ParseNamespace(opts.Namespace) + if err != nil { + return nil, err + } + orgId := ns.OrgID + rsp := &resourcepb.BulkResponse{} + err = sql.DB.WithDbSession(ctx, func(sess *sqlstore.DBSession) error { + for _, res := range opts.Resources { + switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { + case "folder.grafana.app/folders": + summary := &resourcepb.BulkResponse_Summary{} + summary.Group = folders.GROUP + summary.Group = folders.RESOURCE + _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ + " WHERE is_folder=TRUE AND org_id=?", orgId).Get(&summary.Count) + rsp.Summary = append(rsp.Summary, summary) + + case "dashboard.grafana.app/librarypanels": + summary := &resourcepb.BulkResponse_Summary{} + summary.Group = dashboardV1.GROUP + summary.Resource = dashboardV1.LIBRARY_PANEL_RESOURCE + _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("library_element")+ + " WHERE org_id=?", orgId).Get(&summary.Count) + rsp.Summary = append(rsp.Summary, summary) + + case "dashboard.grafana.app/dashboards": + summary := &resourcepb.BulkResponse_Summary{} + summary.Group = dashboardV1.GROUP + summary.Resource = dashboardV1.DASHBOARD_RESOURCE + rsp.Summary = append(rsp.Summary, summary) + + _, err = sess.SQL("SELECT COUNT(*) FROM "+sql.Table("dashboard")+ + " WHERE is_folder=FALSE AND org_id=?", orgId).Get(&summary.Count) + if err != nil { + return err + } + + // Also count history + _, err = sess.SQL(`SELECT COUNT(*) + FROM `+sql.Table("dashboard_version")+` as dv + JOIN `+sql.Table("dashboard")+` as dd + ON dd.id = dv.dashboard_id + WHERE org_id=?`, orgId).Get(&summary.History) + } + if err != nil { + return err + } + } + return nil + }) + return rsp, nil +} + +// MigrateDashboards handles the dashboard migration logic +func (a *dashboardSqlAccess) MigrateDashboards(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { + query := &DashboardQuery{ + OrgID: orgId, + Limit: 100000000, + GetHistory: opts.WithHistory, // include history + AllowFallback: true, // allow fallback to dashboard table during migration + Order: "ASC", // oldest first + } + + blobs := &BlobStoreInfo{} + sql, err := a.sql(ctx) + if err != nil { + return blobs, err + } + + opts.Progress(-1, "migrating dashboards...") + rows, err := a.getRows(ctx, sql, query) + if rows != nil { + defer func() { + _ = rows.Close() + }() + } + if err != nil { + return blobs, err + } + + // Now send each dashboard + for i := 1; rows.Next(); i++ { + dash := rows.row.Dash + if dash.APIVersion == "" { + dash.APIVersion = fmt.Sprintf("%s/v0alpha1", dashboardV1.GROUP) + } + dash.SetNamespace(opts.Namespace) + dash.SetResourceVersion("") // it will be filled in by the backend + + body, err := json.Marshal(dash) + if err != nil { + err = fmt.Errorf("error reading json from: %s // %w", rows.row.Dash.Name, err) + return blobs, err + } + + req := &resourcepb.BulkRequest{ + Key: &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: dashboardV1.GROUP, + Resource: dashboardV1.DASHBOARD_RESOURCE, + Name: rows.Name(), + }, + Value: body, + Folder: rows.row.FolderUID, + Action: resourcepb.BulkRequest_ADDED, + } + if dash.Generation > 1 { + req.Action = resourcepb.BulkRequest_MODIFIED + } else if dash.Generation < 0 { + req.Action = resourcepb.BulkRequest_DELETED + } + + opts.Progress(i, fmt.Sprintf("[v:%2d] %s (size:%d / %d|%d)", dash.Generation, dash.Name, len(req.Value), i, rows.count)) + + err = stream.Send(req) + if err != nil { + if errors.Is(err, io.EOF) { + opts.Progress(i, fmt.Sprintf("stream EOF/cancelled. index=%d", i)) + err = nil + } + return blobs, err + } + } + + if len(rows.rejected) > 0 { + for _, row := range rows.rejected { + id := row.Dash.Labels[utils.LabelKeyDeprecatedInternalID] + a.log.Warn("rejected dashboard", + "namespace", opts.Namespace, + "dashboard", row.Dash.Name, + "uid", row.Dash.UID, + "id", id, + "version", row.Dash.Generation, + ) + opts.Progress(-2, fmt.Sprintf("rejected: id:%s, uid:%s", id, row.Dash.Name)) + } + } + + if rows.Error() != nil { + return blobs, rows.Error() + } + + opts.Progress(-2, fmt.Sprintf("finished dashboards... (%d)", rows.count)) + return blobs, err +} + +// MigrateFolders handles the folder migration logic +func (a *dashboardSqlAccess) MigrateFolders(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { + query := &DashboardQuery{ + OrgID: orgId, + Limit: 100000000, + GetFolders: true, + Order: "ASC", + } + + sql, err := a.sql(ctx) + if err != nil { + return nil, err + } + + opts.Progress(-1, "migrating folders...") + rows, err := a.getRows(ctx, sql, query) + if rows != nil { + defer func() { + _ = rows.Close() + }() + } + if err != nil { + return nil, err + } + + // Now send each dashboard + for i := 1; rows.Next(); i++ { + dash := rows.row.Dash + dash.APIVersion = "folder.grafana.app/v1beta1" + dash.Kind = "Folder" + dash.SetNamespace(opts.Namespace) + dash.SetResourceVersion("") // it will be filled in by the backend + + spec := map[string]any{ + "title": dash.Spec.Object["title"], + } + description := dash.Spec.Object["description"] + if description != nil { + spec["description"] = description + } + dash.Spec.Object = spec + + body, err := json.Marshal(dash) + if err != nil { + return nil, err + } + + req := &resourcepb.BulkRequest{ + Key: &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: "folder.grafana.app", + Resource: "folders", + Name: rows.Name(), + }, + Value: body, + Folder: rows.row.FolderUID, + Action: resourcepb.BulkRequest_ADDED, + } + if dash.Generation > 1 { + req.Action = resourcepb.BulkRequest_MODIFIED + } else if dash.Generation < 0 { + req.Action = resourcepb.BulkRequest_DELETED + } + + opts.Progress(i, fmt.Sprintf("[v:%d] %s (%d)", dash.Generation, dash.Name, len(req.Value))) + + err = stream.Send(req) + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } + return nil, err + } + } + + if rows.Error() != nil { + return nil, rows.Error() + } + + opts.Progress(-2, fmt.Sprintf("finished folders... (%d)", rows.count)) + return nil, err +} + +// MigrateLibraryPanels handles the library panel migration logic +func (a *dashboardSqlAccess) MigrateLibraryPanels(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) { + opts.Progress(-1, "migrating library panels...") + panels, err := a.GetLibraryPanels(ctx, LibraryPanelQuery{ + OrgID: orgId, + Limit: 1000000, + }) + if err != nil { + return nil, err + } + for i, panel := range panels.Items { + meta, err := utils.MetaAccessor(&panel) + if err != nil { + return nil, err + } + body, err := json.Marshal(panel) + if err != nil { + return nil, err + } + + req := &resourcepb.BulkRequest{ + Key: &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: dashboardV1.GROUP, + Resource: dashboardV1.LIBRARY_PANEL_RESOURCE, + Name: panel.Name, + }, + Value: body, + Folder: meta.GetFolder(), + Action: resourcepb.BulkRequest_ADDED, + } + if panel.Generation > 1 { + req.Action = resourcepb.BulkRequest_MODIFIED + } + + opts.Progress(i, fmt.Sprintf("[v:%d] %s (%d)", i, meta.GetName(), len(req.Value))) + + err = stream.Send(req) + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } + return nil, err + } + } + opts.Progress(-2, fmt.Sprintf("finished panels... (%d)", len(panels.Items))) + return nil, nil +} + var _ resource.ListIterator = (*rowsWrapper)(nil) type rowsWrapper struct { diff --git a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go index e33841c044c..b142b144800 100644 --- a/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go +++ b/pkg/registry/apis/dashboard/legacy/sql_dashboards_test.go @@ -21,6 +21,9 @@ import ( "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/user" + "github.com/grafana/grafana/pkg/storage/legacysql" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate/mocks" ) func TestScanRow(t *testing.T) { @@ -529,3 +532,146 @@ func TestParseLibraryPanelRow(t *testing.T) { require.Nil(t, updatedTimestamp) }) } + +func TestDashboardMigrationQuery(t *testing.T) { + // Test that migration queries use AllowFallback flag correctly + nodb := &legacysql.LegacyDatabaseHelper{ + Table: func(n string) string { + return "grafana." + n + }, + } + + t.Run("Migration query should enable AllowFallback flag", func(t *testing.T) { + // Create a migration query as would be used in actual migration + migrationQuery := &DashboardQuery{ + OrgID: 1, + GetHistory: true, // Migration includes history + AllowFallback: true, // This is the key flag for migration + Order: "ASC", // Migration uses ascending order + } + + // Verify UseHistoryTable returns true (requirement for COALESCE logic) + require.True(t, migrationQuery.UseHistoryTable(), "Migration query should use history table") + + // Verify the flag is set correctly + require.True(t, migrationQuery.AllowFallback, "Migration query should allow fallback") + require.True(t, migrationQuery.GetHistory, "Migration query should get history") + require.Equal(t, "ASC", migrationQuery.Order, "Migration should use ascending order") + }) + + t.Run("Regular history query should not use AllowFallback", func(t *testing.T) { + // Regular history query without migration + historyQuery := &DashboardQuery{ + OrgID: 1, + GetHistory: true, + Order: "DESC", + } + + require.True(t, historyQuery.UseHistoryTable(), "History query should use history table") + require.False(t, historyQuery.AllowFallback, "Regular history query should not allow fallback") + require.True(t, historyQuery.GetHistory, "History query should get history") + }) + + t.Run("Migration query template produces COALESCE SQL", func(t *testing.T) { + // Test that the SQL template produces COALESCE logic for migration queries + migrationQuery := &DashboardQuery{ + OrgID: 1, + GetHistory: true, + AllowFallback: true, + Order: "ASC", + } + + req := newQueryReq(nodb, migrationQuery) + req.SQLTemplate = mocks.NewTestingSQLTemplate() + + // Execute the template to get the generated SQL + rawQuery, err := sqltemplate.Execute(sqlQueryDashboards, &req) + require.NoError(t, err) + + sql := rawQuery + + // Verify that COALESCE functions are present in the generated SQL + // These should be used when GetHistory=true AND AllowFallback=true + require.Contains(t, sql, "COALESCE(dashboard_version.created, dashboard.updated)", + "Migration SQL should contain COALESCE for updated timestamp") + require.Contains(t, sql, "COALESCE(dashboard_version.version, dashboard.version)", + "Migration SQL should contain COALESCE for version") + require.Contains(t, sql, "COALESCE(dashboard_version.data, dashboard.data)", + "Migration SQL should contain COALESCE for data") + require.Contains(t, sql, "COALESCE(dashboard_version.api_version, dashboard.api_version)", + "Migration SQL should contain COALESCE for api_version") + require.Contains(t, sql, "COALESCE(dashboard_version.message, '')", + "Migration SQL should contain COALESCE for message with empty string fallback") + + // Verify ORDER BY uses COALESCE as well + require.Contains(t, sql, "COALESCE(dashboard_version.created, dashboard.updated) ASC", + "Migration SQL should ORDER BY COALESCED created timestamp") + require.Contains(t, sql, "COALESCE(dashboard_version.version, dashboard.version) ASC", + "Migration SQL should ORDER BY COALESCED version") + + // Verify it doesn't have the strict history table filter that would exclude NULL version entries + require.NotContains(t, sql, "dashboard_version.id IS NOT NULL", + "Migration SQL should not exclude dashboards without version entries") + }) + + t.Run("Regular history query produces strict SQL", func(t *testing.T) { + // Test that regular history queries still use strict dashboard_version fields + historyQuery := &DashboardQuery{ + OrgID: 1, + GetHistory: true, + Order: "DESC", + } + + req := newQueryReq(nodb, historyQuery) + req.SQLTemplate = mocks.NewTestingSQLTemplate() + + rawQuery, err := sqltemplate.Execute(sqlQueryDashboards, &req) + require.NoError(t, err) + + sql := rawQuery + + // Verify that direct dashboard_version fields are used (no COALESCE) + require.Contains(t, sql, "dashboard_version.created as updated", + "Regular history SQL should use direct dashboard_version.created") + require.Contains(t, sql, "dashboard_version.version", + "Regular history SQL should use direct dashboard_version.version") + require.Contains(t, sql, "dashboard_version.data", + "Regular history SQL should use direct dashboard_version.data") + + // NOTE: We intentionally do NOT add dashboard_version.id IS NOT NULL filter + // to allow for cases where dashboard_version entries might be missing + + // Should not contain COALESCE functions + require.NotContains(t, sql, "COALESCE(dashboard_version.created, dashboard.updated)", + "Regular history SQL should not contain COALESCE for updated") + }) +} + +func TestMigrateDashboardsConfiguration(t *testing.T) { + // Test the actual migration function configuration + + t.Run("Migration options should configure query correctly", func(t *testing.T) { + // Test the migration configuration as used in real migration + opts := MigrateOptions{ + WithHistory: true, // Migration includes history + } + + // This simulates what happens in migrateDashboards function + expectedQuery := &DashboardQuery{ + OrgID: 1, + Limit: 100000000, + GetHistory: opts.WithHistory, // Should be true + AllowFallback: true, // Should be true for migration + Order: "ASC", // Should be ASC for migration + } + + // Verify the configuration matches what migration sets up + require.True(t, expectedQuery.GetHistory, "Migration should enable GetHistory") + require.True(t, expectedQuery.AllowFallback, "Migration should enable AllowFallback") + require.Equal(t, "ASC", expectedQuery.Order, "Migration should use ascending order") + require.Equal(t, 100000000, expectedQuery.Limit, "Migration should use large limit") + + // Verify UseHistoryTable logic + require.True(t, expectedQuery.UseHistoryTable(), "Migration query should use history table") + }) +} diff --git a/pkg/registry/apis/dashboard/legacy/types.go b/pkg/registry/apis/dashboard/legacy/types.go index 4ffa7b80863..31d08af16d8 100644 --- a/pkg/registry/apis/dashboard/legacy/types.go +++ b/pkg/registry/apis/dashboard/legacy/types.go @@ -55,10 +55,9 @@ type LibraryPanelQuery struct { LastID int64 } -type DashboardAccess interface { +type DashboardAccessor interface { resource.StorageBackend resourcepb.ResourceIndexServer - LegacyMigrator GetDashboard(ctx context.Context, orgId int64, uid string, version int64) (*dashboardV1.Dashboard, int64, error) SaveDashboard(ctx context.Context, orgId int64, dash *dashboardV1.Dashboard, failOnExisting bool) (*dashboardV1.Dashboard, bool, error) @@ -67,3 +66,12 @@ type DashboardAccess interface { // Get a typed list GetLibraryPanels(ctx context.Context, query LibraryPanelQuery) (*dashboardV0.LibraryPanelList, error) } + +//go:generate mockery --name MigrationDashboardAccessor --structname MockMigrationDashboardAccessor --inpackage --filename migration_dashboard_accessor_mock.go --with-expecter +type MigrationDashboardAccessor interface { + // Migration helper methods - these support the separate LegacyMigrator + CountResources(ctx context.Context, opts MigrateOptions) (*resourcepb.BulkResponse, error) + MigrateDashboards(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) + MigrateFolders(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) + MigrateLibraryPanels(ctx context.Context, orgId int64, opts MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*BlobStoreInfo, error) +} diff --git a/pkg/registry/apis/dashboard/legacy_storage.go b/pkg/registry/apis/dashboard/legacy_storage.go index d51bea21bf9..1439574efe2 100644 --- a/pkg/registry/apis/dashboard/legacy_storage.go +++ b/pkg/registry/apis/dashboard/legacy_storage.go @@ -22,7 +22,7 @@ import ( ) type DashboardStorage struct { - Access legacy.DashboardAccess + Access legacy.DashboardAccessor DashboardService dashboards.DashboardService } diff --git a/pkg/registry/apis/dashboard/libary_panel.go b/pkg/registry/apis/dashboard/libary_panel.go index aa2b2705bb1..f170831da2b 100644 --- a/pkg/registry/apis/dashboard/libary_panel.go +++ b/pkg/registry/apis/dashboard/libary_panel.go @@ -30,7 +30,7 @@ var ( ) type LibraryPanelStore struct { - Access legacy.DashboardAccess + Access legacy.DashboardAccessor ResourceInfo utils.ResourceInfo service libraryelements.Service } diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index f784fc9c014..fb42543de33 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -169,7 +169,7 @@ func RegisterAPIService( publicDashboardService: publicDashboardService, legacy: &DashboardStorage{ - Access: legacy.NewDashboardAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features), + Access: legacy.NewDashboardSQLAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features), DashboardService: dashboardService, }, } diff --git a/pkg/registry/apis/dashboard/sub_dto.go b/pkg/registry/apis/dashboard/sub_dto.go index f2e9bc0b6df..04a14387fc7 100644 --- a/pkg/registry/apis/dashboard/sub_dto.go +++ b/pkg/registry/apis/dashboard/sub_dto.go @@ -30,7 +30,7 @@ type dtoBuilder = func(dashboard runtime.Object, access *dashboard.DashboardAcce // The DTO returns everything the UI needs in a single request type DTOConnector struct { getter rest.Getter - legacy legacy.DashboardAccess + legacy legacy.DashboardAccessor unified resource.ResourceClient largeObjects apistore.LargeObjectSupport accessControl accesscontrol.AccessControl @@ -42,7 +42,7 @@ type DTOConnector struct { func NewDTOConnector( getter rest.Getter, largeObjects apistore.LargeObjectSupport, - legacyAccess legacy.DashboardAccess, + legacyAccess legacy.DashboardAccessor, resourceClient resource.ResourceClient, accessControl accesscontrol.AccessControl, scheme *runtime.Scheme, diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go index b7b4bb45a4f..48add96ee16 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources.go @@ -14,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs/export" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature" + unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/parquet" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" @@ -29,7 +30,7 @@ type LegacyResourcesMigrator interface { type legacyResourcesMigrator struct { repositoryResources resources.RepositoryResourcesFactory parsers resources.ParserFactory - legacyMigrator legacy.LegacyMigrator + dashboardAccess legacy.MigrationDashboardAccessor signerFactory signature.SignerFactory clients resources.ClientFactory exportFn export.ExportFn @@ -38,7 +39,7 @@ type legacyResourcesMigrator struct { func NewLegacyResourcesMigrator( repositoryResources resources.RepositoryResourcesFactory, parsers resources.ParserFactory, - legacyMigrator legacy.LegacyMigrator, + dashboardAccess legacy.MigrationDashboardAccessor, signerFactory signature.SignerFactory, clients resources.ClientFactory, exportFn export.ExportFn, @@ -46,7 +47,7 @@ func NewLegacyResourcesMigrator( return &legacyResourcesMigrator{ repositoryResources: repositoryResources, parsers: parsers, - legacyMigrator: legacyMigrator, + dashboardAccess: dashboardAccess, signerFactory: signerFactory, clients: clients, exportFn: exportFn, @@ -94,7 +95,7 @@ func (m *legacyResourcesMigrator) Migrate(ctx context.Context, rw repository.Rea reader := newLegacyResourceMigrator( rw, - m.legacyMigrator, + m.dashboardAccess, parser, repositoryResources, progress, @@ -113,21 +114,21 @@ func (m *legacyResourcesMigrator) Migrate(ctx context.Context, rw repository.Rea } type legacyResourceResourceMigrator struct { - repo repository.ReaderWriter - legacy legacy.LegacyMigrator - parser resources.Parser - progress jobs.JobProgressRecorder - namespace string - kind schema.GroupResource - options provisioning.MigrateJobOptions - resources resources.RepositoryResources - signer signature.Signer - history map[string]string // UID >> file path + repo repository.ReaderWriter + dashboardAccess legacy.MigrationDashboardAccessor + parser resources.Parser + progress jobs.JobProgressRecorder + namespace string + kind schema.GroupResource + options provisioning.MigrateJobOptions + resources resources.RepositoryResources + signer signature.Signer + history map[string]string // UID >> file path } func newLegacyResourceMigrator( repo repository.ReaderWriter, - legacy legacy.LegacyMigrator, + dashboardAccess legacy.MigrationDashboardAccessor, parser resources.Parser, resources resources.RepositoryResources, progress jobs.JobProgressRecorder, @@ -141,16 +142,16 @@ func newLegacyResourceMigrator( history = make(map[string]string) } return &legacyResourceResourceMigrator{ - repo: repo, - legacy: legacy, - parser: parser, - progress: progress, - options: options, - namespace: namespace, - kind: kind, - resources: resources, - signer: signer, - history: history, + repo: repo, + dashboardAccess: dashboardAccess, + parser: parser, + progress: progress, + options: options, + namespace: namespace, + kind: kind, + resources: resources, + signer: signer, + history: history, } } @@ -225,14 +226,21 @@ func (r *legacyResourceResourceMigrator) Write(ctx context.Context, key *resourc func (r *legacyResourceResourceMigrator) Migrate(ctx context.Context) error { r.progress.SetMessage(ctx, fmt.Sprintf("migrate %s resource", r.kind.Resource)) + + // Create a parquet migrator with this instance as the BulkResourceWriter + parquetClient := parquet.NewBulkResourceWriterClient(r) + migrator := unifiedmigrations.ProvideUnifiedMigratorParquet( + r.dashboardAccess, + parquetClient, + ) + opts := legacy.MigrateOptions{ Namespace: r.namespace, WithHistory: r.options.History, Resources: []schema.GroupResource{r.kind}, - Store: parquet.NewBulkResourceWriterClient(r), OnlyCount: true, // first get the count } - stats, err := r.legacy.Migrate(ctx, opts) + stats, err := migrator.Migrate(ctx, opts) if err != nil { return fmt.Errorf("unable to count legacy items %w", err) } @@ -248,7 +256,7 @@ func (r *legacyResourceResourceMigrator) Migrate(ctx context.Context) error { } opts.OnlyCount = false // this time actually write - _, err = r.legacy.Migrate(ctx, opts) + _, err = migrator.Migrate(ctx, opts) if err != nil { return fmt.Errorf("migrate legacy %s: %w", r.kind.Resource, err) } diff --git a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go index 89077848f25..7f4318639c2 100644 --- a/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go +++ b/pkg/registry/apis/provisioning/jobs/migrate/legacy_resources_test.go @@ -93,8 +93,8 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) { mockRepoResourcesFactory.On("Client", mock.Anything, mock.Anything). Return(mockRepoResources, nil) - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" })).Return(&resourcepb.BulkResponse{}, errors.New("legacy migrator error")) @@ -115,7 +115,7 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) { migrator := NewLegacyResourcesMigrator( mockRepoResourcesFactory, mockParserFactory, - mockLegacyMigrator, + mockDashboardAccess, signerFactory, mockClientFactory, mockExportFn.Execute, @@ -136,7 +136,7 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) { mockParserFactory.AssertExpectations(t) mockRepoResourcesFactory.AssertExpectations(t) - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) progress.AssertExpectations(t) mockExportFn.AssertExpectations(t) mockClientFactory.AssertExpectations(t) @@ -308,22 +308,18 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) { History: true, }).Return(mockSigner, nil) - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + // Mock CountResources for the count phase + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + })).Return(&resourcepb.BulkResponse{}, nil).Once() + // Mock MigrateDashboards for the actual migration phase (dashboards resource) + mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return !opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{ - Summary: []*resourcepb.BulkResponse_Summary{ - { - Group: "test.grafana.app", - Resource: "tests", - Count: 10, - History: 5, - }, - }, - }, nil).Once() // Migration phase + }), mock.Anything).Return(&legacy.BlobStoreInfo{ + Count: 10, + Size: 5, + }, nil).Once() mockClients := resources.NewMockResourceClients(t) mockClientFactory := resources.NewMockClientFactory(t) @@ -339,7 +335,7 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) { migrator := NewLegacyResourcesMigrator( mockRepoResourcesFactory, mockParserFactory, - mockLegacyMigrator, + mockDashboardAccess, mockSignerFactory, mockClientFactory, mockExportFn.Execute, @@ -362,7 +358,7 @@ func TestLegacyResourcesMigrator_Migrate(t *testing.T) { mockParserFactory.AssertExpectations(t) mockRepoResourcesFactory.AssertExpectations(t) - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) mockClientFactory.AssertExpectations(t) mockExportFn.AssertExpectations(t) progress.AssertExpectations(t) @@ -742,8 +738,8 @@ func TestLegacyResourceResourceMigrator_Write(t *testing.T) { func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) { t.Run("should fail when legacy migrate count fails", func(t *testing.T) { - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" })).Return(&resourcepb.BulkResponse{}, errors.New("count error")) @@ -752,7 +748,7 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) { migrator := newLegacyResourceMigrator( nil, - mockLegacyMigrator, + mockDashboardAccess, nil, nil, progress, @@ -766,89 +762,91 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "unable to count legacy items") - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) progress.AssertExpectations(t) }) t.Run("should fail when legacy migrate write fails", func(t *testing.T) { - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" })).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + // For test-resources GroupResource, we don't know which method it will call, but since it's not dashboards/folders/librarypanels, + // the Migrate will fail trying to map the resource type. Let's make it dashboards for this test. + mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return !opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, errors.New("write error")).Once() // Write phase + }), mock.Anything).Return(nil, errors.New("write error")).Once() // Write phase progress := jobs.NewMockJobProgressRecorder(t) progress.On("SetMessage", mock.Anything, mock.Anything).Return() migrator := newLegacyResourceMigrator( nil, - mockLegacyMigrator, + mockDashboardAccess, nil, nil, progress, provisioning.MigrateJobOptions{}, "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "test-resources"}, + schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, signature.NewGrafanaSigner(), ) err := migrator.Migrate(context.Background()) require.Error(t, err) - require.Contains(t, err.Error(), "migrate legacy test-resources: write error") + require.Contains(t, err.Error(), "migrate legacy dashboards: write error") - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) progress.AssertExpectations(t) }) t.Run("should successfully migrate resource", func(t *testing.T) { - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" })).Return(&resourcepb.BulkResponse{}, nil).Once() // Count phase - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return !opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() // Write phase + }), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase progress := jobs.NewMockJobProgressRecorder(t) progress.On("SetMessage", mock.Anything, mock.Anything).Return() migrator := newLegacyResourceMigrator( nil, - mockLegacyMigrator, + mockDashboardAccess, nil, nil, progress, provisioning.MigrateJobOptions{}, "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, + schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, signature.NewGrafanaSigner(), ) err := migrator.Migrate(context.Background()) require.NoError(t, err) - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) progress.AssertExpectations(t) }) t.Run("should set total to history if history is greater than count", func(t *testing.T) { - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" })).Return(&resourcepb.BulkResponse{ Summary: []*resourcepb.BulkResponse_Summary{ { - Group: "test.grafana.app", - Resource: "tests", + Group: "dashboard.grafana.app", + Resource: "dashboards", Count: 1, History: 100, }, }, }, nil).Once() // Count phase - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return !opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() // Write phase + }), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase progress := jobs.NewMockJobProgressRecorder(t) progress.On("SetMessage", mock.Anything, mock.Anything).Return() @@ -856,39 +854,39 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) { migrator := newLegacyResourceMigrator( nil, - mockLegacyMigrator, + mockDashboardAccess, nil, nil, progress, provisioning.MigrateJobOptions{}, "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, + schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, signature.NewGrafanaSigner(), ) err := migrator.Migrate(context.Background()) require.NoError(t, err) - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) progress.AssertExpectations(t) }) t.Run("should set total to count if history is less than count", func(t *testing.T) { - mockLegacyMigrator := legacy.NewMockLegacyMigrator(t) - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess := legacy.NewMockMigrationDashboardAccessor(t) + mockDashboardAccess.On("CountResources", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return opts.OnlyCount && opts.Namespace == "test-namespace" })).Return(&resourcepb.BulkResponse{ Summary: []*resourcepb.BulkResponse_Summary{ { - Group: "test.grafana.app", - Resource: "tests", + Group: "dashboard.grafana.app", + Resource: "dashboards", Count: 200, History: 1, }, }, }, nil).Once() // Count phase - mockLegacyMigrator.On("Migrate", mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { + mockDashboardAccess.On("MigrateDashboards", mock.Anything, mock.Anything, mock.MatchedBy(func(opts legacy.MigrateOptions) bool { return !opts.OnlyCount && opts.Namespace == "test-namespace" - })).Return(&resourcepb.BulkResponse{}, nil).Once() // Write phase + }), mock.Anything).Return(&legacy.BlobStoreInfo{}, nil).Once() // Write phase progress := jobs.NewMockJobProgressRecorder(t) progress.On("SetMessage", mock.Anything, mock.Anything).Return() @@ -897,20 +895,20 @@ func TestLegacyResourceResourceMigrator_Migrate(t *testing.T) { migrator := newLegacyResourceMigrator( nil, - mockLegacyMigrator, + mockDashboardAccess, nil, nil, progress, provisioning.MigrateJobOptions{}, "test-namespace", - schema.GroupResource{Group: "test.grafana.app", Resource: "tests"}, + schema.GroupResource{Group: "dashboard.grafana.app", Resource: "dashboards"}, signer, ) err := migrator.Migrate(context.Background()) require.NoError(t, err) - mockLegacyMigrator.AssertExpectations(t) + mockDashboardAccess.AssertExpectations(t) progress.AssertExpectations(t) }) } diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index c30b43eeb60..d18fc1156a8 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -60,6 +60,7 @@ import ( "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" ) @@ -109,7 +110,7 @@ type APIBuilder struct { jobHistoryConfig *JobHistoryConfig jobHistoryLoki *jobs.LokiJobHistory resourceLister resources.ResourceLister - legacyMigrator legacy.LegacyMigrator + dashboardAccess legacy.MigrationDashboardAccessor storageStatus dualwrite.Service unified resource.ResourceClient repoFactory repository.Factory @@ -135,7 +136,7 @@ func NewAPIBuilder( features featuremgmt.FeatureToggles, unified resource.ResourceClient, configProvider apiserver.RestConfigProvider, - legacyMigrator legacy.LegacyMigrator, + dashboardAccess legacy.MigrationDashboardAccessor, storageStatus dualwrite.Service, usageStats usagestats.Service, access authlib.AccessChecker, @@ -158,6 +159,7 @@ func NewAPIBuilder( clients = resources.NewClientFactory(configProvider) } parsers := resources.NewParserFactory(clients) + legacyMigrator := migrations.ProvideUnifiedMigrator(dashboardAccess, unified) resourceLister := resources.NewResourceListerForMigrations(unified, legacyMigrator, storageStatus) b := &APIBuilder{ @@ -170,7 +172,7 @@ func NewAPIBuilder( parsers: parsers, repositoryResources: resources.NewRepositoryResourcesFactory(parsers, clients, resourceLister), resourceLister: resourceLister, - legacyMigrator: legacyMigrator, + dashboardAccess: dashboardAccess, storageStatus: storageStatus, unified: unified, access: access, @@ -234,7 +236,7 @@ func RegisterAPIService( client resource.ResourceClient, // implements resource.RepositoryClient configProvider apiserver.RestConfigProvider, access authlib.AccessClient, - legacyMigrator legacy.LegacyMigrator, + dashboardAccess legacy.MigrationDashboardAccessor, storageStatus dualwrite.Service, usageStats usagestats.Service, tracer tracing.Tracer, @@ -258,7 +260,7 @@ func RegisterAPIService( features, client, configProvider, - legacyMigrator, storageStatus, + dashboardAccess, storageStatus, usageStats, access, tracer, @@ -722,7 +724,7 @@ func (b *APIBuilder) GetPostStartHooks() (map[string]genericapiserver.PostStartH legacyResources := migrate.NewLegacyResourcesMigrator( b.repositoryResources, b.parsers, - b.legacyMigrator, + b.dashboardAccess, signerFactory, b.clients, export.ExportAll, @@ -1241,8 +1243,9 @@ func (b *APIBuilder) tryRunningOnlyUnifiedStorage() error { return nil } - // Count how many things exist - rsp, err := b.legacyMigrator.Migrate(ctx, legacy.MigrateOptions{ + // Count how many things exist - create a migrator on-demand for this + legacyMigrator := migrations.ProvideUnifiedMigrator(b.dashboardAccess, b.unified) + rsp, err := legacyMigrator.Migrate(ctx, legacy.MigrateOptions{ Namespace: "default", // FIXME! this works for single org, but need to check multi-org Resources: []schema.GroupResource{{ Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE, diff --git a/pkg/registry/apis/provisioning/resources/object.go b/pkg/registry/apis/provisioning/resources/object.go index 4d08de5c05c..0a016786410 100644 --- a/pkg/registry/apis/provisioning/resources/object.go +++ b/pkg/registry/apis/provisioning/resources/object.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" + "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" ) @@ -30,9 +31,9 @@ type ResourceStore interface { } type ResourceListerFromSearch struct { - store ResourceStore - legacyMigrator legacy.LegacyMigrator - storageStatus dualwrite.Service + store ResourceStore + migrator migrations.UnifiedMigrator + storageStatus dualwrite.Service } func NewResourceLister(store ResourceStore) ResourceLister { @@ -42,13 +43,13 @@ func NewResourceLister(store ResourceStore) ResourceLister { // FIXME: the logic about migration and storage should probably be separated from this func NewResourceListerForMigrations( store ResourceStore, - legacyMigrator legacy.LegacyMigrator, + migrator migrations.UnifiedMigrator, storageStatus dualwrite.Service, ) ResourceLister { return &ResourceListerFromSearch{ - store: store, - legacyMigrator: legacyMigrator, - storageStatus: storageStatus, + store: store, + migrator: migrator, + storageStatus: storageStatus, } } @@ -133,8 +134,8 @@ func (o *ResourceListerFromSearch) Stats(ctx context.Context, namespace, reposit } // Get the stats based on what a migration could support - if o.storageStatus != nil && o.legacyMigrator != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, o.storageStatus) { - rsp, err := o.legacyMigrator.Migrate(ctx, legacy.MigrateOptions{ + if o.storageStatus != nil && o.migrator != nil && dualwrite.IsReadingLegacyDashboardsAndFolders(ctx, o.storageStatus) { + rsp, err := o.migrator.Migrate(ctx, legacy.MigrateOptions{ Namespace: namespace, Resources: []schema.GroupResource{{ Group: dashboard.GROUP, Resource: dashboard.DASHBOARD_RESOURCE, diff --git a/pkg/registry/backgroundsvcs/background_services.go b/pkg/registry/backgroundsvcs/background_services.go index 5f997875985..e80bcb720c9 100644 --- a/pkg/registry/backgroundsvcs/background_services.go +++ b/pkg/registry/backgroundsvcs/background_services.go @@ -48,7 +48,6 @@ import ( "github.com/grafana/grafana/pkg/services/supportbundles/supportbundlesimpl" "github.com/grafana/grafana/pkg/services/team/teamapi" "github.com/grafana/grafana/pkg/services/updatemanager" - unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations" ) func ProvideBackgroundServiceRegistry( @@ -74,7 +73,6 @@ func ProvideBackgroundServiceRegistry( dashboardServiceImpl *service.DashboardServiceImpl, secretsGarbageCollectionWorker *secretsgarbagecollectionworker.Worker, fixedRolesLoader *accesscontrol.FixedRolesLoader, - unifiedStorageMigrationProvider unifiedmigrations.UnifiedStorageMigrationProvider, // Need to make sure these are initialized, is there a better place to put them? _ dashboardsnapshots.Service, _ serviceaccounts.Service, @@ -91,7 +89,6 @@ func ProvideBackgroundServiceRegistry( notifications, rendering, tokenService, - unifiedStorageMigrationProvider, provisioning, grafanaUpdateChecker, pluginsUpdateChecker, diff --git a/pkg/server/wire.go b/pkg/server/wire.go index 2fe6db16b31..0d4bb10c0b5 100644 --- a/pkg/server/wire.go +++ b/pkg/server/wire.go @@ -128,6 +128,7 @@ import ( "github.com/grafana/grafana/pkg/services/pluginsintegration/pluginaccesscontrol" "github.com/grafana/grafana/pkg/services/preference/prefimpl" promTypeMigration "github.com/grafana/grafana/pkg/services/promtypemigration" + "github.com/grafana/grafana/pkg/services/provisioning" "github.com/grafana/grafana/pkg/services/publicdashboards" publicdashboardsApi "github.com/grafana/grafana/pkg/services/publicdashboards/api" publicdashboardsStore "github.com/grafana/grafana/pkg/services/publicdashboards/database" @@ -238,7 +239,9 @@ var wireBasicSet = wire.NewSet( uss.ProvideService, wire.Bind(new(usagestats.Service), new(*uss.UsageStats)), validator.ProvideService, - legacy.ProvideLegacyMigrator, + provisioning.ProvideStubProvisioningService, + legacy.ProvideMigratorDashboardAccessor, + unifiedmigrations.ProvideUnifiedMigrator, pluginsintegration.WireSet, pluginDashboards.ProvideFileStoreManager, wire.Bind(new(pluginDashboards.FileStore), new(*pluginDashboards.FileStoreManager)), @@ -466,8 +469,7 @@ var wireBasicSet = wire.NewSet( // Unified storage resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, - unifiedmigrations.ProvideUnifiedStorageMigrationProvider, - wire.Bind(new(unifiedmigrations.UnifiedStorageMigrationProvider), new(*unifiedmigrations.UnifiedStorageMigrationProviderImpl)), + unifiedmigrations.ProvideUnifiedStorageMigrationService, // Kubernetes API server grafanaapiserver.WireSet, apiregistry.WireSet, diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 91a1e50a224..0cb27791c19 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -219,7 +219,7 @@ import ( "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/services/secrets/database" kvstore2 "github.com/grafana/grafana/pkg/services/secrets/kvstore" - migrations2 "github.com/grafana/grafana/pkg/services/secrets/kvstore/migrations" + migrations3 "github.com/grafana/grafana/pkg/services/secrets/kvstore/migrations" "github.com/grafana/grafana/pkg/services/secrets/manager" migrator2 "github.com/grafana/grafana/pkg/services/secrets/migrator" "github.com/grafana/grafana/pkg/services/serviceaccounts" @@ -255,13 +255,14 @@ import ( "github.com/grafana/grafana/pkg/services/user/userimpl" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" database4 "github.com/grafana/grafana/pkg/storage/secret/database" "github.com/grafana/grafana/pkg/storage/secret/encryption" "github.com/grafana/grafana/pkg/storage/secret/metadata" "github.com/grafana/grafana/pkg/storage/secret/migrator" "github.com/grafana/grafana/pkg/storage/unified" - migrations3 "github.com/grafana/grafana/pkg/storage/unified/migrations" + migrations2 "github.com/grafana/grafana/pkg/storage/unified/migrations" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/search" "github.com/grafana/grafana/pkg/storage/unified/sql" @@ -526,7 +527,15 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg) + legacyDatabaseProvider := legacysql.NewDatabaseProvider(sqlStore) + stubProvisioningService, err := provisioning.ProvideStubProvisioningService(cfg) + if err != nil { + return nil, err + } + migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles) + unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient) + unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore) + dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService) if err != nil { return nil, err } @@ -716,8 +725,8 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api csrfCSRF := csrf.ProvideCSRFFilter(cfg) playlistService := playlistimpl.ProvideService(sqlStore, tracingService) secretsMigrator := migrator2.ProvideSecretsMigrator(serviceService, secretsService, sqlStore, ossImpl, featureToggles) - dataSourceSecretMigrationService := migrations2.ProvideDataSourceMigrationService(service15, kvStore, featureToggles) - secretMigrationProviderImpl := migrations2.ProvideSecretMigrationProvider(serverLockService, dataSourceSecretMigrationService) + dataSourceSecretMigrationService := migrations3.ProvideDataSourceMigrationService(service15, kvStore, featureToggles) + secretMigrationProviderImpl := migrations3.ProvideSecretMigrationProvider(serverLockService, dataSourceSecretMigrationService) publicDashboardServiceImpl := service3.ProvideService(cfg, featureToggles, publicDashboardStoreImpl, queryServiceImpl, repositoryImpl, accessControl, publicDashboardServiceWrapperImpl, dashboardService, ossLicensingService) middleware := api2.ProvideMiddleware() apiApi := api2.ProvideApi(publicDashboardServiceImpl, routeRegisterImpl, accessControl, featureToggles, middleware, cfg, ossLicensingService) @@ -838,8 +847,6 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api dashboardUpdater := service8.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) worker := garbagecollectionworker.ProvideWorker(cfg, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService) fixedRolesLoader := accesscontrol.ProvideFixedRolesLoader(acimplService, featureToggles) - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) - unifiedStorageMigrationProviderImpl := migrations3.ProvideUnifiedStorageMigrationProvider(legacyMigrator, cfg, resourceClient, sqlStore) healthService, err := grpcserver.ProvideHealthService(cfg, grpcserverProvider) if err != nil { return nil, err @@ -892,7 +899,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, legacyMigrator, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) if err != nil { return nil, err } @@ -920,7 +927,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api } ossUserProtectionImpl := authinfoimpl.ProvideOSSUserProtectionService() registration := authnimpl.ProvideRegistration(cfg, authnService, orgService, userAuthTokenService, acimplService, permissionRegistry, apikeyService, userService, authService, ossUserProtectionImpl, loginattemptimplService, quotaService, authinfoimplService, renderingService, featureToggles, oauthtokenService, socialService, remoteCache, ldapImpl, ossImpl, tracingService, tempuserService, notificationService) - backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, unifiedStorageMigrationProviderImpl, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) + backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) usageStatsProvidersRegistry := usagestatssvcs.ProvideUsageStatsProvidersRegistry(acimplService, userService) server, err := New(opts, cfg, httpServer, acimplService, provisioningServiceImpl, backgroundServiceRegistry, usageStatsProvidersRegistry, statscollectorService, tracingService, featureToggles, registerer) if err != nil { @@ -1167,7 +1174,15 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg) + legacyDatabaseProvider := legacysql.NewDatabaseProvider(sqlStore) + stubProvisioningService, err := provisioning.ProvideStubProvisioningService(cfg) + if err != nil { + return nil, err + } + migrationDashboardAccessor := legacy.ProvideMigratorDashboardAccessor(legacyDatabaseProvider, stubProvisioningService, accessControl, featureToggles) + unifiedMigrator := migrations2.ProvideUnifiedMigrator(migrationDashboardAccessor, resourceClient) + unifiedStorageMigrationService := migrations2.ProvideUnifiedStorageMigrationService(unifiedMigrator, cfg, sqlStore, kvStore) + dualwriteService, err := dualwrite.ProvideService(featureToggles, kvStore, cfg, unifiedStorageMigrationService) if err != nil { return nil, err } @@ -1359,8 +1374,8 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac csrfCSRF := csrf.ProvideCSRFFilter(cfg) playlistService := playlistimpl.ProvideService(sqlStore, tracingService) secretsMigrator := migrator2.ProvideSecretsMigrator(serviceService, secretsService, sqlStore, ossImpl, featureToggles) - dataSourceSecretMigrationService := migrations2.ProvideDataSourceMigrationService(service15, kvStore, featureToggles) - secretMigrationProviderImpl := migrations2.ProvideSecretMigrationProvider(serverLockService, dataSourceSecretMigrationService) + dataSourceSecretMigrationService := migrations3.ProvideDataSourceMigrationService(service15, kvStore, featureToggles) + secretMigrationProviderImpl := migrations3.ProvideSecretMigrationProvider(serverLockService, dataSourceSecretMigrationService) publicDashboardServiceImpl := service3.ProvideService(cfg, featureToggles, publicDashboardStoreImpl, queryServiceImpl, repositoryImpl, accessControl, publicDashboardServiceWrapperImpl, dashboardService, ossLicensingService) middleware := api2.ProvideMiddleware() apiApi := api2.ProvideApi(publicDashboardServiceImpl, routeRegisterImpl, accessControl, featureToggles, middleware, cfg, ossLicensingService) @@ -1481,8 +1496,6 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac dashboardUpdater := service8.ProvideDashboardUpdater(inProcBus, pluginstoreService, service14, importDashboardService, service13, pluginService, dashboardService) worker := garbagecollectionworker.ProvideWorker(cfg, secureValueMetadataStorage, keeperMetadataStorage, ossKeeperService) fixedRolesLoader := accesscontrol.ProvideFixedRolesLoader(acimplService, featureToggles) - legacyMigrator := legacy.ProvideLegacyMigrator(sqlStore, provisioningServiceImpl, libraryPanelService, dashboardPermissionsService, accessControl, featureToggles) - unifiedStorageMigrationProviderImpl := migrations3.ProvideUnifiedStorageMigrationProvider(legacyMigrator, cfg, resourceClient, sqlStore) healthService, err := grpcserver.ProvideHealthService(cfg, grpcserverProvider) if err != nil { return nil, err @@ -1535,7 +1548,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, legacyMigrator, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) + provisioningAPIBuilder, err := provisioning2.RegisterAPIService(cfg, featureToggles, apiserverService, registerer, resourceClient, eventualRestConfigProvider, accessClient, migrationDashboardAccessor, dualwriteService, usageStats, tracingService, v3, v4, repositoryFactory) if err != nil { return nil, err } @@ -1563,7 +1576,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac } ossUserProtectionImpl := authinfoimpl.ProvideOSSUserProtectionService() registration := authnimpl.ProvideRegistration(cfg, authnService, orgService, userAuthTokenService, acimplService, permissionRegistry, apikeyService, userService, authService, ossUserProtectionImpl, loginattemptimplService, quotaService, authinfoimplService, renderingService, featureToggles, oauthtokentestService, socialService, remoteCache, ldapImpl, ossImpl, tracingService, tempuserService, notificationServiceMock) - backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, unifiedStorageMigrationProviderImpl, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) + backgroundServiceRegistry := backgroundsvcs.ProvideBackgroundServiceRegistry(httpServer, alertNG, cleanUpService, grafanaLive, gateway, notificationService, pluginstoreService, renderingService, userAuthTokenService, tracingService, provisioningServiceImpl, usageStats, statscollectorService, grafanaService, pluginsService, internalMetricsService, secretsService, remoteCache, storageService, searchService, entityEventsService, serviceAccountsService, grpcserverProvider, secretMigrationProviderImpl, loginattemptimplService, supportbundlesimplService, metricService, keyRetriever, angulardetectorsproviderDynamic, apiserverService, anonDeviceService, ssosettingsimplService, pluginexternalService, plugininstallerService, zanzanaReconciler, appregistryService, dashboardUpdater, dashboardServiceImpl, worker, fixedRolesLoader, serviceImpl, serviceAccountsProxy, healthService, reflectionService, apiService, apiregistryService, idimplService, teamAPI, ssosettingsimplService, cloudmigrationService, registration) usageStatsProvidersRegistry := usagestatssvcs.ProvideUsageStatsProvidersRegistry(acimplService, userService) server, err := New(opts, cfg, httpServer, acimplService, provisioningServiceImpl, backgroundServiceRegistry, usageStatsProvidersRegistry, statscollectorService, tracingService, featureToggles, registerer) if err != nil { @@ -1759,7 +1772,7 @@ var withOTelSet = wire.NewSet( otelTracer, grpcserver.ProvideService, interceptors.ProvideAuthenticator, ) -var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, legacy.ProvideLegacyMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations2.ProvideDataSourceMigrationService, migrations2.ProvideSecretMigrationProvider, wire.Bind(new(migrations2.SecretMigrationProvider), new(*migrations2.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations3.ProvideUnifiedStorageMigrationProvider, wire.Bind(new(migrations3.UnifiedStorageMigrationProvider), new(*migrations3.UnifiedStorageMigrationProviderImpl)), apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) +var wireBasicSet = wire.NewSet(annotationsimpl.ProvideService, wire.Bind(new(annotations.Repository), new(*annotationsimpl.RepositoryImpl)), New, api.ProvideHTTPServer, query.ProvideService, wire.Bind(new(query.Service), new(*query.ServiceImpl)), bus.ProvideBus, wire.Bind(new(bus.Bus), new(*bus.InProcBus)), rendering.ProvideService, wire.Bind(new(rendering.Service), new(*rendering.RenderingService)), routing.ProvideRegister, wire.Bind(new(routing.RouteRegister), new(*routing.RouteRegisterImpl)), hooks.ProvideService, kvstore.ProvideService, localcache.ProvideService, bundleregistry.ProvideService, wire.Bind(new(supportbundles.Service), new(*bundleregistry.Service)), updatemanager.ProvideGrafanaService, updatemanager.ProvidePluginsService, service.ProvideService, wire.Bind(new(usagestats.Service), new(*service.UsageStats)), validator3.ProvideService, provisioning.ProvideStubProvisioningService, legacy.ProvideMigratorDashboardAccessor, migrations2.ProvideUnifiedMigrator, pluginsintegration.WireSet, dashboards.ProvideFileStoreManager, wire.Bind(new(dashboards.FileStore), new(*dashboards.FileStoreManager)), cloudwatch.ProvideService, cloudmonitoring.ProvideService, azuremonitor.ProvideService, postgres.ProvideService, mysql.ProvideService, mssql.ProvideService, store.ProvideEntityEventsService, dualwrite.ProvideService, httpclientprovider.New, wire.Bind(new(httpclient.Provider), new(*httpclient2.Provider)), serverlock.ProvideService, wire.Bind(new(installsync.ServerLock), new(*serverlock.ServerLockService)), annotationsimpl.ProvideCleanupService, wire.Bind(new(annotations.Cleaner), new(*annotationsimpl.CleanupServiceImpl)), cleanup.ProvideService, shorturlimpl.ProvideService, wire.Bind(new(shorturls.Service), new(*shorturlimpl.ShortURLService)), queryhistory.ProvideService, wire.Bind(new(queryhistory.Service), new(*queryhistory.QueryHistoryService)), correlations.ProvideService, wire.Bind(new(correlations.Service), new(*correlations.CorrelationsService)), quotaimpl.ProvideService, remotecache.ProvideService, wire.Bind(new(remotecache.CacheStorage), new(*remotecache.RemoteCache)), authinfoimpl.ProvideService, wire.Bind(new(login.AuthInfoService), new(*authinfoimpl.Service)), authinfoimpl.ProvideStore, datasourceproxy.ProvideService, sort.ProvideService, search2.ProvideService, searchV2.ProvideService, searchV2.ProvideSearchHTTPService, store.ProvideService, store.ProvideSystemUsersService, live.ProvideService, pushhttp.ProvideService, contexthandler.ProvideService, service12.ProvideService, wire.Bind(new(service12.LDAP), new(*service12.LDAPImpl)), jwt.ProvideService, wire.Bind(new(jwt.JWTService), new(*jwt.AuthService)), store2.ProvideDBStore, image.ProvideDeleteExpiredService, ngalert.ProvideService, librarypanels.ProvideService, wire.Bind(new(librarypanels.Service), new(*librarypanels.LibraryPanelService)), libraryelements.ProvideService, wire.Bind(new(libraryelements.Service), new(*libraryelements.LibraryElementService)), notifications.ProvideService, notifications.ProvideSmtpService, github.ProvideFactory, tracing.ProvideService, tracing.ProvideTracingConfig, wire.Bind(new(tracing.Tracer), new(*tracing.TracingService)), withOTelSet, testdatasource.ProvideService, api4.ProvideService, opentsdb.ProvideService, socialimpl.ProvideService, influxdb.ProvideService, wire.Bind(new(social.Service), new(*socialimpl.SocialService)), tempo.ProvideService, loki.ProvideService, graphite.ProvideService, prometheus.ProvideService, elasticsearch.ProvideService, pyroscope.ProvideService, parca.ProvideService, zipkin.ProvideService, jaeger.ProvideService, service9.ProvideCacheService, wire.Bind(new(datasources.CacheService), new(*service9.CacheServiceImpl)), service2.ProvideEncryptionService, wire.Bind(new(encryption2.Internal), new(*service2.Service)), manager.ProvideSecretsService, wire.Bind(new(secrets.Service), new(*manager.SecretsService)), database.ProvideSecretsStore, wire.Bind(new(secrets.Store), new(*database.SecretsStoreImpl)), garbagecollectionworker.ProvideWorker, grafanads.ProvideService, wire.Bind(new(dashboardsnapshots.Store), new(*database5.DashboardSnapshotStore)), database5.ProvideStore, wire.Bind(new(dashboardsnapshots.Service), new(*service10.ServiceImpl)), service10.ProvideService, service9.ProvideService, wire.Bind(new(datasources.DataSourceService), new(*service9.Service)), service9.ProvideLegacyDataSourceLookup, retriever.ProvideService, wire.Bind(new(serviceaccounts.ServiceAccountRetriever), new(*retriever.Service)), ossaccesscontrol.ProvideServiceAccountPermissions, wire.Bind(new(accesscontrol.ServiceAccountPermissionsService), new(*ossaccesscontrol.ServiceAccountPermissionsService)), manager3.ProvideServiceAccountsService, proxy.ProvideServiceAccountsProxy, wire.Bind(new(serviceaccounts.Service), new(*proxy.ServiceAccountsProxy)), dsquerierclient.NewNullQSDatasourceClientBuilder, expr.ProvideService, featuremgmt.ProvideManagerService, featuremgmt.ProvideToggles, service7.ProvideDashboardServiceImpl, wire.Bind(new(dashboards2.PermissionsRegistrationService), new(*service7.DashboardServiceImpl)), service7.ProvideDashboardService, service7.ProvideDashboardProvisioningService, service7.ProvideDashboardPluginService, database2.ProvideDashboardStore, folderimpl.ProvideService, wire.Bind(new(folder.Service), new(*folderimpl.Service)), wire.Bind(new(folder.LegacyService), new(*folderimpl.Service)), folderimpl.ProvideStore, wire.Bind(new(folder.Store), new(*folderimpl.FolderStoreImpl)), service11.ProvideService, wire.Bind(new(dashboardimport.Service), new(*service11.ImportDashboardService)), service8.ProvideService, wire.Bind(new(plugindashboards.Service), new(*service8.Service)), service8.ProvideDashboardUpdater, kvstore2.ProvideService, avatar.ProvideAvatarCacheServer, statscollector.ProvideService, csrf.ProvideCSRFFilter, wire.Bind(new(csrf.Service), new(*csrf.CSRF)), ossaccesscontrol.ProvideTeamPermissions, wire.Bind(new(accesscontrol.TeamPermissionsService), new(*ossaccesscontrol.TeamPermissionsService)), ossaccesscontrol.ProvideFolderPermissions, wire.Bind(new(accesscontrol.FolderPermissionsService), new(*ossaccesscontrol.FolderPermissionsService)), ossaccesscontrol.ProvideDashboardPermissions, wire.Bind(new(accesscontrol.DashboardPermissionsService), new(*ossaccesscontrol.DashboardPermissionsService)), ossaccesscontrol.ProvideReceiverPermissionsService, wire.Bind(new(accesscontrol.ReceiverPermissionsService), new(*ossaccesscontrol.ReceiverPermissionsService)), starimpl.ProvideService, playlistimpl.ProvideService, apikeyimpl.ProvideService, dashverimpl.ProvideService, service3.ProvideService, wire.Bind(new(publicdashboards.Service), new(*service3.PublicDashboardServiceImpl)), database3.ProvideStore, wire.Bind(new(publicdashboards.Store), new(*database3.PublicDashboardStoreImpl)), metric.ProvideService, api2.ProvideApi, api3.ProvideApi, userimpl.ProvideService, orgimpl.ProvideService, orgimpl.ProvideDeletionService, statsimpl.ProvideService, grpccontext.ProvideContextHandler, grpcserver.ProvideHealthService, grpcserver.ProvideReflectionService, resolver.ProvideEntityReferenceResolver, teamimpl.ProvideService, teamapi.ProvideTeamAPI, tempuserimpl.ProvideService, loginattemptimpl.ProvideService, wire.Bind(new(loginattempt.Service), new(*loginattemptimpl.Service)), migrations3.ProvideDataSourceMigrationService, migrations3.ProvideSecretMigrationProvider, wire.Bind(new(migrations3.SecretMigrationProvider), new(*migrations3.SecretMigrationProviderImpl)), promtypemigration.ProvideAzurePromMigrationService, promtypemigration.ProvideAmazonPromMigrationService, promtypemigration.ProvidePromTypeMigrationProvider, wire.Bind(new(promtypemigration.PromTypeMigrationProvider), new(*promtypemigration.PromTypeMigrationProviderImpl)), resourcepermissions.NewActionSetService, wire.Bind(new(accesscontrol.ActionResolver), new(resourcepermissions.ActionSetService)), wire.Bind(new(pluginaccesscontrol.ActionSetRegistry), new(resourcepermissions.ActionSetService)), permreg.ProvidePermissionRegistry, acimpl.ProvideAccessControl, accesscontrol.ProvideFixedRolesLoader, dualwrite2.ProvideZanzanaReconciler, navtreeimpl.ProvideService, wire.Bind(new(accesscontrol.AccessControl), new(*acimpl.AccessControl)), wire.Bind(new(notifications.TempUserStore), new(tempuser.Service)), tagimpl.ProvideService, wire.Bind(new(tag.Service), new(*tagimpl.Service)), authnimpl.ProvideService, authnimpl.ProvideIdentitySynchronizer, authnimpl.ProvideAuthnService, authnimpl.ProvideAuthnServiceAuthenticateOnly, authnimpl.ProvideRegistration, supportbundlesimpl.ProvideService, extsvcaccounts.ProvideExtSvcAccountsService, wire.Bind(new(serviceaccounts.ExtSvcAccountsService), new(*extsvcaccounts.ExtSvcAccountsService)), registry2.ProvideExtSvcRegistry, wire.Bind(new(extsvcauth.ExternalServiceRegistry), new(*registry2.Registry)), anonstore.ProvideAnonDBStore, wire.Bind(new(anonstore.AnonStore), new(*anonstore.AnonDBStore)), loggermw.Provide, slogadapter.Provide, signingkeysimpl.ProvideEmbeddedSigningKeysService, wire.Bind(new(signingkeys.Service), new(*signingkeysimpl.Service)), ssosettingsimpl.ProvideService, wire.Bind(new(ssosettings.Service), new(*ssosettingsimpl.Service)), idimpl.ProvideService, wire.Bind(new(auth.IDService), new(*idimpl.Service)), cloudmigrationimpl.ProvideService, caching.ProvideCachingServiceClient, userimpl.ProvideVerifier, connectors.ProvideOrgRoleMapper, wire.Bind(new(user.Verifier), new(*userimpl.Verifier)), authz.WireSet, metadata.ProvideSecureValueMetadataStorage, metadata.ProvideKeeperMetadataStorage, metadata.ProvideDecryptStorage, decrypt.ProvideDecryptAuthorizer, wire.Value([]decrypt.ExtraOwnerDecrypter(nil)), decrypt.ProvideDecryptService, inline.ProvideInlineSecureValueService, encryption.ProvideDataKeyStorage, encryption.ProvideGlobalDataKeyStorage, encryption.ProvideEncryptedValueStorage, encryption.ProvideGlobalEncryptedValueStorage, encryption.ProvideEncryptedValueMigrationExecutor, service5.ProvideSecureValueService, validator.ProvideKeeperValidator, validator.ProvideSecureValueValidator, mutator.ProvideKeeperMutator, mutator.ProvideSecureValueMutator, migrator.NewWithEngine, database4.ProvideDatabase, clock.ProvideClock, wire.Bind(new(contracts.Database), new(*database4.Database)), wire.Bind(new(contracts.Clock), new(*clock.Clock)), manager2.ProvideEncryptionManager, service4.ProvideAESGCMCipherService, resource.ProvideStorageMetrics, resource.ProvideIndexMetrics, migrations2.ProvideUnifiedStorageMigrationService, apiserver.WireSet, apiregistry.WireSet, appregistry.WireSet, client.ProvideK8sClientWithFallback) var wireSet = wire.NewSet( wireBasicSet, metrics.WireSet, sqlstore.ProvideService, metrics2.ProvideService, wire.Bind(new(notifications.Service), new(*notifications.NotificationService)), wire.Bind(new(notifications.WebhookSender), new(*notifications.NotificationService)), wire.Bind(new(notifications.EmailSender), new(*notifications.NotificationService)), wire.Bind(new(db.DB), new(*sqlstore.SQLStore)), prefimpl.ProvideService, oauthtoken.ProvideService, wire.Bind(new(oauthtoken.OAuthTokenService), new(*oauthtoken.Service)), wire.Bind(new(cleanup.AlertRuleService), new(*store2.DBstore)), diff --git a/pkg/server/wireexts_oss.go b/pkg/server/wireexts_oss.go index b2b134ee4a9..9f069f807fc 100644 --- a/pkg/server/wireexts_oss.go +++ b/pkg/server/wireexts_oss.go @@ -62,6 +62,7 @@ import ( "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/services/validations" "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/unified" "github.com/grafana/grafana/pkg/storage/unified/resource" search2 "github.com/grafana/grafana/pkg/storage/unified/search" @@ -98,6 +99,7 @@ var wireExtsBasicSet = wire.NewSet( wire.Bind(new(validations.DataSourceRequestURLValidator), new(*validations.OSSDataSourceRequestURLValidator)), provisioning.ProvideService, wire.Bind(new(provisioning.ProvisioningService), new(*provisioning.ProvisioningServiceImpl)), + legacysql.NewDatabaseProvider, backgroundsvcs.ProvideBackgroundServiceRegistry, wire.Bind(new(registry.BackgroundServiceRegistry), new(*backgroundsvcs.BackgroundServiceRegistry)), migrations.ProvideOSSMigrations, diff --git a/pkg/services/apiserver/appinstaller/storage.go b/pkg/services/apiserver/appinstaller/storage.go index 3ecc34cce58..98989508858 100644 --- a/pkg/services/apiserver/appinstaller/storage.go +++ b/pkg/services/apiserver/appinstaller/storage.go @@ -107,7 +107,7 @@ func NewDualWriter( if currentMode != mode { klog.Warningf("Requested DualWrite mode: %d, but using %d for %+v", mode, currentMode, gr) } - return dualwrite.NewDualWriter(gr, currentMode, legacy, storage) + return dualwrite.NewStaticStorage(gr, currentMode, legacy, storage) } func getRequestInfo(gr schema.GroupResource, namespaceMapper request.NamespaceMapper) *k8srequest.RequestInfo { diff --git a/pkg/services/apiserver/builder/helper.go b/pkg/services/apiserver/builder/helper.go index 98a9c08ed25..603c49f3cb2 100644 --- a/pkg/services/apiserver/builder/helper.go +++ b/pkg/services/apiserver/builder/helper.go @@ -379,7 +379,7 @@ func InstallAPIs( case grafanarest.Mode4, grafanarest.Mode5: return storage, nil default: - return dualwrite.NewDualWriter(gr, currentMode, legacy, storage) + return dualwrite.NewStaticStorage(gr, currentMode, legacy, storage) } } } diff --git a/pkg/services/apiserver/service.go b/pkg/services/apiserver/service.go index 7d4ee3c0d5a..28da416536c 100644 --- a/pkg/services/apiserver/service.go +++ b/pkg/services/apiserver/service.go @@ -97,7 +97,7 @@ type service struct { authorizer *authorizer.GrafanaAuthorizer serverLockService builder.ServerLockService - storageStatus dualwrite.Service + dualWriter dualwrite.Service kvStore kvstore.KVStore pluginClient plugins.Client @@ -127,7 +127,7 @@ func ProvideService( datasources datasource.ScopedPluginDatasourceProvider, contextProvider datasource.PluginContextWrapper, pluginStore pluginstore.Store, - storageStatus dualwrite.Service, + dualWriter dualwrite.Service, unified resource.ResourceClient, secrets secret.InlineSecureValueSupport, restConfigProvider RestConfigProvider, @@ -158,7 +158,7 @@ func ProvideService( contextProvider: contextProvider, pluginStore: pluginStore, serverLockService: serverLockService, - storageStatus: storageStatus, + dualWriter: dualWriter, unified: unified, secrets: secrets, restConfigProvider: restConfigProvider, @@ -385,12 +385,17 @@ func (s *service) start(ctx context.Context) error { } // Install the API group+version for existing builders - err = builder.InstallAPIs(s.scheme, s.codecs, server, serverConfig.RESTOptionsGetter, builders, o.StorageOptions, + err = builder.InstallAPIs(s.scheme, + s.codecs, + server, + serverConfig.RESTOptionsGetter, + builders, + o.StorageOptions, s.metrics, request.GetNamespaceMapper(s.cfg), kvstore.WithNamespace(s.kvStore, 0, "storage.dualwriting"), s.serverLockService, - s.storageStatus, + s.dualWriter, optsregister, s.features, s.dualWriterMetrics, @@ -409,7 +414,7 @@ func (s *service) start(ctx context.Context) error { kvstore.WithNamespace(s.kvStore, 0, "storage.dualwriting"), s.serverLockService, request.GetNamespaceMapper(s.cfg), - s.storageStatus, + s.dualWriter, s.dualWriterMetrics, s.builderMetrics, serverConfig.MergedResourceConfig, diff --git a/pkg/services/provisioning/stubs.go b/pkg/services/provisioning/stubs.go new file mode 100644 index 00000000000..b40e66aac5c --- /dev/null +++ b/pkg/services/provisioning/stubs.go @@ -0,0 +1,69 @@ +package provisioning + +import ( + "os" + "path/filepath" + + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/services/provisioning/dashboards" + "github.com/grafana/grafana/pkg/setting" +) + +type StubProvisioningService interface { + GetDashboardProvisionerResolvedPath(name string) string + GetAllowUIUpdatesFromConfig(name string) bool +} + +func ProvideStubProvisioningService(cfg *setting.Cfg) (StubProvisioningService, error) { + return NewStubProvisioning(cfg.ProvisioningPath) +} + +func NewStubProvisioning(path string) (StubProvisioningService, error) { + cfgs, err := dashboards.ReadDashboardConfig(filepath.Join(path, "dashboards")) + if err != nil { + return nil, err + } + stub := &stubProvisioning{ + path: make(map[string]string), + allowUIUpdates: make(map[string]bool), + log: log.New("provisioning.stub"), + } + for _, cfg := range cfgs { + stub.path[cfg.Name] = cfg.Options["path"].(string) + stub.allowUIUpdates[cfg.Name] = cfg.AllowUIUpdates + } + return stub, nil +} + +type stubProvisioning struct { + path map[string]string // name > options.path + allowUIUpdates map[string]bool + log log.Logger +} + +func (s *stubProvisioning) GetAllowUIUpdatesFromConfig(name string) bool { + return s.allowUIUpdates[name] +} + +func (s *stubProvisioning) GetDashboardProvisionerResolvedPath(name string) string { + path := s.path[name] + if _, err := os.Stat(path); os.IsNotExist(err) { + s.log.Warn("Cannot read directory", "error", err) + } + + path, err := filepath.Abs(path) + if err != nil { + s.log.Warn("Could not create absolute path", "path", path, "error", err) + } + + path, err = filepath.EvalSymlinks(path) + if err != nil { + s.log.Warn("Failed to read content of symlinked path", "path", path, "error", err) + } + + if path == "" { + path = s.path[name] + s.log.Info("falling back to original path due to EvalSymlink/Abs failure") + } + return path +} diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go b/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go index b864e3a360d..777d791d4e5 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter_mode1_test.go @@ -71,7 +71,7 @@ func TestMode1_Create(t *testing.T) { tt.setupStorageFn(us.Mock) } - dw, err := NewDualWriter(kind, rest.Mode1, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode1, ls, us) require.NoError(t, err) obj, err := dw.Create(context.Background(), tt.input, func(context.Context, runtime.Object) error { return nil }, &metav1.CreateOptions{}) @@ -154,7 +154,7 @@ func TestMode1_Get(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode1, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode1, ls, us) require.NoError(t, err) obj, err := dw.Get(context.Background(), name, &metav1.GetOptions{}) @@ -217,7 +217,7 @@ func TestMode1_List(t *testing.T) { tt.setupStorageFn(us.Mock) } - dw, err := NewDualWriter(kind, rest.Mode1, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode1, ls, us) require.NoError(t, err) _, err = dw.List(context.Background(), &metainternalversion.ListOptions{}) @@ -286,7 +286,7 @@ func TestMode1_Delete(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode1, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode1, ls, us) require.NoError(t, err) obj, _, err := dw.Delete(context.Background(), name, func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{}) @@ -361,7 +361,7 @@ func TestMode1_DeleteCollection(t *testing.T) { tt.setupStorageFn(us.Mock, tt.input) } - dw, err := NewDualWriter(kind, rest.Mode1, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode1, ls, us) require.NoError(t, err) obj, err := dw.DeleteCollection(context.Background(), func(ctx context.Context, obj runtime.Object) error { return nil }, tt.input, &metainternalversion.ListOptions{}) @@ -434,7 +434,7 @@ func TestMode1_Update(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode1, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode1, ls, us) require.NoError(t, err) obj, _, err := dw.Update(context.Background(), name, updatedObjInfoObj{}, func(ctx context.Context, obj runtime.Object) error { return nil }, func(ctx context.Context, obj, old runtime.Object) error { return nil }, false, &metav1.UpdateOptions{}) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_mode2_test.go b/pkg/storage/legacysql/dualwrite/dualwriter_mode2_test.go index cd1e87b0b9a..a45dfa1285d 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter_mode2_test.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter_mode2_test.go @@ -69,7 +69,7 @@ func TestMode2_Create(t *testing.T) { tt.setupStorageFn(us.Mock, tt.input) } - dw, err := NewDualWriter(kind, rest.Mode2, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode2, ls, us) require.NoError(t, err) obj, err := dw.Create(context.Background(), tt.input, createFn, &metav1.CreateOptions{}) @@ -154,7 +154,7 @@ func TestMode2_Get(t *testing.T) { tt.setupStorageFn(us.Mock, tt.input) } - dw, err := NewDualWriter(kind, rest.Mode2, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode2, ls, us) require.NoError(t, err) obj, err := dw.Get(context.Background(), tt.input, &metav1.GetOptions{}) @@ -229,7 +229,7 @@ func TestMode2_List(t *testing.T) { tt.setupStorageFn(us.Mock) } - dw, err := NewDualWriter(kind, rest.Mode2, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode2, ls, us) require.NoError(t, err) obj, err := dw.List(context.Background(), &metainternalversion.ListOptions{}) @@ -331,7 +331,7 @@ func TestMode2_Delete(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode2, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode2, ls, us) require.NoError(t, err) obj, _, err := dw.Delete(context.Background(), name, func(context.Context, runtime.Object) error { return nil }, &metav1.DeleteOptions{}) @@ -401,7 +401,7 @@ func TestMode2_DeleteCollection(t *testing.T) { tt.setupStorageFn(us.Mock) } - dw, err := NewDualWriter(kind, rest.Mode2, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode2, ls, us) require.NoError(t, err) obj, err := dw.DeleteCollection(context.Background(), func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{TypeMeta: metav1.TypeMeta{Kind: name}}, &metainternalversion.ListOptions{}) @@ -471,7 +471,7 @@ func TestMode2_Update(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode2, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode2, ls, us) require.NoError(t, err) obj, _, err := dw.Update(context.Background(), name, updatedObjInfoObj{}, func(ctx context.Context, obj runtime.Object) error { return nil }, func(ctx context.Context, obj, old runtime.Object) error { return nil }, false, &metav1.UpdateOptions{}) diff --git a/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go b/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go index 3076d8a9409..d2485efb762 100644 --- a/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go +++ b/pkg/storage/legacysql/dualwrite/dualwriter_mode3_test.go @@ -75,7 +75,7 @@ func TestMode3_Create(t *testing.T) { tt.setupStorageFn(us.Mock, tt.input) } - dw, err := NewDualWriter(kind, rest.Mode3, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode3, ls, us) require.NoError(t, err) obj, err := dw.Create(context.Background(), tt.input, createFn, &metav1.CreateOptions{}) @@ -134,7 +134,7 @@ func TestMode3_Get(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode3, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode3, ls, us) require.NoError(t, err) obj, err := dw.Get(context.Background(), name, &metav1.GetOptions{}) @@ -185,7 +185,7 @@ func TestMode3_List(t *testing.T) { tt.setupStorageFn(us.Mock, &metainternalversion.ListOptions{TypeMeta: metav1.TypeMeta{Kind: "foo"}}) } - dw, err := NewDualWriter(kind, rest.Mode3, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode3, ls, us) require.NoError(t, err) res, err := dw.List(context.Background(), &metainternalversion.ListOptions{TypeMeta: metav1.TypeMeta{Kind: "foo"}}) @@ -276,7 +276,7 @@ func TestMode3_Delete(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode3, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode3, ls, us) require.NoError(t, err) obj, _, err := dw.Delete(context.Background(), name, func(context.Context, runtime.Object) error { return nil }, &metav1.DeleteOptions{}) @@ -346,7 +346,7 @@ func TestMode3_DeleteCollection(t *testing.T) { tt.setupStorageFn(us.Mock) } - dw, err := NewDualWriter(kind, rest.Mode3, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode3, ls, us) require.NoError(t, err) obj, err := dw.DeleteCollection(context.Background(), func(ctx context.Context, obj runtime.Object) error { return nil }, &metav1.DeleteOptions{TypeMeta: metav1.TypeMeta{Kind: name}}, &metainternalversion.ListOptions{}) @@ -416,7 +416,7 @@ func TestMode3_Update(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - dw, err := NewDualWriter(kind, rest.Mode3, ls, us) + dw, err := NewStaticStorage(kind, rest.Mode3, ls, us) require.NoError(t, err) obj, _, err := dw.Update(context.Background(), name, updatedObjInfoObj{}, func(ctx context.Context, obj runtime.Object) error { return nil }, func(ctx context.Context, obj, old runtime.Object) error { return nil }, false, &metav1.UpdateOptions{}) diff --git a/pkg/storage/legacysql/dualwrite/runtime.go b/pkg/storage/legacysql/dualwrite/runtime.go index 180beeb255a..b9c5355050c 100644 --- a/pkg/storage/legacysql/dualwrite/runtime.go +++ b/pkg/storage/legacysql/dualwrite/runtime.go @@ -14,37 +14,6 @@ import ( grafanarest "github.com/grafana/grafana/pkg/apiserver/rest" ) -func (m *service) NewStorage(gr schema.GroupResource, legacy grafanarest.Storage, unified grafanarest.Storage) (grafanarest.Storage, error) { - status, err := m.Status(context.Background(), gr) - if err != nil { - return nil, err - } - - if m.enabled && status.Runtime { - // Dynamic storage behavior - return &runtimeDualWriter{ - service: m, - legacy: legacy, - unified: unified, - dualwrite: &dualWriter{legacy: legacy, unified: unified}, // not used for read - gr: gr, - }, nil - } - - if status.ReadUnified { - if status.WriteLegacy { - // Write both, read unified - return &dualWriter{legacy: legacy, unified: unified, readUnified: true}, nil - } - return unified, nil - } - if status.WriteUnified { - // Write both, read legacy - return &dualWriter{legacy: legacy, unified: unified}, nil - } - return legacy, nil -} - // The runtime dual writer implements the various modes we have described as: mode:1/2/3/4/5 // However the behavior can be configured at runtime rather than just at startup. // When a resource is marked as "migrating", all write requests will be 503 unavailable diff --git a/pkg/storage/legacysql/dualwrite/runtime_test.go b/pkg/storage/legacysql/dualwrite/runtime_test.go index b59801e3da8..01dd79647a7 100644 --- a/pkg/storage/legacysql/dualwrite/runtime_test.go +++ b/pkg/storage/legacysql/dualwrite/runtime_test.go @@ -77,7 +77,7 @@ func TestRuntime_Create(t *testing.T) { tt.setupStorageFn(us.Mock, tt.input) } - m, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagManagedDualWriter), kvstore.NewFakeKVStore(), nil) + m, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagManagedDualWriter), kvstore.NewFakeKVStore(), NewFakeConfig(), NewFakeMigrator()) require.NoError(t, err) dw, err := m.NewStorage(kind, ls, us) require.NoError(t, err) @@ -150,7 +150,7 @@ func TestRuntime_Get(t *testing.T) { tt.setupStorageFn(us.Mock, name) } - m, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagManagedDualWriter), kvstore.NewFakeKVStore(), nil) + m, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagManagedDualWriter), kvstore.NewFakeKVStore(), NewFakeConfig(), NewFakeMigrator()) require.NoError(t, err) dw, err := m.NewStorage(kind, ls, us) require.NoError(t, err) @@ -235,7 +235,7 @@ func TestRuntime_CreateWhileMigrating(t *testing.T) { } // Shared provider across all tests - dual, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagManagedDualWriter), kvstore.NewFakeKVStore(), nil) + dual, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagManagedDualWriter), kvstore.NewFakeKVStore(), NewFakeConfig(), NewFakeMigrator()) require.NoError(t, err) for _, tt := range tests { diff --git a/pkg/storage/legacysql/dualwrite/service.go b/pkg/storage/legacysql/dualwrite/service.go index 77ecc761de1..8ac88deb281 100644 --- a/pkg/storage/legacysql/dualwrite/service.go +++ b/pkg/storage/legacysql/dualwrite/service.go @@ -12,8 +12,28 @@ import ( "github.com/grafana/grafana/pkg/infra/kvstore" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" + unifiedmigrations "github.com/grafana/grafana/pkg/storage/unified/migrations/contract" ) +// fakeMigrator is a no-op implementation of UnifiedStorageMigrationService +type fakeMigrator struct{} + +func (f *fakeMigrator) Run(ctx context.Context) error { + return nil +} + +var _ unifiedmigrations.UnifiedStorageMigrationService = (*fakeMigrator)(nil) + +func NewFakeMigrator() unifiedmigrations.UnifiedStorageMigrationService { + return &fakeMigrator{} +} + +func NewFakeConfig() *setting.Cfg { + return &setting.Cfg{ + UnifiedStorage: make(map[string]setting.UnifiedStorageConfig), + } +} + func ProvideStaticServiceForTests(cfg *setting.Cfg) Service { if cfg == nil { cfg = &setting.Cfg{} @@ -25,7 +45,14 @@ func ProvideService( features featuremgmt.FeatureToggles, kv kvstore.KVStore, cfg *setting.Cfg, + migrator unifiedmigrations.UnifiedStorageMigrationService, ) (Service, error) { + // Ensure migrations have run before starting dualwrite + err := migrator.Run(context.Background()) + if err != nil { + return nil, fmt.Errorf("unable to start dualwrite service due to migration error: %w", err) + } + //nolint:staticcheck // not yet migrated to OpenFeature enabled := features.IsEnabledGlobally(featuremgmt.FlagManagedDualWriter) || features.IsEnabledGlobally(featuremgmt.FlagProvisioning) // required for git provisioning @@ -64,6 +91,37 @@ type service struct { enabled bool } +func (m *service) NewStorage(gr schema.GroupResource, legacy rest.Storage, unified rest.Storage) (rest.Storage, error) { + status, err := m.Status(context.Background(), gr) + if err != nil { + return nil, err + } + + if m.enabled && status.Runtime { + // Dynamic storage behavior + return &runtimeDualWriter{ + service: m, + legacy: legacy, + unified: unified, + dualwrite: &dualWriter{legacy: legacy, unified: unified}, // not used for read + gr: gr, + }, nil + } + + if status.ReadUnified { + if status.WriteLegacy { + // Write both, read unified + return &dualWriter{legacy: legacy, unified: unified, readUnified: true}, nil + } + return unified, nil + } + if status.WriteUnified { + // Write both, read legacy + return &dualWriter{legacy: legacy, unified: unified}, nil + } + return legacy, nil +} + // Hardcoded list of resources that should be controlled by the database (eventually everything?) func (m *service) ShouldManage(gr schema.GroupResource) bool { if !m.enabled { diff --git a/pkg/storage/legacysql/dualwrite/service_test.go b/pkg/storage/legacysql/dualwrite/service_test.go index a083e6470be..f9fcb9f9b38 100644 --- a/pkg/storage/legacysql/dualwrite/service_test.go +++ b/pkg/storage/legacysql/dualwrite/service_test.go @@ -17,7 +17,7 @@ import ( func TestService(t *testing.T) { t.Run("dynamic", func(t *testing.T) { ctx := context.Background() - mode, err := ProvideService(featuremgmt.WithFeatures(), kvstore.NewFakeKVStore(), nil) + mode, err := ProvideService(featuremgmt.WithFeatures(featuremgmt.FlagProvisioning), kvstore.NewFakeKVStore(), NewFakeConfig(), NewFakeMigrator()) require.NoError(t, err) gr := schema.GroupResource{Group: "ggg", Resource: "rrr"} @@ -122,7 +122,7 @@ func TestService(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { ctx := context.Background() - svc, err := ProvideService(tc.flags, kvstore.NewFakeKVStore(), &tc.cfg) + svc, err := ProvideService(tc.flags, kvstore.NewFakeKVStore(), &tc.cfg, NewFakeMigrator()) if tc.error != "" { require.ErrorContains(t, err, tc.error) require.Nil(t, svc, "expect a nil service when an error exts") diff --git a/pkg/storage/legacysql/dualwrite/static.go b/pkg/storage/legacysql/dualwrite/static.go index 3835cf80752..bd51b9b6ac6 100644 --- a/pkg/storage/legacysql/dualwrite/static.go +++ b/pkg/storage/legacysql/dualwrite/static.go @@ -10,8 +10,8 @@ import ( "github.com/grafana/grafana/pkg/setting" ) -// NewDualWriter -- temporary shim -func NewDualWriter( +// NewStaticStorage -- temporary shim +func NewStaticStorage( gr schema.GroupResource, mode rest.DualWriterMode, legacy rest.Storage, diff --git a/pkg/storage/unified/migrations/contract/migrations.go b/pkg/storage/unified/migrations/contract/migrations.go new file mode 100644 index 00000000000..fe1c42cea21 --- /dev/null +++ b/pkg/storage/unified/migrations/contract/migrations.go @@ -0,0 +1,12 @@ +package contract + +import ( + "github.com/grafana/grafana/pkg/registry" +) + +// UnifiedStorageMigrationService provides unified storage migrations as a background service. +// This interface is defined in a separate package to avoid import cycles between +// the migrations implementation and packages that need to depend on it (like dualwrite). +type UnifiedStorageMigrationService interface { + registry.BackgroundService +} diff --git a/pkg/storage/unified/migrations/dashboard_folder_migration.go b/pkg/storage/unified/migrations/dashboard_folder_migration.go deleted file mode 100644 index 96c5a824c8a..00000000000 --- a/pkg/storage/unified/migrations/dashboard_folder_migration.go +++ /dev/null @@ -1,93 +0,0 @@ -package migrations - -import ( - "context" - "fmt" - - "github.com/grafana/authlib/types" - "github.com/grafana/grafana/pkg/apimachinery/identity" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/grafana/grafana/pkg/util/xorm" - "k8s.io/apimachinery/pkg/runtime/schema" -) - -const ( - FoldersAndDashboardsMigrationID = "folders and dashboards migration" - UnifiedStorageDataMigrationSQL = "unified storage data migration" -) - -type dashboardAndFolderMigration struct { - migrator.MigrationBase - legacyMigrator legacy.LegacyMigrator - bulkStoreClient resource.ResourceClient -} - -var _ migrator.CodeMigration = (*dashboardAndFolderMigration)(nil) - -// SQL implements migrator.Migration interface. Returns a description string. -func (sp *dashboardAndFolderMigration) SQL(dialect migrator.Dialect) string { - return UnifiedStorageDataMigrationSQL -} - -func (sp *dashboardAndFolderMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { - ctx := context.Background() - logger := mg.Logger - - resources := []schema.GroupResource{ - { - Group: "folder.grafana.app", - Resource: "folders", - }, - { - Group: "dashboard.grafana.app", - Resource: "dashboards", - }, - } - - storageMigrator := newUnifiedStorageMigrator(sp.legacyMigrator, sp.bulkStoreClient, resources, "unified-storage-migration.folders-dashboards") - - orgs, err := sp.getAllOrgs(sess) - if err != nil { - logger.Error("failed to get organizations for folders and dashboards migration", "error", err) - return fmt.Errorf("failed to get organizations: %w", err) - } - - if len(orgs) == 0 { - logger.Info("No organizations found to migrate, skipping migration") - return nil - } - - logger.Info("Starting migration for all organizations", "org_count", len(orgs)) - - for _, org := range orgs { - namespace := types.OrgNamespaceFormatter(org.ID) - logger.Info("Migrating organization", "org_id", org.ID, "org_name", org.Name, "namespace", namespace) - - // Create a service identity context for this namespace to authenticate with unified storage - migrationCtx, _ := identity.WithServiceIdentityForSingleNamespace(ctx, namespace) - - if err := storageMigrator.executeMigration(migrationCtx, sess, mg, namespace); err != nil { - logger.Error("migration failed for organization", "org_id", org.ID, "org_name", org.Name, "error", err) - return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, err) - } - } - - logger.Info("Migration completed successfully for all organizations", "org_count", len(orgs)) - return nil -} - -type orgInfo struct { - ID int64 `xorm:"id"` - Name string `xorm:"name"` -} - -func (sp *dashboardAndFolderMigration) getAllOrgs(sess *xorm.Session) ([]orgInfo, error) { - var orgs []orgInfo - err := sess.Table("org").Cols("id", "name").Find(&orgs) - if err != nil { - return nil, err - } - return orgs, nil -} diff --git a/pkg/storage/unified/migrations/migrations.go b/pkg/storage/unified/migrations/migrations.go deleted file mode 100644 index a30777e308a..00000000000 --- a/pkg/storage/unified/migrations/migrations.go +++ /dev/null @@ -1,112 +0,0 @@ -package migrations - -import ( - "context" - "fmt" - "os" - - "github.com/grafana/grafana/pkg/infra/db" - "github.com/grafana/grafana/pkg/infra/log" - "github.com/grafana/grafana/pkg/registry" - "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" - "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/storage/unified/resource" - "github.com/prometheus/client_golang/prometheus" - "go.opentelemetry.io/otel" -) - -var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") -var logger = log.New("storage.unified.migrations") - -// UnifiedStorageMigrationProvider provides unified storage migrations as a background service -type UnifiedStorageMigrationProvider interface { - registry.BackgroundService -} - -type UnifiedStorageMigrationProviderImpl struct { - legacyMigrator legacy.LegacyMigrator - cfg *setting.Cfg - client resource.ResourceClient - sqlStore db.DB -} - -var _ UnifiedStorageMigrationProvider = (*UnifiedStorageMigrationProviderImpl)(nil) - -// ProvideUnifiedStorageMigrationProvider is a Wire provider that creates the migration service. -// The service implements registry.BackgroundService and runs migrations during server startup. -func ProvideUnifiedStorageMigrationProvider( - legacyMigrator legacy.LegacyMigrator, - cfg *setting.Cfg, - client resource.ResourceClient, - sqlStore db.DB, -) *UnifiedStorageMigrationProviderImpl { - return &UnifiedStorageMigrationProviderImpl{ - legacyMigrator: legacyMigrator, - cfg: cfg, - client: client, - sqlStore: sqlStore, - } -} - -// Run executes unified storage migrations as a background service. -// This blocks until migrations complete. If migrations fail, an error is returned -// which will prevent Grafana from starting. -func (p *UnifiedStorageMigrationProviderImpl) Run(ctx context.Context) error { - // skip migrations in test environments to prevent integration test timeouts. - if os.Getenv("GRAFANA_TEST_DB") != "" { - return nil - } - // skip migrations if disabled in config - if p.cfg.DisableDataMigrations { - logger.Info("Data migrations are disabled, skipping") - return nil - } - // TODO: Re-enable once migrations are ready - // return RegisterMigrations(p.legacyMigrator, p.cfg, p.client, p.sqlStore) - return nil -} - -// RegisterMigrations initializes and registers all unified storage migrations. -// This function is the entry point for all data migrations from legacy storage -// to unified storage. It returns an error if migrations fail, preventing Grafana -// from starting with inconsistent data. -func RegisterMigrations( - legacyMigrator legacy.LegacyMigrator, - cfg *setting.Cfg, - client resource.ResourceClient, - sqlStore db.DB, -) error { - ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations") - defer span.End() - mg := migrator.NewScopedMigrator(sqlStore.GetEngine(), cfg, "unified_storage") - mg.AddCreateMigration() - - if err := prometheus.Register(mg); err != nil { - logger.Warn("Failed to register migrator metrics", "error", err) - } - - // Add new migration registrations here for each resource type - registerDashboardAndFolderMigration(mg, legacyMigrator, client) - - // Run all registered migrations (blocking) - sec := cfg.Raw.Section("database") - if err := mg.RunMigrations(ctx, sec.Key("migration_locking").MustBool(true), sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { - return fmt.Errorf("unified storage data migration failed: %w", err) - } - - logger.Info("Unified storage migrations completed successfully") - return nil -} - -func registerDashboardAndFolderMigration( - mg *migrator.Migrator, - legacyMigrator legacy.LegacyMigrator, - bulkStoreClient resource.ResourceClient, -) { - migration := &dashboardAndFolderMigration{ - legacyMigrator: legacyMigrator, - bulkStoreClient: bulkStoreClient, - } - mg.AddMigration(FoldersAndDashboardsMigrationID, migration) -} diff --git a/pkg/storage/unified/migrations/migrator.go b/pkg/storage/unified/migrations/migrator.go index cab6ce38432..bdbb69f98a1 100644 --- a/pkg/storage/unified/migrations/migrator.go +++ b/pkg/storage/unified/migrations/migrator.go @@ -3,164 +3,200 @@ package migrations import ( "context" "fmt" - "time" - "github.com/grafana/authlib/types" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" - "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "google.golang.org/grpc/metadata" + + authlib "github.com/grafana/authlib/types" + + v1beta1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1" + folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" - "github.com/grafana/grafana/pkg/util/xorm" - "k8s.io/apimachinery/pkg/runtime/schema" ) -// StorageMigrator defines the interface for executing unified storage migrations -type StorageMigrator interface { - executeMigration(ctx context.Context, sess *xorm.Session, mg *migrator.Migrator, namespace string) error +// Read from legacy and write into unified storage +// +//go:generate mockery --name UnifiedMigrator --structname MockUnifiedMigrator --inpackage --filename migrator_mock.go --with-expecter +type UnifiedMigrator interface { + Migrate(ctx context.Context, opts legacy.MigrateOptions) (*resourcepb.BulkResponse, error) } -type unifiedStorageMigrator struct { - migrator legacy.LegacyMigrator - bulkStoreClient resource.ResourceClient - resources []schema.GroupResource - log log.Logger +// unifiedMigration handles the migration of legacy resources to unified storage +type unifiedMigration struct { + legacy.MigrationDashboardAccessor + streamProvider streamProvider + log log.Logger } -func newUnifiedStorageMigrator(migrator legacy.LegacyMigrator, bulkStoreClient resource.ResourceClient, resources []schema.GroupResource, logPrefix string) StorageMigrator { - return &unifiedStorageMigrator{ - migrator: migrator, - bulkStoreClient: bulkStoreClient, - resources: resources, - log: log.New(logPrefix), - } +// streamProvider abstracts the different ways to create a bulk process stream +type streamProvider interface { + createStream(ctx context.Context, opts legacy.MigrateOptions) (resourcepb.BulkStore_BulkProcessClient, error) } -func (m *unifiedStorageMigrator) executeMigration(ctx context.Context, sess *xorm.Session, mg *migrator.Migrator, namespace string) error { - startTime := time.Now() - m.log.Info("Starting unified storage migration", "namespace", namespace, "resources", m.resources) - - opts := legacy.MigrateOptions{ - Namespace: namespace, - Store: m.bulkStoreClient, - LargeObjects: nil, // Not using large object support to avoid import cycles - Resources: m.resources, - WithHistory: true, // Migrate with full history - OnlyCount: false, - Progress: func(count int, msg string) { - m.log.Info("Migration progress", "count", count, "message", msg) - }, - } - - // Execute the migration via legacy migrator - response, err := m.migrator.Migrate(ctx, opts) - if err != nil { - m.log.Error("Migration failed", "error", err, "duration", time.Since(startTime)) - return fmt.Errorf("failed to migrate resources: %w", err) - } - - // Validate the migration results - if err := m.validateMigration(sess, response); err != nil { - m.log.Error("Migration validation failed", "error", err, "duration", time.Since(startTime)) - return fmt.Errorf("migration validation failed: %w", err) - } - - m.log.Info("Migration completed successfully", - "duration", time.Since(startTime), - "processed", response.Processed, - "summaries", len(response.Summary), - "rejected", len(response.Rejected)) - - return nil +// resourceClientStreamProvider creates streams using resource.ResourceClient +type resourceClientStreamProvider struct { + client resource.ResourceClient } -func (m *unifiedStorageMigrator) validateMigration(sess *xorm.Session, response *resourcepb.BulkResponse) error { - // Check for rejected items - if len(response.Rejected) > 0 { - m.log.Warn("Migration had rejected items", "count", len(response.Rejected)) - for i, rejected := range response.Rejected { - if i < 10 { // Log first 10 rejected items - m.log.Warn("Rejected item", - "namespace", rejected.Key.Namespace, - "group", rejected.Key.Group, - "resource", rejected.Key.Resource, - "name", rejected.Key.Name, - "reason", rejected.Error) - } +func (r *resourceClientStreamProvider) createStream(ctx context.Context, opts legacy.MigrateOptions) (resourcepb.BulkStore_BulkProcessClient, error) { + // Build collection settings for resource client + settings := resource.BulkSettings{ + RebuildCollection: true, + SkipValidation: true, + } + for _, res := range opts.Resources { + switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { + case "folder.grafana.app/folders": + settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: folders.GROUP, + Resource: folders.RESOURCE, + }) + case "dashboard.grafana.app/librarypanels": + settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: v1beta1.GROUP, + Resource: v1beta1.LIBRARY_PANEL_RESOURCE, + }) + case "dashboard.grafana.app/dashboards": + settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: v1beta1.GROUP, + Resource: v1beta1.DASHBOARD_RESOURCE, + }) } - // Rejections are not fatal - they may be expected for invalid data + } + ctx = metadata.NewOutgoingContext(ctx, settings.ToMD()) + return r.client.BulkProcess(ctx) +} + +// bulkStoreClientStreamProvider creates streams using resourcepb.BulkStoreClient +type bulkStoreClientStreamProvider struct { + client resourcepb.BulkStoreClient +} + +func (b *bulkStoreClientStreamProvider) createStream(ctx context.Context, opts legacy.MigrateOptions) (resourcepb.BulkStore_BulkProcessClient, error) { + // Build collection settings for resource client + settings := resource.BulkSettings{ + RebuildCollection: true, + SkipValidation: true, + } + for _, res := range opts.Resources { + switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { + case "folder.grafana.app/folders": + settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: folders.GROUP, + Resource: folders.RESOURCE, + }) + case "dashboard.grafana.app/librarypanels": + settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: v1beta1.GROUP, + Resource: v1beta1.LIBRARY_PANEL_RESOURCE, + }) + case "dashboard.grafana.app/dashboards": + settings.Collection = append(settings.Collection, &resourcepb.ResourceKey{ + Namespace: opts.Namespace, + Group: v1beta1.GROUP, + Resource: v1beta1.DASHBOARD_RESOURCE, + }) + } + } + ctx = metadata.NewOutgoingContext(ctx, settings.ToMD()) + return b.client.BulkProcess(ctx) +} + +// This can migrate Folders, Dashboards and LibraryPanels +func ProvideUnifiedMigrator( + dashboardAccess legacy.MigrationDashboardAccessor, + client resource.ResourceClient, +) UnifiedMigrator { + return newUnifiedMigrator( + dashboardAccess, + &resourceClientStreamProvider{client: client}, + log.New("storage.unified.migrator"), + ) +} + +func ProvideUnifiedMigratorParquet( + dashboardAccess legacy.MigrationDashboardAccessor, + client resourcepb.BulkStoreClient, +) UnifiedMigrator { + return newUnifiedMigrator( + dashboardAccess, + &bulkStoreClientStreamProvider{client: client}, + log.New("storage.unified.migrator.parquet"), + ) +} + +func newUnifiedMigrator( + dashboardAccess legacy.MigrationDashboardAccessor, + streamProvider streamProvider, + log log.Logger, +) UnifiedMigrator { + return &unifiedMigration{ + MigrationDashboardAccessor: dashboardAccess, + streamProvider: streamProvider, + log: log, + } +} + +// migrate function -- works for a single kind +type migratorFunc = func(ctx context.Context, orgId int64, opts legacy.MigrateOptions, stream resourcepb.BulkStore_BulkProcessClient) (*legacy.BlobStoreInfo, error) + +func (m *unifiedMigration) Migrate(ctx context.Context, opts legacy.MigrateOptions) (*resourcepb.BulkResponse, error) { + info, err := authlib.ParseNamespace(opts.Namespace) + if err != nil { + return nil, err + } + if opts.Progress == nil { + opts.Progress = func(count int, msg string) {} // noop } - // Validate counts for each resource type - for _, summary := range response.Summary { - legacyCount, err := m.getLegacyCount(sess, summary.Group, summary.Resource, summary.Namespace) + if len(opts.Resources) < 1 { + return nil, fmt.Errorf("missing resource selector") + } + + if opts.OnlyCount { + return m.CountResources(ctx, opts) + } + + stream, err := m.streamProvider.createStream(ctx, opts) + if err != nil { + return nil, err + } + + migratorFuncs := []migratorFunc{} + for _, res := range opts.Resources { + switch fmt.Sprintf("%s/%s", res.Group, res.Resource) { + case "folder.grafana.app/folders": + migratorFuncs = append(migratorFuncs, m.MigrateFolders) + case "dashboard.grafana.app/librarypanels": + migratorFuncs = append(migratorFuncs, m.MigrateLibraryPanels) + case "dashboard.grafana.app/dashboards": + migratorFuncs = append(migratorFuncs, m.MigrateDashboards) + default: + return nil, fmt.Errorf("unsupported resource: %s", res) + } + } + + // Execute migrations + blobStore := legacy.BlobStoreInfo{} + m.log.Info("start migrating legacy resources", "namespace", opts.Namespace, "orgId", info.OrgID, "stackId", info.StackID) + for _, fn := range migratorFuncs { + blobs, err := fn(ctx, info.OrgID, opts, stream) if err != nil { - return fmt.Errorf("failed to get legacy count for %s/%s: %w", summary.Group, summary.Resource, err) + m.log.Error("error migrating legacy resources", "error", err, "namespace", opts.Namespace) + return nil, err } - - // Account for rejected items in validation - expectedCount := summary.Count + int64(len(response.Rejected)) - - m.log.Info("Count validation", - "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), - "namespace", summary.Namespace, - "legacy_count", legacyCount, - "unified_count", summary.Count, - "rejected", len(response.Rejected), - "history", summary.History) - - // Validate that we migrated all items (allowing for rejected items) - if legacyCount > expectedCount { - return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d", - summary.Resource, summary.Group, summary.Namespace, - legacyCount, summary.Count, len(response.Rejected)) + if blobs != nil { + blobStore.Count += blobs.Count + blobStore.Size += blobs.Size } } - - return nil -} - -func (m *unifiedStorageMigrator) getLegacyCount(sess *xorm.Session, group, resourceType, namespace string) (int64, error) { - // Parse namespace to get org ID - orgID, err := ParseOrgIDFromNamespace(namespace) - if err != nil { - return 0, fmt.Errorf("invalid namespace %s: %w", namespace, err) - } - - // Map group/resource to legacy table - tableName, whereClause := m.getLegacyTableInfo(group, resourceType) - if tableName == "" { - return 0, fmt.Errorf("unknown resource type: %s.%s", resourceType, group) - } - - // Count items in legacy table using Table() before Count() - count, err := sess.Table(tableName).Where(whereClause, orgID).Count() - if err != nil { - return 0, fmt.Errorf("failed to count %s: %w", tableName, err) - } - - return count, nil -} - -func (m *unifiedStorageMigrator) getLegacyTableInfo(group, resource string) (table string, whereClause string) { - // Map unified storage group/resource to legacy tables - switch { - case group == "dashboard.grafana.app" && resource == "dashboards": - return "dashboard", "org_id = ? and is_folder = false" - case group == "folder.grafana.app" && resource == "folders": - return "dashboard", "org_id = ? and is_folder = true" - case group == "playlist.grafana.app" && resource == "playlists": - return "playlist", "org_id = ?" - default: - return "", "" - } -} - -func ParseOrgIDFromNamespace(namespace string) (int64, error) { - // Use authlib to properly parse all namespace formats including "default" for org 1 - info, err := types.ParseNamespace(namespace) - if err != nil { - return 0, fmt.Errorf("failed to parse namespace: %w", err) - } - return info.OrgID, nil + m.log.Info("finished migrating legacy resources", "blobStore", blobStore) + return stream.CloseAndRecv() } diff --git a/pkg/storage/unified/migrations/migrator_mock.go b/pkg/storage/unified/migrations/migrator_mock.go new file mode 100644 index 00000000000..3b460ca6e5b --- /dev/null +++ b/pkg/storage/unified/migrations/migrator_mock.go @@ -0,0 +1,98 @@ +// Code generated by mockery v2.53.4. DO NOT EDIT. + +package migrations + +import ( + context "context" + + legacy "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + mock "github.com/stretchr/testify/mock" + + resourcepb "github.com/grafana/grafana/pkg/storage/unified/resourcepb" +) + +// MockUnifiedMigrator is an autogenerated mock type for the UnifiedMigrator type +type MockUnifiedMigrator struct { + mock.Mock +} + +type MockUnifiedMigrator_Expecter struct { + mock *mock.Mock +} + +func (_m *MockUnifiedMigrator) EXPECT() *MockUnifiedMigrator_Expecter { + return &MockUnifiedMigrator_Expecter{mock: &_m.Mock} +} + +// Migrate provides a mock function with given fields: ctx, opts +func (_m *MockUnifiedMigrator) Migrate(ctx context.Context, opts legacy.MigrateOptions) (*resourcepb.BulkResponse, error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for Migrate") + } + + var r0 *resourcepb.BulkResponse + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, legacy.MigrateOptions) (*resourcepb.BulkResponse, error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, legacy.MigrateOptions) *resourcepb.BulkResponse); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*resourcepb.BulkResponse) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, legacy.MigrateOptions) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockUnifiedMigrator_Migrate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Migrate' +type MockUnifiedMigrator_Migrate_Call struct { + *mock.Call +} + +// Migrate is a helper method to define mock.On call +// - ctx context.Context +// - opts legacy.MigrateOptions +func (_e *MockUnifiedMigrator_Expecter) Migrate(ctx interface{}, opts interface{}) *MockUnifiedMigrator_Migrate_Call { + return &MockUnifiedMigrator_Migrate_Call{Call: _e.mock.On("Migrate", ctx, opts)} +} + +func (_c *MockUnifiedMigrator_Migrate_Call) Run(run func(ctx context.Context, opts legacy.MigrateOptions)) *MockUnifiedMigrator_Migrate_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(legacy.MigrateOptions)) + }) + return _c +} + +func (_c *MockUnifiedMigrator_Migrate_Call) Return(_a0 *resourcepb.BulkResponse, _a1 error) *MockUnifiedMigrator_Migrate_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockUnifiedMigrator_Migrate_Call) RunAndReturn(run func(context.Context, legacy.MigrateOptions) (*resourcepb.BulkResponse, error)) *MockUnifiedMigrator_Migrate_Call { + _c.Call.Return(run) + return _c +} + +// NewMockUnifiedMigrator creates a new instance of MockUnifiedMigrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockUnifiedMigrator(t interface { + mock.TestingT + Cleanup(func()) +}) *MockUnifiedMigrator { + mock := &MockUnifiedMigrator{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/storage/unified/migrations/resource_migration.go b/pkg/storage/unified/migrations/resource_migration.go new file mode 100644 index 00000000000..663bf030f67 --- /dev/null +++ b/pkg/storage/unified/migrations/resource_migration.go @@ -0,0 +1,275 @@ +package migrations + +import ( + "context" + "fmt" + "time" + + "github.com/grafana/authlib/types" + "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/infra/log" + "github.com/grafana/grafana/pkg/registry/apis/dashboard/legacy" + "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/util/xorm" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +// ValidationFunc is a function that validates migration results. +// It receives the database session, migration response, and logger for reporting. +// Return an error if validation fails, nil if validation passes or is skipped. +type ValidationFunc func(sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error + +// ResourceMigration handles migration of specific resource types from legacy to unified storage. +// It implements migrator.CodeMigration and provides a generic, extensible way to migrate any +// resource type by: +// +// 1. Iterating through all organizations +// 2. For each org, delegating to LegacyMigrator to read from legacy and write to unified storage +// 3. Validating migration results using the provided validation function (if any) +// +// To add a new resource type migration, simply create a new ResourceMigration instance in +// service.go with the appropriate schema.GroupResource specifications and optional validation function. +type ResourceMigration struct { + migrator.MigrationBase + migrator UnifiedMigrator + resources []schema.GroupResource + migrationID string + validationFunc ValidationFunc // Optional: custom validation logic for this migration + log log.Logger +} + +// NewResourceMigration creates a new migration for the specified resources. +// This is the primary way to register new resource migrations. +// +// Parameters: +// - legacyMigrator: handles reading from legacy storage and writing to unified storage +// - resources: list of GroupResource to migrate +// - migrationID: unique identifier for this migration +// - validationFunc: optional validation function to verify migration results. +// If nil, no validation will be performed. +// +// Example with legacy table count validation: +// +// NewResourceMigration( +// migrator, +// []schema.GroupResource{{Group: "playlist.grafana.app", Resource: "playlists"}}, +// "playlists", +// NewLegacyTableCountValidator(map[string]LegacyTableInfo{ +// "playlist.grafana.app/playlists": {Table: "playlist", WhereClause: "org_id = ?"}, +// }), +// ) +// +// Example without validation: +// +// NewResourceMigration(migrator, resources, "new-resource", nil) +func NewResourceMigration( + migrator UnifiedMigrator, + resources []schema.GroupResource, + migrationID string, + validationFunc ValidationFunc, +) *ResourceMigration { + return &ResourceMigration{ + migrator: migrator, + resources: resources, + migrationID: migrationID, + validationFunc: validationFunc, + log: log.New("storage.unified.resource_migration." + migrationID), + } +} + +var _ migrator.CodeMigration = (*ResourceMigration)(nil) + +// SQL implements migrator.Migration interface. Returns a description string. +func (m *ResourceMigration) SQL(_ migrator.Dialect) string { + return fmt.Sprintf("unified storage data migration: %s", m.migrationID) +} + +// Exec implements migrator.CodeMigration interface. Executes the migration across all organizations. +func (m *ResourceMigration) Exec(sess *xorm.Session, mg *migrator.Migrator) error { + ctx := context.Background() + + orgs, err := m.getAllOrgs(sess) + if err != nil { + m.log.Error("failed to get organizations", "error", err) + return fmt.Errorf("failed to get organizations: %w", err) + } + + if len(orgs) == 0 { + m.log.Info("No organizations found to migrate, skipping migration") + return nil + } + + m.log.Info("Starting migration for all organizations", "org_count", len(orgs), "resources", m.resources) + + for _, org := range orgs { + if err := m.migrateOrg(ctx, sess, org); err != nil { + return err + } + } + + m.log.Info("Migration completed successfully for all organizations", "org_count", len(orgs)) + return nil +} + +// migrateOrg handles migration for a single organization +func (m *ResourceMigration) migrateOrg(ctx context.Context, sess *xorm.Session, org orgInfo) error { + namespace := types.OrgNamespaceFormatter(org.ID) + m.log.Info("Migrating organization", "org_id", org.ID, "org_name", org.Name, "namespace", namespace) + + // Create a service identity context for this namespace to authenticate with unified storage + migrationCtx, _ := identity.WithServiceIdentityForSingleNamespace(ctx, namespace) + + startTime := time.Now() + + opts := legacy.MigrateOptions{ + Namespace: namespace, + Resources: m.resources, + WithHistory: true, // Migrate with full history + Progress: func(count int, msg string) { + m.log.Info("Migration progress", "org_id", org.ID, "count", count, "message", msg) + }, + } + + // Execute the migration via legacy migrator + response, err := m.migrator.Migrate(migrationCtx, opts) + if err != nil { + m.log.Error("Migration failed", "org_id", org.ID, "error", err, "duration", time.Since(startTime)) + return fmt.Errorf("migration failed for org %d (%s): %w", org.ID, org.Name, err) + } + + // Validate the migration results + if err := m.validateMigration(sess, response); err != nil { + m.log.Error("Migration validation failed", "org_id", org.ID, "error", err, "duration", time.Since(startTime)) + return fmt.Errorf("migration validation failed for org %d (%s): %w", org.ID, org.Name, err) + } + + m.log.Info("Migration completed for organization", + "org_id", org.ID, + "duration", time.Since(startTime), + "processed", response.Processed, + "summaries", len(response.Summary), + "rejected", len(response.Rejected)) + + return nil +} + +// validateMigration calls the custom validation function if provided +func (m *ResourceMigration) validateMigration(sess *xorm.Session, response *resourcepb.BulkResponse) error { + if m.validationFunc == nil { + m.log.Debug("No validation function provided, skipping validation") + return nil + } + + return m.validationFunc(sess, response, m.log) +} + +// LegacyTableInfo defines how to map a unified storage resource to its legacy table +type LegacyTableInfo struct { + Table string // Legacy table name (e.g., "dashboard", "playlist") + WhereClause string // WHERE clause template with org_id parameter (e.g., "org_id = ? and is_folder = false") +} + +// NewLegacyTableCountValidator creates a ValidationFunc that validates migration by comparing +// counts between legacy tables and unified storage. +// +// This is a helper for the common case of validating that all items from legacy tables +// were successfully migrated to unified storage. +// +// Parameters: +// - legacyTableMap: maps "group/resource" keys to LegacyTableInfo for validation. +// Only resources with mappings will be validated. +// +// Example: +// +// validator := NewLegacyTableCountValidator(map[string]LegacyTableInfo{ +// "dashboard.grafana.app/dashboards": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = false"}, +// "folder.grafana.app/folders": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = true"}, +// }) +func NewLegacyTableCountValidator(legacyTableMap map[string]LegacyTableInfo) ValidationFunc { + return func(sess *xorm.Session, response *resourcepb.BulkResponse, log log.Logger) error { + // Check for rejected items + if len(response.Rejected) > 0 { + log.Warn("Migration had rejected items", "count", len(response.Rejected)) + for i, rejected := range response.Rejected { + if i < 10 { // Log first 10 rejected items + log.Warn("Rejected item", + "namespace", rejected.Key.Namespace, + "group", rejected.Key.Group, + "resource", rejected.Key.Resource, + "name", rejected.Key.Name, + "reason", rejected.Error) + } + } + // Rejections are not fatal - they may be expected for invalid data + } + + // Validate counts for each resource type + for _, summary := range response.Summary { + key := fmt.Sprintf("%s/%s", summary.Group, summary.Resource) + tableInfo, ok := legacyTableMap[key] + if !ok { + log.Debug("No legacy table mapping for resource, skipping count validation", + "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), + "namespace", summary.Namespace) + continue + } + + // Get legacy count + orgID, err := ParseOrgIDFromNamespace(summary.Namespace) + if err != nil { + return fmt.Errorf("invalid namespace %s: %w", summary.Namespace, err) + } + + legacyCount, err := sess.Table(tableInfo.Table).Where(tableInfo.WhereClause, orgID).Count() + if err != nil { + return fmt.Errorf("failed to count %s: %w", tableInfo.Table, err) + } + + // Account for rejected items in validation + expectedCount := summary.Count + int64(len(response.Rejected)) + + log.Info("Count validation", + "resource", fmt.Sprintf("%s.%s", summary.Resource, summary.Group), + "namespace", summary.Namespace, + "legacy_count", legacyCount, + "unified_count", summary.Count, + "rejected", len(response.Rejected), + "history", summary.History) + + // Validate that we migrated all items (allowing for rejected items) + if legacyCount > expectedCount { + return fmt.Errorf("count mismatch for %s.%s in namespace %s: legacy has %d, unified has %d, rejected %d", + summary.Resource, summary.Group, summary.Namespace, + legacyCount, summary.Count, len(response.Rejected)) + } + } + + return nil + } +} + +func ParseOrgIDFromNamespace(namespace string) (int64, error) { + // Use authlib to properly parse all namespace formats including "default" for org 1 + info, err := types.ParseNamespace(namespace) + if err != nil { + return 0, fmt.Errorf("failed to parse namespace: %w", err) + } + return info.OrgID, nil +} + +// orgInfo represents basic organization information +type orgInfo struct { + ID int64 `xorm:"id"` + Name string `xorm:"name"` +} + +// getAllOrgs retrieves all organizations from the database +func (m *ResourceMigration) getAllOrgs(sess *xorm.Session) ([]orgInfo, error) { + var orgs []orgInfo + err := sess.Table("org").Cols("id", "name").Find(&orgs) + if err != nil { + return nil, err + } + return orgs, nil +} diff --git a/pkg/storage/unified/migrations/service.go b/pkg/storage/unified/migrations/service.go new file mode 100644 index 00000000000..5bcb2fdf8c2 --- /dev/null +++ b/pkg/storage/unified/migrations/service.go @@ -0,0 +1,118 @@ +package migrations + +import ( + "context" + "fmt" + "os" + + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/infra/kvstore" + "github.com/grafana/grafana/pkg/infra/log" + sqlstoremigrator "github.com/grafana/grafana/pkg/services/sqlstore/migrator" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/migrations/contract" + "github.com/prometheus/client_golang/prometheus" + "go.opentelemetry.io/otel" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/migrations") +var logger = log.New("storage.unified.migrations") + +type UnifiedStorageMigrationServiceImpl struct { + migrator UnifiedMigrator + cfg *setting.Cfg + sqlStore db.DB + kv kvstore.KVStore +} + +var _ contract.UnifiedStorageMigrationService = (*UnifiedStorageMigrationServiceImpl)(nil) + +// ProvideUnifiedStorageMigrationService is a Wire provider that creates the migration service. +// The service implements registry.BackgroundService and runs migrations during server startup. +func ProvideUnifiedStorageMigrationService( + migrator UnifiedMigrator, + cfg *setting.Cfg, + sqlStore db.DB, + kv kvstore.KVStore, +) contract.UnifiedStorageMigrationService { + return &UnifiedStorageMigrationServiceImpl{ + migrator: migrator, + cfg: cfg, + sqlStore: sqlStore, + kv: kv, + } +} + +// Run executes unified storage migrations as a background service. +// This blocks until migrations complete. If migrations fail, an error is returned +// which will prevent Grafana from starting. +func (p *UnifiedStorageMigrationServiceImpl) Run(ctx context.Context) error { + // TODO: temporary skip migrations in test environments to prevent integration test timeouts. + if os.Getenv("GRAFANA_TEST_DB") != "" { + return nil + } + + // skip migrations if disabled in config + if p.cfg.DisableDataMigrations { + logger.Info("Data migrations are disabled, skipping") + return nil + } + + // TODO: Re-enable once migrations are ready + // TODO: add guarantee that this only runs once + // return RegisterMigrations(p.migrator, p.cfg, p.sqlStore) + return nil +} + +// RegisterMigrations initializes and registers all unified storage migrations. +// This function is the entry point for all data migrations from legacy storage +// to unified storage. It returns an error if migrations fail, preventing Grafana +// from starting with inconsistent data. +func RegisterMigrations( + migrator UnifiedMigrator, + cfg *setting.Cfg, + sqlStore db.DB, +) error { + ctx, span := tracer.Start(context.Background(), "storage.unified.RegisterMigrations") + defer span.End() + mg := sqlstoremigrator.NewScopedMigrator(sqlStore.GetEngine(), cfg, "unifiedstorage") + mg.AddCreateMigration() + + if err := prometheus.Register(mg); err != nil { + logger.Warn("Failed to register migrator metrics", "error", err) + } + + // Register resource migrations + // To add a new resource type, simply add another migration here with the appropriate resources + registerResourceMigrations(mg, migrator) + + // Run all registered migrations (blocking) + sec := cfg.Raw.Section("database") + if err := mg.RunMigrations(ctx, + sec.Key("migration_locking").MustBool(true), + sec.Key("locking_attempt_timeout_sec").MustInt()); err != nil { + return fmt.Errorf("unified storage data migration failed: %w", err) + } + + logger.Info("Unified storage migrations completed successfully") + return nil +} + +// registerResourceMigrations registers all unified storage resource migrations. +// Add new resource types here by creating additional ResourceMigration instances. +func registerResourceMigrations(mg *sqlstoremigrator.Migrator, migrator UnifiedMigrator) { + dashboardsAndFolders := NewResourceMigration( + migrator, + []schema.GroupResource{ + {Group: "folder.grafana.app", Resource: "folders"}, + {Group: "dashboard.grafana.app", Resource: "dashboards"}, + }, + "folders-dashboards", + NewLegacyTableCountValidator(map[string]LegacyTableInfo{ + "folder.grafana.app/folders": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = true"}, + "dashboard.grafana.app/dashboards": {Table: "dashboard", WhereClause: "org_id = ? and is_folder = false"}, + }), + ) + mg.AddMigration("folders and dashboards migration", dashboardsAndFolders) +} From 534ed3421b75505c6a6f376e18c062a7b34833d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Peter=20=C5=A0tibran=C3=BD?= Date: Thu, 20 Nov 2025 17:09:49 +0100 Subject: [PATCH 125/125] Fix search by both tags and folders. (#114246) * Fix search by both tags and folders. * Move // nolint:gocyclo to the new method. * Revert unnecessary change. --- pkg/registry/apis/dashboard/search.go | 118 ++--- pkg/registry/apis/dashboard/search_test.go | 491 +++++++++++++++++---- 2 files changed, 470 insertions(+), 139 deletions(-) diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index ec2f6053b95..0f1b83a7e18 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -3,6 +3,7 @@ package dashboard import ( "context" "encoding/json" + "errors" "fmt" "net/http" "net/url" @@ -272,7 +273,8 @@ func (s *SearchHandler) DoSortable(w http.ResponseWriter, r *http.Request) { const rootFolder = "general" -// nolint:gocyclo +var errEmptyResults = fmt.Errorf("empty results") + func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { ctx, span := s.tracer.Start(r.Context(), "dashboard.search") defer span.End() @@ -289,6 +291,51 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { return } + searchRequest, err := convertHttpSearchRequestToResourceSearchRequest(queryParams, user, func() ([]string, error) { + return s.getDashboardsUIDsSharedWithUser(ctx, user) + }) + if err != nil { + if errors.Is(err, errEmptyResults) { + s.write(w, dashboardv0alpha1.SearchResults{ + Hits: []dashboardv0alpha1.DashboardHit{}, + }) + } else { + errhttp.Write(ctx, err, w) + } + return + } + + result, err := s.client.Search(ctx, searchRequest) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if result != nil { + s.log.Debug("search result hits and cost", "total_hits", result.TotalHits, "query_cost", result.QueryCost) + } + + parsedResults, err := dashboardsearch.ParseResults(result, searchRequest.Offset) + if err != nil { + errhttp.Write(ctx, err, w) + return + } + + if len(searchRequest.SortBy) == 0 { + // default sort by resource descending ( folders then dashboards ) then title + sort.Slice(parsedResults.Hits, func(i, j int) bool { + // Just sorting by resource for now. The rest should be sorted by search score already + return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource + }) + } + + s.write(w, parsedResults) +} + +// convertHttpSearchRequestToResourceSearchRequest create ResourceSearchRequest from query parameters. +// Supplied function is used to get dashboards shared with user. +// nolint:gocyclo +func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, user identity.Requester, getDashboardsUIDsSharedWithUser func() ([]string, error)) (*resourcepb.ResourceSearchRequest, error) { // get limit and offset from query params limit := 50 offset := 0 @@ -339,6 +386,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { hasDash := len(types) == 0 || slices.Contains(types, "dashboard") hasFolder := len(types) == 0 || slices.Contains(types, "folder") // If both types are present, we need to search both dashboards and folders, by default is nothing is set we also search both. + var err error if (hasDash && hasFolder) || (!hasDash && !hasFolder) { searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) if err == nil { @@ -352,8 +400,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { searchRequest.Options.Key, err = asResourceKey(user.GetNamespace(), dashboardv0alpha1.DASHBOARD_RESOURCE) } if err != nil { - errhttp.Write(ctx, err, w) - return + return nil, err } // Add sorting @@ -384,20 +431,20 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { // The tags filter if tags, ok := queryParams["tag"]; ok { - searchRequest.Options.Fields = []*resourcepb.Requirement{{ + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: "tags", Operator: "=", Values: tags, - }} + }) } // The libraryPanel filter if libraryPanel, ok := queryParams["libraryPanel"]; ok { - searchRequest.Options.Fields = []*resourcepb.Requirement{{ + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: search.DASHBOARD_LIBRARY_PANEL_REFERENCE, Operator: "=", Values: libraryPanel, - }} + }) } // The names filter @@ -406,17 +453,13 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { // Add the folder constraint. Note this does not do recursive search folder := queryParams.Get("folder") if folder == foldermodel.SharedWithMeFolderUID { - dashboardUIDs, err := s.getDashboardsUIDsSharedWithUser(ctx, user) + dashboardUIDs, err := getDashboardsUIDsSharedWithUser() if err != nil { - errhttp.Write(ctx, err, w) - return + return nil, err } if len(dashboardUIDs) == 0 { - s.write(w, dashboardv0alpha1.SearchResults{ - Hits: []dashboardv0alpha1.DashboardHit{}, - }) - return + return nil, errEmptyResults } // hijacks the "name" query param to only search for shared dashboard UIDs names = append(names, dashboardUIDs...) @@ -424,50 +467,21 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { if folder == rootFolder { folder = "" // root folder is empty in the search index } - searchRequest.Options.Fields = []*resourcepb.Requirement{{ + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ Key: "folder", Operator: "=", Values: []string{folder}, - }} - } - - if len(names) > 0 { - if searchRequest.Options.Fields == nil { - searchRequest.Options.Fields = []*resourcepb.Requirement{} - } - namesFilter := []*resourcepb.Requirement{{ - Key: "name", - Operator: "in", - Values: names, - }} - searchRequest.Options.Fields = append(searchRequest.Options.Fields, namesFilter...) - } - - result, err := s.client.Search(ctx, searchRequest) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if result != nil { - s.log.Debug("search result hits and cost", "total_hits", result.TotalHits, "query_cost", result.QueryCost) - } - - parsedResults, err := dashboardsearch.ParseResults(result, searchRequest.Offset) - if err != nil { - errhttp.Write(ctx, err, w) - return - } - - if len(searchRequest.SortBy) == 0 { - // default sort by resource descending ( folders then dashboards ) then title - sort.Slice(parsedResults.Hits, func(i, j int) bool { - // Just sorting by resource for now. The rest should be sorted by search score already - return parsedResults.Hits[i].Resource > parsedResults.Hits[j].Resource }) } - s.write(w, parsedResults) + if len(names) > 0 { + searchRequest.Options.Fields = append(searchRequest.Options.Fields, &resourcepb.Requirement{ + Key: "name", + Operator: "in", + Values: names, + }) + } + return searchRequest, nil } func (s *SearchHandler) write(w http.ResponseWriter, obj any) { diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index b392cb1606c..ab7f05e7d00 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http/httptest" + "net/url" "testing" "github.com/stretchr/testify/assert" @@ -17,6 +18,7 @@ import ( "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/services/dashboards" + "github.com/grafana/grafana/pkg/services/dashboards/dashboardaccess" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/user" "github.com/grafana/grafana/pkg/setting" @@ -282,93 +284,6 @@ func TestSearchHandlerPagination(t *testing.T) { } func TestSearchHandler(t *testing.T) { - t.Run("Multiple comma separated fields will be appended to default dashboard search fields", func(t *testing.T) { - // Create a mock client - mockClient := &MockClient{} - - features := featuremgmt.WithFeatures() - // Initialize the search handler with the mock client - searchHandler := SearchHandler{ - log: log.New("test", "test"), - client: mockClient, - tracer: tracing.NewNoopTracerService(), - features: features, - } - - rr := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/search?field=field1&field=field2&field=field3", nil) - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) - - searchHandler.DoSearch(rr, req) - - if mockClient.LastSearchRequest == nil { - t.Fatalf("expected Search to be called, but it was not") - } - expectedFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id", "field1", "field2", "field3"} - if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) { - t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields) - } - }) - - t.Run("Single field will be appended to default dashboard search fields", func(t *testing.T) { - // Create a mock client - mockClient := &MockClient{} - - features := featuremgmt.WithFeatures() - // Initialize the search handler with the mock client - searchHandler := SearchHandler{ - log: log.New("test", "test"), - client: mockClient, - tracer: tracing.NewNoopTracerService(), - features: features, - } - - rr := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/search?field=field1", nil) - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) - - searchHandler.DoSearch(rr, req) - - if mockClient.LastSearchRequest == nil { - t.Fatalf("expected Search to be called, but it was not") - } - expectedFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id", "field1"} - if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) { - t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields) - } - }) - - t.Run("Passing no fields will search using default dashboard fields", func(t *testing.T) { - // Create a mock client - mockClient := &MockClient{} - - features := featuremgmt.WithFeatures() - // Initialize the search handler with the mock client - searchHandler := SearchHandler{ - log: log.New("test", "test"), - client: mockClient, - tracer: tracing.NewNoopTracerService(), - features: features, - } - - rr := httptest.NewRecorder() - req := httptest.NewRequest("GET", "/search", nil) - req.Header.Add("content-type", "application/json") - req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test"})) - - searchHandler.DoSearch(rr, req) - - if mockClient.LastSearchRequest == nil { - t.Fatalf("expected Search to be called, but it was not") - } - expectedFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id"} - if fmt.Sprintf("%v", mockClient.LastSearchRequest.Fields) != fmt.Sprintf("%v", expectedFields) { - t.Errorf("expected fields %v, got %v", expectedFields, mockClient.LastSearchRequest.Fields) - } - }) - t.Run("Sort - default sort by resource", func(t *testing.T) { rows := make([]*resourcepb.ResourceTableRow, len(mockResults)) for i, r := range mockResults { @@ -689,6 +604,408 @@ func TestSearchHandlerSharedDashboards(t *testing.T) { }) } +func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { + testUser := &user.SignedInUser{ + Namespace: "test-namespace", + OrgID: 1, + } + + dashboardKey := &resourcepb.ResourceKey{ + Group: "dashboard.grafana.app", + Resource: "dashboards", + Namespace: "test-namespace", + } + folderKey := &resourcepb.ResourceKey{ + Group: "folder.grafana.app", + Resource: "folders", + Namespace: "test-namespace", + } + defaultFields := []string{"title", "folder", "tags", "description", "manager.kind", "manager.id"} + + tests := map[string]struct { + queryString string + sharedDashboards []string + sharedDashboardsError error + expected *resourcepb.ResourceSearchRequest + expectedError error + }{ + "default values with no query params": { + queryString: "", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "custom limit and offset": { + queryString: "limit=100&offset=50", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 100, + Offset: 50, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "pagination with page parameter": { + queryString: "limit=25&page=3", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 25, + Offset: 50, + Page: 3, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "query string and explain": { + queryString: "query=test-query&explain=true", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "test-query", + Limit: 50, + Offset: 0, + Page: 1, + Explain: true, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "additional fields": { + queryString: "field=custom1&field=custom2", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: append(defaultFields, "custom1", "custom2"), + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "view permission": { + queryString: "permission=view", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Permission: int64(dashboardaccess.PERMISSION_VIEW), + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "edit permission": { + queryString: "permission=Edit", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Permission: int64(dashboardaccess.PERMISSION_EDIT), + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "admin permission": { + queryString: "permission=ADMIN", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Permission: int64(dashboardaccess.PERMISSION_ADMIN), + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "type dashboard only": { + queryString: "type=dashboard", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + }, + }, + "type folder only": { + queryString: "type=folder", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: folderKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + }, + }, + "both types should include federated": { + queryString: "type=dashboard&type=folder", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "sort ascending": { + queryString: "sort=title", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + SortBy: []*resourcepb.ResourceSearchRequest_Sort{{Field: "title", Desc: false}}, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "sort descending": { + queryString: "sort=-title", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + SortBy: []*resourcepb.ResourceSearchRequest_Sort{{Field: "title", Desc: true}}, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "facet fields": { + queryString: "facet=tags&facet=folder", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{Key: dashboardKey}, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Facet: map[string]*resourcepb.ResourceSearchRequest_Facet{ + "tags": {Field: "tags", Limit: 50}, + "folder": {Field: "folder", Limit: 50}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "tag filter": { + queryString: "tag=tag1&tag=tag2", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{{Key: "tags", Operator: "=", Values: []string{"tag1", "tag2"}}}, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "folder filter": { + queryString: "folder=my-folder", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{{Key: "folder", Operator: "=", Values: []string{"my-folder"}}}, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "tag and folder filter together": { + queryString: "tag=tag1&tag=tag2&folder=my-folder", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{ + {Key: "tags", Operator: "=", Values: []string{"tag1", "tag2"}}, + {Key: "folder", Operator: "=", Values: []string{"my-folder"}}, + }, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "root folder should be converted to empty string": { + queryString: "folder=general", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{{Key: "folder", Operator: "=", Values: []string{""}}}, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "shared with me folder with dashboards": { + queryString: "folder=sharedwithme", + sharedDashboards: []string{"dash1", "dash2", "dash3"}, + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{{Key: "name", Operator: "in", Values: []string{"dash1", "dash2", "dash3"}}}, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "shared with me folder without dashboards returns error": { + queryString: "folder=sharedwithme", + sharedDashboards: []string{}, + expectedError: errEmptyResults, + }, + "name filter": { + queryString: "name=name1&name=name2", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{{Key: "name", Operator: "in", Values: []string{"name1", "name2"}}}, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "comprehensive filter with query, tags, folder, and name": { + queryString: "query=search-term&tag=monitoring&tag=prod&folder=my-folder&name=dash1&name=dash2", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{ + {Key: "tags", Operator: "=", Values: []string{"monitoring", "prod"}}, + {Key: "folder", Operator: "=", Values: []string{"my-folder"}}, + {Key: "name", Operator: "in", Values: []string{"dash1", "dash2"}}, + }, + }, + Query: "search-term", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "libraryPanel filter": { + queryString: "libraryPanel=panel1&libraryPanel=panel2", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{{Key: "reference.LibraryPanel", Operator: "=", Values: []string{"panel1", "panel2"}}}, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "libraryPanel and tag filter together": { + queryString: "libraryPanel=panel1&tag=monitoring&tag=prod", + expected: &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: dashboardKey, + Fields: []*resourcepb.Requirement{ + {Key: "tags", Operator: "=", Values: []string{"monitoring", "prod"}}, + {Key: "reference.LibraryPanel", Operator: "=", Values: []string{"panel1"}}, + }, + }, + Query: "", + Limit: 50, + Offset: 0, + Page: 1, + Explain: false, + Fields: defaultFields, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + queryParams, err := url.ParseQuery(tt.queryString) + require.NoError(t, err) + + getDashboardsFunc := func() ([]string, error) { + if tt.sharedDashboardsError != nil { + return nil, tt.sharedDashboardsError + } + return tt.sharedDashboards, nil + } + + result, err := convertHttpSearchRequestToResourceSearchRequest(queryParams, testUser, getDashboardsFunc) + + if tt.expectedError != nil { + assert.ErrorIs(t, err, tt.expectedError) + return + } + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, tt.expected, result) + }) + } +} + // MockClient implements the ResourceIndexClient interface for testing type MockClient struct { resourcepb.ResourceIndexClient