From 7812f783bb44e10cb0aea4cbc36af4c3ca47d854 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 19 Dec 2025 17:36:46 +0200 Subject: [PATCH 01/80] Provisioning: Enable editing dashboard via JSON model (#115420) * Provisioning: Enable save for json model changes * Do not pass props * Simplify logic and fix warnings * add tests * Show diff for json changes * Add try/catch --- eslint-suppressions.json | 5 -- .../dashboard-scene/scene/DashboardScene.tsx | 41 ++++++++--- .../serialization/DashboardSceneSerializer.ts | 21 ++++-- .../settings/JsonModelEditView.tsx | 15 +++- .../SaveProvisionedDashboardForm.test.tsx | 72 ++++++++++++++++++- .../SaveProvisionedDashboardForm.tsx | 21 +++--- public/locales/en-US/grafana.json | 2 +- 7 files changed, 147 insertions(+), 30 deletions(-) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 36eb78aaa72..597f2b94a1a 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -1911,11 +1911,6 @@ "count": 1 } }, - "public/app/features/dashboard-scene/settings/JsonModelEditView.tsx": { - "react/no-unescaped-entities": { - "count": 2 - } - }, "public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx": { "react-hooks/rules-of-hooks": { "count": 4 diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 82a20dbcd52..7ddd7c4e779 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -59,6 +59,7 @@ import { gridItemToGridLayoutItemKind } from '../serialization/layoutSerializers import { getElement } from '../serialization/layoutSerializers/utils'; import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; import { gridItemToPanel } from '../serialization/transformSceneToSaveModel'; +import { JsonModelEditView } from '../settings/JsonModelEditView'; import { DecoratedRevisionModel } from '../settings/VersionsEditView'; import { DashboardEditView } from '../settings/utils'; import { historySrv } from '../settings/version-history/HistorySrv'; @@ -855,30 +856,54 @@ export class DashboardScene extends SceneObjectBase impleme return this.serializer.getSaveModel(this); } - // Get the dashboard in native K8s form (using the appropriate apiVersion) - getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + // Helper method to build K8s resource structure + private buildResourceForCreate(spec: Dashboard | DashboardV2Spec, isNew: boolean): ResourceForCreate { const { meta } = this.state; - const spec = this.getSaveAsModel(options); - - const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; // get from the dashboard? + const apiVersion = this.serializer instanceof V2DashboardSerializer ? 'v2beta1' : 'v1beta1'; return { apiVersion: `dashboard.grafana.app/${apiVersion}`, kind: 'Dashboard', metadata: { ...meta.k8s, - name: options.isNew ? undefined : (meta.uid ?? meta.k8s?.name), - generateName: options.isNew ? 'd' : undefined, + name: isNew ? undefined : (meta.uid ?? meta.k8s?.name), + generateName: isNew ? 'd' : undefined, }, spec, }; } + // Get the dashboard in native K8s form (using the appropriate apiVersion) + getSaveResource(options: SaveDashboardAsOptions): ResourceForCreate { + const spec = this.getSaveAsModel(options); + return this.buildResourceForCreate(spec, options.isNew ?? false); + } + + // Wrap a raw dashboard spec in K8s resource format + // Used by JSON model editor for Git sync dashboards + getSaveResourceFromSpec(rawSpec: Dashboard | DashboardV2Spec): ResourceForCreate { + return this.buildResourceForCreate(rawSpec, false); + } + + // Get raw JSON from JSON model editor if currently active + // Returns undefined if not in JSON editor mode or if JSON is invalid + getRawJsonFromEditor(): Dashboard | DashboardV2Spec | undefined { + if (this.state.editview instanceof JsonModelEditView) { + try { + return JSON.parse(this.state.editview.state.jsonText); + } catch { + return undefined; + } + } + return undefined; + } + getSaveAsModel(options: SaveDashboardAsOptions): Dashboard | DashboardV2Spec { return this.serializer.getSaveAsModel(this, options); } getDashboardChanges(saveTimeRange?: boolean, saveVariables?: boolean, saveRefresh?: boolean): DashboardChangeInfo { - return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh }); + const rawJson = this.getRawJsonFromEditor(); + return this.serializer.getDashboardChangesFromScene(this, { saveTimeRange, saveVariables, saveRefresh, rawJson }); } getManagerKind(): ManagerKind | undefined { diff --git a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts index cede8f605a5..130c5450bf0 100644 --- a/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts +++ b/public/app/features/dashboard-scene/serialization/DashboardSceneSerializer.ts @@ -46,6 +46,7 @@ export interface DashboardSceneSerializerLike DashboardChangeInfo; onSaveComplete(saveModel: T, result: SaveDashboardResponseDTO): void; @@ -184,9 +185,15 @@ export class V1DashboardSerializer getDashboardChangesFromScene( scene: DashboardScene, - options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + rawJson?: Dashboard | DashboardV2Spec; + } ) { - const changedSaveModel = this.getSaveModel(scene); + const changedSaveModel = + options.rawJson && !isDashboardV2Spec(options.rawJson) ? options.rawJson : this.getSaveModel(scene); const changeInfo = getRawDashboardChanges( this.initialSaveModel!, changedSaveModel, @@ -396,9 +403,15 @@ export class V2DashboardSerializer getDashboardChangesFromScene( scene: DashboardScene, - options: { saveTimeRange?: boolean; saveVariables?: boolean; saveRefresh?: boolean } + options: { + saveTimeRange?: boolean; + saveVariables?: boolean; + saveRefresh?: boolean; + rawJson?: Dashboard | DashboardV2Spec; + } ) { - const changedSaveModel = this.getSaveModel(scene); + const changedSaveModel = + options.rawJson && isDashboardV2Spec(options.rawJson) ? options.rawJson : this.getSaveModel(scene); const changeInfo = getRawDashboardV2Changes( this.initialSaveModel!, changedSaveModel, diff --git a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx index 30979075d2a..99522077ae3 100644 --- a/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx +++ b/public/app/features/dashboard-scene/settings/JsonModelEditView.tsx @@ -3,7 +3,7 @@ import { useState } from 'react'; import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; import { Trans, t } from '@grafana/i18n'; -import { SceneComponentProps, SceneObjectBase, sceneUtils } from '@grafana/scenes'; +import { SceneComponentProps, SceneObjectBase, SceneObjectRef, sceneUtils } from '@grafana/scenes'; import { Dashboard } from '@grafana/schema'; import { Spec as DashboardV2Spec } from '@grafana/schema/dist/esm/schema/dashboard/v2'; import { Alert, Box, Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui'; @@ -11,8 +11,10 @@ import { Page } from 'app/core/components/Page/Page'; import { getDashboardAPI } from 'app/features/dashboard/api/dashboard_api'; import { isDashboardV2Spec } from 'app/features/dashboard/api/utils'; import { getPrettyJSON } from 'app/features/inspector/utils/utils'; +import { useIsProvisionedNG } from 'app/features/provisioning/hooks/useIsProvisionedNG'; import { DashboardDataDTO, SaveDashboardResponseDTO } from 'app/types/dashboard'; +import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer'; import { NameAlreadyExistsError, isNameExistsError, @@ -105,12 +107,21 @@ function JsonModelEditViewComponent({ model }: SceneComponentProps { + if (isProvisionedNG) { + const drawer = new SaveDashboardDrawer({ + dashboardRef: new SceneObjectRef(dashboard), + }); + dashboard.setState({ overlay: drawer }); + return; + } + const result = await onSaveDashboard(dashboard, { folderUid: dashboard.state.meta.folderUid, overwrite, @@ -136,7 +147,7 @@ function JsonModelEditViewComponent({ model }: SceneComponentProps {overwrite ? ( - 'Save and overwrite' + Save and overwrite ) : ( Save changes )} diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx index bca9ca1939c..69a94c5e9ae 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.test.tsx @@ -9,7 +9,7 @@ import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScen import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv'; import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile'; -import { SaveProvisionedDashboardForm, Props } from './SaveProvisionedDashboardForm'; +import { Props, SaveProvisionedDashboardForm } from './SaveProvisionedDashboardForm'; jest.mock('@grafana/runtime', () => { const actual = jest.requireActual('@grafana/runtime'); @@ -118,6 +118,7 @@ function setup(props: Partial = {}) { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue(mockDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, drawer: { onClose: jest.fn(), @@ -291,6 +292,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveResource: jest.fn().mockReturnValue(updatedDashboard), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -385,6 +387,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -431,6 +434,7 @@ describe('SaveProvisionedDashboardForm', () => { closeModal: jest.fn(), getSaveAsModel: jest.fn().mockReturnValue({}), setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(undefined), } as unknown as DashboardScene, }); @@ -445,4 +449,70 @@ describe('SaveProvisionedDashboardForm', () => { expect(saveButton).toBeEnabled(); }); }); + + it('should save dashboard with raw JSON from editor', async () => { + const mockAction = jest.fn(); + const mockRequest = { ...mockRequestBase, isSuccess: true }; + (useCreateOrUpdateRepositoryFile as jest.Mock).mockReturnValue([mockAction, mockRequest]); + + const rawJson = JSON.stringify({ + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }); + + const dashboardFromRawJson = { + apiVersion: 'dashboard.grafana.app/v1alpha1', + kind: 'Dashboard', + metadata: { + generateName: 'p', + name: undefined, + }, + spec: { + title: 'Raw JSON Dashboard', + panels: [], + schemaVersion: 36, + }, + }; + + const { user } = setup({ + dashboard: { + useState: () => ({ + meta: { + folderUid: 'folder-uid', + slug: 'test-dashboard', + }, + title: 'Test Dashboard', + description: 'Test Description', + isDirty: false, + }), + setState: jest.fn(), + closeModal: jest.fn(), + getSaveAsModel: jest.fn().mockReturnValue({}), + getSaveResource: jest.fn().mockReturnValue(dashboardFromRawJson), + getSaveResourceFromSpec: jest.fn().mockReturnValue(dashboardFromRawJson), + setManager: jest.fn(), + getRawJsonFromEditor: jest.fn().mockReturnValue(rawJson), + } as unknown as DashboardScene, + }); + + const saveButton = screen.getByRole('button', { name: /save/i }); + expect(saveButton).toBeEnabled(); + + const commentInput = screen.getByRole('textbox', { name: /comment/i }); + await user.clear(commentInput); + await user.type(commentInput, 'Save with raw JSON'); + + await user.click(saveButton); + + await waitFor(() => { + expect(mockAction).toHaveBeenCalledWith({ + ref: 'dashboard/2023-01-01-abcde', + name: 'test-repo', + path: 'test-dashboard.json', + message: 'Save with raw JSON', + body: dashboardFromRawJson, + }); + }); + }); }); diff --git a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx index a2df43a1f95..3fee476b189 100644 --- a/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx +++ b/public/app/features/provisioning/components/Dashboards/SaveProvisionedDashboardForm.tsx @@ -65,8 +65,9 @@ export function SaveProvisionedDashboardForm({ register, formState: { dirtyFields }, } = methods; - // button enabled if form comment is dirty or dashboard state is dirty - const isDirtyState = Boolean(dirtyFields.comment) || isDirty; + // button enabled if form comment is dirty or dashboard state is dirty or raw JSON was provided from editor + const rawDashboardJSON = dashboard.getRawJsonFromEditor(); + const isDirtyState = Boolean(dirtyFields.comment) || isDirty || Boolean(rawDashboardJSON); const [workflow, ref, path] = watch(['workflow', 'ref', 'path']); // Update the form if default values change @@ -191,13 +192,15 @@ export function SaveProvisionedDashboardForm({ const message = comment || `Save dashboard: ${dashboard.state.title}`; - const body = dashboard.getSaveResource({ - isNew, - title, - description, - copyTags, - saveAsCopy, - }); + const body = rawDashboardJSON + ? dashboard.getSaveResourceFromSpec(rawDashboardJSON) + : dashboard.getSaveResource({ + isNew, + title, + description, + copyTags, + saveAsCopy, + }); reportInteraction('grafana_provisioning_dashboard_save_submitted', { workflow, diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 7a1dd06e31f..8e4ed2c7b20 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -6210,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Someone else has updated this dashboard", "would-still-dashboard": "Would you still like to save this dashboard?" }, - "save-and-overwrite": "'Save and overwrite'" + "save-and-overwrite": "Save and overwrite" }, "library-viz-panel-info": { "last-edited": "{{timeAgo}} by ", From aa69d97f1eb6a871fa1d7dc3d82f207c1754652d Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 19 Dec 2025 10:38:22 -0500 Subject: [PATCH 02/80] Variables: Show variable reference instead of interpolated datasource in query variable editor (#115624) show variable ref --- .../settings/variables/editors/QueryVariableEditor.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx index c32bc8eda8d..3f0583d3245 100644 --- a/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx +++ b/public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.tsx @@ -265,7 +265,7 @@ export function Editor({ variable }: { variable: QueryVariable }) { htmlFor="data-source-picker" noMargin > - + {selectedDatasource && VariableQueryEditor && ( From 0e4b1c7b1e23c5ad40669fdd6ee4b28b6e33cfb4 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Fri, 19 Dec 2025 17:57:45 +0200 Subject: [PATCH 03/80] Provisioning: Fix error loop in synchronise step (#115570) * Refactor requiresMigration * Remove InlineSecureValueWarning * Prevent error loop * Fix error loop * Cleanup * i18n --- .../utils/createOnCacheEntryAdded.ts | 1 + .../provisioning/Config/ConfigForm.tsx | 2 -- public/app/features/provisioning/HomePage.tsx | 2 -- .../provisioning/Job/FinishedJobStatus.tsx | 5 +++ .../features/provisioning/Job/JobStatus.tsx | 21 ++++++++---- .../Repository/RepositoryStatusPage.tsx | 9 ++++-- .../Wizard/hooks/useResourceStats.ts | 18 ++--------- .../components/InlineSecureValueWarning.tsx | 32 ------------------- .../features/provisioning/utils/httpUtils.ts | 3 ++ public/locales/en-US/grafana.json | 4 ++- 10 files changed, 37 insertions(+), 60 deletions(-) delete mode 100644 public/app/features/provisioning/components/InlineSecureValueWarning.tsx diff --git a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts index c3c44ede267..0d39d4fa402 100644 --- a/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts +++ b/public/app/api/clients/provisioning/utils/createOnCacheEntryAdded.ts @@ -58,6 +58,7 @@ export function createOnCacheEntryAdded(resourceName: string) { }); } catch (error) { console.error('Error in onCacheEntryAdded:', error); + return; } await cacheEntryRemoved; diff --git a/public/app/features/provisioning/Config/ConfigForm.tsx b/public/app/features/provisioning/Config/ConfigForm.tsx index 9c86403fee6..3b1aebc1911 100644 --- a/public/app/features/provisioning/Config/ConfigForm.tsx +++ b/public/app/features/provisioning/Config/ConfigForm.tsx @@ -28,7 +28,6 @@ import { FormPrompt } from 'app/core/components/FormPrompt/FormPrompt'; import { DeleteRepositoryButton } from '../Repository/DeleteRepositoryButton'; import { TokenPermissionsInfo } from '../Shared/TokenPermissionsInfo'; import { getGitProviderFields, getLocalProviderFields } from '../Wizard/fields'; -import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; import { useCreateOrUpdateRepository } from '../hooks/useCreateOrUpdateRepository'; import { RepositoryFormData } from '../types'; @@ -178,7 +177,6 @@ export function ConfigForm({ data }: ConfigFormProps) { {gitFields && ( <> - } > - diff --git a/public/app/features/provisioning/Job/JobStatus.tsx b/public/app/features/provisioning/Job/JobStatus.tsx index 02e0b733bbb..7aec776f8aa 100644 --- a/public/app/features/provisioning/Job/JobStatus.tsx +++ b/public/app/features/provisioning/Job/JobStatus.tsx @@ -1,8 +1,11 @@ +import { useEffect } from 'react'; + import { Trans, t } from '@grafana/i18n'; import { Spinner, Stack, Text } from '@grafana/ui'; import { Job, useListJobQuery } from 'app/api/clients/provisioning/v0alpha1'; import { StepStatusInfo } from '../Wizard/types'; +import { getErrorMessage } from '../utils/httpUtils'; import { FinishedJobStatus } from './FinishedJobStatus'; import { JobContent } from './JobContent'; @@ -25,6 +28,18 @@ export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { const activeQueryCompleted = !activeQuery.isUninitialized && !activeQuery.isLoading; const shouldCheckFinishedJobs = activeQueryCompleted && !activeJob && !!repoLabel; + useEffect(() => { + if (activeQuery.isError) { + onStatusChange?.({ + status: 'error', + error: { + title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'), + message: getErrorMessage(activeQuery.error), + }, + }); + } + }, [activeQuery.isError, activeQuery.error, onStatusChange]); + if (activeQuery.isLoading) { return ( @@ -37,12 +52,6 @@ export function JobStatus({ jobType, watch, onStatusChange }: JobStatusProps) { } if (activeQuery.isError) { - onStatusChange?.({ - status: 'error', - error: { - title: t('provisioning.job-status.title.error-fetching-active-job', 'Error fetching active job'), - }, - }); return null; } diff --git a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx index a8984eaa51f..e18e47500da 100644 --- a/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx +++ b/public/app/features/provisioning/Repository/RepositoryStatusPage.tsx @@ -10,8 +10,8 @@ import { Page } from 'app/core/components/Page/Page'; import { useQueryParams } from 'app/core/hooks/useQueryParams'; import { isNotFoundError } from 'app/features/alerting/unified/api/util'; -import { InlineSecureValueWarning } from '../components/InlineSecureValueWarning'; import { PROVISIONING_URL } from '../constants'; +import { getErrorMessage } from '../utils/httpUtils'; import { RepositoryActions } from './RepositoryActions'; import { RepositoryOverview } from './RepositoryOverview'; @@ -36,6 +36,7 @@ export default function RepositoryStatusPage() { const tab = queryParams['tab'] ?? TabSelection.Overview; const notFound = query.isError && isNotFoundError(query.error); + const hasError = query.isError && !notFound; const tabInfo = useMemo>( () => [ @@ -63,7 +64,11 @@ export default function RepositoryStatusPage() { actions={data && } > - + {hasError && ( + + {getErrorMessage(query.error)} + + )} {notFound ? ( 0; - - // Calculate final requiresMigration based on sync target and user selection - // For instance sync: always use baseRequiresMigration (checkbox is disabled and always true) + // Calculate requiresMigration based on sync target and user selection + // For instance sync: migrate if there are resources (checkbox is disabled and always true) // For folder sync: only migrate if user explicitly opts in via checkbox - const requiresMigration = useMemo(() => { - if (syncTarget === 'instance') { - return baseRequiresMigration; - } - if (syncTarget === 'folder') { - return migrateResources ?? false; - } - return baseRequiresMigration; - }, [syncTarget, baseRequiresMigration, migrateResources]); - + const requiresMigration = syncTarget === 'instance' ? resourceCount > 0 : (migrateResources ?? false); const shouldSkipSync = (resourceCount === 0 || syncTarget === 'folder') && fileCount === 0; // Format display strings diff --git a/public/app/features/provisioning/components/InlineSecureValueWarning.tsx b/public/app/features/provisioning/components/InlineSecureValueWarning.tsx deleted file mode 100644 index 9ed4a23b7b1..00000000000 --- a/public/app/features/provisioning/components/InlineSecureValueWarning.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import { t } from '@grafana/i18n'; -import { Alert } from '@grafana/ui'; -import { Repository } from 'app/api/clients/provisioning/v0alpha1'; - -interface Props { - repo?: Repository; - items?: Repository[]; -} - -// TODO: remove this after 12.2 -export function InlineSecureValueWarning({ repo, items }: Props) { - const isRepoValid = (r?: Repository) => r?.spec?.type === 'local' || !!r?.secure?.token?.name; - - if (isRepoValid(repo)) { - return null; - } - - // When a list is passed in, show an error if anything is missing - if (items?.every(isRepoValid)) { - return null; - } - - return ( - - ); -} diff --git a/public/app/features/provisioning/utils/httpUtils.ts b/public/app/features/provisioning/utils/httpUtils.ts index 6e9cbc4e933..3fb6bd7704f 100644 --- a/public/app/features/provisioning/utils/httpUtils.ts +++ b/public/app/features/provisioning/utils/httpUtils.ts @@ -1,4 +1,5 @@ import { t } from '@grafana/i18n'; +import { isFetchError } from '@grafana/runtime'; import { HttpError, isHttpError } from '../guards'; @@ -187,6 +188,8 @@ export function getErrorMessage(err: unknown) { } else if (err.message) { errorMessage = err.message; } + } else if (isFetchError(err)) { + errorMessage = err.data.message; } return errorMessage; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 8e4ed2c7b20..9678b91e619 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11985,7 +11985,6 @@ "resource-not-found": "Resource not found. Please check the URL or repository.", "unsupported-repository-type": "Unsupported repository type: {{repositoryType}}" }, - "inline-secure-values-warning": "You need to save your access tokens again due to a system update", "instance-sync-deprecation": { "message": "Instance sync is currently not fully supported and breaks library panels and alerts. To use library panels and alerts, disconnect your repository and reconnect it using folder sync instead.", "title": "Instance sync is not fully supported" @@ -12095,6 +12094,9 @@ "webhook-last-event": "Last Event:", "webhook-url": "View Webhook" }, + "repository-status": { + "error": "Failed to load repository" + }, "repository-status-page": { "back-to-repositories": "Back to repositories", "cleaning-up-resources": "Cleaning up repository resources", From fa73caf6c84062d9437c577d21e5a65f806af386 Mon Sep 17 00:00:00 2001 From: Collin Fingar Date: Fri, 19 Dec 2025 11:30:24 -0500 Subject: [PATCH 04/80] Snapshots: Fix V2 Snapshot data coupling (#115278) * Snapshots: Potential fix for rendering V2 snaps * removing comments * Added unit test --- .../transformSceneToSaveModelSchemaV2.test.ts | 102 +++++++++++++++++- .../transformSceneToSaveModelSchemaV2.ts | 72 ++++++++++--- 2 files changed, 160 insertions(+), 14 deletions(-) diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts index 6d0c450add3..4dcc732fb01 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.test.ts @@ -1,4 +1,4 @@ -import { VariableRefresh } from '@grafana/data'; +import { VariableRefresh, PanelData, LoadingState, toDataFrame, FieldType, getDefaultTimeRange } from '@grafana/data'; import { config } from '@grafana/runtime'; import { AdHocFiltersVariable, @@ -18,6 +18,8 @@ import { VizPanel, SceneDataQuery, SceneQueryRunner, + SceneDataTransformer, + SceneDataNode, sceneUtils, dataLayers, } from '@grafana/scenes'; @@ -33,6 +35,7 @@ import { TabsLayoutSpec, defaultDataQueryKind, } from '@grafana/schema/dist/esm/schema/dashboard/v2'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { DashboardEditPane } from '../edit-pane/DashboardEditPane'; import { DashboardAnnotationsDataLayer } from '../scene/DashboardAnnotationsDataLayer'; @@ -954,6 +957,103 @@ describe('getVizPanelQueries', () => { expect(result[1].spec.query.group).toBe('prometheus'); expect(result[1].spec.query.version).toBe('v0'); }); + + describe('snapshot mode', () => { + it('should return empty queries when isSnapshot is true but panel has no data provider', () => { + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + // No $data provider + }); + + const result = getVizPanelQueries(vizPanel, undefined, true); + expect(result).toEqual([]); + }); + + it('should create snapshot query from SceneQueryRunner data when isSnapshot is true', () => { + const mockDataFrame = toDataFrame({ + name: 'test-series', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000, 3000] }, + { name: 'value', type: FieldType.number, values: [1, 2, 3] }, + ], + }); + + const panelData: PanelData = { + series: [mockDataFrame], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }; + + const queryRunner = new SceneQueryRunner({ + queries: [], + data: panelData, + }); + + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + $data: queryRunner, + }); + + const result = getVizPanelQueries(vizPanel, undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].kind).toBe('PanelQuery'); + expect(result[0].spec.refId).toBe('A'); + expect(result[0].spec.hidden).toBe(false); + expect(result[0].spec.query.kind).toBe('DataQuery'); + expect(result[0].spec.query.version).toBe(defaultDataQueryKind().version); + expect(result[0].spec.query.group).toBe('grafana'); + expect(result[0].spec.query.datasource).toEqual({ name: 'grafana' }); + expect(result[0].spec.query.spec.queryType).toBe(GrafanaQueryType.Snapshot); + expect(result[0].spec.query.spec.snapshot).toBeDefined(); + expect(result[0].spec.query.spec.snapshot).toHaveLength(1); + expect(result[0].spec.query.spec.snapshot[0].schema?.fields).toBeDefined(); + }); + + it('should create snapshot query from SceneDataTransformer data when isSnapshot is true', () => { + const mockDataFrame = toDataFrame({ + name: 'transformed-series', + fields: [ + { name: 'time', type: FieldType.time, values: [1000, 2000] }, + { name: 'transformed', type: FieldType.number, values: [10, 20] }, + ], + }); + + const panelData: PanelData = { + series: [mockDataFrame], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }; + + const dataNode = new SceneDataNode({ + data: panelData, + }); + + const dataTransformer = new SceneDataTransformer({ + $data: dataNode, + transformations: [], + }); + + const vizPanel = new VizPanel({ + key: 'panel-1', + pluginId: 'timeseries', + $data: dataTransformer, + }); + + const result = getVizPanelQueries(vizPanel, undefined, true); + + expect(result).toHaveLength(1); + expect(result[0].kind).toBe('PanelQuery'); + expect(result[0].spec.query.kind).toBe('DataQuery'); + expect(result[0].spec.query.spec.queryType).toBe(GrafanaQueryType.Snapshot); + expect(result[0].spec.query.spec.snapshot).toBeDefined(); + expect(result[0].spec.query.spec.snapshot).toHaveLength(1); + // Verify it gets data from the nested $data (SceneDataNode) not the transformer + expect(result[0].spec.query.spec.snapshot[0].schema?.fields).toBeDefined(); + }); + }); }); function getMinimalSceneState(body: DashboardLayoutManager): Partial { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 4a63c62af93..0ef2a5e5f05 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -13,8 +13,10 @@ import { SceneVariableSet, VizPanel, } from '@grafana/scenes'; -import { DataSourceRef, VariableRefresh } from '@grafana/schema'; +import { DataSourceRef } from '@grafana/schema'; import { sortedDeepCloneWithoutNulls } from 'app/core/utils/object'; +import { getPanelDataFrames } from 'app/features/dashboard/components/HelpWizard/utils'; +import { GrafanaQueryType } from 'app/plugins/datasource/grafana/types'; import { Spec as DashboardV2Spec, @@ -127,7 +129,7 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps // EOF variables // elements - elements: getElements(scene, dsReferencesMapping), + elements: getElements(scene, dsReferencesMapping, isSnapshot), // EOF elements // annotations @@ -170,17 +172,18 @@ function getLiveNow(state: DashboardSceneState) { return Boolean(liveNow); } -function getElements(scene: DashboardScene, dsReferencesMapping?: DSReferencesMapping) { +function getElements(scene: DashboardScene, dsReferencesMapping?: DSReferencesMapping, isSnapshot = false) { const panels = scene.state.body.getVizPanels() ?? []; const panelsArray = panels.map((vizPanel) => { - return vizPanelToSchemaV2(vizPanel, dsReferencesMapping); + return vizPanelToSchemaV2(vizPanel, dsReferencesMapping, isSnapshot); }); return createElements(panelsArray, scene); } export function vizPanelToSchemaV2( vizPanel: VizPanel, - dsReferencesMapping?: DSReferencesMapping + dsReferencesMapping?: DSReferencesMapping, + isSnapshot = false ): PanelKind | LibraryPanelKind { if (isLibraryPanel(vizPanel)) { const behavior = getLibraryPanelBehavior(vizPanel)!; @@ -216,7 +219,7 @@ export function vizPanelToSchemaV2( data: { kind: 'QueryGroup', spec: { - queries: getVizPanelQueries(vizPanel, dsReferencesMapping), + queries: getVizPanelQueries(vizPanel, dsReferencesMapping, isSnapshot), transformations: getVizPanelTransformations(vizPanel), queryOptions: getVizPanelQueryOptions(vizPanel), }, @@ -290,9 +293,51 @@ function getPanelLinks(panel: VizPanel): DataLink[] { return []; } -export function getVizPanelQueries(vizPanel: VizPanel, dsReferencesMapping?: DSReferencesMapping): PanelQueryKind[] { +export function getVizPanelQueries( + vizPanel: VizPanel, + dsReferencesMapping?: DSReferencesMapping, + isSnapshot = false +): PanelQueryKind[] { const queries: PanelQueryKind[] = []; const queryRunner = getQueryRunnerFor(vizPanel); + + if (isSnapshot) { + const dataProvider = vizPanel.state.$data; + if (!dataProvider) { + return queries; + } + + let snapshotData = getPanelDataFrames(dataProvider.state.data); + if (dataProvider instanceof SceneDataTransformer) { + snapshotData = getPanelDataFrames(dataProvider.state.$data!.state.data); + } + + const snapshotQuery: DataQueryKind = { + kind: 'DataQuery', + version: defaultDataQueryKind().version, + group: 'grafana', + datasource: { + name: 'grafana', + }, + spec: { + queryType: GrafanaQueryType.Snapshot, + snapshot: snapshotData, + }, + }; + + queries.push({ + kind: 'PanelQuery', + spec: { + query: snapshotQuery, + refId: 'A', + hidden: false, + }, + }); + + return queries; + } + + // Regular query handling (non-snapshot) const vizPanelQueries = queryRunner?.state.queries; if (vizPanelQueries) { @@ -631,15 +676,16 @@ export function trimDashboardForSnapshot(title: string, time: TimeRange, dash: D if (spec.variables) { spec.variables.forEach((variable) => { - if ('query' in variable) { - variable.query = ''; + if ('query' in variable.spec) { + variable.spec.query = ''; } - if ('options' in variable && 'current' in variable) { - variable.options = variable.current && !isEmptyObject(variable.current) ? [variable.current] : []; + if ('options' in variable.spec && 'current' in variable.spec) { + variable.spec.options = + variable.spec.current && !isEmptyObject(variable.spec.current) ? [variable.spec.current] : []; } - if ('refresh' in variable) { - variable.refresh = VariableRefresh.never; + if ('refresh' in variable.spec) { + variable.spec.refresh = 'never'; } }); } From 8b316cca250d6976893a655fa51874f73f0aef10 Mon Sep 17 00:00:00 2001 From: Rodrigo Vasconcelos de Barros Date: Fri, 19 Dec 2025 11:39:48 -0500 Subject: [PATCH 05/80] Alerting: Add tests for AlertRuleMenu component (#115473) * Alerting: Add tests for AlertRuleMenu component * Refactor test mocks according TESTING.md * Remove duplicate mock functions * Replace snapshot test with more readable assertion * Remove SETUP_ALERTING_DEV.md file * Refactor feature flags usage in tests --- .../rule-viewer/AlertRuleMenu.test.tsx | 1612 +++++++++++++++++ 1 file changed, 1612 insertions(+) create mode 100644 public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx diff --git a/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx new file mode 100644 index 00000000000..e95b321c02c --- /dev/null +++ b/public/app/features/alerting/unified/components/rule-viewer/AlertRuleMenu.test.tsx @@ -0,0 +1,1612 @@ +import { render, screen, testWithFeatureToggles, userEvent, waitFor } from 'test/test-utils'; +import { byLabelText, byRole } from 'testing-library-selector'; + +import { useAssistant } from '@grafana/assistant'; +import { GrafanaEdition } from '@grafana/data/internal'; +import { config, setPluginLinksHook } from '@grafana/runtime'; +import AlertRuleMenu from 'app/features/alerting/unified/components/rule-viewer/AlertRuleMenu'; +import { mockFolderApi, setupMswServer } from 'app/features/alerting/unified/mockApi'; +import { + getCloudRule, + getGrafanaRule, + grantUserPermissions, + mockDataSource, + mockFolder, + mockGrafanaRulerRule, + mockPromAlertingRule, + mockPromRecordingRule, + mockRulerGrafanaRecordingRule, +} from 'app/features/alerting/unified/mocks'; +import { setFolderAccessControl } from 'app/features/alerting/unified/mocks/server/configure'; +import { setupDataSources } from 'app/features/alerting/unified/testSetup/datasources'; +import * as miscUtils from 'app/features/alerting/unified/utils/misc'; +import { fromCombinedRule } from 'app/features/alerting/unified/utils/rule-id'; +import { AccessControlAction } from 'app/types/accessControl'; +import { PromAlertingRuleState, PromRuleType } from 'app/types/unified-alerting-dto'; + +import { AlertRuleAction } from '../../hooks/useAbilities'; + +const mockOpenAssistant = jest.fn(); +jest.mock('@grafana/assistant', () => ({ + useAssistant: jest.fn(), + createAssistantContextItem: jest.fn((type: string, data: T) => ({ + type, + ...data, + })), +})); + +const mockUseAssistant = jest.mocked(useAssistant); + +const mockPauseExecute = jest.fn().mockResolvedValue(undefined); +jest.mock('../../hooks/ruleGroup/usePauseAlertRule', () => ({ + usePauseRuleInGroup: () => [ + { + execute: mockPauseExecute, + }, + { loading: false, error: undefined }, + ], +})); + +const server = setupMswServer(); + +setPluginLinksHook(() => ({ + links: [], + isLoading: false, +})); + +setupDataSources(); + +const user = userEvent.setup(); +const handleSilence = jest.fn(); +const handleDelete = jest.fn(); +const handleDuplicateRule = jest.fn(); + +const ui = { + moreButton: byLabelText(/More/), + menu: byRole('menu'), + menuItems: { + pause: byRole('menuitem', { name: /Pause evaluation/i }), + resume: byRole('menuitem', { name: /Resume evaluation/i }), + silence: byRole('menuitem', { name: /Silence notifications/i }), + duplicate: byRole('menuitem', { name: /Duplicate/i }), + copyLink: byRole('menuitem', { name: /Copy link/i }), + export: byRole('menuitem', { name: /Export/i }), + delete: byRole('menuitem', { name: /Delete/i }), + manageEnrichments: byRole('menuitem', { name: /Manage enrichments/i }), + declareIncident: byRole('link', { name: /Declare incident/i }), + analyzeRule: byRole('menuitem', { name: /Analyze rule/i }), + }, +}; + +const getMenuContents = async () => { + await screen.findByRole('menu'); + const allMenuItems = screen.queryAllByRole('menuitem').map((el) => el.textContent); + const allLinkItems = screen.queryAllByRole('link').map((el) => el.textContent); + + return [...allMenuItems, ...allLinkItems]; +}; + +const openMenu = async () => { + await user.click(await ui.moreButton.find()); + await waitFor(() => { + expect(ui.menu.query()).toBeInTheDocument(); + }); +}; + +// Helper function to create a default rule setup +const createDefaultRuleSetup = () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + return { mockRule, identifier, groupIdentifier }; +}; + +describe('AlertRuleMenu', () => { + const originalBuildInfo = config.buildInfo; + + beforeEach(() => { + jest.clearAllMocks(); + mockPauseExecute.mockResolvedValue(undefined); + mockOpenAssistant.mockClear(); + // Default: assistant unavailable + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + // Reset config to defaults + config.buildInfo = { ...originalBuildInfo }; + + // Set up default folder mock for Grafana rules (namespace-uid is the default folder UID) + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + // Set up default permissions (no permissions granted by default) + grantUserPermissions([]); + setFolderAccessControl({}); + }); + + afterEach(() => { + config.buildInfo = originalBuildInfo; + }); + + describe('Basic Rendering', () => { + it('renders MoreButton correctly', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + expect(await ui.moreButton.find()).toBeInTheDocument(); + }); + + it('opens menu when MoreButton is clicked', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('closes menu when clicking outside', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + + // Click outside the menu + await user.click(document.body); + + await waitFor(() => { + expect(ui.menu.query()).not.toBeInTheDocument(); + }); + }); + + it('menu contains expected structure', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + + const menuContents = await getMenuContents(); + expect(menuContents).toHaveLength(1); + expect(menuContents).toContain('Copy link'); + }); + }); + + describe('Permissions', () => { + // Generalized test to reduce repetition for menu item visibility + type MenuItemTestCase = { + description: string; + action: AlertRuleAction; + menuItem: keyof typeof ui.menuItems; + granted: boolean; + shouldShow: boolean; + }; + + const testMenuItemVisibility = ({ description, action, menuItem, granted, shouldShow }: MenuItemTestCase) => { + it(description, async () => { + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + // Grant permissions based on action + const permissions: AccessControlAction[] = []; + const folderAccessControl: Record = {}; + + if (granted) { + switch (action) { + case AlertRuleAction.Pause: + case AlertRuleAction.Update: + permissions.push(AccessControlAction.AlertingRuleUpdate); + folderAccessControl[AccessControlAction.AlertingRuleUpdate] = true; + break; + case AlertRuleAction.Delete: + permissions.push(AccessControlAction.AlertingRuleDelete); + folderAccessControl[AccessControlAction.AlertingRuleDelete] = true; + break; + case AlertRuleAction.Duplicate: + permissions.push(AccessControlAction.AlertingRuleCreate); + break; + case AlertRuleAction.Silence: + permissions.push(AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingSilenceCreate); + break; + case AlertRuleAction.ModifyExport: + permissions.push(AccessControlAction.AlertingRuleRead); + break; + } + } + + grantUserPermissions(permissions); + setFolderAccessControl(folderAccessControl); + + // Set up folder mock for Grafana rules with matching access control + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: folderAccessControl, + }) + ); + + render( + + ); + + await openMenu(); + if (shouldShow) { + expect(await ui.menuItems[menuItem].find()).toBeInTheDocument(); + } else { + expect(ui.menuItems[menuItem].query()).not.toBeInTheDocument(); + } + }); + }; + + describe('Pause/Resume visibility', () => { + testMenuItemVisibility({ + description: 'shows Pause when pause permission is granted', + action: AlertRuleAction.Pause, + menuItem: 'pause', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Pause when pause permission is denied', + action: AlertRuleAction.Pause, + menuItem: 'pause', + granted: false, + shouldShow: false, + }); + }); + + describe('Delete visibility', () => { + testMenuItemVisibility({ + description: 'shows Delete when delete permission is granted', + action: AlertRuleAction.Delete, + menuItem: 'delete', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Delete when delete permission is denied', + action: AlertRuleAction.Delete, + menuItem: 'delete', + granted: false, + shouldShow: false, + }); + }); + + describe('Duplicate visibility', () => { + testMenuItemVisibility({ + description: 'shows Duplicate when duplicate permission is granted', + action: AlertRuleAction.Duplicate, + menuItem: 'duplicate', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Duplicate when duplicate permission is denied', + action: AlertRuleAction.Duplicate, + menuItem: 'duplicate', + granted: false, + shouldShow: false, + }); + }); + + describe('Silence visibility', () => { + testMenuItemVisibility({ + description: 'shows Silence when silence permission is granted', + action: AlertRuleAction.Silence, + menuItem: 'silence', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Silence when silence permission is denied', + action: AlertRuleAction.Silence, + menuItem: 'silence', + granted: false, + shouldShow: false, + }); + }); + + describe('Export visibility', () => { + testMenuItemVisibility({ + description: 'shows Export when export permission is granted', + action: AlertRuleAction.ModifyExport, + menuItem: 'export', + granted: true, + shouldShow: true, + }); + + testMenuItemVisibility({ + description: 'hides Export when export permission is denied', + action: AlertRuleAction.ModifyExport, + menuItem: 'export', + granted: false, + shouldShow: false, + }); + }); + + describe('Copy Link visibility', () => { + it('shows Copy Link when shareUrl exists', async () => { + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.copyLink.find()).toBeInTheDocument(); + }); + }); + }); + + describe('Rule Types', () => { + beforeEach(() => { + // Grant all permissions for testing rule type differences + grantUserPermissions([ + AccessControlAction.AlertingRuleRead, + AccessControlAction.AlertingRuleUpdate, + AccessControlAction.AlertingRuleDelete, + AccessControlAction.AlertingRuleCreate, + AccessControlAction.AlertingInstanceCreate, + AccessControlAction.AlertingSilenceCreate, + ]); + setFolderAccessControl({ + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { + [AccessControlAction.AlertingRuleUpdate]: true, + [AccessControlAction.AlertingRuleDelete]: true, + }, + }) + ); + }); + + describe('Grafana-managed rules', () => { + it('shows Pause option for Grafana-managed alerting rules', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.pause.find()).toBeInTheDocument(); + }); + + it('does not show Pause option for datasource-managed rules', async () => { + const datasource = mockDataSource({ uid: 'mimir', name: 'Mimir' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + }); + + describe('Alerting vs Recording rules', () => { + it('shows Silence option for alerting rules', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ type: PromRuleType.Alerting }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.silence.find()).toBeInTheDocument(); + }); + + it('does not show Silence option for recording rules', async () => { + const mockRule = getGrafanaRule({ + promRule: mockPromRecordingRule({ type: PromRuleType.Recording }), + }); + // Override the rulerRule to be a recording rule + mockRule.rulerRule = mockRulerGrafanaRecordingRule( + {}, + { + uid: 'mock-rule-uid-123', + namespace_uid: 'namespace-uid', + } + ); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + }) + ); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.silence.query()).not.toBeInTheDocument(); + }); + }); + + describe('Provisioned rules', () => { + it('hides Delete option for provisioned rules', async () => { + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ provenance: 'file' }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + }); + + it('hides Pause option for provisioned rules', async () => { + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ provenance: 'file' }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + }); + + describe('Mixed data scenarios', () => { + it('works with only promRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('works with only rulerRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + + it('works with both promRule and rulerRule available', async () => { + const mockRule = getGrafanaRule(); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menu.query()).toBeInTheDocument(); + }); + }); + }); + + describe('Handler Callbacks', () => { + describe('handleSilence', () => { + it('calls handleSilence when Silence menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingInstanceCreate, AccessControlAction.AlertingSilenceCreate]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.silence.find()); + + expect(handleSilence).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleManageEnrichments', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('calls handleManageEnrichments when Manage enrichments menu item is clicked', async () => { + // Both toggles need to be enabled: alertEnrichment for the hook, alertingEnrichmentPerRule for the component + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.manageEnrichments.find()); + + expect(handleManageEnrichments).toHaveBeenCalledTimes(1); + }); + }); + + describe('handleDelete', () => { + it('calls handleDelete with correct identifier and groupIdentifier when Delete menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleDelete]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleDelete]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleDelete]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.delete.find()); + + expect(handleDelete).toHaveBeenCalledTimes(1); + expect(handleDelete).toHaveBeenCalledWith(identifier, groupIdentifier); + }); + + it('does not call handleDelete when identifier is not editable', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleDelete]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleDelete]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleDelete]: true }, + }) + ); + + const { mockRule, groupIdentifier } = createDefaultRuleSetup(); + // Create a non-editable identifier (e.g., Prometheus rule identifier without rulerRule) + // For external rules without rulerRule, the delete button should not appear + const identifier = fromCombinedRule('prometheus', { + ...mockRule, + rulerRule: undefined, + }); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + expect(handleDelete).not.toHaveBeenCalled(); + }); + }); + + describe('handleDuplicateRule', () => { + it('calls handleDuplicateRule with correct identifier when Duplicate menu item is clicked', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleCreate]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.duplicate.find()); + + expect(handleDuplicateRule).toHaveBeenCalledTimes(1); + expect(handleDuplicateRule).toHaveBeenCalledWith(identifier); + }); + }); + + describe('onPauseChange', () => { + it('calls onPauseChange after pause state change when Pause menu item is clicked', async () => { + const onPauseChange = jest.fn(); + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.pause.find()); + + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(onPauseChange).toHaveBeenCalledTimes(1); + }); + expect(onPauseChange).toHaveBeenCalledWith(); + }); + + it('calls onPauseChange after resume state change when Resume menu item is clicked', async () => { + const onPauseChange = jest.fn(); + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const mockRule = getGrafanaRule({ + rulerRule: mockGrafanaRulerRule({ is_paused: true }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.resume.find()); + + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + + await waitFor(() => { + expect(onPauseChange).toHaveBeenCalledTimes(1); + }); + expect(onPauseChange).toHaveBeenCalledWith(); + }); + }); + }); + + describe('Feature Flags', () => { + describe('alertingEnrichmentPerRule', () => { + describe('when feature flag is disabled', () => { + testWithFeatureToggles({ + disable: ['alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when feature flag is disabled', async () => { + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + + describe('when feature flag is enabled', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('shows Manage enrichments when feature flag is enabled and all conditions are met', async () => { + // Both toggles need to be enabled: alertEnrichment for the hook, alertingEnrichmentPerRule for the component + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.manageEnrichments.find()).toBeInTheDocument(); + }); + }); + + describe('when feature flags are enabled but permission is missing', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when enrichment ability is not allowed', async () => { + // Enable both toggles to ensure we're testing the permission check, not the toggles + grantUserPermissions([]); + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Open-source vs Enterprise', () => { + it('hides Declare Incident in open-source edition for firing alerting rules', async () => { + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + + it('shows Declare Incident in enterprise edition for firing alerting rules', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.declareIncident.find()).toBeInTheDocument(); + }); + + it('shows Declare Incident in dev mode even for open-source edition', async () => { + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'development'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Firing }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.declareIncident.find()).toBeInTheDocument(); + }); + + it('hides Declare Incident for non-firing alerting rules in enterprise', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + + it('hides Declare Incident for recording rules', async () => { + config.buildInfo.edition = GrafanaEdition.Enterprise; + config.buildInfo.env = 'production'; + + const mockRule = getGrafanaRule({ + promRule: mockPromRecordingRule({ type: PromRuleType.Recording }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + }); + }); + + describe('Assistant availability', () => { + beforeEach(() => { + // Reset config to ensure clean state for assistant tests + config.buildInfo = { ...originalBuildInfo }; + config.buildInfo.env = 'production'; + config.buildInfo.edition = GrafanaEdition.OpenSource; + // Reset assistant mock to default (unavailable) for each test + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + }); + + it('shows Analyze Rule when assistant is available for Grafana-managed rules', async () => { + // Override mock to return available + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + // Create a Grafana rule with promRule that has uid and folderUid + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ + uid: 'test-rule-uid', + folderUid: 'test-folder-uid', + }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(await ui.menuItems.analyzeRule.find()).toBeInTheDocument(); + }); + + it('hides Analyze Rule when assistant is unavailable', async () => { + // Mock already set to unavailable in beforeEach, but be explicit + mockUseAssistant.mockReturnValue({ + isAvailable: false, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + + it('hides Analyze Rule for datasource-managed rules even when assistant is available', async () => { + mockUseAssistant.mockReturnValue({ + isAvailable: true, + openAssistant: mockOpenAssistant, + } as unknown as ReturnType); + + const datasource = mockDataSource({ uid: 'mimir', name: 'Mimir' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + }); + }); + + describe('Edge Cases', () => { + describe('Missing Data', () => { + describe('with enrichment feature flags enabled', () => { + testWithFeatureToggles({ + enable: ['alertEnrichment', 'alertingEnrichmentPerRule'], + }); + + it('hides Manage enrichments when ruleUid is missing even if all other conditions are met', async () => { + grantUserPermissions([AccessControlAction.AlertingEnrichmentsRead]); + // Note: Cloud rules typically don't have a ruleUid, so this tests that case + mockFolderApi(server).folder('namespace-uid', mockFolder({ uid: 'namespace-uid', title: 'Test Folder' })); + + const handleManageEnrichments = jest.fn(); + const datasource = mockDataSource({ uid: 'prometheus', name: 'Prometheus' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + }); + }); + + it('hides Pause option when ruleUid is missing even if pause permission is granted', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + + const datasource = mockDataSource({ uid: 'prometheus', name: 'Prometheus' }); + const mockRule = getCloudRule({}, { rulesSource: datasource }); + const identifier = fromCombinedRule(datasource.name, mockRule); + const groupIdentifier = { + groupOrigin: 'datasource' as const, + rulesSource: { uid: datasource.uid, name: datasource.name, ruleSourceType: 'datasource' as const }, + namespace: { name: 'namespace-name' }, + groupName: 'group-name', + }; + + render( + + ); + + await openMenu(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + }); + + it('pause still works when onPauseChange is not provided', async () => { + grantUserPermissions([AccessControlAction.AlertingRuleUpdate]); + setFolderAccessControl({ [AccessControlAction.AlertingRuleUpdate]: true }); + mockFolderApi(server).folder( + 'namespace-uid', + mockFolder({ + uid: 'namespace-uid', + title: 'Test Folder', + accessControl: { [AccessControlAction.AlertingRuleUpdate]: true }, + }) + ); + + const { mockRule, identifier, groupIdentifier } = createDefaultRuleSetup(); + + render( + + ); + + await openMenu(); + await user.click(await ui.menuItems.pause.find()); + + // Pause should still execute even without callback + await waitFor(() => { + expect(mockPauseExecute).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('Empty States', () => { + it('shows minimal menu with only Copy Link when no permissions are granted', async () => { + // Use a non-firing rule to avoid Declare Incident showing + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + render( + + ); + + await openMenu(); + + expect(await ui.menuItems.copyLink.find()).toBeInTheDocument(); + expect(ui.menuItems.pause.query()).not.toBeInTheDocument(); + expect(ui.menuItems.silence.query()).not.toBeInTheDocument(); + expect(ui.menuItems.duplicate.query()).not.toBeInTheDocument(); + expect(ui.menuItems.export.query()).not.toBeInTheDocument(); + expect(ui.menuItems.delete.query()).not.toBeInTheDocument(); + expect(ui.menuItems.manageEnrichments.query()).not.toBeInTheDocument(); + expect(ui.menuItems.declareIncident.query()).not.toBeInTheDocument(); + expect(ui.menuItems.analyzeRule.query()).not.toBeInTheDocument(); + }); + + it('menu still opens even when no applicable items are available', async () => { + // All abilities denied and no shareUrl + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + // Mock createShareLink to return undefined + const createShareLinkSpy = jest.spyOn(miscUtils, 'createShareLink'); + createShareLinkSpy.mockReturnValue(undefined); + + render( + + ); + + await openMenu(); + + expect(ui.menu.query()).toBeInTheDocument(); + const menuItems = screen.queryAllByRole('menuitem'); + const linkItems = screen.queryAllByRole('link'); + expect(menuItems.length).toBe(0); + expect(linkItems.length).toBe(0); + + createShareLinkSpy.mockRestore(); + }); + }); + + describe('Error Handling', () => { + it('handles gracefully when clipboard API is unavailable', async () => { + const originalClipboard = navigator.clipboard; + // Mock clipboard as undefined to simulate unavailable API + Object.defineProperty(navigator, 'clipboard', { + value: undefined, + writable: true, + configurable: true, + }); + + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + render( + + ); + + await openMenu(); + const copyLinkItem = await ui.menuItems.copyLink.find(); + + await expect(user.click(copyLinkItem)).resolves.not.toThrow(); + + // Restore clipboard + Object.defineProperty(navigator, 'clipboard', { + value: originalClipboard, + writable: true, + configurable: true, + }); + }); + + it('handles gracefully when shareUrl is undefined', async () => { + // Use a non-firing rule to avoid Declare Incident showing + const mockRule = getGrafanaRule({ + promRule: mockPromAlertingRule({ state: PromAlertingRuleState.Inactive }), + }); + const identifier = fromCombinedRule('grafana', mockRule); + const groupIdentifier = { + groupOrigin: 'grafana' as const, + namespace: { uid: 'namespace-uid' }, + groupName: 'group-name', + }; + + config.buildInfo.edition = GrafanaEdition.OpenSource; + config.buildInfo.env = 'production'; + + // Mock createShareLink to return undefined + const createShareLinkSpy = jest.spyOn(require('app/features/alerting/unified/utils/misc'), 'createShareLink'); + createShareLinkSpy.mockReturnValue(undefined); + + render( + + ); + + await openMenu(); + + expect(ui.menuItems.copyLink.query()).not.toBeInTheDocument(); + expect(ui.menu.query()).toBeInTheDocument(); + + createShareLinkSpy.mockRestore(); + }); + }); + }); +}); From 49032ae3d7a55a62563fcfcc6d45bb69b82b2159 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 19 Dec 2025 11:23:27 -0600 Subject: [PATCH 06/80] VizSuggestions: Update selected suggestion styling (#115581) * update selected suggestion style * update highlight styles for light theme, add inert to div * remove commented-out original idea --------- Co-authored-by: Paul Marbach --- .../VisualizationSuggestionCard.tsx | 39 +++++++++---------- 1 file changed, 18 insertions(+), 21 deletions(-) diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestionCard.tsx index 7248dbcc1e5..632c143fe3a 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, HTMLAttributes, ReactNode } from 'react'; -import { colorManipulator, GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; +import { GrafanaTheme2, PanelData, PanelPluginVisualizationSuggestion } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { config } from '@grafana/runtime'; import { Tooltip, useStyles2 } from '@grafana/ui'; @@ -31,7 +31,11 @@ export function VisualizationSuggestionCard({ const commonButtonProps = { 'aria-label': suggestion.name, - className: cx(className, styles.vizBox), + className: cx( + className, + styles.vizBox, + config.featureToggles.newVizSuggestions && isSelected && styles.selectedBox + ), 'data-testid': selectors.components.VisualizationPreview.card(suggestion.name), style: outerStyles, tabIndex: -1, // selection is handled by parent container @@ -56,7 +60,12 @@ export function VisualizationSuggestionCard({ content = ( ); @@ -82,22 +89,8 @@ export function VisualizationSuggestionCard({ const getStyles = (theme: GrafanaTheme2) => { return { - hoverPane: css({ - position: 'absolute', - top: -4, - left: -4, - right: -2, - bottom: -2, - borderRadius: theme.spacing(0.5), - background: 'transparent', - [theme.transitions.handleMotion('no-preference', 'reduce')]: { - transition: theme.transitions.create(['background'], { - duration: theme.transitions.duration.short, - }), - }, - }), - hoverPaneSelected: css({ - background: colorManipulator.alpha(theme.colors.text.primary, 0.1), + selectedSuggestion: css({ + filter: `blur(1px) ${theme.isDark ? 'brightness(0.5)' : 'opacity(0.3)'}`, }), vizBox: css({ position: 'relative', @@ -149,6 +142,10 @@ const getStyles = (theme: GrafanaTheme2) => { top: '6px', left: '6px', }), + selectedBox: css({ + border: `1px solid ${theme.colors.primary.border}`, + background: theme.colors.action.selected, + }), }; }; From f91efcfe2c8f6e6078d64118e3fd1332f9c12873 Mon Sep 17 00:00:00 2001 From: Jesse David Peterson Date: Fri, 19 Dec 2025 13:12:01 -0500 Subject: [PATCH 07/80] TimeSeries: Fix truncated label text in legend table mode (#115647) * fix(legend-table): remove arbitrary 600px max width for full width cells * test(legend-table): backfill test coverage for viz legend table * test(legend-table): backfill test coverage for viz legend table item * refactor(legend-table): use derived theme spacing, not hard-coded values --- .../VizLegend/VizLegendTable.test.tsx | 78 ++++++++++++ .../components/VizLegend/VizLegendTable.tsx | 1 - .../VizLegend/VizLegendTableItem.test.tsx | 112 ++++++++++++++++++ .../VizLegend/VizLegendTableItem.tsx | 64 ++++++---- 4 files changed, 232 insertions(+), 23 deletions(-) create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx create mode 100644 packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx new file mode 100644 index 00000000000..131133bcdfb --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from '@testing-library/react'; + +import { VizLegendTable } from './VizLegendTable'; +import { VizLegendItem } from './types'; + +describe('VizLegendTable', () => { + const mockItems: VizLegendItem[] = [ + { label: 'Series 1', color: 'red', yAxis: 1 }, + { label: 'Series 2', color: 'blue', yAxis: 1 }, + { label: 'Series 3', color: 'green', yAxis: 1 }, + ]; + + it('renders without crashing', () => { + const { container } = render(); + expect(container.querySelector('table')).toBeInTheDocument(); + }); + + it('renders all items', () => { + render(); + expect(screen.getByText('Series 1')).toBeInTheDocument(); + expect(screen.getByText('Series 2')).toBeInTheDocument(); + expect(screen.getByText('Series 3')).toBeInTheDocument(); + }); + + it('renders table headers when items have display values', () => { + const itemsWithStats: VizLegendItem[] = [ + { + label: 'Series 1', + color: 'red', + yAxis: 1, + getDisplayValues: () => [ + { numeric: 100, text: '100', title: 'Max' }, + { numeric: 50, text: '50', title: 'Min' }, + ], + }, + ]; + render(); + expect(screen.getByText('Max')).toBeInTheDocument(); + expect(screen.getByText('Min')).toBeInTheDocument(); + }); + + it('renders sort icon when sorted', () => { + const { container } = render( + + ); + expect(container.querySelector('svg')).toBeInTheDocument(); + }); + + it('calls onToggleSort when header is clicked', () => { + const onToggleSort = jest.fn(); + render(); + const header = screen.getByText('Name'); + header.click(); + expect(onToggleSort).toHaveBeenCalledWith('Name'); + }); + + it('does not call onToggleSort when not sortable', () => { + const onToggleSort = jest.fn(); + render(); + const header = screen.getByText('Name'); + header.click(); + expect(onToggleSort).not.toHaveBeenCalled(); + }); + + it('renders with long labels', () => { + const itemsWithLongLabels: VizLegendItem[] = [ + { + label: 'This is a very long series name that should be scrollable within its table cell', + color: 'red', + yAxis: 1, + }, + ]; + render(); + expect( + screen.getByText('This is a very long series name that should be scrollable within its table cell') + ).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx index b654a2d3ac6..0c2859453eb 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTable.tsx @@ -119,7 +119,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ table: css({ width: '100%', 'th:first-child': { - width: '100%', borderBottom: `1px solid ${theme.colors.border.weak}`, }, }), diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx new file mode 100644 index 00000000000..4ca95aa395c --- /dev/null +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from '@testing-library/react'; + +import { LegendTableItem } from './VizLegendTableItem'; +import { VizLegendItem } from './types'; + +describe('LegendTableItem', () => { + const mockItem: VizLegendItem = { + label: 'Series 1', + color: 'red', + yAxis: 1, + }; + + it('renders without crashing', () => { + const { container } = render( + + + + +
+ ); + expect(container.querySelector('tr')).toBeInTheDocument(); + }); + + it('renders label text', () => { + render( + + + + +
+ ); + expect(screen.getByText('Series 1')).toBeInTheDocument(); + }); + + it('renders with long label text', () => { + const longLabelItem: VizLegendItem = { + ...mockItem, + label: 'This is a very long series name that should be scrollable in the table cell', + }; + render( + + + + +
+ ); + expect( + screen.getByText('This is a very long series name that should be scrollable in the table cell') + ).toBeInTheDocument(); + }); + + it('renders stat values when provided', () => { + const itemWithStats: VizLegendItem = { + ...mockItem, + getDisplayValues: () => [ + { numeric: 100, text: '100', title: 'Max' }, + { numeric: 50, text: '50', title: 'Min' }, + ], + }; + render( + + + + +
+ ); + expect(screen.getByText('100')).toBeInTheDocument(); + expect(screen.getByText('50')).toBeInTheDocument(); + }); + + it('renders right y-axis indicator when yAxis is 2', () => { + const rightAxisItem: VizLegendItem = { + ...mockItem, + yAxis: 2, + }; + render( + + + + +
+ ); + expect(screen.getByText('(right y-axis)')).toBeInTheDocument(); + }); + + it('calls onLabelClick when label is clicked', () => { + const onLabelClick = jest.fn(); + render( + + + + +
+ ); + const button = screen.getByRole('button'); + button.click(); + expect(onLabelClick).toHaveBeenCalledWith(mockItem, expect.any(Object)); + }); + + it('does not call onClick when readonly', () => { + const onLabelClick = jest.fn(); + render( + + + + +
+ ); + const button = screen.getByRole('button'); + expect(button).toBeDisabled(); + }); +}); diff --git a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx index 335cf4309e9..56ec6cb733e 100644 --- a/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx +++ b/packages/grafana-ui/src/components/VizLegend/VizLegendTableItem.tsx @@ -69,7 +69,7 @@ export const LegendTableItem = ({ return ( - + - +
+ +
{item.getDisplayValues && @@ -128,6 +130,27 @@ const getStyles = (theme: GrafanaTheme2) => { background: rowHoverBg, }, }), + labelCell: css({ + label: 'LegendLabelCell', + maxWidth: 0, + width: '100%', + }), + labelCellInner: css({ + label: 'LegendLabelCellInner', + display: 'block', + flex: 1, + minWidth: 0, + overflowX: 'auto', + overflowY: 'hidden', + paddingRight: theme.spacing(3), + scrollbarWidth: 'none', + msOverflowStyle: 'none', + maskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, + WebkitMaskImage: `linear-gradient(to right, black calc(100% - ${theme.spacing(3)}), transparent 100%)`, + '&::-webkit-scrollbar': { + display: 'none', + }, + }), label: css({ label: 'LegendLabel', whiteSpace: 'nowrap', @@ -135,9 +158,6 @@ const getStyles = (theme: GrafanaTheme2) => { border: 'none', fontSize: 'inherit', padding: 0, - maxWidth: '600px', - textOverflow: 'ellipsis', - overflow: 'hidden', userSelect: 'text', }), labelDisabled: css({ From 471d6f5236b6521b09a0c5d95c7e6f33764a4865 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Fri, 19 Dec 2025 13:27:39 -0500 Subject: [PATCH 08/80] Docs: Add suggested dashboards (#114729) --- docs/sources/datasources/_index.md | 6 +++++ .../create-dashboard/index.md | 24 ++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/docs/sources/datasources/_index.md b/docs/sources/datasources/_index.md index e7da61ea2f5..28d46c9c600 100644 --- a/docs/sources/datasources/_index.md +++ b/docs/sources/datasources/_index.md @@ -112,6 +112,12 @@ For example, this video demonstrates the visual Prometheus query builder: For general information about querying in Grafana, and common options and user interface elements across all query editors, refer to [Query and transform data](ref:query-transform-data). +## Build a dashboard from the data source + +After you've configured a data source, you can start creating a dashboard directly from it, by clicking the **Build a dashboard** button. + +For more information, refer to [Begin dashboard creation from data source configuration](https://grafana.com/docs/grafana//visualizations/dashboards/build-dashboards/create-dashboard/#begin-dashboard-creation-from-connections). + ## Special data sources Grafana includes three special data sources: diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md index 3d5e765e0cd..10196a40811 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dashboard/index.md @@ -99,7 +99,7 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee - Understand the query language of the target data source. - Ensure that data source for which you are writing a query has been added. For more information about adding a data source, refer to [Add a data source](ref:add-a-data-source) if you need instructions. -**To create a dashboard**: +To create a dashboard, follow these steps: {{< shared id="create-dashboard" >}} @@ -171,6 +171,28 @@ Dashboards and panels allow you to show your data in visual form. Each panel nee Now, when you want to make more changes to the saved dashboard, click **Edit** in the top-right corner. +### Begin dashboard creation from data source configuration + +You can start the process of creating a dashboard directly from a data source rather than from the **Dashboards** page. + +To begin building a dashboard directly from a data source, follow these steps: + +1. Navigate to **Connections > Data sources**. +1. On the row of the data source for which you want to build a dashboard, click **Build a dashboard**. + + The empty dashboard page opens. + +1. Do one of the following: + - Click **+Add visualization** to configure all the elements of the new dashboard. + - Select one of the suggested dashboards by clicking its **Use dashboard** button. This can be helpful when you're not sure how to most effectively visualize your data. + The suggested dashboards are specific to your data source type (for example, Prometheus, Loki, or Elasticsearch). If there are more than three dashboard suggestions, you can click **View all** to see the rest of them. + + ![Empty dashboard with add visualization and suggested dashboard options](/media/docs/grafana/dashboards/screenshot-suggested-dashboards-v12.3.png) + + {{< docs/public-preview product="Suggested dashboards" >}} + +1. Complete the rest of the dashboard configuration. For more detailed steps, refer to [Create a dashboard](#create-a-dashboard), beginning at step five. + ## Copy a dashboard To copy a dashboard, follow these steps: From 14c595f2066151ad5b9a4f1d852f60eac63a8b29 Mon Sep 17 00:00:00 2001 From: Liza Detrick <114438185+L2D2Grafana@users.noreply.github.com> Date: Fri, 19 Dec 2025 10:52:02 -0800 Subject: [PATCH 09/80] Logs: Cell format value on inspect should use Code view for arrays, objects, and JSON strings (#115037) --- .../src/components/Table/CellActions.tsx | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/grafana-ui/src/components/Table/CellActions.tsx b/packages/grafana-ui/src/components/Table/CellActions.tsx index e2fe8a658ba..f21be659c4f 100644 --- a/packages/grafana-ui/src/components/Table/CellActions.tsx +++ b/packages/grafana-ui/src/components/Table/CellActions.tsx @@ -1,3 +1,4 @@ +import { isPlainObject } from 'lodash'; import { useCallback } from 'react'; import * as React from 'react'; @@ -63,7 +64,18 @@ export function CellActions({ tooltip={t('grafana-ui.table.cell-inspect', 'Inspect value')} onClick={() => { if (setInspectCell) { - setInspectCell({ value: cell.value, mode: previewMode }); + let mode = TableCellInspectorMode.text; + let inspectValue = cell.value; + try { + const parsed = typeof inspectValue === 'string' ? JSON.parse(inspectValue) : inspectValue; + if (Array.isArray(parsed) || isPlainObject(parsed)) { + inspectValue = JSON.stringify(parsed, null, 2); + mode = TableCellInspectorMode.code; + } + } catch { + // do nothing + } + setInspectCell({ value: inspectValue, mode }); } }} {...commonButtonProps} From 4164239f561e70669549ef376bea20b39c638f6e Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:27:06 -0500 Subject: [PATCH 10/80] unified-storage: implement sqlkv Save method (#115458) * unified-storage: sqlkv save method --- .../data/sqlkv_delete_legacy_resource.sql | 5 + .../resource/data/sqlkv_insert_datastore.sql | 21 ++ .../data/sqlkv_insert_legacy_resource.sql | 31 +++ .../sqlkv_insert_legacy_resource_history.sql | 44 ++++ .../resource/data/sqlkv_save_event.sql | 15 ++ .../resource/data/sqlkv_update_datastore.sql | 3 + .../data/sqlkv_update_legacy_resource.sql | 9 + pkg/storage/unified/resource/datastore.go | 44 +++- pkg/storage/unified/resource/eventstore.go | 1 + pkg/storage/unified/resource/sqlkv.go | 249 ++++++++++++++++-- .../unified/resource/storage_backend.go | 59 ++++- .../unified/sql/rvmanager/rv_manager.go | 27 +- pkg/storage/unified/sql/server.go | 27 +- pkg/storage/unified/testing/kv.go | 45 ++-- pkg/storage/unified/testing/kv_test.go | 1 - 15 files changed, 534 insertions(+), 47 deletions(-) create mode 100644 pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_save_event.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_update_datastore.sql create mode 100644 pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql diff --git a/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql new file mode 100644 index 00000000000..e27ee578a41 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_delete_legacy_resource.sql @@ -0,0 +1,5 @@ +DELETE FROM {{ .Ident "resource" }} +WHERE {{ .Ident "group" }} = {{ .Arg .Group }} +AND {{ .Ident "resource" }} = {{ .Arg .Resource }} +AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +AND {{ .Ident "name" }} = {{ .Arg .Name }}; diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql new file mode 100644 index 00000000000..8372eb73463 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_datastore.sql @@ -0,0 +1,21 @@ +INSERT INTO {{ .Ident .TableName }} +( + {{ .Ident "guid" }}, + {{ .Ident "key_path" }}, + {{ .Ident "value" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }} +) +VALUES ( + {{ .Arg .GUID }}, + {{ .Arg .KeyPath }}, + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }} +); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql new file mode 100644 index 00000000000..1f58bd28b43 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource.sql @@ -0,0 +1,31 @@ +INSERT INTO {{ .Ident "resource" }} +( + {{ .Ident "value" }}, + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }}, + {{ .Ident "folder" }}, + {{ .Ident "previous_resource_version" }} +) +VALUES ( + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .GUID }}, + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }}, + {{ .Arg .Folder }}, + CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( + SELECT {{ .Ident "resource_version" }} + FROM {{ .Ident "resource" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 + ) END +); diff --git a/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql new file mode 100644 index 00000000000..d52aac5063d --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_insert_legacy_resource_history.sql @@ -0,0 +1,44 @@ +INSERT INTO {{ .Ident "resource_history" }} +( + {{ .Ident "value" }}, + {{ .Ident "guid" }}, + {{ .Ident "group" }}, + {{ .Ident "resource" }}, + {{ .Ident "namespace" }}, + {{ .Ident "name" }}, + {{ .Ident "action" }}, + {{ .Ident "folder" }}, + {{ .Ident "previous_resource_version" }}, + {{ .Ident "generation" }} +) +VALUES ( + COALESCE({{ .Arg .Value }}, ""), + {{ .Arg .GUID }}, + {{ .Arg .Group }}, + {{ .Arg .Resource }}, + {{ .Arg .Namespace }}, + {{ .Arg .Name }}, + {{ .Arg .Action }}, + {{ .Arg .Folder }}, + CASE WHEN {{ .Arg .Action }} = 1 THEN 0 ELSE ( + SELECT {{ .Ident "resource_version" }} + FROM {{ .Ident "resource_history" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ORDER BY {{ .Ident "resource_version" }} DESC LIMIT 1 + ) END, + CASE + WHEN {{ .Arg .Action }} = 1 THEN 1 + WHEN {{ .Arg .Action }} = 3 THEN 0 + ELSE 1 + ( + SELECT COUNT(1) + FROM {{ .Ident "resource_history" }} + WHERE {{ .Ident "group" }} = {{ .Arg .Group }} + AND {{ .Ident "resource" }} = {{ .Arg .Resource }} + AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} + AND {{ .Ident "name" }} = {{ .Arg .Name }} + ) + END +); diff --git a/pkg/storage/unified/resource/data/sqlkv_save_event.sql b/pkg/storage/unified/resource/data/sqlkv_save_event.sql new file mode 100644 index 00000000000..669497dbb19 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_save_event.sql @@ -0,0 +1,15 @@ +INSERT INTO {{ .Ident .TableName }} +( + {{ .Ident "key_path" }}, + {{ .Ident "value" }} +) +VALUES ( + {{ .Arg .KeyPath }}, + COALESCE({{ .Arg .Value }}, "") +) +{{- if eq .DialectName "mysql" }} +ON DUPLICATE KEY UPDATE {{ .Ident "value" }} = {{ .Arg .Value }} +{{- else }} +ON CONFLICT ({{ .Ident "key_path" }}) DO UPDATE SET {{ .Ident "value" }} = {{ .Arg .Value }} +{{- end }} +; diff --git a/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql b/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql new file mode 100644 index 00000000000..677666e00a4 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_datastore.sql @@ -0,0 +1,3 @@ +UPDATE {{ .Ident .TableName }} +SET {{ .Ident "value" }} = {{ .Arg .Value }} +WHERE {{ .Ident "key_path" }} = {{ .Arg .KeyPath }}; diff --git a/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql new file mode 100644 index 00000000000..1565d0894a4 --- /dev/null +++ b/pkg/storage/unified/resource/data/sqlkv_update_legacy_resource.sql @@ -0,0 +1,9 @@ +UPDATE {{ .Ident "resource" }} +SET + {{ .Ident "value" }} = {{ .Arg .Value }}, + {{ .Ident "action" }} = {{ .Arg .Action }}, + {{ .Ident "folder" }} = {{ .Arg .Folder }} +WHERE {{ .Ident "group" }} = {{ .Arg .Group }} +AND {{ .Ident "resource" }} = {{ .Arg .Resource }} +AND {{ .Ident "namespace" }} = {{ .Arg .Namespace }} +AND {{ .Ident "name" }} = {{ .Arg .Name }}; diff --git a/pkg/storage/unified/resource/datastore.go b/pkg/storage/unified/resource/datastore.go index 8a930492420..7a1b614323e 100644 --- a/pkg/storage/unified/resource/datastore.go +++ b/pkg/storage/unified/resource/datastore.go @@ -49,6 +49,9 @@ type DataKey struct { ResourceVersion int64 Action DataAction Folder string + + // needed to maintain backwards compatibility with unified/sql + GUID string } // GroupResource represents a unique group/resource combination @@ -61,6 +64,12 @@ func (k DataKey) String() string { return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder) } +// Temporary while we need to support unified/sql/backend compatibility +// Remove once we stop using RvManager in storage_backend.go +func (k DataKey) StringWithGUID() string { + return fmt.Sprintf("%s/%s/%s/%s/%d~%s~%s~%s", k.Group, k.Resource, k.Namespace, k.Name, k.ResourceVersion, k.Action, k.Folder, k.GUID) +} + func (k DataKey) Equals(other DataKey) bool { return k.Group == other.Group && k.Resource == other.Resource && k.Namespace == other.Namespace && k.Name == other.Name && k.ResourceVersion == other.ResourceVersion && k.Action == other.Action && k.Folder == other.Folder } @@ -516,7 +525,13 @@ func (d *dataStore) Save(ctx context.Context, key DataKey, value io.Reader) erro return fmt.Errorf("invalid data key: %w", err) } - writer, err := d.kv.Save(ctx, dataSection, key.String()) + var writer io.WriteCloser + var err error + if key.GUID != "" { + writer, err = d.kv.Save(ctx, dataSection, key.StringWithGUID()) + } else { + writer, err = d.kv.Save(ctx, dataSection, key.String()) + } if err != nil { return err } @@ -583,6 +598,33 @@ func ParseKey(key string) (DataKey, error) { }, nil } +// Temporary while we need to support unified/sql/backend compatibility +// Remove once we stop using RvManager in storage_backend.go +func ParseKeyWithGUID(key string) (DataKey, error) { + parts := strings.Split(key, "/") + if len(parts) != 5 { + return DataKey{}, fmt.Errorf("invalid key: %s", key) + } + rvActionFolderGUIDParts := strings.Split(parts[4], "~") + if len(rvActionFolderGUIDParts) != 4 { + return DataKey{}, fmt.Errorf("invalid key: %s", key) + } + rv, err := strconv.ParseInt(rvActionFolderGUIDParts[0], 10, 64) + if err != nil { + return DataKey{}, fmt.Errorf("invalid resource version '%s' in key %s: %w", rvActionFolderGUIDParts[0], key, err) + } + return DataKey{ + Group: parts[0], + Resource: parts[1], + Namespace: parts[2], + Name: parts[3], + ResourceVersion: rv, + Action: DataAction(rvActionFolderGUIDParts[1]), + Folder: rvActionFolderGUIDParts[2], + GUID: rvActionFolderGUIDParts[3], + }, nil +} + // SameResource checks if this key represents the same resource as another key. // It compares the identifying fields: Group, Resource, Namespace, and Name. // ResourceVersion, Action, and Folder are ignored as they don't identify the resource itself. diff --git a/pkg/storage/unified/resource/eventstore.go b/pkg/storage/unified/resource/eventstore.go index 7f80fb6b87a..e0c01afb550 100644 --- a/pkg/storage/unified/resource/eventstore.go +++ b/pkg/storage/unified/resource/eventstore.go @@ -32,6 +32,7 @@ type EventKey struct { ResourceVersion int64 Action DataAction Folder string + GUID string } func (k EventKey) String() string { diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 3c07403296b..9cc2cc32dd0 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -12,8 +12,10 @@ import ( "strings" "text/template" + "github.com/google/uuid" "github.com/grafana/grafana/pkg/storage/unified/sql/db" "github.com/grafana/grafana/pkg/storage/unified/sql/dbutil" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) @@ -34,11 +36,18 @@ func mustTemplate(filename string) *template.Template { // Templates. var ( - sqlKVKeys = mustTemplate("sqlkv_keys.sql") - sqlKVGet = mustTemplate("sqlkv_get.sql") - sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") - sqlKVDelete = mustTemplate("sqlkv_delete.sql") - sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") + sqlKVKeys = mustTemplate("sqlkv_keys.sql") + sqlKVGet = mustTemplate("sqlkv_get.sql") + sqlKVBatchGet = mustTemplate("sqlkv_batch_get.sql") + sqlKVSaveEvent = mustTemplate("sqlkv_save_event.sql") + sqlKVInsertData = mustTemplate("sqlkv_insert_datastore.sql") + sqlKVUpdateData = mustTemplate("sqlkv_update_datastore.sql") + sqlKVInsertLegacyResourceHistory = mustTemplate("sqlkv_insert_legacy_resource_history.sql") + sqlKVInsertLegacyResource = mustTemplate("sqlkv_insert_legacy_resource.sql") + sqlKVUpdateLegacyResource = mustTemplate("sqlkv_update_legacy_resource.sql") + sqlKVDeleteLegacyResource = mustTemplate("sqlkv_delete_legacy_resource.sql") + sqlKVDelete = mustTemplate("sqlkv_delete.sql") + sqlKVBatchDelete = mustTemplate("sqlkv_batch_delete.sql") ) // sqlKVSection can be embedded in structs used when rendering query templates @@ -128,6 +137,45 @@ func (req sqlKVBatchRequest) KeyPaths() []string { return result } +type sqlKVSaveRequest struct { + sqltemplate.SQLTemplate + sqlKVSectionKey + Value []byte + + // old fields that can be removed once we prune resource_history + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string +} + +func (req sqlKVSaveRequest) Validate() error { + return req.sqlKVSectionKey.Validate() +} + +type sqlKVLegacySaveRequest struct { + sqltemplate.SQLTemplate + Value []byte + GUID string + Group string + Resource string + Namespace string + Name string + Action int64 + Folder string +} + +func (req sqlKVLegacySaveRequest) Validate() error { + return nil +} + +func (req sqlKVLegacySaveRequest) Results() ([]byte, error) { + return req.Value, nil +} + type sqlKVKeysRequest struct { sqltemplate.SQLTemplate sqlKVSection @@ -285,20 +333,187 @@ func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) ite } } -// TODO: this function only exists to support the testing of the sqlkv implementation before -// we have a proper implementation of `Save`. -func (k *sqlKV) TestingSave(ctx context.Context, key string, value []byte) error { - stmt := fmt.Sprintf( - `INSERT INTO resource_events (key_path, value) VALUES (%s, %s)`, - k.dialect.ArgPlaceholder(1), k.dialect.ArgPlaceholder(2), - ) +func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { + sectionKey := sqlKVSectionKey{sqlKVSection{section}, key} + if err := sectionKey.Validate(); err != nil { + return nil, err + } - _, err := k.db.ExecContext(ctx, stmt, eventsSection+"/"+key, value) - return err + return &sqlWriteCloser{ + kv: k, + ctx: ctx, + sectionKey: sectionKey, + buf: &bytes.Buffer{}, + closed: false, + }, nil } -func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { - panic("not implemented!") +type sqlWriteCloser struct { + kv *sqlKV + ctx context.Context + sectionKey sqlKVSectionKey + buf *bytes.Buffer + closed bool +} + +func (w *sqlWriteCloser) Write(value []byte) (int, error) { + if w.closed { + return 0, errors.New("write to closed writer") + } + + return w.buf.Write(value) +} + +func (w *sqlWriteCloser) Close() error { + if w.closed { + return nil + } + + w.closed = true + + // do regular kv save: simple key_path + value insert with conflict check. + // can only do this on resource_events for now, until we drop the columns in resource_history + if w.sectionKey.Section == eventsSection { + _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVSaveEvent, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to save: %w", err) + } + + return nil + } + + // if storage_backend is running with an RvManager, it will inject a transaction into the context + // used to keep backwards compatibility between sql-based kvstore and unified/sql/backend + tx, ok := rvmanager.TxFromCtx(w.ctx) + if !ok { + // temporary save for dataStore without rvmanager + // we can use the same template as the event one after we: + // - move PK from GUID to key_path + // - remove all unnecessary columns (or at least their NOT NULL constraints) + _, err := w.kv.Get(w.ctx, w.sectionKey.Section, w.sectionKey.Key) + if errors.Is(err, ErrNotFound) { + _, err := dbutil.Exec(w.ctx, w.kv.db, sqlKVInsertData, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + GUID: uuid.New().String(), + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to insert to datastore: %w", err) + } + + return nil + } + + if err != nil { + return fmt.Errorf("failed to get for save: %w", err) + } + + _, err = dbutil.Exec(w.ctx, w.kv.db, sqlKVUpdateData, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + }) + + if err != nil { + return fmt.Errorf("failed to update to datastore: %w", err) + } + + return nil + } + + // special, temporary save that includes all the fields in resource_history that are not relevant for the kvstore, + // as well as the resource table. This is only called if an RvManager was passed to storage_backend, as that + // component will be responsible for populating the resource_version and key_path columns + // note that we are not touching resource_version table, neither the resource_version columns or the key_path column + // as the RvManager will be responsible for this + dataKey, err := ParseKeyWithGUID(w.sectionKey.Key) + if err != nil { + return fmt.Errorf("failed to parse key: %w", err) + } + + var action int64 + switch dataKey.Action { + case DataActionCreated: + action = 1 + case DataActionUpdated: + action = 2 + case DataActionDeleted: + action = 3 + default: + return fmt.Errorf("failed to parse key: %w", err) + } + + _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResourceHistory, sqlKVSaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + sqlKVSectionKey: w.sectionKey, + Value: w.buf.Bytes(), + GUID: dataKey.GUID, + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to save to resource_history: %w", err) + } + + switch dataKey.Action { + case DataActionCreated: + _, err = dbutil.Exec(w.ctx, tx, sqlKVInsertLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Value: w.buf.Bytes(), + GUID: dataKey.GUID, + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to insert to resource: %w", err) + } + case DataActionUpdated: + _, err = dbutil.Exec(w.ctx, tx, sqlKVUpdateLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Value: w.buf.Bytes(), + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + Action: action, + Folder: dataKey.Folder, + }) + + if err != nil { + return fmt.Errorf("failed to update resource: %w", err) + } + case DataActionDeleted: + _, err = dbutil.Exec(w.ctx, tx, sqlKVDeleteLegacyResource, sqlKVLegacySaveRequest{ + SQLTemplate: sqltemplate.New(w.kv.dialect), + Group: dataKey.Group, + Resource: dataKey.Resource, + Namespace: dataKey.Namespace, + Name: dataKey.Name, + }) + + if err != nil { + return fmt.Errorf("failed to delete from resource: %w", err) + } + } + + return nil } func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { @@ -319,6 +534,8 @@ func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { return ErrNotFound } + // TODO reflect change to resource table + return nil } diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index b0f51702775..13f2b9d6159 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -14,6 +14,7 @@ import ( "time" "github.com/bwmarrin/snowflake" + "github.com/google/uuid" "github.com/grafana/grafana-app-sdk/logging" "github.com/prometheus/client_golang/prometheus" "go.opentelemetry.io/otel/trace" @@ -22,6 +23,8 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/utils" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" + "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" "github.com/grafana/grafana/pkg/util/debouncer" ) @@ -68,6 +71,8 @@ type kvStorageBackend struct { withExperimentalClusterScope bool //tracer trace.Tracer //reg prometheus.Registerer + + rvManager *rvmanager.ResourceVersionManager } var _ KVBackend = &kvStorageBackend{} @@ -85,6 +90,10 @@ type KVBackendOptions struct { EventPruningInterval time.Duration // How often to run the event pruning (default: 5 minutes) Tracer trace.Tracer // TODO add tracing Reg prometheus.Registerer // TODO add metrics + + // Adding RvManager overrides the RV generated with snowflake in order to keep backwards compatibility with + // unified/sql + RvManager *rvmanager.ResourceVersionManager } func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { @@ -119,6 +128,7 @@ func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { eventRetentionPeriod: eventRetentionPeriod, eventPruningInterval: eventPruningInterval, withExperimentalClusterScope: opts.WithExperimentalClusterScope, + rvManager: opts.RvManager, } err = backend.initPruner(ctx) if err != nil { @@ -317,9 +327,28 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Action: action, Folder: obj.GetFolder(), } - err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) - if err != nil { - return 0, fmt.Errorf("failed to write data: %w", err) + + if k.rvManager != nil { + dataKey.GUID = uuid.New().String() + var err error + rv, err = k.rvManager.ExecWithRV(ctx, event.Key, func(tx db.Tx) (string, error) { + err := k.dataStore.Save(rvmanager.ContextWithTx(ctx, tx), dataKey, bytes.NewReader(event.Value)) + if err != nil { + return "", fmt.Errorf("failed to write data: %w", err) + } + + return dataKey.GUID, nil + }) + if err != nil { + return 0, fmt.Errorf("failed to write data: %w", err) + } + + dataKey.ResourceVersion = rv + } else { + err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value)) + if err != nil { + return 0, fmt.Errorf("failed to write data: %w", err) + } } // Optimistic concurrency control to verify our write is the latest version @@ -340,14 +369,22 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent write with higher RV happened - if latestKey.ResourceVersion != rv { + if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { // Delete the data we just wrote since it's not the latest + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent modification detected") } - if prevKey.ResourceVersion != event.PreviousRV { + if !rvmanager.IsRvEqual(prevKey.ResourceVersion, event.PreviousRV) { // Another concurrent write happened between our read and write + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: resource was modified concurrently (expected previous RV %d, found %d)", event.PreviousRV, prevKey.ResourceVersion) } @@ -366,8 +403,12 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in } // Check if the RV we just wrote is the latest. If not, a concurrent create with higher RV happened - if latestKey.ResourceVersion != rv { + if !rvmanager.IsRvEqual(latestKey.ResourceVersion, rv) { // Delete the data we just wrote since it's not the latest + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -375,6 +416,10 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in // Verify that the immediate predecessor is not a create if prevKey.Action == DataActionCreated { // Another concurrent create happened - delete our write and return error + // if we're running with rvManager, convert the ResourceVersion back to snowflake to delete + if k.rvManager != nil { + dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(dataKey.ResourceVersion) + } _ = k.dataStore.Delete(ctx, dataKey) return 0, fmt.Errorf("optimistic locking failed: concurrent create detected") } @@ -391,7 +436,7 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in Folder: obj.GetFolder(), PreviousRV: event.PreviousRV, } - err = k.eventStore.Save(ctx, eventData) + err := k.eventStore.Save(ctx, eventData) if err != nil { // Clean up the data we wrote since event save failed _ = k.dataStore.Delete(ctx, dataKey) diff --git a/pkg/storage/unified/sql/rvmanager/rv_manager.go b/pkg/storage/unified/sql/rvmanager/rv_manager.go index b4f3b5de596..b10685f22ad 100644 --- a/pkg/storage/unified/sql/rvmanager/rv_manager.go +++ b/pkg/storage/unified/sql/rvmanager/rv_manager.go @@ -21,6 +21,19 @@ import ( "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) +type contextKey string + +const txKey contextKey = "rvmanager_db_tx" + +func ContextWithTx(ctx context.Context, tx db.Tx) context.Context { + return context.WithValue(ctx, txKey, tx) +} + +func TxFromCtx(ctx context.Context) (db.Tx, bool) { + tx, ok := ctx.Value(txKey).(db.Tx) + return tx, ok +} + var tracer = otel.Tracer("github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager") var ( @@ -294,7 +307,7 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // Allocate the RVs for i, guid := range guids { guidToRV[guid] = rv - guidToSnowflakeRV[guid] = snowflakeFromRv(rv) + guidToSnowflakeRV[guid] = SnowflakeFromRv(rv) rvs[i] = rv rv++ } @@ -353,10 +366,20 @@ func (m *ResourceVersionManager) execBatch(ctx context.Context, group, resource // takes a unix microsecond rv and transforms into a snowflake format. The timestamp is converted from microsecond to // millisecond (the integer division) and the remainder is saved in the stepbits section. machine id is always 0 -func snowflakeFromRv(rv int64) int64 { +func SnowflakeFromRv(rv int64) int64 { return (((rv / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (rv % 1000) } +// helper utility to compare two RVs. The first RV must be in snowflake format. Will convert rv2 to snowflake and retry +// if comparison fails +func IsRvEqual(rv1, rv2 int64) bool { + if rv1 == rv2 { + return true + } + + return rv1 == SnowflakeFromRv(rv2) +} + // Lock locks the resource version for the given key func (m *ResourceVersionManager) Lock(ctx context.Context, x db.ContextExecer, group, resource string) (nextRV int64, err error) { // 1. Lock the row and prevent concurrent updates until the transaction is committed diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 84eda71ca20..f4a1ee3ce77 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -20,6 +20,8 @@ import ( "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/storage/unified/sql/rvmanager" + "github.com/grafana/grafana/pkg/storage/unified/sql/sqltemplate" ) type QOSEnqueueDequeuer interface { @@ -103,11 +105,34 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, fmt.Errorf("error creating sqlkv: %s", err) } - kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{ + kvBackendOpts := resource.KVBackendOptions{ KvStore: sqlkv, Tracer: opts.Tracer, Reg: opts.Reg, + } + + ctx := context.Background() + dbConn, err := eDB.Init(ctx) + if err != nil { + return nil, fmt.Errorf("error initializing DB: %w", err) + } + + dialect := sqltemplate.DialectForDriver(dbConn.DriverName()) + if dialect == nil { + return nil, fmt.Errorf("unsupported database driver: %s", dbConn.DriverName()) + } + + rvManager, err := rvmanager.NewResourceVersionManager(rvmanager.ResourceManagerOptions{ + Dialect: dialect, + DB: dbConn, }) + if err != nil { + return nil, fmt.Errorf("failed to create resource version manager: %w", err) + } + + // TODO add config to decide whether to pass RvManager or not + kvBackendOpts.RvManager = rvManager + kvBackend, err := resource.NewKVStorageBackend(kvBackendOpts) if err != nil { return nil, fmt.Errorf("error creating kv backend: %s", err) } diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index 770bf9ac6e6..d1900c7a46e 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -148,14 +148,13 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) - section := nsPrefix + "-save" t.Run("save new key", func(t *testing.T) { testValue := "new test value" - saveKVHelper(t, kv, ctx, section, "new-key", strings.NewReader(testValue)) + saveKVHelper(t, kv, ctx, testSection, "new-key", strings.NewReader(testValue)) // Verify it was saved - reader, err := kv.Get(ctx, section, "new-key") + reader, err := kv.Get(ctx, testSection, "new-key") require.NoError(t, err) value, err := io.ReadAll(reader) @@ -166,6 +165,26 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save overwrite existing key", func(t *testing.T) { + // First save + saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader("old value")) + + // Overwrite + newValue := "new value" + saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader(newValue)) + + // Verify it was updated + reader, err := kv.Get(ctx, testSection, "overwrite-key") + require.NoError(t, err) + + value, err := io.ReadAll(reader) + require.NoError(t, err) + assert.Equal(t, newValue, string(value)) + err = reader.Close() + require.NoError(t, err) + }) + + t.Run("save overwrite existing key (datastore)", func(t *testing.T) { + section := "unified/data" // First save saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader("old value")) @@ -192,10 +211,10 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("save binary data", func(t *testing.T) { binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD} - saveKVHelper(t, kv, ctx, section, "binary-key", bytes.NewReader(binaryData)) + saveKVHelper(t, kv, ctx, testSection, "binary-key", bytes.NewReader(binaryData)) // Verify binary data - reader, err := kv.Get(ctx, section, "binary-key") + reader, err := kv.Get(ctx, testSection, "binary-key") require.NoError(t, err) value, err := io.ReadAll(reader) @@ -207,10 +226,10 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("save key with no data", func(t *testing.T) { // Save a key with empty data - saveKVHelper(t, kv, ctx, section, "empty-key", strings.NewReader("")) + saveKVHelper(t, kv, ctx, testSection, "empty-key", strings.NewReader("")) // Verify it was saved with empty data - reader, err := kv.Get(ctx, section, "empty-key") + reader, err := kv.Get(ctx, testSection, "empty-key") require.NoError(t, err) value, err := io.ReadAll(reader) @@ -907,18 +926,6 @@ func runTestKVBatchDelete(t *testing.T, kv resource.KV, nsPrefix string) { func saveKVHelper(t *testing.T, kv resource.KV, ctx context.Context, section, key string, value io.Reader) { t.Helper() - // TODO: remove this check once the sqlkv implementation supports `Save`. - type testingSaver interface { - TestingSave(context.Context, string, []byte) error - } - - if saver, ok := kv.(testingSaver); ok { - blob, err := io.ReadAll(value) - require.NoError(t, err) - require.NoError(t, saver.TestingSave(ctx, key, blob)) - return - } - writer, err := kv.Save(ctx, section, key) require.NoError(t, err) _, err = io.Copy(writer, value) diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 3814df17b7e..5e94ccd8a7f 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -47,7 +47,6 @@ func TestSQLKV(t *testing.T) { }, &KVTestOptions{ NSPrefix: "sql-kv-test", SkipTests: map[string]bool{ - TestKVSave: true, TestKVConcurrent: true, TestKVUnixTimestamp: true, }, From 2fbe2f77e389d59ea84a63fb095d3bc887e72009 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Fri, 19 Dec 2025 13:17:39 -0700 Subject: [PATCH 11/80] Folders: Add max depth check with descendant to /apis (#115305) --- pkg/api/apierrors/folder.go | 3 +- pkg/api/apierrors/folder_test.go | 2 +- pkg/api/folder.go | 2 +- pkg/registry/apis/folders/register.go | 2 +- pkg/registry/apis/folders/register_test.go | 4 + pkg/registry/apis/folders/validate.go | 162 +++++++++++++++++- pkg/registry/apis/folders/validate_test.go | 113 +++++++++++- .../folderimpl/folder_unifiedstorage.go | 38 +--- 8 files changed, 275 insertions(+), 51 deletions(-) diff --git a/pkg/api/apierrors/folder.go b/pkg/api/apierrors/folder.go index 9509ff4ff55..a938b165852 100644 --- a/pkg/api/apierrors/folder.go +++ b/pkg/api/apierrors/folder.go @@ -29,7 +29,8 @@ func ToFolderErrorResponse(err error) response.Response { errors.Is(err, dashboards.ErrDashboardTypeMismatch) || errors.Is(err, dashboards.ErrDashboardInvalidUid) || errors.Is(err, dashboards.ErrDashboardUidTooLong) || - errors.Is(err, folder.ErrFolderCannotBeParentOfItself) { + errors.Is(err, folder.ErrFolderCannotBeParentOfItself) || + errors.Is(err, folder.ErrMaximumDepthReached) { return response.Error(http.StatusBadRequest, err.Error(), nil) } diff --git a/pkg/api/apierrors/folder_test.go b/pkg/api/apierrors/folder_test.go index 0ca8b16fc87..233254dc6cc 100644 --- a/pkg/api/apierrors/folder_test.go +++ b/pkg/api/apierrors/folder_test.go @@ -30,7 +30,7 @@ func TestToFolderErrorResponse(t *testing.T) { { name: "maximum depth reached", input: folder.ErrMaximumDepthReached.Errorf("Maximum nested folder depth reached"), - want: response.Err(folder.ErrMaximumDepthReached.Errorf("Maximum nested folder depth reached")), + want: response.Error(http.StatusBadRequest, "[folder.maximum-depth-reached] Maximum nested folder depth reached", nil), }, { name: "bad request errors", diff --git a/pkg/api/folder.go b/pkg/api/folder.go index 851910a9e1d..5b0bf9d121e 100644 --- a/pkg/api/folder.go +++ b/pkg/api/folder.go @@ -214,7 +214,7 @@ func (hs *HTTPServer) MoveFolder(c *contextmodel.ReqContext) response.Response { cmd.SignedInUser = c.SignedInUser theFolder, err := hs.folderService.Move(c.Req.Context(), &cmd) if err != nil { - return response.ErrOrFallback(http.StatusInternalServerError, "move folder failed", err) + return apierrors.ToFolderErrorResponse(err) } folderDTO, err := hs.newToFolderDto(c, theFolder) diff --git a/pkg/registry/apis/folders/register.go b/pkg/registry/apis/folders/register.go index 64e51c06312..7c39fbe22ae 100644 --- a/pkg/registry/apis/folders/register.go +++ b/pkg/registry/apis/folders/register.go @@ -356,7 +356,7 @@ func (b *FolderAPIBuilder) Validate(ctx context.Context, a admission.Attributes, if !ok { return fmt.Errorf("obj is not folders.Folder") } - return validateOnUpdate(ctx, f, old, b.storage, b.parents, folder.MaxNestedFolderDepth) + return validateOnUpdate(ctx, f, old, b.storage, b.parents, b.searcher, folder.MaxNestedFolderDepth) default: return nil } diff --git a/pkg/registry/apis/folders/register_test.go b/pkg/registry/apis/folders/register_test.go index 066f8793776..1eb00f0a7e5 100644 --- a/pkg/registry/apis/folders/register_test.go +++ b/pkg/registry/apis/folders/register_test.go @@ -376,6 +376,10 @@ func TestFolderAPIBuilder_Validate_Update(t *testing.T) { m.On("Get", mock.Anything, "new-parent", mock.Anything).Return( &folders.Folder{}, nil).Once() + // also retrieves old parent for depth difference calculation + m.On("Get", mock.Anything, "valid-parent", mock.Anything).Return( + &folders.Folder{}, + nil).Once() }, }, { diff --git a/pkg/registry/apis/folders/validate.go b/pkg/registry/apis/folders/validate.go index eb0c29b30a1..3c7c30a1aea 100644 --- a/pkg/registry/apis/folders/validate.go +++ b/pkg/registry/apis/folders/validate.go @@ -6,6 +6,7 @@ import ( "slices" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/selection" "k8s.io/apiserver/pkg/registry/rest" folders "github.com/grafana/grafana/apps/folder/pkg/apis/folder/v1beta1" @@ -13,6 +14,7 @@ import ( "github.com/grafana/grafana/pkg/services/accesscontrol" "github.com/grafana/grafana/pkg/services/dashboards" "github.com/grafana/grafana/pkg/services/folder" + "github.com/grafana/grafana/pkg/storage/unified/resource" "github.com/grafana/grafana/pkg/storage/unified/resourcepb" "github.com/grafana/grafana/pkg/util" ) @@ -73,6 +75,7 @@ func validateOnUpdate(ctx context.Context, old *folders.Folder, getter rest.Getter, parents parentsGetter, + searcher resourcepb.ResourceIndexClient, maxDepth int, ) error { folderObj, err := utils.MetaAccessor(obj) @@ -95,7 +98,10 @@ func validateOnUpdate(ctx context.Context, // Validate the move operation newParent := folderObj.GetFolder() - // If we move to root, we don't need to validate the depth. + // If we move to root, we don't need to validate the depth, because the folder already existed + // before and wasn't too deep. This move will make it more shallow. + // + // We also don't need to validate circular references because the root folder cannot have a parent. if newParent == folder.RootFolderUID { return nil } @@ -113,9 +119,6 @@ func validateOnUpdate(ctx context.Context, if !ok { return fmt.Errorf("expected folder, found %T", parentObj) } - - //FIXME: until we have a way to represent the tree, we can only - // look at folder parents to check how deep the new folder tree will be info, err := parents(ctx, parent) if err != nil { return err @@ -129,13 +132,162 @@ func validateOnUpdate(ctx context.Context, } } - // if by moving a folder we exceed the max depth, return an error + // if by moving a folder we exceed the max depth just from its parents + itself, return an error if len(info.Items) > maxDepth+1 { return folder.ErrMaximumDepthReached.Errorf("maximum folder depth reached") } + // To try to save some computation, get the parents of the old parent (this is typically cheaper + // than looking at the children of the folder). If the old parent has more parents or the same + // number of parents as the new parent, we can return early, because we know the folder had to be + // safe from the creation validation. If we cannot access the older parent, we will continue to check the children. + if canSkipChildrenCheck(ctx, oldFolder, getter, parents, len(info.Items)) { + return nil + } + + // Now comes the more expensive part: we need to check if moving this folder will cause + // any descendant folders to exceed the max depth. + // + // Calculate the maximum allowed subtree depth after the move. + allowedDepth := (maxDepth + 1) - len(info.Items) + if allowedDepth <= 0 { + return nil + } + + return checkSubtreeDepth(ctx, searcher, obj.Namespace, obj.Name, allowedDepth, maxDepth) +} + +// canSkipChildrenCheck determines if we can skip the expensive children depth check. +// If the old parent depth is >= the new parent depth, the folder was already valid +// and this move won't make descendants exceed max depth. +func canSkipChildrenCheck(ctx context.Context, oldFolder utils.GrafanaMetaAccessor, getter rest.Getter, parents parentsGetter, newParentDepth int) bool { + if oldFolder.GetFolder() == folder.RootFolderUID { + return false + } + + oldParentObj, err := getter.Get(ctx, oldFolder.GetFolder(), &metav1.GetOptions{}) + if err != nil { + return false + } + + oldParent, ok := oldParentObj.(*folders.Folder) + if !ok { + return false + } + + oldInfo, err := parents(ctx, oldParent) + if err != nil { + return false + } + + oldParentDepth := len(oldInfo.Items) + levelDifference := newParentDepth - oldParentDepth + return levelDifference <= 0 +} + +// checkSubtreeDepth uses a hybrid DFS+batching approach: +// 1. fetches one page of children for the current folder(s) +// 2. batches all those children into one request to get their children +// 3. continues depth-first (batching still) until max depth or violation +// 4. only fetches more siblings after fully exploring current batch +func checkSubtreeDepth(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, folderUID string, remainingDepth int, maxDepth int) error { + if remainingDepth <= 0 { + return nil + } + + // Start with the folder being moved + return checkSubtreeDepthBatched(ctx, searcher, namespace, []string{folderUID}, remainingDepth, maxDepth) +} + +// checkSubtreeDepthBatched checks depth for a batch of folders at the same level +func checkSubtreeDepthBatched(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, remainingDepth int, maxDepth int) error { + if remainingDepth <= 0 || len(parentUIDs) == 0 { + return nil + } + + const pageSize int64 = 1000 + var offset int64 + totalPages := 0 + hasMore := true + + // Using an upper limit to ensure no infinite loops can happen + for hasMore && totalPages < 1000 { + totalPages++ + + var err error + var children []string + children, hasMore, err = getChildrenBatch(ctx, searcher, namespace, parentUIDs, pageSize, offset) + if err != nil { + return fmt.Errorf("failed to get children: %w", err) + } + + if len(children) == 0 { + return nil + } + + // if we are at the last allowed depth and children exist, we will hit the max + if remainingDepth == 1 { + return folder.ErrMaximumDepthReached.Errorf("maximum folder depth %d would be exceeded after move", maxDepth) + } + + if err := checkSubtreeDepthBatched(ctx, searcher, namespace, children, remainingDepth-1, maxDepth); err != nil { + return err + } + + if !hasMore { + return nil + } + + offset += pageSize + } + return nil } +// getChildrenBatch fetches children for multiple parents +func getChildrenBatch(ctx context.Context, searcher resourcepb.ResourceIndexClient, namespace string, parentUIDs []string, limit int64, offset int64) ([]string, bool, error) { + if len(parentUIDs) == 0 { + return nil, false, nil + } + + resp, err := searcher.Search(ctx, &resourcepb.ResourceSearchRequest{ + Options: &resourcepb.ListOptions{ + Key: &resourcepb.ResourceKey{ + Namespace: namespace, + Group: folders.FolderResourceInfo.GroupVersionResource().Group, + Resource: folders.FolderResourceInfo.GroupVersionResource().Resource, + }, + Fields: []*resourcepb.Requirement{{ + Key: resource.SEARCH_FIELD_FOLDER, + Operator: string(selection.In), + Values: parentUIDs, + }}, + }, + Limit: limit, + Offset: offset, + }) + if err != nil { + return nil, false, fmt.Errorf("failed to search folders: %w", err) + } + + if resp.Error != nil { + return nil, false, fmt.Errorf("search error: %s", resp.Error.Message) + } + + if resp.Results == nil || len(resp.Results.Rows) == 0 { + return nil, false, nil + } + + children := make([]string, 0, len(resp.Results.Rows)) + for _, row := range resp.Results.Rows { + if row.Key != nil { + children = append(children, row.Key.Name) + } + } + + hasMore := resp.Results.NextPageToken != "" + return children, hasMore, nil +} + func validateOnDelete(ctx context.Context, f *folders.Folder, searcher resourcepb.ResourceIndexClient, diff --git a/pkg/registry/apis/folders/validate_test.go b/pkg/registry/apis/folders/validate_test.go index 7fdb3cfae12..b9f55571ce0 100644 --- a/pkg/registry/apis/folders/validate_test.go +++ b/pkg/registry/apis/folders/validate_test.go @@ -282,6 +282,7 @@ func TestValidateUpdate(t *testing.T) { old *folders.Folder parents *folders.FolderInfoList parentsError error + allFolders []folders.Folder expectedErr string maxDepth int // defaults to 5 unless set }{ @@ -454,6 +455,74 @@ func TestValidateUpdate(t *testing.T) { }, expectedErr: "cannot move folder under its own descendant", }, + { + name: "error when moving folder from root to level2 with children exceeds max depth", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "level2", + }, + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "level2", Parent: "level1"}, + {Name: "level1", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + allFolders: []folders.Folder{ + {ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}}, + }, + maxDepth: 4, + expectedErr: "[folder.maximum-depth-reached]", + }, + { + name: "can move folder from root level to level1 with children when within max depth", + folder: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + Annotations: map[string]string{ + utils.AnnoKeyFolder: "level1", + }, + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + old: &folders.Folder{ + ObjectMeta: metav1.ObjectMeta{ + Name: "folderWithChildren", + }, + Spec: folders.FolderSpec{ + Title: "folder with children", + }, + }, + parents: &folders.FolderInfoList{ + Items: []folders.FolderInfo{ + {Name: "level1", Parent: folder.GeneralFolderUID}, + {Name: folder.GeneralFolderUID}, + }, + }, + allFolders: []folders.Folder{ + {ObjectMeta: metav1.ObjectMeta{Name: "child1", Annotations: map[string]string{utils.AnnoKeyFolder: "folderWithChildren"}}}, + {ObjectMeta: metav1.ObjectMeta{Name: "grandchild1", Annotations: map[string]string{utils.AnnoKeyFolder: "child1"}}}, + }, + maxDepth: 4, + }, } for _, tt := range tests { @@ -474,11 +543,17 @@ func TestValidateUpdate(t *testing.T) { }, nil).Maybe() } } + for i := range tt.allFolders { + f := tt.allFolders[i] + m.On("Get", context.Background(), f.Name, &metav1.GetOptions{}).Return(&f, nil).Maybe() + } err := validateOnUpdate(context.Background(), tt.folder, tt.old, m, func(ctx context.Context, folder *folders.Folder) (*folders.FolderInfoList, error) { return tt.parents, tt.parentsError - }, maxDepth) + }, + &mockSearchClient{folders: tt.allFolders}, + maxDepth) if tt.expectedErr == "" { require.NoError(t, err) @@ -693,8 +768,7 @@ type mockSearchClient struct { stats *resourcepb.ResourceStatsResponse statsErr error - search *resourcepb.ResourceSearchResponse - searchErr error + folders []folders.Folder } // GetStats implements resourcepb.ResourceIndexClient. @@ -703,8 +777,37 @@ func (m *mockSearchClient) GetStats(ctx context.Context, in *resourcepb.Resource } // Search implements resourcepb.ResourceIndexClient. -func (m *mockSearchClient) Search(ctx context.Context, in *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { - return m.search, m.searchErr +func (m *mockSearchClient) Search(ctx context.Context, req *resourcepb.ResourceSearchRequest, opts ...grpc.CallOption) (*resourcepb.ResourceSearchResponse, error) { + // get the list of parents from the search request + parentSet := make(map[string]bool) + if req.Options != nil && req.Options.Fields != nil { + for _, field := range req.Options.Fields { + if field.Key == "folder" && field.Operator == "in" { + for _, v := range field.Values { + parentSet[v] = true + } + } + } + } + + // find children that match the parent filter + var rows []*resourcepb.ResourceTableRow + for i := range m.folders { + meta, err := utils.MetaAccessor(&m.folders[i]) + if err != nil { + continue + } + parentUID := meta.GetFolder() + if parentSet[parentUID] { + rows = append(rows, &resourcepb.ResourceTableRow{ + Key: &resourcepb.ResourceKey{Name: m.folders[i].Name}, + }) + } + } + + return &resourcepb.ResourceSearchResponse{ + Results: &resourcepb.ResourceTable{Rows: rows}, + }, nil } // RebuildIndexes implements resourcepb.ResourceIndexClient. diff --git a/pkg/services/folder/folderimpl/folder_unifiedstorage.go b/pkg/services/folder/folderimpl/folder_unifiedstorage.go index 9b238b769fe..69b8cb59311 100644 --- a/pkg/services/folder/folderimpl/folder_unifiedstorage.go +++ b/pkg/services/folder/folderimpl/folder_unifiedstorage.go @@ -726,19 +726,6 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") } - f, err := s.unifiedStore.Get(ctx, folder.GetFolderQuery{ - UID: &cmd.UID, - OrgID: cmd.OrgID, - SignedInUser: cmd.SignedInUser, - }) - if err != nil { - return nil, err - } - - if f != nil && f.ParentUID == accesscontrol.K6FolderUID { - return nil, folder.ErrBadRequest.Errorf("k6 project may not be moved") - } - // Check that the user is allowed to move the folder to the destination folder hasAccess, evalErr := s.canMoveViaApiServer(ctx, cmd) if evalErr != nil { @@ -748,30 +735,7 @@ func (s *Service) moveOnApiServer(ctx context.Context, cmd *folder.MoveFolderCom return nil, dashboards.ErrFolderAccessDenied } - // here we get the folder, we need to get the height of current folder - // and the depth of the new parent folder, the sum can't bypass 8 - folderHeight, err := s.unifiedStore.GetHeight(ctx, cmd.UID, cmd.OrgID, &cmd.NewParentUID) - if err != nil { - return nil, err - } - parents, err := s.unifiedStore.GetParents(ctx, folder.GetParentsQuery{UID: cmd.NewParentUID, OrgID: cmd.OrgID}) - if err != nil { - return nil, err - } - - // height of the folder that is being moved + this current folder itself + depth of the NewParent folder should be less than or equal MaxNestedFolderDepth - if folderHeight+len(parents)+1 > folder.MaxNestedFolderDepth { - return nil, folder.ErrMaximumDepthReached.Errorf("failed to move folder") - } - - for _, parent := range parents { - // if the current folder is already a parent of newparent, we should return error - if parent.UID == cmd.UID { - return nil, folder.ErrCircularReference.Errorf("failed to move folder") - } - } - - f, err = s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ + f, err := s.unifiedStore.Update(ctx, folder.UpdateFolderCommand{ UID: cmd.UID, OrgID: cmd.OrgID, NewParentUID: &cmd.NewParentUID, From 3522efdf3223762660b442325043daf902b51db9 Mon Sep 17 00:00:00 2001 From: Adela Almasan <88068998+adela-almasan@users.noreply.github.com> Date: Fri, 19 Dec 2025 14:22:26 -0600 Subject: [PATCH 12/80] VizSuggestions: Error handling (#115428) * error handling * retry fetching suggestions * add translation * useAsyncRetry * hasError test * update error handling * clean up the text panel stuff for the current version * cleanup for loop * some more tests for some failure cases * fix lint issue --------- Co-authored-by: Paul Marbach --- .../panel-edit/PanelEditor.tsx | 2 +- .../VisualizationSuggestions.tsx | 194 ++++++++++-------- .../app/features/panel/suggestions/consts.ts | 1 + .../suggestions/getAllSuggestions.test.ts | 113 +++++++--- .../panel/suggestions/getAllSuggestions.ts | 97 +++++---- .../app/features/plugins/importPanelPlugin.ts | 6 + public/app/plugins/panel/table/suggestions.ts | 3 +- public/app/plugins/panel/text/module.tsx | 9 +- public/app/plugins/panel/text/plugin.json | 2 +- public/locales/en-US/grafana.json | 7 +- 10 files changed, 284 insertions(+), 150 deletions(-) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index 85813a99f6d..e656a39e6a1 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -120,7 +120,7 @@ export class PanelEditor extends SceneObjectBase { dataObject.subscribeToState(async () => { const { data } = dataObject.state; if (hasData(data) && panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { - const suggestions = await getAllSuggestions(data); + const { suggestions } = await getAllSuggestions(data); if (suggestions.length > 0) { const defaultFirstSuggestion = suggestions[0]; diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index e93e74f358d..d1763ad835f 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { Fragment, useState, useEffect, useCallback, useMemo } from 'react'; -import { useAsync, useMeasure } from 'react-use'; +import { useAsyncRetry, useMeasure } from 'react-use'; import { GrafanaTheme2, @@ -28,19 +28,23 @@ export interface Props { panel?: PanelModel; } +const useSuggestions = (data: PanelData | undefined) => { + const [hasFetched, setHasFetched] = useState(false); + const { value, loading, error, retry } = useAsyncRetry(async () => { + await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0)); + setHasFetched(true); + return await getAllSuggestions(data); + }, [hasFetched, data]); + return { value, loading, error, retry }; +}; + export function VisualizationSuggestions({ onChange, data, panel }: Props) { const styles = useStyles2(getStyles); - const { - value: suggestions, - loading, - error, - } = useAsync(async () => { - if (!hasData(data)) { - return []; - } - return await getAllSuggestions(data); - }, [data]); + const { value: result, loading, error, retry } = useSuggestions(data); + + const suggestions = result?.suggestions; + const hasLoadingErrors = result?.hasErrors ?? false; const [suggestionHash, setSuggestionHash] = useState(null); const [firstCardRef, { width }] = useMeasure(); const [firstCardHash, setFirstCardHash] = useState(null); @@ -131,80 +135,97 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { } return ( -
- {isNewVizSuggestionsEnabled - ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( - -
- - {vizType?.info && } - {vizType?.name || t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} - -
- {vizTypeSuggestions?.map((suggestion, index) => { - const isCardSelected = suggestionHash === suggestion.hash; - return ( -
{ - if (ev.key === 'Enter' || ev.key === ' ') { - ev.preventDefault(); - applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); - } - }} - ref={index === 0 ? firstCardRef : undefined} - > - {isCardSelected && ( - +
+ + )} +
+ {isNewVizSuggestionsEnabled + ? suggestionsByVizType.map(([vizType, vizTypeSuggestions], groupIndex) => ( + +
+ + {vizType?.info && } + {vizType?.name || + t('panel.visualization-suggestions.unknown-viz-type', 'Unknown visualization type')} + +
+ {vizTypeSuggestions?.map((suggestion, index) => { + const isCardSelected = suggestionHash === suggestion.hash; + return ( +
{ + if (ev.key === 'Enter' || ev.key === ' ') { + ev.preventDefault(); + applySuggestion(suggestion, isNewVizSuggestionsEnabled && !isCardSelected); } - > - {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} - - )} - applySuggestion(suggestion, true)} - /> -
- ); - })} -
- )) - : suggestions?.map((suggestion, index) => ( -
- applySuggestion(suggestion)} - /> -
- ))} -
+ }} + ref={index === 0 ? firstCardRef : undefined} + > + {isCardSelected && ( + + )} + applySuggestion(suggestion, true)} + /> +
+ ); + })} + + )) + : suggestions?.map((suggestion, index) => ( +
+ applySuggestion(suggestion)} + /> +
+ ))} + + ); } @@ -217,6 +238,11 @@ const getStyles = (theme: GrafanaTheme2) => { width: '100%', marginTop: theme.spacing(6), }), + alertContent: css({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + }), filterRow: css({ display: 'flex', flexDirection: 'row', diff --git a/public/app/features/panel/suggestions/consts.ts b/public/app/features/panel/suggestions/consts.ts index 76e012a7425..decdf8ac7b4 100644 --- a/public/app/features/panel/suggestions/consts.ts +++ b/public/app/features/panel/suggestions/consts.ts @@ -16,4 +16,5 @@ export const panelsToCheckFirst = [ 'heatmap', 'histogram', 'geomap', + 'text', ]; diff --git a/public/app/features/panel/suggestions/getAllSuggestions.test.ts b/public/app/features/panel/suggestions/getAllSuggestions.test.ts index e76a7df15e3..8657824cc27 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.test.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.test.ts @@ -1,4 +1,5 @@ import { + AppEvents, DataFrame, FieldType, getDefaultTimeRange, @@ -18,10 +19,20 @@ import { StackingMode, VizOrientation, } from '@grafana/schema'; +import { appEvents } from 'app/core/app_events'; import { config } from 'app/core/config'; +import { clearPanelPluginCache } from 'app/features/plugins/importPanelPlugin'; +import { pluginImporter } from 'app/features/plugins/importer/pluginImporter'; import { panelsToCheckFirst } from './consts'; -import { getAllSuggestions, sortSuggestions } from './getAllSuggestions'; +import { getAllSuggestions, loadPlugins, sortSuggestions } from './getAllSuggestions'; + +jest.mock('app/core/app_events', () => ({ + appEvents: { + subscribe: jest.fn(() => ({ unsubscribe: jest.fn() })), + publish: jest.fn(), + }, +})); config.featureToggles.externalVizSuggestions = true; @@ -52,28 +63,6 @@ for (const pluginId of panelsToCheckFirst) { }; } -config.panels.text = { - id: 'text', - module: 'core:plugin/text', - sort: idx++, - name: 'Text', - type: PluginType.panel, - baseUrl: 'public/app/plugins/panel', - skipDataQuery: true, - suggestions: false, - info: { - version: '1.0.0', - updated: '2025-01-01', - links: [], - screenshots: [], - author: { - name: 'Grafana Labs', - }, - description: 'Text panel', - logos: { small: 'small/logo', large: 'large/logo' }, - }, -}; - jest.mock('../state/util', () => { const originalModule = jest.requireActual('../state/util'); return { @@ -103,7 +92,8 @@ class ScenarioContext { timeRange: getDefaultTimeRange(), }; - this.suggestions = await getAllSuggestions(panelData); + const result = await getAllSuggestions(panelData); + this.suggestions = result.suggestions; } names() { @@ -554,6 +544,81 @@ describe('sortSuggestions', () => { }); }); +describe('Visualization suggestions error handling', () => { + it('returns result with hasErrors flag', async () => { + const result = await getAllSuggestions({ + series: [ + toDataFrame({ + fields: [ + { name: 'Time', type: FieldType.time, values: [1, 2] }, + { name: 'Max', type: FieldType.number, values: [1, 10] }, + ], + }), + ], + state: LoadingState.Done, + timeRange: getDefaultTimeRange(), + }); + + expect(result).toHaveProperty('suggestions'); + expect(result).toHaveProperty('hasErrors'); + expect(result.hasErrors).toBe(false); + }); +}); + +// this needs to happen before any +describe('loadPlugins', () => { + beforeEach(() => { + clearPanelPluginCache(); + }); + + afterEach(() => { + if (jest.isMockFunction(pluginImporter.importPanel)) { + jest.mocked(pluginImporter.importPanel).mockRestore(); + } + }); + + it('should swallow errors when failing to load core plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const _importPanel = pluginImporter.importPanel; + jest.spyOn(pluginImporter, 'importPanel').mockImplementation(async (meta) => { + if (meta.id === 'timeseries') { + throw new Error('Failed to load core panel plugin'); + } + return await _importPanel(meta); + }); + + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([expect.objectContaining({ meta: expect.objectContaining({ id: 'table' }) })]); + expect(hasErrors).toBe(true); + expect(appEvents.publish).not.toHaveBeenCalled(); + }); + + it('should swallow errors when failing to load external plugins', async () => { + jest.spyOn(console, 'error').mockImplementation(); + + const panelIds = ['non-existent-panel']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins).toEqual([]); + expect(hasErrors).toBe(false); + expect(appEvents.publish).toHaveBeenCalledWith({ + type: AppEvents.alertError.name, + payload: [expect.stringContaining('Failed to load panel plugin: non-existent-panel.')], + }); + }); + + it('should load panel plugins with suggestions', async () => { + const panelIds = ['timeseries', 'table']; + const { plugins, hasErrors } = await loadPlugins(panelIds); + + expect(plugins.map((p) => p.meta.id)).toEqual(expect.arrayContaining(['timeseries', 'table'])); + expect(hasErrors).toBe(false); + }); +}); + function repeatFrame(count: number, frame: DataFrame): DataFrame[] { const frames: DataFrame[] = []; for (let i = 0; i < count; i++) { diff --git a/public/app/features/panel/suggestions/getAllSuggestions.ts b/public/app/features/panel/suggestions/getAllSuggestions.ts index ea94add39ab..d0bb73dfb83 100644 --- a/public/app/features/panel/suggestions/getAllSuggestions.ts +++ b/public/app/features/panel/suggestions/getAllSuggestions.ts @@ -1,4 +1,5 @@ import { + AppEvents, getPanelDataSummary, PanelData, PanelDataSummary, @@ -7,41 +8,67 @@ import { PreferredVisualisationType, VisualizationSuggestionScore, } from '@grafana/data'; +import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { appEvents } from 'app/core/app_events'; import { importPanelPlugin, isBuiltInPlugin } from 'app/features/plugins/importPanelPlugin'; import { getAllPanelPluginMeta } from '../state/util'; import { panelsToCheckFirst } from './consts'; -/** - * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions - */ -async function getPanelsWithSuggestions(): Promise { - // list of plugins to load is determined by the feature flag - const pluginIds: string[] = config.featureToggles.externalVizSuggestions +interface PluginLoadResult { + plugins: PanelPlugin[]; + hasErrors: boolean; +} + +function getPanelPluginIds(): string[] { + return config.featureToggles.externalVizSuggestions ? getAllPanelPluginMeta() .filter((panel) => panel.suggestions) .map((m) => m.id) : panelsToCheckFirst; +} +/** + * gather and cache the plugins which provide visualization suggestions so they can be invoked to build suggestions + */ +export async function loadPlugins(pluginIds: string[]): Promise { // import the plugins in parallel using Promise.allSettled const plugins: PanelPlugin[] = []; - const settledPromises = await Promise.allSettled(pluginIds.map((id) => importPanelPlugin(id))); + let hasErrors = false; + const settledPromises = await Promise.allSettled( + pluginIds.map(async (pluginId) => { + return await importPanelPlugin(pluginId); + }) + ); + for (let i = 0; i < settledPromises.length; i++) { const settled = settledPromises[i]; - if (settled.status === 'fulfilled') { plugins.push(settled.value); + } else { + const pluginId = pluginIds[i]; + console.error(`Failed to load ${pluginId} for visualization suggestions:`, settled.reason); + + if (isBuiltInPlugin(pluginId)) { + hasErrors = true; + } else { + appEvents.publish({ + type: AppEvents.alertError.name, + payload: [ + t( + 'panel.visualization-suggestions.error-loading-suggestions.plugin-failed', + 'Failed to load panel plugin: {{ pluginId }}.', + { pluginId } + ), + ], + }); + } } - // TODO: do we want to somehow log if there were errors loading some of the plugins? } - if (plugins.length === 0) { - throw new Error('No panel plugins with visualization suggestions found'); - } - - return plugins; + return { plugins, hasErrors }; } /** @@ -89,41 +116,37 @@ export function sortSuggestions(suggestions: PanelPluginVisualizationSuggestion[ }); } +export interface SuggestionsResult { + suggestions: PanelPluginVisualizationSuggestion[]; + hasErrors: boolean; +} + /** * given PanelData, return a sorted list of Suggestions from all plugins which support it. * @param {PanelData} data queried and transformed data for the panel - * @returns {PanelPluginVisualizationSuggestion[]} sorted list of suggestions + * @returns {SuggestionsResult} sorted list of suggestions and error status */ -export async function getAllSuggestions(data?: PanelData): Promise { +export async function getAllSuggestions(data?: PanelData): Promise { const dataSummary = getPanelDataSummary(data?.series); const list: PanelPluginVisualizationSuggestion[] = []; - for (const plugin of await getPanelsWithSuggestions()) { - const suggestions = plugin.getSuggestions(dataSummary); - if (suggestions) { - list.push(...suggestions); - } - } + const pluginIds: string[] = getPanelPluginIds(); + const { plugins, hasErrors: pluginLoadErrors } = await loadPlugins(pluginIds); - if (dataSummary.fieldCount === 0) { - for (const plugin of Object.values(config.panels)) { - if (!plugin.skipDataQuery || plugin.hideFromList) { - continue; + let pluginSuggestionsError = false; + for (const plugin of plugins) { + try { + const suggestions = plugin.getSuggestions(dataSummary); + if (suggestions) { + list.push(...suggestions); } - - list.push({ - name: plugin.name, - pluginId: plugin.id, - description: plugin.info.description, - hash: 'plugin-empty-' + plugin.id, - cardOptions: { - imgSrc: plugin.info.logos.small, - }, - }); + } catch (e) { + console.warn(`error when loading suggestions from plugin "${plugin.meta.id}"`, e); + pluginSuggestionsError = true; } } sortSuggestions(list, dataSummary); - return list; + return { suggestions: list, hasErrors: pluginLoadErrors || pluginSuggestionsError }; } diff --git a/public/app/features/plugins/importPanelPlugin.ts b/public/app/features/plugins/importPanelPlugin.ts index e541b8f7893..ef8d955f562 100644 --- a/public/app/features/plugins/importPanelPlugin.ts +++ b/public/app/features/plugins/importPanelPlugin.ts @@ -62,3 +62,9 @@ export function syncGetPanelPlugin(id: string): PanelPlugin | undefined { function getPanelPlugin(meta: PanelPluginMeta): Promise { return pluginImporter.importPanel(meta); } + +export function clearPanelPluginCache(): void { + for (const key of Object.keys(promiseCache)) { + delete promiseCache[key]; + } +} diff --git a/public/app/plugins/panel/table/suggestions.ts b/public/app/plugins/panel/table/suggestions.ts index 8e0d6e44953..260e73b43eb 100644 --- a/public/app/plugins/panel/table/suggestions.ts +++ b/public/app/plugins/panel/table/suggestions.ts @@ -1,4 +1,5 @@ import { PanelDataSummary, VisualizationSuggestionScore, VisualizationSuggestionsSupplier } from '@grafana/data'; +import { config } from 'app/core/config'; import icnTablePanelSvg from 'app/plugins/panel/table/img/icn-table-panel.svg'; import { Options, FieldConfig } from './panelcfg.gen'; @@ -29,7 +30,7 @@ export const tableSuggestionsSupplier: VisualizationSuggestionsSupplier(TextPanel) defaultValue: defaultOptions.content, }); }) - .setMigrationHandler(textPanelMigrationHandler); + .setMigrationHandler(textPanelMigrationHandler) + .setSuggestionsSupplier((ds) => + ds.fieldCount === 0 && !config.featureToggles.newVizSuggestions + ? [{ cardOptions: { imgSrc: icnTextPanelSvg } }] + : [] + ); diff --git a/public/app/plugins/panel/text/plugin.json b/public/app/plugins/panel/text/plugin.json index ce437e97bf5..a1acce42c6e 100644 --- a/public/app/plugins/panel/text/plugin.json +++ b/public/app/plugins/panel/text/plugin.json @@ -2,7 +2,7 @@ "type": "panel", "name": "Text", "id": "text", - + "suggestions": true, "skipDataQuery": true, "info": { diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 9678b91e619..a4e39d534a3 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -11216,9 +11216,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "Apply {{suggestionName}} visualization", + "error-loading-some-suggestions": { + "message": "Some suggestions could not be loaded" + }, "error-loading-suggestions": { "message": "An error occurred when loading visualization suggestions.", - "title": "Error" + "plugin-failed": "Failed to load panel plugin: {{ pluginId }}.", + "title": "Error", + "try-again-button": "Try again" }, "unknown-viz-type": "Unknown visualization type", "use-this-suggestion": "Use this suggestion" From 0284d1e669cee4569e1cc33c11467a122da6955d Mon Sep 17 00:00:00 2001 From: Renato Costa <103441181+renatolabs@users.noreply.github.com> Date: Fri, 19 Dec 2025 15:35:22 -0500 Subject: [PATCH 13/80] unified-storage: add `UnixTimestamp` support to the sqlkv implementation (#115651) * unified-storage: add `UnixTimestamp` support to sqlkv implementation * unified-storage: improve tests and enable all of them on sqlkv --- pkg/storage/unified/resource/sqlkv.go | 3 +- pkg/storage/unified/testing/kv.go | 264 +++++++++++++------------ pkg/storage/unified/testing/kv_test.go | 8 +- 3 files changed, 143 insertions(+), 132 deletions(-) diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go index 9cc2cc32dd0..6d406294a96 100644 --- a/pkg/storage/unified/resource/sqlkv.go +++ b/pkg/storage/unified/resource/sqlkv.go @@ -11,6 +11,7 @@ import ( "iter" "strings" "text/template" + "time" "github.com/google/uuid" "github.com/grafana/grafana/pkg/storage/unified/sql/db" @@ -556,7 +557,7 @@ func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) } func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { - panic("not implemented!") + return time.Now().Unix(), nil } func closeRows[T any](rows db.Rows, yield func(T, error) bool) { diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index d1900c7a46e..2031a3e38b2 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -148,13 +148,15 @@ func runTestKVGet(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(30*time.Second)) + nsPrefix += "-save" t.Run("save new key", func(t *testing.T) { + newKey := namespacedKey(nsPrefix, "new-key") testValue := "new test value" - saveKVHelper(t, kv, ctx, testSection, "new-key", strings.NewReader(testValue)) + saveKVHelper(t, kv, ctx, testSection, newKey, strings.NewReader(testValue)) // Verify it was saved - reader, err := kv.Get(ctx, testSection, "new-key") + reader, err := kv.Get(ctx, testSection, newKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -165,15 +167,17 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save overwrite existing key", func(t *testing.T) { + overwriteKey := namespacedKey(nsPrefix, "overwrite-key") + // First save - saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader("old value")) + saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader("old value")) // Overwrite newValue := "new value" - saveKVHelper(t, kv, ctx, testSection, "overwrite-key", strings.NewReader(newValue)) + saveKVHelper(t, kv, ctx, testSection, overwriteKey, strings.NewReader(newValue)) // Verify it was updated - reader, err := kv.Get(ctx, testSection, "overwrite-key") + reader, err := kv.Get(ctx, testSection, overwriteKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -185,15 +189,17 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { t.Run("save overwrite existing key (datastore)", func(t *testing.T) { section := "unified/data" + overwriteKey := namespacedKey(nsPrefix, "overwrite-key") + // First save - saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader("old value")) + saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader("old value")) // Overwrite newValue := "new value" - saveKVHelper(t, kv, ctx, section, "overwrite-key", strings.NewReader(newValue)) + saveKVHelper(t, kv, ctx, section, overwriteKey, strings.NewReader(newValue)) // Verify it was updated - reader, err := kv.Get(ctx, section, "overwrite-key") + reader, err := kv.Get(ctx, section, overwriteKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -210,11 +216,13 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save binary data", func(t *testing.T) { + binaryKey := namespacedKey(nsPrefix, "binary-key") + binaryData := []byte{0x00, 0x01, 0x02, 0x03, 0xFF, 0xFE, 0xFD} - saveKVHelper(t, kv, ctx, testSection, "binary-key", bytes.NewReader(binaryData)) + saveKVHelper(t, kv, ctx, testSection, binaryKey, bytes.NewReader(binaryData)) // Verify binary data - reader, err := kv.Get(ctx, testSection, "binary-key") + reader, err := kv.Get(ctx, testSection, binaryKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -225,11 +233,13 @@ func runTestKVSave(t *testing.T, kv resource.KV, nsPrefix string) { }) t.Run("save key with no data", func(t *testing.T) { + emptyKey := namespacedKey(nsPrefix, "empty-key") + // Save a key with empty data - saveKVHelper(t, kv, ctx, testSection, "empty-key", strings.NewReader("")) + saveKVHelper(t, kv, ctx, testSection, emptyKey, strings.NewReader("")) // Verify it was saved with empty data - reader, err := kv.Get(ctx, testSection, "empty-key") + reader, err := kv.Get(ctx, testSection, emptyKey) require.NoError(t, err) value, err := io.ReadAll(reader) @@ -495,128 +505,134 @@ func runTestKVKeysWithSort(t *testing.T, kv resource.KV, nsPrefix string) { func runTestKVConcurrent(t *testing.T, kv resource.KV, nsPrefix string) { ctx := testutil.NewTestContext(t, time.Now().Add(60*time.Second)) - section := nsPrefix + "-concurrent" + nsPrefix += "-concurrent" - t.Run("concurrent save and get operations", func(t *testing.T) { - const numGoroutines = 10 - const numOperations = 20 + // Test concurrent operations for both sections, as they have different behaviours + // in the sqlkv implementation. + for _, testSection := range []string{"unified/data", "unified/events"} { + t.Run(testSection, func(t *testing.T) { + t.Run("concurrent save and get operations", func(t *testing.T) { + const numGoroutines = 10 + const numOperations = 20 - done := make(chan error, numGoroutines) + done := make(chan error, numGoroutines) - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - var err error - defer func() { done <- err }() + for goroutineID := range numGoroutines { + go func() { + var err error + defer func() { done <- err }() - for j := 0; j < numOperations; j++ { - key := fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j) - value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j) + for j := range numOperations { + key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-key-%d-%d", goroutineID, j)) + value := fmt.Sprintf("concurrent-value-%d-%d", goroutineID, j) - // Save - writer, err := kv.Save(ctx, section, key) - if err != nil { - return - } - defer func() { - err := writer.Close() - require.NoError(t, err) + // Save + writer, err := kv.Save(ctx, testSection, key) + if err != nil { + return + } + defer func() { + err := writer.Close() + require.NoError(t, err) + }() + _, err = io.Copy(writer, strings.NewReader(value)) + if err != nil { + return + } + err = writer.Close() + if err != nil { + return + } + + // Get immediately + reader, err := kv.Get(ctx, testSection, key) + if err != nil { + return + } + + readValue, err := io.ReadAll(reader) + require.NoError(t, err) + err = reader.Close() + require.NoError(t, err) + assert.Equal(t, value, string(readValue)) + } }() - _, err = io.Copy(writer, strings.NewReader(value)) - if err != nil { - return - } - err = writer.Close() - if err != nil { - return - } + } - // Get immediately - reader, err := kv.Get(ctx, section, key) - if err != nil { - return - } - - readValue, err := io.ReadAll(reader) + // Wait for all goroutines to complete + for range numGoroutines { + err := <-done require.NoError(t, err) - err = reader.Close() + } + }) + + t.Run("concurrent save, delete, and list operations", func(t *testing.T) { + const numGoroutines = 5 + done := make(chan error, numGoroutines) + + for i := range numGoroutines { + go func(goroutineID int) { + var err error + defer func() { done <- err }() + + key := namespacedKey(nsPrefix, fmt.Sprintf("concurrent-ops-key-%d", goroutineID)) + value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID) + + // Save + writer, err := kv.Save(ctx, testSection, key) + if err != nil { + return + } + defer func() { + err := writer.Close() + require.NoError(t, err) + }() + _, err = io.Copy(writer, strings.NewReader(value)) + if err != nil { + return + } + err = writer.Close() + if err != nil { + return + } + + // List to verify it exists + found := false + for k, err := range kv.Keys(ctx, testSection, resource.ListOptions{}) { + if err != nil { + return + } + if k == key { + found = true + break + } + } + if !found { + err = fmt.Errorf("key %s not found in list", key) + return + } + + // Delete + err = kv.Delete(ctx, testSection, key) + if err != nil { + return + } + + // Verify it's deleted + _, err = kv.Get(ctx, testSection, key) + require.ErrorIs(t, resource.ErrNotFound, err) + err = nil // Expected error, so clear it + }(i) + } + + // Wait for all goroutines to complete + for range numGoroutines { + err := <-done require.NoError(t, err) - assert.Equal(t, value, string(readValue)) } - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < numGoroutines; i++ { - err := <-done - require.NoError(t, err) - } - }) - - t.Run("concurrent save, delete, and list operations", func(t *testing.T) { - const numGoroutines = 5 - done := make(chan error, numGoroutines) - - for i := 0; i < numGoroutines; i++ { - go func(goroutineID int) { - var err error - defer func() { done <- err }() - - key := fmt.Sprintf("concurrent-ops-key-%d", goroutineID) - value := fmt.Sprintf("concurrent-ops-value-%d", goroutineID) - - // Save - writer, err := kv.Save(ctx, section, key) - if err != nil { - return - } - defer func() { - err := writer.Close() - require.NoError(t, err) - }() - _, err = io.Copy(writer, strings.NewReader(value)) - if err != nil { - return - } - err = writer.Close() - if err != nil { - return - } - - // List to verify it exists - found := false - for k, err := range kv.Keys(ctx, section, resource.ListOptions{}) { - if err != nil { - return - } - if k == key { - found = true - break - } - } - if !found { - err = fmt.Errorf("key %s not found in list", key) - return - } - - // Delete - err = kv.Delete(ctx, section, key) - if err != nil { - return - } - - // Verify it's deleted - _, err = kv.Get(ctx, section, key) - require.ErrorIs(t, resource.ErrNotFound, err) - err = nil // Expected error, so clear it - }(i) - } - - // Wait for all goroutines to complete - for i := 0; i < numGoroutines; i++ { - err := <-done - require.NoError(t, err) - } - }) + }) + }) + } } func runTestKVUnixTimestamp(t *testing.T, kv resource.KV, nsPrefix string) { diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 5e94ccd8a7f..af7de65e52c 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -44,11 +44,5 @@ func TestSQLKV(t *testing.T) { kv, err := resource.NewSQLKV(eDB) require.NoError(t, err) return kv - }, &KVTestOptions{ - NSPrefix: "sql-kv-test", - SkipTests: map[string]bool{ - TestKVConcurrent: true, - TestKVUnixTimestamp: true, - }, - }) + }, &KVTestOptions{NSPrefix: "sql-kv-test"}) } From 8cfac85b48145f485b2caac2cac2721e352717e9 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Fri, 19 Dec 2025 15:41:57 -0500 Subject: [PATCH 14/80] Gauge: Add guide dots for rounded bars to help with accuracy, update color logic for more consistent gradients (#115285) * Gauge: Fit-and-finish tweaks to glows, text position, and sparkline size * adjust text height and positions a little more * cohesive no data handling * more tweaks * fix migration test * Fix JSON formatting by adding missing newline * remove new line * Gauge: Add guide dots for rounded bars to help with accuracy * 30% width * remove spotlight, starting to make gradients a bit more predictable * fix segmented * update rotation of gauge color * update i18n and migration tests * fix spacing * more fixture updates * wip: using clip-path and CSS for drawing the gauge * wip: overhaul color in gauge * wip: progress on everything * refactoring defs into utils * its all working * fixme comment * fix backend migration tests * remove any other mentions of spotlights * one more tweak * update gdev * add lots of tests and reorganize the code a bit * fix dev dashboard fixture * more cleanup, optimization * fix a couple of bugs * fix bad import * disable storybook test due to false positive * a more sweeping disable of the color-contrast * update backend tests * update gradient for fixed color * test all dark/light theme variants * set opacity to 0.5 for dots * move min degrees for start dot render to a const * change endpoint marks to be configurable * update gdev and fixtures * i18n * shore up testing a bit * remove period for consistency * hide glow at small angles * more testing and cleanup * addressing PR comments * Update packages/grafana-ui/src/components/RadialGauge/colors.ts Co-authored-by: Jesse David Peterson * Update packages/grafana-ui/src/components/RadialGauge/colors.ts Co-authored-by: Jesse David Peterson * break out binary search stuff and write tests * fix lint issues --------- Co-authored-by: Jesse David Peterson --- .../v0alpha1.gauge_tests_new.v42.json | 171 ++------- .../v0alpha1.gauge_tests_old_to_new.v42.json | 2 - .../v0alpha1.gauge_tests_new.v42.v1beta1.json | 221 +++--------- ...v0alpha1.gauge_tests_new.v42.v2alpha1.json | 247 +++---------- .../v0alpha1.gauge_tests_new.v42.v2beta1.json | 250 +++---------- ...a1.gauge_tests_old_to_new.v42.v1beta1.json | 6 +- ...1.gauge_tests_old_to_new.v42.v2alpha1.json | 6 +- ...a1.gauge_tests_old_to_new.v42.v2beta1.json | 6 +- .../panel-gauge/gauge_tests_new.v42.json | 336 ++++++------------ .../gauge_tests_old_to_new.v42.json | 6 +- .../panel-gauge/gauge_tests_new.json | 335 ++++++----------- .../panel-gauge/gauge_tests_old_to_new.json | 2 - .../panelcfg/x/NewGaugePanelCfg_types.gen.ts | 8 +- .../components/RadialGauge/RadialArcPath.tsx | 185 +++++++--- .../src/components/RadialGauge/RadialBar.tsx | 119 +++---- .../RadialGauge/RadialBarSegmented.tsx | 164 +++------ .../RadialGauge/RadialColorDefs.tsx | 141 -------- .../RadialGauge/RadialGauge.story.tsx | 128 +++---- .../RadialGauge/RadialGauge.test.tsx | 25 +- .../components/RadialGauge/RadialGauge.tsx | 69 ++-- .../RadialGauge/RadialScaleLabels.tsx | 119 +++---- .../RadialGauge/RadialSparkline.tsx | 99 ++++-- .../src/components/RadialGauge/RadialText.tsx | 222 ++++++------ .../components/RadialGauge/ThresholdsBar.tsx | 42 +-- .../__snapshots__/colors.test.ts.snap | 144 ++++++++ .../__snapshots__/utils.test.ts.snap | 17 + .../src/components/RadialGauge/colors.test.ts | 306 ++++++++++++++++ .../src/components/RadialGauge/colors.ts | 195 ++++++++++ .../src/components/RadialGauge/effects.tsx | 89 +++-- .../src/components/RadialGauge/types.ts | 25 ++ .../src/components/RadialGauge/utils.test.ts | 197 +++++++++- .../src/components/RadialGauge/utils.ts | 171 +++++++-- .../plugins/panel/radialbar/EffectsEditor.tsx | 11 - .../panel/radialbar/RadialBarPanel.tsx | 6 +- public/app/plugins/panel/radialbar/module.tsx | 30 ++ .../app/plugins/panel/radialbar/panelcfg.cue | 6 +- .../plugins/panel/radialbar/panelcfg.gen.ts | 8 +- .../plugins/panel/radialbar/suggestions.ts | 13 - public/locales/en-US/grafana.json | 19 +- 39 files changed, 2142 insertions(+), 2004 deletions(-) delete mode 100644 packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx create mode 100644 packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap create mode 100644 packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap create mode 100644 packages/grafana-ui/src/components/RadialGauge/colors.test.ts create mode 100644 packages/grafana-ui/src/components/RadialGauge/colors.ts create mode 100644 packages/grafana-ui/src/components/RadialGauge/types.ts diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json index 7c7c479199d..62648d7a4aa 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.json @@ -71,12 +71,11 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -150,12 +149,11 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -229,12 +227,11 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": false, "gradient": false }, "orientation": "auto", @@ -271,85 +268,6 @@ "title": "Center and bar glow", "type": "radialbar" }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "rounded": true, - "spotlight": true, - "gradient": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, { "datasource": { "type": "grafana-testdata-datasource" @@ -391,10 +309,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -470,10 +387,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": false, - "spotlight": true, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -549,10 +465,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": false, - "spotlight": true, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -641,10 +556,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -720,10 +634,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -799,10 +712,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -878,10 +790,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -974,10 +885,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1053,10 +963,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1132,10 +1041,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1211,10 +1119,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1290,10 +1197,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1386,10 +1292,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1469,10 +1374,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1552,10 +1456,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1641,13 +1544,13 @@ "options": { "barWidth": 12, "barWidthFactor": 0.4, + "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1662,8 +1565,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1730,10 +1632,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1748,8 +1649,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1830,10 +1730,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1848,8 +1747,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1917,9 +1815,6 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "sparkline": false, - "spotlight": true, "gradient": true }, "glow": "both", @@ -1934,10 +1829,10 @@ "segmentCount": 12, "segmentSpacing": 0.3, "shape": "circle", + "barShape": "rounded", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2004,10 +1899,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2022,8 +1916,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2090,10 +1983,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "rounded": true, - "spotlight": true, "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2108,8 +2000,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json index a3de6df336a..dda0fbcd432 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.json @@ -955,8 +955,6 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false }, "orientation": "auto", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json index 2eeb3040e6d..e04d448a5b8 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json @@ -77,13 +77,12 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -156,13 +155,12 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -235,13 +233,12 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -277,85 +274,6 @@ "title": "Center and bar glow", "type": "radialbar" }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 12, - "y": 1 - }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, { "datasource": { "type": "grafana-testdata-datasource" @@ -393,13 +311,12 @@ "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -472,13 +389,12 @@ "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -551,13 +467,12 @@ "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -643,13 +558,12 @@ "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -722,13 +636,12 @@ "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -801,13 +714,12 @@ "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -880,13 +792,12 @@ "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -976,13 +887,12 @@ "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1055,13 +965,12 @@ "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1134,13 +1043,12 @@ "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1213,13 +1121,12 @@ "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1292,13 +1199,12 @@ "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1388,13 +1294,12 @@ "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1471,13 +1376,12 @@ "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1554,13 +1458,12 @@ "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1645,15 +1548,15 @@ "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1668,8 +1571,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1731,14 +1633,13 @@ "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1754,8 +1655,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1831,14 +1731,13 @@ "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1854,8 +1753,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1918,15 +1816,13 @@ "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1942,8 +1838,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2005,14 +1900,13 @@ "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2028,8 +1922,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2091,14 +1984,13 @@ "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2114,8 +2006,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2160,4 +2051,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json index 4aecf4e0d9c..0e6e3e13da5 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json @@ -73,13 +73,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -165,14 +164,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -188,8 +186,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "fieldConfig": { "defaults": { @@ -262,14 +259,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -285,8 +281,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -360,15 +355,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -384,8 +377,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -459,14 +451,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -482,8 +473,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -556,14 +546,13 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -579,8 +568,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -653,13 +641,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -745,13 +732,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -837,13 +823,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -929,13 +914,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1021,13 +1005,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1113,13 +1096,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1201,13 +1183,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1293,13 +1274,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1385,13 +1365,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1477,13 +1456,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1573,13 +1551,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1661,13 +1638,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1753,13 +1729,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1849,13 +1824,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1945,13 +1919,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2045,105 +2018,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "value": 0, - "color": "green" - }, - { - "value": 80, - "color": "red" - } - ] - }, - "color": { - "mode": "thresholds" - } - }, - "overrides": [] - } - } - } - } - }, - "panel-5": { - "kind": "Panel", - "spec": { - "id": 5, - "title": "Spotlight", - "description": "", - "links": [], - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "query": { - "kind": "grafana-testdata-datasource", - "spec": { - "alias": "1", - "max": 100, - "min": 1, - "noise": 22, - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - }, - "refId": "A", - "hidden": false - } - } - ], - "transformations": [], - "queryOptions": { - "maxDataPoints": 20 - } - } - }, - "vizConfig": { - "kind": "radialbar", - "spec": { - "pluginVersion": "13.0.0-pre", - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2229,13 +2109,12 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2321,15 +2200,15 @@ "spec": { "pluginVersion": "13.0.0-pre", "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2344,8 +2223,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -2429,19 +2307,6 @@ } } }, - { - "kind": "GridLayoutItem", - "spec": { - "x": 12, - "y": 0, - "width": 4, - "height": 6, - "element": { - "kind": "ElementReference", - "name": "panel-5" - } - } - }, { "kind": "GridLayoutItem", "spec": { @@ -2826,4 +2691,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json index 6b567f19b5e..ad2b8ca0385 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json @@ -77,13 +77,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -172,14 +171,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -195,8 +193,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "fieldConfig": { "defaults": { @@ -272,14 +269,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -295,8 +291,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -373,15 +368,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "sparkline": false, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -397,8 +390,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -475,14 +467,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -498,8 +489,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -575,14 +565,13 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -598,8 +587,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -675,13 +663,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -770,13 +757,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -865,13 +851,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -960,13 +945,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1055,13 +1039,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1150,13 +1133,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1241,13 +1223,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1336,13 +1317,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1431,13 +1411,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1526,13 +1505,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1625,13 +1603,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1716,13 +1693,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1811,13 +1787,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1910,13 +1885,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2009,13 +1983,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2112,108 +2085,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "fieldConfig": { - "defaults": { - "min": 0, - "max": 100, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "value": 0, - "color": "green" - }, - { - "value": 80, - "color": "red" - } - ] - }, - "color": { - "mode": "thresholds" - } - }, - "overrides": [] - } - } - } - } - }, - "panel-5": { - "kind": "Panel", - "spec": { - "id": 5, - "title": "Spotlight", - "description": "", - "links": [], - "data": { - "kind": "QueryGroup", - "spec": { - "queries": [ - { - "kind": "PanelQuery", - "spec": { - "query": { - "kind": "DataQuery", - "group": "grafana-testdata-datasource", - "version": "v0", - "spec": { - "alias": "1", - "max": 100, - "min": 1, - "noise": 22, - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - }, - "refId": "A", - "hidden": false - } - } - ], - "transformations": [], - "queryOptions": { - "maxDataPoints": 20 - } - } - }, - "vizConfig": { - "kind": "VizConfig", - "group": "radialbar", - "version": "13.0.0-pre", - "spec": { - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2302,13 +2179,12 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -2397,15 +2273,15 @@ "version": "13.0.0-pre", "spec": { "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2420,8 +2296,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "fieldConfig": { "defaults": { @@ -2505,19 +2380,6 @@ } } }, - { - "kind": "GridLayoutItem", - "spec": { - "x": 12, - "y": 0, - "width": 4, - "height": 6, - "element": { - "kind": "ElementReference", - "name": "panel-5" - } - } - }, { "kind": "GridLayoutItem", "spec": { @@ -2902,4 +2764,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json index 959f0193ad6..1d9f7e56513 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json @@ -961,9 +961,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1175,4 +1173,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json index 66b29e88d13..7b3f601b5cf 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json @@ -864,9 +864,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1620,4 +1618,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json index b870d0a91ad..534e7a1600c 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json @@ -901,9 +901,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1672,4 +1670,4 @@ "storedVersion": "v0alpha1" } } -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json index a89d8744f39..cb130445efc 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_new.v42.json @@ -75,10 +75,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -154,10 +153,9 @@ "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -233,10 +231,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -305,85 +302,6 @@ "x": 12, "y": 1 }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": [ - "lastNotNull" - ], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 16, - "y": 1 - }, "id": 8, "maxDataPoints": 20, "options": { @@ -391,10 +309,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -460,8 +377,8 @@ "gridPos": { "h": 6, "w": 4, - "x": 0, - "y": 7 + "x": 16, + "y": 1 }, "id": 22, "maxDataPoints": 20, @@ -470,10 +387,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -539,8 +455,8 @@ "gridPos": { "h": 6, "w": 4, - "x": 4, - "y": 7 + "x": 20, + "y": 1 }, "id": 23, "maxDataPoints": 20, @@ -549,10 +465,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -593,7 +508,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 7 }, "id": 17, "panels": [], @@ -630,9 +545,9 @@ }, "gridPos": { "h": 6, - "w": 5, + "w": 4, "x": 0, - "y": 14 + "y": 8 }, "id": 18, "maxDataPoints": 20, @@ -641,10 +556,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -709,9 +623,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 5, - "y": 14 + "w": 4, + "x": 4, + "y": 8 }, "id": 19, "maxDataPoints": 20, @@ -720,10 +634,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -788,9 +701,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 10, - "y": 14 + "w": 4, + "x": 8, + "y": 8 }, "id": 20, "maxDataPoints": 20, @@ -799,10 +712,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -867,9 +779,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 15, - "y": 14 + "w": 4, + "x": 12, + "y": 8 }, "id": 21, "maxDataPoints": 20, @@ -878,10 +790,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, + "barShape": "rounded", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -922,7 +833,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 14 }, "id": 24, "panels": [], @@ -963,9 +874,9 @@ }, "gridPos": { "h": 6, - "w": 6, + "w": 4, "x": 0, - "y": 21 + "y": 15 }, "id": 25, "maxDataPoints": 20, @@ -974,10 +885,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1042,9 +952,9 @@ }, "gridPos": { "h": 6, - "w": 6, - "x": 6, - "y": 21 + "w": 4, + "x": 4, + "y": 15 }, "id": 26, "maxDataPoints": 20, @@ -1053,10 +963,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1121,9 +1030,9 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 12, - "y": 21 + "w": 4, + "x": 8, + "y": 15 }, "id": 29, "maxDataPoints": 20, @@ -1132,10 +1041,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1199,10 +1107,10 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 27 + "h": 6, + "w": 4, + "x": 12, + "y": 15 }, "id": 30, "maxDataPoints": 20, @@ -1211,10 +1119,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1278,10 +1185,10 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 27 + "h": 6, + "w": 4, + "x": 16, + "y": 15 }, "id": 28, "maxDataPoints": 20, @@ -1290,10 +1197,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1330,7 +1236,7 @@ "h": 1, "w": 24, "x": 0, - "y": 34 + "y": 21 }, "id": 31, "panels": [], @@ -1377,7 +1283,7 @@ "h": 10, "w": 7, "x": 0, - "y": 35 + "y": 22 }, "id": 32, "maxDataPoints": 20, @@ -1386,10 +1292,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1460,7 +1365,7 @@ "h": 10, "w": 7, "x": 7, - "y": 35 + "y": 22 }, "id": 34, "maxDataPoints": 20, @@ -1469,10 +1374,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1543,7 +1447,7 @@ "h": 10, "w": 6, "x": 14, - "y": 35 + "y": 22 }, "id": 33, "maxDataPoints": 20, @@ -1552,10 +1456,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -1592,7 +1495,7 @@ "h": 1, "w": 24, "x": 0, - "y": 45 + "y": 32 }, "id": 6, "panels": [], @@ -1633,20 +1536,20 @@ "h": 6, "w": 24, "x": 0, - "y": 46 + "y": 33 }, "id": 9, "maxDataPoints": 20, "options": { "barWidth": 12, "barWidthFactor": 0.4, + "barShape": "rounded", "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1661,8 +1564,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1717,7 +1619,7 @@ "h": 6, "w": 24, "x": 0, - "y": 52 + "y": 39 }, "id": 11, "maxDataPoints": 20, @@ -1727,10 +1629,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1745,8 +1646,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1773,7 +1673,7 @@ "h": 1, "w": 24, "x": 0, - "y": 58 + "y": 45 }, "id": 12, "panels": [], @@ -1815,7 +1715,7 @@ "h": 7, "w": 4, "x": 0, - "y": 59 + "y": 46 }, "id": 13, "maxDataPoints": 20, @@ -1825,10 +1725,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1843,8 +1742,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1900,7 +1798,7 @@ "h": 7, "w": 5, "x": 4, - "y": 59 + "y": 46 }, "id": 14, "maxDataPoints": 20, @@ -1910,10 +1808,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1928,8 +1825,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1984,7 +1880,7 @@ "h": 7, "w": 5, "x": 9, - "y": 59 + "y": 46 }, "id": 15, "maxDataPoints": 20, @@ -1994,10 +1890,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2012,8 +1907,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2068,7 +1962,7 @@ "h": 7, "w": 6, "x": 14, - "y": 59 + "y": 46 }, "id": 16, "maxDataPoints": 20, @@ -2078,10 +1972,9 @@ "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "barShape": "rounded", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -2096,8 +1989,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2124,7 +2016,7 @@ "h": 1, "w": 24, "x": 0, - "y": 66 + "y": 53 }, "id": 35, "panels": [], @@ -2155,10 +2047,10 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, + "h": 5, + "w": 12, "x": 0, - "y": 67 + "y": 54 }, "id": 36, "options": { @@ -2166,10 +2058,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2223,10 +2114,10 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 67 + "h": 5, + "w": 12, + "x": 12, + "y": 54 }, "id": 37, "options": { @@ -2234,10 +2125,9 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, + "barShape": "flat", "orientation": "auto", "reduceOptions": { "calcs": [ @@ -2279,4 +2169,4 @@ "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", "weekStart": "" -} \ No newline at end of file +} diff --git a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json index 4a5ac97a6b5..dda0fbcd432 100644 --- a/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json +++ b/apps/dashboard/pkg/migration/testdata/dev-dashboards-output/panel-gauge/gauge_tests_old_to_new.v42.json @@ -955,9 +955,7 @@ "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1162,4 +1160,4 @@ "title": "Panel tests - Old gauge to new", "uid": "panel-tests-old-gauge-to-new", "weekStart": "" -} \ No newline at end of file +} diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json index b3c47c9aa7a..65cc0b0a5ae 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_new.json @@ -71,13 +71,12 @@ "id": 1, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -148,13 +147,12 @@ "id": 4, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": false, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -225,13 +223,12 @@ "id": 3, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -299,93 +296,15 @@ "x": 12, "y": 1 }, - "id": 5, - "maxDataPoints": 20, - "options": { - "barWidthFactor": 0.4, - "effects": { - "barGlow": true, - "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true - }, - "orientation": "auto", - "reduceOptions": { - "calcs": ["lastNotNull"], - "fields": "", - "values": false - }, - "segmentCount": 1, - "segmentSpacing": 0.3, - "shape": "circle", - "showThresholdLabels": false, - "showThresholdMarkers": false, - "sparkline": false - }, - "pluginVersion": "13.0.0-pre", - "targets": [ - { - "alias": "1", - "datasource": { - "type": "grafana-testdata-datasource" - }, - "max": 100, - "min": 1, - "noise": 22, - "refId": "A", - "scenarioId": "random_walk", - "spread": 22, - "startValue": 1 - } - ], - "title": "Spotlight", - "type": "radialbar" - }, - { - "datasource": { - "type": "grafana-testdata-datasource" - }, - "fieldConfig": { - "defaults": { - "color": { - "mode": "thresholds" - }, - "mappings": [], - "max": 100, - "min": 0, - "thresholds": { - "mode": "absolute", - "steps": [ - { - "color": "green", - "value": 0 - }, - { - "color": "red", - "value": 80 - } - ] - } - }, - "overrides": [] - }, - "gridPos": { - "h": 6, - "w": 4, - "x": 16, - "y": 1 - }, "id": 8, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -450,19 +369,18 @@ "gridPos": { "h": 6, "w": 4, - "x": 0, - "y": 7 + "x": 16, + "y": 1 }, "id": 22, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -527,19 +445,18 @@ "gridPos": { "h": 6, "w": 4, - "x": 4, - "y": 7 + "x": 20, + "y": 1 }, "id": 23, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": false, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -579,7 +496,7 @@ "h": 1, "w": 24, "x": 0, - "y": 13 + "y": 7 }, "id": 17, "panels": [], @@ -616,20 +533,19 @@ }, "gridPos": { "h": 6, - "w": 5, + "w": 4, "x": 0, - "y": 14 + "y": 8 }, "id": 18, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.1, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -693,20 +609,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 5, - "y": 14 + "w": 4, + "x": 4, + "y": 8 }, "id": 19, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.32, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -770,20 +685,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 10, - "y": 14 + "w": 4, + "x": 8, + "y": 8 }, "id": 20, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.57, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -847,20 +761,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 15, - "y": 14 + "w": 4, + "x": 12, + "y": 8 }, "id": 21, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidthFactor": 0.8, "effects": { "barGlow": true, "centerGlow": true, - "gradient": false, - "rounded": true, - "spotlight": true + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -900,7 +813,7 @@ "h": 1, "w": 24, "x": 0, - "y": 20 + "y": 14 }, "id": 24, "panels": [], @@ -941,20 +854,19 @@ }, "gridPos": { "h": 6, - "w": 6, + "w": 4, "x": 0, - "y": 21 + "y": 15 }, "id": 25, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1018,20 +930,19 @@ }, "gridPos": { "h": 6, - "w": 6, - "x": 6, - "y": 21 + "w": 4, + "x": 4, + "y": 15 }, "id": 26, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1095,20 +1006,19 @@ }, "gridPos": { "h": 6, - "w": 5, - "x": 12, - "y": 21 + "w": 4, + "x": 8, + "y": 15 }, "id": 29, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1171,21 +1081,20 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 0, - "y": 27 + "h": 6, + "w": 4, + "x": 12, + "y": 15 }, "id": 30, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1248,21 +1157,20 @@ "overrides": [] }, "gridPos": { - "h": 7, - "w": 6, - "x": 6, - "y": 27 + "h": 6, + "w": 4, + "x": 16, + "y": 15 }, "id": 28, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.72, "effects": { "barGlow": false, "centerGlow": false, - "gradient": false, - "rounded": false, - "spotlight": false + "gradient": false }, "orientation": "auto", "reduceOptions": { @@ -1298,7 +1206,7 @@ "h": 1, "w": 24, "x": 0, - "y": 34 + "y": 21 }, "id": 31, "panels": [], @@ -1345,18 +1253,17 @@ "h": 10, "w": 7, "x": 0, - "y": 35 + "y": 22 }, "id": 32, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1426,18 +1333,17 @@ "h": 10, "w": 7, "x": 7, - "y": 35 + "y": 22 }, "id": 34, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1507,18 +1413,17 @@ "h": 10, "w": 6, "x": 14, - "y": 35 + "y": 22 }, "id": 33, "maxDataPoints": 20, "options": { + "barShape": "flat", "barWidthFactor": 0.9, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -1554,7 +1459,7 @@ "h": 1, "w": 24, "x": 0, - "y": 45 + "y": 32 }, "id": 6, "panels": [], @@ -1595,20 +1500,20 @@ "h": 6, "w": 24, "x": 0, - "y": 46 + "y": 33 }, "id": 9, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, + "endpointMarker": "glow", "glow": "both", "orientation": "auto", "reduceOptions": { @@ -1621,8 +1526,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1677,19 +1581,18 @@ "h": 6, "w": 24, "x": 0, - "y": 52 + "y": 39 }, "id": 11, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.4, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1703,8 +1606,7 @@ "shape": "gauge", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": true, - "spotlight": true + "sparkline": true }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1731,7 +1633,7 @@ "h": 1, "w": 24, "x": 0, - "y": 58 + "y": 45 }, "id": 12, "panels": [], @@ -1773,19 +1675,18 @@ "h": 7, "w": 4, "x": 0, - "y": 59 + "y": 46 }, "id": 13, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1799,8 +1700,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1856,19 +1756,18 @@ "h": 7, "w": 5, "x": 4, - "y": 59 + "y": 46 }, "id": 14, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.49, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1882,8 +1781,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -1938,19 +1836,18 @@ "h": 7, "w": 5, "x": 9, - "y": 59 + "y": 46 }, "id": 15, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.84, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -1964,8 +1861,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2020,19 +1916,18 @@ "h": 7, "w": 6, "x": 14, - "y": 59 + "y": 46 }, "id": 16, "maxDataPoints": 20, "options": { + "barShape": "rounded", "barWidth": 12, "barWidthFactor": 0.66, "effects": { "barGlow": true, "centerGlow": true, - "gradient": true, - "rounded": true, - "spotlight": true + "gradient": true }, "glow": "both", "orientation": "auto", @@ -2046,8 +1941,7 @@ "shape": "circle", "showThresholdLabels": false, "showThresholdMarkers": false, - "sparkline": false, - "spotlight": true + "sparkline": false }, "pluginVersion": "13.0.0-pre", "targets": [ @@ -2074,7 +1968,7 @@ "h": 1, "w": 24, "x": 0, - "y": 66 + "y": 53 }, "id": 35, "panels": [], @@ -2105,20 +1999,19 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, + "h": 5, + "w": 12, "x": 0, - "y": 67 + "y": 54 }, "id": 36, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2171,20 +2064,19 @@ "overrides": [] }, "gridPos": { - "h": 8, - "w": 6, - "x": 6, - "y": 67 + "h": 5, + "w": 12, + "x": 12, + "y": 54 }, "id": 37, "options": { + "barShape": "flat", "barWidthFactor": 0.5, "effects": { "barGlow": false, "centerGlow": false, - "gradient": true, - "rounded": false, - "spotlight": false + "gradient": true }, "orientation": "auto", "reduceOptions": { @@ -2224,5 +2116,6 @@ "timezone": "browser", "title": "Panel tests - Gauge (new)", "uid": "panel-tests-gauge-new", - "version": 9 + "version": 22, + "weekStart": "" } diff --git a/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json b/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json index b071ddff802..bee1ece914e 100644 --- a/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json +++ b/devenv/dev-dashboards/panel-gauge/gauge_tests_old_to_new.json @@ -956,8 +956,6 @@ "effects": { "barGlow": false, "centerGlow": false, - "rounded": false, - "spotlight": false, "gradient": false } } diff --git a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts index aedc041cf71..7c29eb7b463 100644 --- a/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/newgauge/panelcfg/x/NewGaugePanelCfg_types.gen.ts @@ -16,21 +16,19 @@ export interface GaugePanelEffects { barGlow?: boolean; centerGlow?: boolean; gradient?: boolean; - rounded?: boolean; - spotlight?: boolean; } export const defaultGaugePanelEffects: Partial = { barGlow: false, centerGlow: false, gradient: true, - rounded: false, - spotlight: false, }; export interface Options extends common.SingleStatBaseOptions { + barShape: ('flat' | 'rounded'); barWidthFactor: number; effects: GaugePanelEffects; + endpointMarker?: ('point' | 'glow' | 'none'); segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); @@ -40,8 +38,10 @@ export interface Options extends common.SingleStatBaseOptions { } export const defaultOptions: Partial = { + barShape: 'flat', barWidthFactor: 0.5, effects: {}, + endpointMarker: 'point', segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx index f54f3cd6f22..f59614acd53 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialArcPath.tsx @@ -1,52 +1,149 @@ -import { GaugeDimensions, toRad } from './utils'; +import { useId, memo, HTMLAttributes, ReactNode } from 'react'; -export interface RadialArcPathProps { - startAngle: number; - dimensions: GaugeDimensions; - color: string; - glowFilter?: string; +import { FieldDisplay } from '@grafana/data'; + +import { getBarEndcapColors, getGradientCss, getEndpointMarkerColors } from './colors'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; +import { drawRadialArcPath, toRad } from './utils'; + +export interface RadialArcPathPropsBase { arcLengthDeg: number; + barEndcaps?: boolean; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; roundedBars?: boolean; + shape: RadialShape; + endpointMarker?: 'point' | 'glow'; + startAngle: number; + glowFilter?: string; + endpointMarkerGlowFilter?: string; } -export function RadialArcPath({ - startAngle: angle, - dimensions, - color, - glowFilter, - arcLengthDeg, - roundedBars, -}: RadialArcPathProps) { - const { radius, centerX, centerY, barWidth } = dimensions; +interface RadialArcPathPropsWithColor extends RadialArcPathPropsBase { + color: string; +} - if (arcLengthDeg === 360) { - // For some reason a 100% full arc cannot be rendered - arcLengthDeg = 359.99; +interface RadialArcPathPropsWithGradient extends RadialArcPathPropsBase { + gradient: GradientStop[]; +} + +type RadialArcPathProps = RadialArcPathPropsWithColor | RadialArcPathPropsWithGradient; + +const ENDPOINT_MARKER_MIN_ANGLE = 10; +const DOT_OPACITY = 0.5; +const DOT_RADIUS_FACTOR = 0.4; +const MAX_DOT_RADIUS = 8; + +export const RadialArcPath = memo( + ({ + arcLengthDeg, + dimensions, + fieldDisplay, + roundedBars, + shape, + endpointMarker, + barEndcaps, + startAngle: angle, + glowFilter, + endpointMarkerGlowFilter, + ...rest + }: RadialArcPathProps) => { + const id = useId(); + + const bgDivStyle: HTMLAttributes['style'] = { width: '100%', height: '100%' }; + if ('color' in rest) { + bgDivStyle.backgroundColor = rest.color; + } else { + bgDivStyle.backgroundImage = getGradientCss(rest.gradient, shape); + } + + const { radius, centerX, centerY, barWidth } = dimensions; + + const path = drawRadialArcPath(angle, arcLengthDeg, dimensions, roundedBars); + + const startRadians = toRad(angle); + const endRadians = toRad(angle + arcLengthDeg); + + const xStart = centerX + radius * Math.cos(startRadians); + const yStart = centerY + radius * Math.sin(startRadians); + const xEnd = centerX + radius * Math.cos(endRadians); + const yEnd = centerY + radius * Math.sin(endRadians); + + const dotRadius = + endpointMarker === 'point' ? Math.min((barWidth / 2) * DOT_RADIUS_FACTOR, MAX_DOT_RADIUS) : barWidth / 2; + + let barEndcapColors: [string, string] | undefined; + let endpointMarks: ReactNode = null; + if ('gradient' in rest) { + if (endpointMarker && (rest.gradient?.length ?? 0) > 0) { + switch (endpointMarker) { + case 'point': + const [pointColorStart, pointColorEnd] = getEndpointMarkerColors( + rest.gradient!, + fieldDisplay.display.percent + ); + endpointMarks = ( + <> + {arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE && ( + + )} + + + ); + break; + case 'glow': + const offsetAngle = toRad(ENDPOINT_MARKER_MIN_ANGLE); + const xStartMark = centerX + radius * Math.cos(endRadians + offsetAngle); + const yStartMark = centerY + radius * Math.sin(endRadians + offsetAngle); + endpointMarks = + arcLengthDeg > ENDPOINT_MARKER_MIN_ANGLE ? ( + + ) : null; + break; + default: + break; + } + } + + if (barEndcaps) { + barEndcapColors = getBarEndcapColors(rest.gradient, fieldDisplay.display.percent); + } + } + + return ( + <> + {/* FIXME: optimize this by only using clippath + foreign obj for gradients */} + + + + + + +
+ + {barEndcapColors?.[0] && } + {barEndcapColors?.[1] && ( + + )} + + + {endpointMarks} + + ); } +); - const startRadians = toRad(angle); - const endRadians = toRad(angle + arcLengthDeg); - - let x1 = centerX + radius * Math.cos(startRadians); - let y1 = centerY + radius * Math.sin(startRadians); - let x2 = centerX + radius * Math.cos(endRadians); - let y2 = centerY + radius * Math.sin(endRadians); - - const largeArc = arcLengthDeg > 180 ? 1 : 0; - - const path = ['M', x1, y1, 'A', radius, radius, 0, largeArc, 1, x2, y2].join(' '); - - return ( - - ); -} +RadialArcPath.displayName = 'RadialArcPath'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx index 2bacc3af3e8..719ec52c625 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBar.tsx @@ -1,97 +1,64 @@ -import { GrafanaTheme2 } from '@grafana/data'; +import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions, toRad } from './utils'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; export interface RadialBarProps { - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; - angleRange: number; angle: number; - startAngle: number; + angleRange: number; + dimensions: RadialGaugeDimensions; + fieldDisplay: FieldDisplay; + gradient?: GradientStop[]; roundedBars?: boolean; - spotlightStroke: string; + endpointMarker?: 'point' | 'glow'; + shape: RadialShape; + startAngle: number; glowFilter?: string; + endpointMarkerGlowFilter?: string; } export function RadialBar({ - dimensions, - colorDefs, - angleRange, angle, - startAngle, + angleRange, + dimensions, + fieldDisplay, + gradient, roundedBars, - spotlightStroke, + endpointMarker, + shape, + startAngle, glowFilter, + endpointMarkerGlowFilter, }: RadialBarProps) { const theme = useTheme2(); - + const colorProps = gradient ? { gradient } : { color: fieldDisplay.display.color ?? FALLBACK_COLOR }; return ( <> - - {/** Track */} - - {/** The colored bar */} - - {spotlightStroke && angle > 8 && ( - - )} - - {colorDefs.getDefs()} + {/** Track */} + + {/** The colored bar */} + ); } - -interface SpotlightEffectProps { - dimensions: GaugeDimensions; - angle: number; - glowFilter?: string; - spotlightStroke: string; - theme: GrafanaTheme2; - roundedBars?: boolean; -} - -function SpotlightSquareEffect({ dimensions, angle, glowFilter, spotlightStroke, roundedBars }: SpotlightEffectProps) { - const { radius, centerX, centerY, barWidth } = dimensions; - - const angleRadian = toRad(angle); - const x1 = centerX + radius * Math.cos(angleRadian - 0.2); - const y1 = centerY + radius * Math.sin(angleRadian - 0.2); - const x2 = centerX + radius * Math.cos(angleRadian); - const y2 = centerY + radius * Math.sin(angleRadian); - - const path = ['M', x1, y1, 'A', radius, radius, 0, 0, 1, x2, y2].join(' '); - - return ( - - ); -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx index dbc93d2da7f..b51cb4ce2f1 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialBarSegmented.tsx @@ -1,126 +1,74 @@ -import { FieldDisplay } from '@grafana/data'; +import { memo } from 'react'; + +import { FALLBACK_COLOR, FieldDisplay } from '@grafana/data'; import { useTheme2 } from '../../themes/ThemeContext'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialGaugeDimensions, GradientStop } from './types'; +import { + getAngleBetweenSegments, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getOptimalSegmentCount, +} from './utils'; export interface RadialBarSegmentedProps { fieldDisplay: FieldDisplay; - dimensions: GaugeDimensions; - colorDefs: RadialColorDefs; + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; glowFilter?: string; segmentCount: number; segmentSpacing: number; + shape: RadialShape; + gradient?: GradientStop[]; } -export function RadialBarSegmented({ - fieldDisplay, - dimensions, - startAngle, - angleRange, - glowFilter, - segmentCount, - segmentSpacing, - colorDefs, -}: RadialBarSegmentedProps) { - const segments: React.ReactNode[] = []; - const theme = useTheme2(); - const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); - const min = fieldDisplay.field.min ?? 0; - const max = fieldDisplay.field.max ?? 100; - const value = fieldDisplay.display.numeric; - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); - const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; +export const RadialBarSegmented = memo( + ({ + fieldDisplay, + dimensions, + startAngle, + angleRange, + glowFilter, + gradient, + segmentCount, + segmentSpacing, + shape, + }: RadialBarSegmentedProps) => { + const theme = useTheme2(); + const segments: React.ReactNode[] = []; + const segmentCountAdjusted = getOptimalSegmentCount(dimensions, segmentSpacing, segmentCount, angleRange); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const value = fieldDisplay.display.numeric; + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, angleRange); + const segmentArcLengthDeg = angleRange / segmentCountAdjusted - angleBetweenSegments; + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); - for (let i = 0; i < segmentCountAdjusted; i++) { - const angleValue = min + ((max - min) / segmentCountAdjusted) * i; - const angleColor = colorDefs.getSegmentColor(angleValue); - const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; - const segmentColor = angleValue >= value ? theme.colors.action.hover : angleColor; + for (let i = 0; i < segmentCountAdjusted; i++) { + const angleValue = min + ((max - min) / segmentCountAdjusted) * i; + const segmentAngle = startAngle + (angleRange / segmentCountAdjusted) * i + 0.01; + const segmentColor = + angleValue >= value ? theme.colors.border.medium : (displayProcessor(angleValue).color ?? FALLBACK_COLOR); + const colorProps = angleValue < value && gradient ? { gradient } : { color: segmentColor }; - segments.push( - - ); + segments.push( + + ); + } + + return {segments}; } +); - return ( - <> - {segments} - {colorDefs.getDefs()} - - ); -} - -export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { - // Max spacing is 8 degrees between segments - // Changing this constant could be considered a breaking change - const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); - return segmentSpacing * maxAngleBetweenSegments; -} - -function getOptimalSegmentCount( - dimensions: GaugeDimensions, - segmentSpacing: number, - segmentCount: number, - range: number -) { - const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); - - const innerRadius = dimensions.radius - dimensions.barWidth / 2; - const circumference = Math.PI * innerRadius * 2 * (range / 360); - const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); - - return Math.min(maxSegments, segmentCount); -} - -// export function RadialSegmentLine({ -// gaugeId, -// center, -// angle, -// size, -// color, -// barWidth, -// roundedBars, -// glow, -// margin, -// segmentWidth, -// }: RadialSegmentProps) { -// const arcSize = size - barWidth; -// const radius = arcSize / 2 - margin; - -// const angleRad = (Math.PI * (angle - 90)) / 180; -// const lineLength = radius - barWidth; - -// const x1 = center + radius * Math.cos(angleRad); -// const y1 = center + radius * Math.sin(angleRad); -// const x2 = center + lineLength * Math.cos(angleRad); -// const y2 = center + lineLength * Math.sin(angleRad); - -// return ( -// -// ); -// } +RadialBarSegmented.displayName = 'RadialBarSegmented'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx deleted file mode 100644 index 6bcf4876880..00000000000 --- a/packages/grafana-ui/src/components/RadialGauge/RadialColorDefs.tsx +++ /dev/null @@ -1,141 +0,0 @@ -import tinycolor from 'tinycolor2'; - -import { DisplayProcessor, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; - -import { RadialGradientMode, RadialShape } from './RadialGauge'; -import { GaugeDimensions } from './utils'; - -export interface RadialColorDefsOptions { - gradient: RadialGradientMode; - fieldDisplay: FieldDisplay; - theme: GrafanaTheme2; - dimensions: GaugeDimensions; - shape: RadialShape; - gaugeId: string; - displayProcessor: DisplayProcessor; -} - -export class RadialColorDefs { - private colorToIds: Record = {}; - private defs: React.ReactNode[] = []; - - constructor(private options: RadialColorDefsOptions) {} - - getSegmentColor(forValue: number): string { - const { displayProcessor } = this.options; - const baseColor = displayProcessor(forValue).color ?? FALLBACK_COLOR; - - return this.getColor(baseColor, true); - } - - getColor(baseColor: string, forSegment?: boolean): string { - const { gradient, dimensions, gaugeId, fieldDisplay, shape, theme } = this.options; - - const id = `value-color-${baseColor}-${gaugeId}`; - - if (this.colorToIds[id]) { - return this.colorToIds[id]; - } - - // If no gradient, just return the base color - if (gradient === 'none') { - this.colorToIds[id] = baseColor; - return baseColor; - } - - const returnColor = (this.colorToIds[id] = `url(#${id})`); - const colorModeId = fieldDisplay.field.color?.mode; - const colorMode = getFieldColorMode(colorModeId); - const valuePercent = fieldDisplay.display.percent ?? 0; - - // Handle continusous color modes first - // If it's a segment color we don't want to do continuous gradients - if (colorMode.isContinuous && colorMode.getColors && !forSegment) { - const colors = colorMode.getColors(theme); - const count = colors.length; - - this.defs.push( - - {colors.map((stopColor, i) => ( - - ))} - - ); - - return returnColor; - } - - // For value based colors we want to stay more true to the specific color - // So a radial gradient that adds a bit of light and shade works best - if (colorMode.isByValue) { - const color1 = tinycolor(baseColor).darken(5); - - this.defs.push( - - - - - - ); - - return returnColor; - } - - // For fixed / palette based color scales we can create a more fun - // hue and light based linear gradient that we rotate/move with the value - - const x2 = shape === 'circle' ? 0 : dimensions.centerX + dimensions.radius; - const y2 = shape === 'circle' ? dimensions.centerY + dimensions.radius : 0; - const color1 = tinycolor(baseColor).spin(-20).darken(5); - const color2 = tinycolor(baseColor).saturate(20).spin(20).brighten(10); - - // this makes it so the gradient is always brightest at the current value - const transform = - shape === 'circle' - ? `rotate(${360 * valuePercent - 180} ${dimensions.centerX} ${dimensions.centerY})` - : `translate(-${dimensions.radius * 2 * (1 - valuePercent)}, 0)`; - - this.defs.push( - - {theme.isDark ? ( - <> - - - - ) : ( - <> - - - - )} - - ); - - return returnColor; - } - - getMainBarColor(): string { - return this.getColor(this.options.fieldDisplay.display.color ?? FALLBACK_COLOR); - } - - getDefs(): React.ReactNode[] { - return this.defs; - } -} diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx index c098beda886..b0574ef3f87 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.story.tsx @@ -13,7 +13,8 @@ import { FieldColorModeId } from '@grafana/schema'; import { useTheme2 } from '../../themes/ThemeContext'; import { Stack } from '../Layout/Stack/Stack'; -import { RadialGauge, RadialGaugeProps, RadialGradientMode, RadialShape, RadialTextMode } from './RadialGauge'; +import { RadialGauge, RadialGaugeProps } from './RadialGauge'; +import { RadialShape, RadialTextMode } from './types'; interface StoryProps extends RadialGaugeProps { value: number; @@ -31,10 +32,27 @@ const meta: Meta = { controls: { exclude: ['theme', 'values', 'vizCount'], }, + a11y: { + config: { + rules: [ + { + id: 'scrollable-region-focusable', + selector: 'body', + enabled: false, + }, + // NOTE: this is necessary due to a false positive with the filered svg glow in one of the examples. + // The color-contrast in this component should be accessible! + { + id: 'color-contrast', + selector: 'text', + enabled: false, + }, + ], + }, + }, }, args: { barWidthFactor: 0.2, - spotlight: false, glowBar: false, glowCenter: false, sparkline: false, @@ -42,7 +60,7 @@ const meta: Meta = { width: 200, height: 200, shape: 'circle', - gradient: 'none', + gradient: false, seriesCount: 1, segmentCount: 0, segmentSpacing: 0.2, @@ -56,14 +74,14 @@ const meta: Meta = { width: { control: { type: 'range', min: 50, max: 600 } }, height: { control: { type: 'range', min: 50, max: 600 } }, value: { control: { type: 'range', min: 0, max: 110 } }, - spotlight: { control: 'boolean' }, roundedBars: { control: 'boolean' }, sparkline: { control: 'boolean' }, thresholdsBar: { control: 'boolean' }, - gradient: { control: { type: 'radio' } }, + gradient: { control: { type: 'boolean' } }, seriesCount: { control: { type: 'range', min: 1, max: 20 } }, segmentCount: { control: { type: 'range', min: 0, max: 100 } }, segmentSpacing: { control: { type: 'range', min: 0, max: 1, step: 0.01 } }, + endpointMarker: { control: { type: 'select' }, options: ['none', 'point', 'glow'] }, colorScheme: { control: { type: 'select' }, options: [ @@ -102,57 +120,17 @@ export const Examples: StoryFn = (args) => {
Bar width
- - - - + + + +
Effects
- - - - + + + +
Shape: Gauge & color scale
@@ -160,14 +138,14 @@ export const Examples: StoryFn = (args) => { value={40} shape="gauge" width={250} - gradient="auto" + gradient colorScheme={FieldColorModeId.ContinuousGrYlRd} glowCenter={true} barWidthFactor={0.6} /> = (args) => { value={args.value ?? 70} color="blue" shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.2} @@ -194,9 +171,8 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 30} color="green" shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.8} @@ -206,9 +182,8 @@ export const Examples: StoryFn = (args) => { color="red" shape="gauge" width={250} - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.2} @@ -218,9 +193,8 @@ export const Examples: StoryFn = (args) => { color="red" width={250} shape="gauge" - gradient="auto" + gradient sparkline={true} - spotlight glowBar={true} glowCenter={true} barWidthFactor={0.8} @@ -231,7 +205,7 @@ export const Examples: StoryFn = (args) => { = (args) => { = (args) => { = (args) => { = (args) => { value={args.value ?? 80} width={250} colorScheme={FieldColorModeId.ContinuousGrYlRd} - spotlight shape="gauge" - gradient="auto" + gradient glowBar={true} glowCenter={true} segmentCount={40} @@ -285,10 +257,9 @@ export const Examples: StoryFn = (args) => { @@ -296,7 +267,7 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 70} width={250} colorScheme={FieldColorModeId.Thresholds} - gradient="auto" + gradient glowCenter={true} thresholdsBar={true} roundedBars={false} @@ -307,7 +278,7 @@ export const Examples: StoryFn = (args) => { value={args.value ?? 70} width={250} colorScheme={FieldColorModeId.Thresholds} - gradient="auto" + gradient glowCenter={true} thresholdsBar={true} roundedBars={false} @@ -347,14 +318,12 @@ export const Temp: StoryFn = (args) => { shape="gauge" roundedBars={false} barWidthFactor={0.8} - spotlight /> ); }; interface ExampleProps { - gradient?: RadialGradientMode; color?: string; seriesName?: string; value?: number; @@ -363,7 +332,7 @@ interface ExampleProps { max?: number; width?: number; height?: number; - spotlight?: boolean; + gradient?: boolean; glowBar?: boolean; glowCenter?: boolean; barWidthFactor?: number; @@ -376,12 +345,12 @@ interface ExampleProps { roundedBars?: boolean; thresholdsBar?: boolean; colorScheme?: FieldColorModeId; + endpointMarker?: RadialGaugeProps['endpointMarker']; decimals?: number; showScaleLabels?: boolean; } export function RadialGaugeExample({ - gradient = 'none', color, seriesName = 'Server A', value = 70, @@ -390,7 +359,7 @@ export function RadialGaugeExample({ max = 100, width = 200, height = 200, - spotlight = false, + gradient = false, glowBar = false, glowCenter = false, barWidthFactor = 0.4, @@ -403,6 +372,7 @@ export function RadialGaugeExample({ roundedBars = false, thresholdsBar = false, colorScheme = FieldColorModeId.Thresholds, + endpointMarker = 'glow', decimals = 0, showScaleLabels, }: ExampleProps) { @@ -480,7 +450,6 @@ export function RadialGaugeExample({ barWidthFactor={barWidthFactor} gradient={gradient} shape={shape} - spotlight={spotlight} glowBar={glowBar} glowCenter={glowCenter} textMode={textMode} @@ -490,6 +459,7 @@ export function RadialGaugeExample({ roundedBars={roundedBars} thresholdsBar={thresholdsBar} showScaleLabels={showScaleLabels} + endpointMarker={endpointMarker} /> ); } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx index e2fed36ec3f..783e3b764da 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.test.tsx @@ -1,13 +1,28 @@ import { render, screen } from '@testing-library/react'; +import { ComponentProps } from 'react'; import { RadialGaugeExample } from './RadialGauge.story'; describe('RadialGauge', () => { - it('should render', () => { - render(); - - expect(screen.getByRole('img')).toBeInTheDocument(); - }); + it.each([ + { description: 'default', props: {} }, + { description: 'gauge shape', props: { shape: 'gauge' } }, + { description: 'with gradient', props: { gradient: true } }, + { description: 'with glow bar', props: { glowBar: true } }, + { description: 'with glow center', props: { glowCenter: true } }, + { description: 'with segments', props: { segmentCount: 5 } }, + { description: 'with rounded bars', props: { roundedBars: true } }, + { description: 'with endpoint marker glow', props: { roundedBars: true, endpointMarker: 'glow' } }, + { description: 'with endpoint marker point', props: { roundedBars: true, endpointMarker: 'point' } }, + { description: 'with thresholds bar', props: { thresholdsBar: true } }, + { description: 'with sparkline', props: { sparkline: true } }, + ] satisfies Array<{ description: string; props?: ComponentProps }>)( + 'should render $description without throwing', + ({ props }) => { + render(); + expect(screen.getByRole('img')).toBeInTheDocument(); + } + ); it('should render threshold labels', () => { render(); diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx index fadabf8ec72..1251a364230 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialGauge.tsx @@ -1,14 +1,7 @@ import { css, cx } from '@emotion/css'; -import { isNumber } from 'lodash'; import { useId } from 'react'; -import { - DisplayValueAlignmentFactors, - FieldDisplay, - getDisplayProcessor, - GrafanaTheme2, - TimeRange, -} from '@grafana/data'; +import { DisplayValueAlignmentFactors, FALLBACK_COLOR, FieldDisplay, GrafanaTheme2, TimeRange } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2, useTheme2 } from '../../themes/ThemeContext'; @@ -16,12 +9,13 @@ import { getFormattedThresholds } from '../Gauge/utils'; import { RadialBar } from './RadialBar'; import { RadialBarSegmented } from './RadialBarSegmented'; -import { RadialColorDefs } from './RadialColorDefs'; import { RadialScaleLabels } from './RadialScaleLabels'; import { RadialSparkline } from './RadialSparkline'; import { RadialText } from './RadialText'; import { ThresholdsBar } from './ThresholdsBar'; +import { buildGradientColors } from './colors'; import { GlowGradient, MiddleCircleGlow, SpotlightGradient } from './effects'; +import { RadialShape, RadialTextMode } from './types'; import { calculateDimensions, getValueAngleForValue } from './utils'; export interface RadialGaugeProps { @@ -32,7 +26,7 @@ export interface RadialGaugeProps { * Circle or gauge (partial circle) */ shape?: RadialShape; - gradient?: RadialGradientMode; + gradient?: boolean; /** * Bar width is always relative to size of the gauge. * But this gives you control over the width relative to size. @@ -40,12 +34,14 @@ export interface RadialGaugeProps { * Defaults to 0.4 **/ barWidthFactor?: number; - /** Adds a white spotlight for the end position */ - spotlight?: boolean; glowBar?: boolean; glowCenter?: boolean; roundedBars?: boolean; thresholdsBar?: boolean; + /** + * Specify if an endpoint marker should be shown at the end of the bar + */ + endpointMarker?: 'point' | 'glow'; /** * Number of segments depends on size of gauge but this * factor 1-10 gives you relative control @@ -75,10 +71,6 @@ export interface RadialGaugeProps { timeRange?: TimeRange; } -export type RadialGradientMode = 'none' | 'auto'; -export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; -export type RadialShape = 'circle' | 'gauge'; - /** * https://developers.grafana.com/ui/latest/index.html?path=/docs/plugins-radialgauge--docs */ @@ -87,9 +79,8 @@ export function RadialGauge(props: RadialGaugeProps) { width = 256, height = 256, shape = 'circle', - gradient = 'none', + gradient = false, barWidthFactor = 0.4, - spotlight = false, glowBar = false, glowCenter = false, textMode = 'auto', @@ -99,6 +90,7 @@ export function RadialGauge(props: RadialGaugeProps) { roundedBars = true, thresholdsBar = false, showScaleLabels = false, + endpointMarker, onClick, values, } = props; @@ -121,7 +113,8 @@ export function RadialGauge(props: RadialGaugeProps) { for (let barIndex = 0; barIndex < values.length; barIndex++) { const displayValue = values[barIndex]; const { angle, angleRange } = getValueAngleForValue(displayValue, startAngle, endAngle); - const color = displayValue.display.color ?? 'gray'; + const gradientStops = buildGradientColors(gradient, theme, displayValue); + const color = displayValue.display.color ?? FALLBACK_COLOR; const dimensions = calculateDimensions( width, height, @@ -134,20 +127,12 @@ export function RadialGauge(props: RadialGaugeProps) { showScaleLabels ); - const displayProcessor = getFieldDisplayProcessor(displayValue); + // FIXME: I want to move the ids for these filters into a context which the children + // can reference via a hook, rather than passing them down as props const spotlightGradientId = `spotlight-${barIndex}-${gaugeId}`; const glowFilterId = `glow-${gaugeId}`; - const colorDefs = new RadialColorDefs({ - gradient, - fieldDisplay: displayValue, - theme, - dimensions, - shape, - gaugeId, - displayProcessor, - }); - if (spotlight && theme.isDark) { + if (endpointMarker === 'glow') { defs.push( ); } else { @@ -179,13 +165,16 @@ export function RadialGauge(props: RadialGaugeProps) { ); } @@ -245,7 +234,8 @@ export function RadialGauge(props: RadialGaugeProps) { angleRange={angleRange} roundedBars={roundedBars} glowFilter={`url(#${glowFilterId})`} - colorDefs={colorDefs} + shape={shape} + gradient={gradientStops} /> ); } @@ -291,17 +281,6 @@ export function RadialGauge(props: RadialGaugeProps) { ); } -function getFieldDisplayProcessor(displayValue: FieldDisplay) { - if (displayValue.view && isNumber(displayValue.colIndex)) { - const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); - if (dp) { - return dp; - } - } - - return getDisplayProcessor(); -} - function getStyles(theme: GrafanaTheme2) { return { vizWrapper: css({ diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx index 994b3b35eac..6588150602a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialScaleLabels.tsx @@ -1,87 +1,84 @@ +import { memo } from 'react'; + import { FieldDisplay, GrafanaTheme2, Threshold } from '@grafana/data'; import { t } from '@grafana/i18n'; import { measureText } from '../../utils/measureText'; -import { GaugeDimensions, toCartesian } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { getFieldConfigMinMax, toCartesian } from './utils'; interface RadialScaleLabelsProps { fieldDisplay: FieldDisplay; theme: GrafanaTheme2; thresholds: Threshold[]; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; startAngle: number; endAngle: number; angleRange: number; } -export function RadialScaleLabels({ - fieldDisplay, - thresholds, - theme, - dimensions, - startAngle, - endAngle, - angleRange, -}: RadialScaleLabelsProps) { - const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; +const LINE_HEIGHT_FACTOR = 1.2; - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; +export const RadialScaleLabels = memo( + ({ fieldDisplay, thresholds, theme, dimensions, startAngle, endAngle, angleRange }: RadialScaleLabelsProps) => { + const { centerX, centerY, scaleLabelsFontSize, scaleLabelsRadius } = dimensions; + const [min, max] = getFieldConfigMinMax(fieldDisplay); - const fontSize = scaleLabelsFontSize; - const textLineHeight = scaleLabelsFontSize * 1.2; - const radius = scaleLabelsRadius - textLineHeight; + const fontSize = scaleLabelsFontSize; + const textLineHeight = scaleLabelsFontSize * LINE_HEIGHT_FACTOR; + const radius = scaleLabelsRadius - textLineHeight; - function getTextPosition(text: string, value: number, index: number) { - const isLast = index === thresholds.length - 1; - const isFirst = index === 0; + function getTextPosition(text: string, value: number, index: number) { + const isLast = index === thresholds.length - 1; + const isFirst = index === 0; - let valueDeg = ((value - min) / (max - min)) * angleRange; - let finalAngle = startAngle + valueDeg; + let valueDeg = ((value - min) / (max - min)) * angleRange; + let finalAngle = startAngle + valueDeg; - // Now adjust the final angle based on the label text width and the labels position on the arc - let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); - let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; + // Now adjust the final angle based on the label text width and the labels position on the arc + let measure = measureText(text, fontSize, theme.typography.fontWeightMedium); + let textWidthAngle = (measure.width / (2 * Math.PI * radius)) * angleRange; - // the centering is different for gauge or circle shapes for some reason - finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; + // the centering is different for gauge or circle shapes for some reason + finalAngle -= endAngle < 180 ? textWidthAngle : textWidthAngle / 2; - // For circle gauges we need to shift the first label more - if (isFirst) { - finalAngle += textWidthAngle; + // For circle gauges we need to shift the first label more + if (isFirst) { + finalAngle += textWidthAngle; + } + + // For circle gauges we need to shift the last label more + if (isLast && endAngle === 360) { + finalAngle -= textWidthAngle; + } + + const position = toCartesian(centerX, centerY, radius, finalAngle); + + return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; } - // For circle gauges we need to shift the last label more - if (isLast && endAngle === 360) { - finalAngle -= textWidthAngle; - } - - const position = toCartesian(centerX, centerY, radius, finalAngle); - - return { ...position, transform: `rotate(${finalAngle}, ${position.x}, ${position.y})` }; + return ( + + {thresholds.map((threshold, index) => { + const labelPos = getTextPosition(String(threshold.value), threshold.value, index); + return ( + + {threshold.value} + + ); + })} + + ); } +); - return ( - - {thresholds.map((threshold, index) => { - const labelPos = getTextPosition(String(threshold.value), threshold.value, index); - - return ( - - {threshold.value} - - ); - })} - - ); -} +RadialScaleLabels.displayName = 'RadialScaleLabels'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index acb255a3f3e..2d6c45a14bf 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -1,49 +1,76 @@ +import { memo, useMemo } from 'react'; + import { FieldDisplay, GrafanaTheme2, FieldConfig } from '@grafana/data'; import { GraphFieldConfig, GraphGradientMode, LineInterpolation } from '@grafana/schema'; import { Sparkline } from '../Sparkline/Sparkline'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialSparklineProps { - sparkline: FieldDisplay['sparkline']; - dimensions: GaugeDimensions; - theme: GrafanaTheme2; color?: string; - shape?: RadialShape; + dimensions: RadialGaugeDimensions; + shape: RadialShape; + sparkline: FieldDisplay['sparkline']; textMode: Exclude; + theme: GrafanaTheme2; } -export function RadialSparkline({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) { - const { radius, barWidth } = dimensions; - if (!sparkline) { - return null; +const SPARKLINE_HEIGHT_DIVISOR = 4; +const SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE = 4; +const SPARKLINE_WIDTH_FACTOR_ARC = 1.4; +const SPARKLINE_WIDTH_FACTOR_CIRCLE = 1.6; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE = 4; +const SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE = 3.3; +const SPARKLINE_SPACING = 8; + +export function getSparklineDimensions( + radius: number, + barWidth: number, + showNameAndValue: boolean, + shape: RadialShape +): { width: number; height: number } { + const height = radius / (showNameAndValue ? SPARKLINE_HEIGHT_DIVISOR_NAME_AND_VALUE : SPARKLINE_HEIGHT_DIVISOR); + const width = radius * (shape === 'gauge' ? SPARKLINE_WIDTH_FACTOR_ARC : SPARKLINE_WIDTH_FACTOR_CIRCLE) - barWidth; + return { width, height }; +} + +export const RadialSparkline = memo( + ({ sparkline, dimensions, theme, color, shape, textMode }: RadialSparklineProps) => { + const { radius, barWidth } = dimensions; + + const showNameAndValue = textMode === 'value_and_name'; + const { width, height } = getSparklineDimensions(radius, barWidth, showNameAndValue, shape); + const topPos = + shape === 'gauge' + ? dimensions.gaugeBottomY - height - SPARKLINE_SPACING + : `calc(50% + ${radius / (showNameAndValue ? SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE_NAME_AND_VALUE : SPARKLINE_TOP_OFFSET_DIVISOR_CIRCLE)}px)`; + + const config: FieldConfig = useMemo( + () => ({ + color: { + mode: 'fixed', + fixedColor: color ?? 'blue', + }, + custom: { + gradientMode: GraphGradientMode.Opacity, + fillOpacity: 40, + lineInterpolation: LineInterpolation.Smooth, + }, + }), + [color] + ); + + if (!sparkline) { + return null; + } + + return ( +
+ +
+ ); } +); - const showNameAndValue = textMode === 'value_and_name'; - const height = radius / (showNameAndValue ? 4 : 3); - const width = radius * (shape === 'gauge' ? 1.6 : 1.4) - barWidth; - const topPos = - shape === 'gauge' - ? `${dimensions.gaugeBottomY - height}px` - : `calc(50% + ${radius / (showNameAndValue ? 3.3 : 4)}px)`; - - const config: FieldConfig = { - color: { - mode: 'fixed', - fixedColor: color ?? 'blue', - }, - custom: { - gradientMode: GraphGradientMode.Opacity, - fillOpacity: 40, - lineInterpolation: LineInterpolation.Smooth, - }, - }; - - return ( -
- -
- ); -} +RadialSparkline.displayName = 'RadialSparkline'; diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx index 51a1c64c842..69ab16e450e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialText.tsx @@ -1,4 +1,5 @@ import { css } from '@emotion/css'; +import { memo } from 'react'; import { DisplayValue, @@ -11,13 +12,12 @@ import { import { useStyles2 } from '../../themes/ThemeContext'; import { calculateFontSize } from '../../utils/measureText'; -import { RadialShape, RadialTextMode } from './RadialGauge'; -import { GaugeDimensions } from './utils'; +import { RadialShape, RadialTextMode, RadialGaugeDimensions } from './types'; interface RadialTextProps { displayValue: DisplayValue; theme: GrafanaTheme2; - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; textMode: Exclude; shape: RadialShape; sparkline?: FieldSparkline; @@ -26,123 +26,137 @@ interface RadialTextProps { nameManualFontSize?: number; } -export function RadialText({ - displayValue, - theme, - dimensions, - textMode, - shape, - sparkline, - alignmentFactors, - valueManualFontSize, - nameManualFontSize, -}: RadialTextProps) { - const styles = useStyles2(getStyles); - const { centerX, centerY, radius, barWidth } = dimensions; +const LINE_HEIGHT_FACTOR = 1.21; +const VALUE_WIDTH_TO_RADIUS_FACTOR = 0.82; +const NAME_TO_HEIGHT_FACTOR = 0.45; +const LARGE_RADIUS_SCALING_DECAY = 0.86; +const MAX_TEXT_WIDTH_DIVISOR = 7; +const MAX_NAME_HEIGHT_DIVISOR = 4; +const VALUE_SPACE_PERCENTAGE = 0.7; +const SPARKLINE_SPACING = 8; +const MIN_VALUE_FONT_SIZE = 1; +const MIN_NAME_FONT_SIZE = 10; +const MIN_UNIT_FONT_SIZE = 6; - if (textMode === 'none') { - return null; - } +export const RadialText = memo( + ({ + displayValue, + theme, + dimensions, + textMode, + shape, + sparkline, + alignmentFactors, + valueManualFontSize, + nameManualFontSize, + }: RadialTextProps) => { + const styles = useStyles2(getStyles); + const { centerX, centerY, radius, barWidth } = dimensions; - const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; - const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); + if (textMode === 'none') { + return null; + } - const showValue = textMode === 'value' || textMode === 'value_and_name'; - const showName = textMode === 'name' || textMode === 'value_and_name'; - const maxTextWidth = radius * 2 - barWidth - radius / 7; + const nameToAlignTo = (alignmentFactors ? alignmentFactors.title : displayValue.title) ?? ''; + const valueToAlignTo = formattedValueToString(alignmentFactors ? alignmentFactors : displayValue); - // Not sure where this comes from but svg text is not using body line-height - const lineHeight = 1.21; - const valueWidthToRadiusFactor = 0.82; - const nameToHeightFactor = 0.45; - const largeRadiusScalingDecay = 0.86; + const showValue = textMode === 'value' || textMode === 'value_and_name'; + const showName = textMode === 'name' || textMode === 'value_and_name'; + const maxTextWidth = radius * 2 - barWidth - radius / MAX_TEXT_WIDTH_DIVISOR; - // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels - let maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - let maxNameHeight = radius / 4; + // This pow 0.92 factor is to create a decay so the font size does not become rediculously large for very large panels + let maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + let maxNameHeight = radius / MAX_NAME_HEIGHT_DIVISOR; - if (showValue && showName) { - maxValueHeight = valueWidthToRadiusFactor * Math.pow(radius, largeRadiusScalingDecay); - maxNameHeight = nameToHeightFactor * Math.pow(radius, largeRadiusScalingDecay); - } + if (showValue && showName) { + maxValueHeight = VALUE_WIDTH_TO_RADIUS_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + maxNameHeight = NAME_TO_HEIGHT_FACTOR * Math.pow(radius, LARGE_RADIUS_SCALING_DECAY); + } - const valueFontSize = - valueManualFontSize ?? - calculateFontSize( - valueToAlignTo, - maxTextWidth, - maxValueHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const valueFontSize = Math.max( + valueManualFontSize ?? + calculateFontSize( + valueToAlignTo, + maxTextWidth, + maxValueHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ), + MIN_VALUE_FONT_SIZE ); - const nameFontSize = - nameManualFontSize ?? - calculateFontSize( - nameToAlignTo, - maxTextWidth, - maxNameHeight, - lineHeight, - undefined, - theme.typography.body.fontWeight + const nameFontSize = Math.max( + nameManualFontSize ?? + calculateFontSize( + nameToAlignTo, + maxTextWidth, + maxNameHeight, + LINE_HEIGHT_FACTOR, + undefined, + theme.typography.body.fontWeight + ), + MIN_NAME_FONT_SIZE ); - const unitFontSize = Math.max(valueFontSize * 0.7, 5); - const valueHeight = valueFontSize * lineHeight; - const nameHeight = nameFontSize * lineHeight; + const unitFontSize = Math.max(valueFontSize * VALUE_SPACE_PERCENTAGE, MIN_UNIT_FONT_SIZE); + const valueHeight = valueFontSize * LINE_HEIGHT_FACTOR; + const nameHeight = nameFontSize * LINE_HEIGHT_FACTOR; - const valueY = showName ? centerY - nameHeight * 0.3 : centerY; - const nameY = showValue ? valueY + valueHeight * 0.7 : centerY; - const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; - const suffixShift = (valueFontSize - unitFontSize * 1.2) / 2; + const valueY = showName ? centerY - nameHeight * (1 - VALUE_SPACE_PERCENTAGE) : centerY; + const nameY = showValue ? valueY + valueHeight * VALUE_SPACE_PERCENTAGE : centerY; + const nameColor = showValue ? theme.colors.text.secondary : theme.colors.text.primary; + const suffixShift = (valueFontSize - unitFontSize * LINE_HEIGHT_FACTOR) / 2; - // adjust the text up on gauges and when sparklines are present - let yOffset = 0; - if (shape === 'gauge') { - // we render from the center of the gauge, so move up by half of half of the total height - yOffset -= (valueHeight + nameHeight) / 4; - } - if (sparkline) { - yOffset -= 8; + // adjust the text up on gauges and when sparklines are present + let yOffset = 0; + if (shape === 'gauge') { + // we render from the center of the gauge, so move up by half of half of the total height + yOffset -= (valueHeight + nameHeight) / 4; + } + if (sparkline) { + yOffset -= SPARKLINE_SPACING; + } + + return ( + + {showValue && ( + + {displayValue.prefix ?? ''} + {displayValue.text} + + {displayValue.suffix ?? ''} + + + )} + {showName && ( + + {displayValue.title} + + )} + + ); } +); - return ( - - {showValue && ( - - {displayValue.prefix ?? ''} - {displayValue.text} - - {displayValue.suffix ?? ''} - - - )} - {showName && ( - - {displayValue.title} - - )} - - ); -} +RadialText.displayName = 'RadialText'; -const getStyles = (theme: GrafanaTheme2) => ({ +const getStyles = (_theme: GrafanaTheme2) => ({ text: css({ verticalAlign: 'bottom', }), diff --git a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx index 602038ccf10..cb2829934b9 100644 --- a/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/ThresholdsBar.tsx @@ -1,20 +1,22 @@ import { FieldDisplay, Threshold } from '@grafana/data'; import { RadialArcPath } from './RadialArcPath'; -import { RadialColorDefs } from './RadialColorDefs'; -import { GaugeDimensions } from './utils'; +import { GradientStop, RadialGaugeDimensions, RadialShape } from './types'; +import { getFieldConfigMinMax } from './utils'; -export interface Props { - dimensions: GaugeDimensions; +interface ThresholdsBarProps { + dimensions: RadialGaugeDimensions; angleRange: number; startAngle: number; endAngle: number; + shape: RadialShape; fieldDisplay: FieldDisplay; roundedBars?: boolean; glowFilter?: string; - colorDefs: RadialColorDefs; thresholds: Threshold[]; + gradient?: GradientStop[]; } + export function ThresholdsBar({ dimensions, fieldDisplay, @@ -22,19 +24,18 @@ export function ThresholdsBar({ angleRange, roundedBars, glowFilter, - colorDefs, thresholds, -}: Props) { - const fieldConfig = fieldDisplay.field; - const min = fieldConfig.min ?? 0; - const max = fieldConfig.max ?? 100; - + shape, + gradient, +}: ThresholdsBarProps) { const thresholdDimensions = { ...dimensions, barWidth: dimensions.thresholdsBarWidth, radius: dimensions.thresholdsBarRadius, }; + const [min, max] = getFieldConfigMinMax(fieldDisplay); + let currentStart = startAngle; let paths: React.ReactNode[] = []; @@ -48,27 +49,26 @@ export function ThresholdsBar({ valueDeg = 0; } - let lengthDeg = valueDeg - currentStart + startAngle; + const lengthDeg = valueDeg - currentStart + startAngle; + const colorProps = gradient ? { gradient } : { color: threshold.color }; paths.push( ); currentStart += lengthDeg; } - return ( - <> - {paths} - {colorDefs.getDefs()} - - ); + return {paths}; } diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap new file mode 100644 index 00000000000..97b053c2d61 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/colors.test.ts.snap @@ -0,0 +1,144 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 1`] = ` +[ + { + "color": "#444444", + "percent": 0, + }, + { + "color": "#FADE2A", + "percent": 0.5, + }, + { + "color": "#F2495C", + "percent": 0.8, + }, + { + "color": "#444444", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should map threshold colors correctly (with baseColor if displayProcessor does not return colors) 2`] = ` +[ + { + "color": "#FF0000", + "percent": 0, + }, + { + "color": "#FADE2A", + "percent": 0.5, + }, + { + "color": "#F2495C", + "percent": 0.8, + }, + { + "color": "#FF0000", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in dark theme 1`] = ` +[ + { + "color": "#181b1f", + "percent": 0, + }, + { + "color": "#1F60C4", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for by-value color mode in light theme 1`] = ` +[ + { + "color": "#ffffff", + "percent": 0, + }, + { + "color": "#1250B0", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for continuous color modes 1`] = ` +[ + { + "color": "rgb(0, 32, 81)", + "percent": 0, + }, + { + "color": "rgb(17, 54, 108)", + "percent": 0.125, + }, + { + "color": "rgb(60, 77, 110)", + "percent": 0.25, + }, + { + "color": "rgb(98, 100, 111)", + "percent": 0.375, + }, + { + "color": "rgb(127, 124, 117)", + "percent": 0.5, + }, + { + "color": "rgb(154, 148, 120)", + "percent": 0.625, + }, + { + "color": "rgb(187, 175, 113)", + "percent": 0.75, + }, + { + "color": "rgb(226, 203, 92)", + "percent": 0.875, + }, + { + "color": "rgb(253, 234, 69)", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in dark theme 1`] = ` +[ + { + "color": "#37237a", + "percent": 0, + }, + { + "color": "#a146da", + "percent": 0.75, + }, + { + "color": "#a146da", + "percent": 1, + }, +] +`; + +exports[`RadialGauge color utils buildGradientColors should return gradient colors for fixed color mode in light theme 1`] = ` +[ + { + "color": "#a146da", + "percent": 0, + }, + { + "color": "#3e2b9a", + "percent": 0.75, + }, + { + "color": "#3e2b9a", + "percent": 1, + }, +] +`; diff --git a/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap new file mode 100644 index 00000000000..db4c1c40882 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/__snapshots__/utils.test.ts.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for center x and y 1`] = `"M 150 110 A 90 90 0 1 1 149.98429203681178 110.00000137077838 A 10 10 0 0 1 149.98778269529805 130.00000106616096 A 70 70 0 1 0 150 130 A 10 10 0 0 1 150 110 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for half arc 1`] = `"M 100 10 A 90 90 0 0 1 100 190 L 100 170 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow bar width 1`] = `"M 100 17.5 A 82.5 82.5 0 0 1 100 182.5 L 100 177.5 A 77.5 77.5 0 0 0 100 22.5 L 100 17.5 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for narrow radius 1`] = `"M 100 40 A 60 60 0 0 1 100 160 L 100 140 A 40 40 0 0 0 100 60 L 100 40 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for quarter arc 1`] = `"M 100 10 A 90 90 0 0 1 190 100 L 170 100 A 70 70 0 0 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for rounded bars 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 A 10 10 0 0 1 30 100.00000000000001 A 70 70 0 1 0 100 30 A 10 10 0 0 1 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for three quarter arc 1`] = `"M 100 10 A 90 90 0 1 1 10 100.00000000000001 L 30 100.00000000000001 A 70 70 0 1 0 100 30 L 100 10 Z"`; + +exports[`RadialGauge utils drawRadialArcPath should draw correct path for wide bar width 1`] = `"M 100 -5 A 105 105 0 0 1 100 205 L 100 155 A 55 55 0 0 0 100 45 L 100 -5 Z"`; diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.test.ts b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts new file mode 100644 index 00000000000..321e95bb921 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.test.ts @@ -0,0 +1,306 @@ +import { defaultsDeep } from 'lodash'; + +import { createTheme, FALLBACK_COLOR, Field, FieldDisplay, FieldType, ThresholdsMode } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { + buildGradientColors, + colorAtGradientPercent, + getBarEndcapColors, + getEndpointMarkerColors, + getGradientCss, + getGradientStopsForPercent, +} from './colors'; + +export type DeepPartial = { + [P in keyof T]?: DeepPartial; +}; + +describe('RadialGauge color utils', () => { + describe('buildGradientColors', () => { + const createField = (colorMode: FieldColorModeId): Field => + ({ + type: FieldType.number, + name: 'Test Field', + config: { + color: { + mode: colorMode, + }, + thresholds: { + mode: ThresholdsMode.Absolute, + steps: [ + { value: -Infinity, color: 'green' }, + { value: 50, color: 'yellow' }, + { value: 80, color: 'red' }, + ], + }, + }, + values: [70, 40, 30, 90, 55], + }) satisfies Field; + + const buildFieldDisplay = (field: Field, part = {}): FieldDisplay => + defaultsDeep(part, { + field: field.config, + colIndex: 0, + view: { + getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: undefined }))), + }, + display: { + numeric: 75, + }, + }); + + it('should return the baseColor if gradient is false-y', () => { + expect( + buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + + expect( + buildGradientColors(undefined, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)), '#FF0000') + ).toEqual([ + { color: '#FF0000', percent: 0 }, + { color: '#FF0000', percent: 1 }, + ]); + }); + + it('uses the fallback color if no baseColor is set', () => { + expect(buildGradientColors(false, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Fixed)))).toEqual( + [ + { color: FALLBACK_COLOR, percent: 0 }, + { color: FALLBACK_COLOR, percent: 1 }, + ] + ); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors( + true, + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.Thresholds), { + view: { getFieldDisplayProcessor: jest.fn(() => jest.fn(() => ({ color: '#444444' }))) }, + }) + ) + ).toMatchSnapshot(); + }); + + it('should map threshold colors correctly (with baseColor if displayProcessor does not return colors)', () => { + expect( + buildGradientColors(true, createTheme(), buildFieldDisplay(createField(FieldColorModeId.Thresholds)), '#FF0000') + ).toMatchSnapshot(); + }); + + it('should return gradient colors for continuous color modes', () => { + expect( + buildGradientColors( + true, + createTheme(), + buildFieldDisplay(createField(FieldColorModeId.ContinuousCividis)), + '#00FF00' + ) + ).toMatchSnapshot(); + }); + + it.each(['dark', 'light'] as const)('should return gradient colors for by-value color mode in %s theme', (mode) => { + expect( + buildGradientColors( + true, + createTheme({ colors: { mode } }), + buildFieldDisplay(createField(FieldColorModeId.ContinuousBlues)) + ) + ).toMatchSnapshot(); + }); + + it.each(['dark', 'light'] as const)('should return gradient colors for fixed color mode in %s theme', (mode) => { + expect( + buildGradientColors( + true, + createTheme({ colors: { mode } }), + buildFieldDisplay(createField(FieldColorModeId.Fixed)), + '#442299' + ) + ).toMatchSnapshot(); + }); + }); + + describe('colorAtGradientPercent', () => { + it('should calculate the color at a given percent in a gradient of two colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#bf0040'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#800080'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#4000bf'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should calculate the color at a given percent in a gradient of multiple colors', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('will still work if unsorted', () => { + const gradient = [ + { color: '#0000ff', percent: 1 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#ff0000', percent: 0 }, + ]; + expect(colorAtGradientPercent(gradient, 0).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 0.25).toHexString()).toBe('#808000'); + expect(colorAtGradientPercent(gradient, 0.5).toHexString()).toBe('#00ff00'); + expect(colorAtGradientPercent(gradient, 0.75).toHexString()).toBe('#008080'); + expect(colorAtGradientPercent(gradient, 1).toHexString()).toBe('#0000ff'); + }); + + it('should not throw an error when percent is outside 0-1 range', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#0000ff', percent: 1 }, + ]; + expect(colorAtGradientPercent(gradient, -0.5).toHexString()).toBe('#ff0000'); + expect(colorAtGradientPercent(gradient, 1.5).toHexString()).toBe('#0000ff'); + }); + + it('should throw an error when less than two stops are provided', () => { + expect(() => { + colorAtGradientPercent([], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + expect(() => { + colorAtGradientPercent([{ color: '#ff0000', percent: 0 }], 0.5); + }).toThrow('colorAtGradientPercent requires at least two color stops'); + }); + }); + + describe('getBarEndcapColors', () => { + it('should return the first and last colors in the gradient', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getBarEndcapColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#0000ff'); + }); + + it('should return the correct end color based on percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [startColor, endColor] = getBarEndcapColors(gradient, 0.25); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#808000'); + }); + + it('should handle gradients with only one colors', () => { + const gradient = [{ color: '#ff0000', percent: 0 }]; + const [startColor, endColor] = getBarEndcapColors(gradient); + expect(startColor).toBe('#ff0000'); + expect(endColor).toBe('#ff0000'); + }); + + it('should throw an error when no colors are provided', () => { + expect(() => { + getBarEndcapColors([]); + }).toThrow('getBarEndcapColors requires at least one color stop'); + }); + }); + + describe('getGradientCss', () => { + it('should return conic-gradient CSS for circle shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'circle'); + expect(css).toBe('conic-gradient(from 0deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + + it('should return linear-gradient CSS for arc shape', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const css = getGradientCss(gradient, 'gauge'); + expect(css).toBe('linear-gradient(90deg, #ff0000 0.00%, #00ff00 50.00%, #0000ff 100.00%)'); + }); + }); + + describe('getEndpointMarkerColors', () => { + it('should return contrasting guide dot colors based on the gradient endpoints and percent', () => { + const gradient = [ + { color: '#000000', percent: 0 }, + { color: '#ffffff', percent: 0.5 }, + { color: '#ffffff', percent: 1 }, + ]; + const [startDotColor, endDotColor] = getEndpointMarkerColors(gradient, 0.35); + expect(startDotColor).toBe('#fbfbfb'); + expect(endDotColor).toBe('#111217'); + }); + }); + + describe('getGradientStopsForPercent', () => { + it('should return the correct gradient stops for a given percent', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + const [left, right] = getGradientStopsForPercent(gradient, 0.25); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#00ff00', percent: 0.5 }); + }); + + it('should handle edge cases where percent is at the boundaries', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + let [left, right] = getGradientStopsForPercent(gradient, 0); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#ff0000', percent: 0 }); + + [left, right] = getGradientStopsForPercent(gradient, 1); + expect(left).toEqual({ color: '#0000ff', percent: 1 }); + expect(right).toEqual({ color: '#0000ff', percent: 1 }); + }); + + it('should return the same stop if there is one that is equal to the percentage', () => { + const gradient = [ + { color: '#ff0000', percent: 0 }, + { color: '#00ff00', percent: 0.5 }, + { color: '#0000ff', percent: 1 }, + ]; + + let [left, right] = getGradientStopsForPercent(gradient, 0); + expect(left).toEqual({ color: '#ff0000', percent: 0 }); + expect(right).toEqual({ color: '#ff0000', percent: 0 }); + + [left, right] = getGradientStopsForPercent(gradient, 0.5); + expect(left).toEqual({ color: '#00ff00', percent: 0.5 }); + expect(right).toEqual({ color: '#00ff00', percent: 0.5 }); + + [left, right] = getGradientStopsForPercent(gradient, 1); + expect(left).toEqual({ color: '#0000ff', percent: 1 }); + expect(right).toEqual({ color: '#0000ff', percent: 1 }); + }); + }); +}); diff --git a/packages/grafana-ui/src/components/RadialGauge/colors.ts b/packages/grafana-ui/src/components/RadialGauge/colors.ts new file mode 100644 index 00000000000..61160a9f826 --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/colors.ts @@ -0,0 +1,195 @@ +import tinycolor from 'tinycolor2'; + +import { colorManipulator, FALLBACK_COLOR, FieldDisplay, getFieldColorMode, GrafanaTheme2 } from '@grafana/data'; +import { FieldColorModeId } from '@grafana/schema'; + +import { GradientStop, RadialShape } from './types'; +import { getFieldConfigMinMax, getFieldDisplayProcessor, getValuePercentageForValue } from './utils'; + +export function buildGradientColors( + gradient = false, + theme: GrafanaTheme2, + fieldDisplay: FieldDisplay, + baseColor = fieldDisplay.display.color ?? FALLBACK_COLOR +): GradientStop[] { + if (!gradient) { + return [ + { color: baseColor, percent: 0 }, + { color: baseColor, percent: 1 }, + ]; + } + + const colorMode = getFieldColorMode(fieldDisplay.field.color?.mode); + + // thresholds get special handling + if (colorMode.id === FieldColorModeId.Thresholds) { + const displayProcessor = getFieldDisplayProcessor(fieldDisplay); + const [min, max] = getFieldConfigMinMax(fieldDisplay); + const thresholds = fieldDisplay.field.thresholds?.steps ?? []; + + const result: Array<{ color: string; percent: number }> = [ + { color: displayProcessor(min).color ?? baseColor, percent: 0 }, + ]; + + for (const threshold of thresholds) { + if (threshold.value > min && threshold.value < max) { + const percent = (threshold.value - min) / (max - min); + result.push({ color: theme.visualization.getColorByName(threshold.color), percent }); + } + } + + result.push({ color: displayProcessor(max).color ?? baseColor, percent: 1 }); + + return result; + } + + // Handle continuous color modes before other by-value modes + if (colorMode.isContinuous && colorMode.getColors) { + const colors = colorMode.getColors(theme); + return colors.map((color, idx) => ({ color, percent: idx / (colors.length - 1) })); + } + + // For value-based colors, we want to stay more true to the specific color, + // so a radial gradient that adds a bit of light and shade works best + if (colorMode.isByValue) { + const darkerColor = tinycolor(baseColor).darken(5); + const lighterColor = tinycolor(baseColor).spin(20).lighten(10); + + const color1 = theme.isDark ? lighterColor : darkerColor; + const color2 = theme.isDark ? darkerColor : lighterColor; + + return [ + { color: color1.toString(), percent: 0 }, + { color: color2.toString(), percent: 0.6 }, + { color: color2.toString(), percent: 1 }, + ]; + } + + // For fixed / palette based color scales we can create a more hue and light + // based linear gradient that we rotate with the value + const darkerColor = tinycolor(baseColor) + .spin(-20) + .darken(theme.isDark ? 15 : 5); + const lighterColor = tinycolor(baseColor).saturate(20).spin(20).brighten(10).lighten(10); + + const underlyingGradient = [ + { color: theme.isDark ? darkerColor.toString() : lighterColor.toString(), percent: 0 }, + { color: theme.isDark ? lighterColor.toString() : darkerColor.toString(), percent: 1 }, + ]; + + // rotate the gradient so that the highest contrasting point is the value, depending on theme. + const valuePercent = getValuePercentageForValue(fieldDisplay); + const startColor = theme.isDark + ? colorAtGradientPercent(underlyingGradient, 1 - valuePercent).toHexString() + : underlyingGradient[0].color; + const endColor = theme.isDark + ? underlyingGradient[1].color + : colorAtGradientPercent(underlyingGradient, valuePercent).toHexString(); + return [ + { color: startColor, percent: 0 }, + { color: endColor, percent: valuePercent }, + { color: endColor, percent: 1 }, + ]; +} + +/** + * get the relevant gradient stops surrounding a given percentage. could be same stop if the + * percent matches a stop exactly. + * + * @param sortedGradientStops - gradient stops sorted by percent + * @param percent - percentage 0..1 + * @returns {[GradientStop, GradientStop]} - the two gradient stops surrounding the given percentage + */ +export function getGradientStopsForPercent( + sortedGradientStops: GradientStop[], + percent: number +): [GradientStop, GradientStop] { + if (percent <= 0) { + return [sortedGradientStops[0], sortedGradientStops[0]]; + } + if (percent >= 1) { + const last = sortedGradientStops.length - 1; + return [sortedGradientStops[last], sortedGradientStops[last]]; + } + + // find surrounding stops using binary search + let lo = 0; + let hi = sortedGradientStops.length - 1; + while (lo + 1 < hi) { + const mid = (lo + hi) >> 1; + if (percent === sortedGradientStops[mid].percent) { + return [sortedGradientStops[mid], sortedGradientStops[mid]]; + } + + if (percent < sortedGradientStops[mid].percent) { + hi = mid; + } else { + lo = mid; + } + } + return [sortedGradientStops[lo], sortedGradientStops[hi]]; +} + +/** + * @alpha - perhaps this should go in colorManipulator.ts + * Given color stops (each with a color and percentage 0..1) returns the color at a given percentage. + * Uses tinycolor.mix for interpolation. + * @params stops - array of color stops (percentages 0..1) + * @params percent - percentage 0..1 + * @returns color at the given percentage + */ +export function colorAtGradientPercent(stops: GradientStop[], percent: number): tinycolor.Instance { + if (!stops || stops.length < 2) { + throw new Error('colorAtGradientPercent requires at least two color stops'); + } + + const sorted = stops + .map((s: GradientStop): GradientStop => ({ color: s.color, percent: Math.min(Math.max(0, s.percent), 1) })) + .sort((a: GradientStop, b: GradientStop) => a.percent - b.percent); + + const [left, right] = getGradientStopsForPercent(sorted, percent); + const range = right.percent - left.percent; + const t = range === 0 ? 0 : (percent - left.percent) / range; // 0..1 + return tinycolor.mix(left.color, right.color, t * 100); +} + +export function getBarEndcapColors(gradientStops: GradientStop[], percent = 1): [string, string] { + if (gradientStops.length === 0) { + throw new Error('getBarEndcapColors requires at least one color stop'); + } + + const startColor = gradientStops[0].color; + let endColor = gradientStops[gradientStops.length - 1].color; + + // if we have a percentageFilled, use it to get a the correct end color based on where the bar terminates + if (gradientStops.length >= 2) { + const endColorByPercentage = colorAtGradientPercent(gradientStops, percent); + endColor = + endColorByPercentage.getAlpha() === 1 ? endColorByPercentage.toHexString() : endColorByPercentage.toHex8String(); + } + return [startColor, endColor]; +} + +export function getGradientCss(gradientStops: GradientStop[], shape: RadialShape): string { + const colorStrings = gradientStops.map((stop) => `${stop.color} ${(stop.percent * 100).toFixed(2)}%`); + if (shape === 'circle') { + return `conic-gradient(from 0deg, ${colorStrings.join(', ')})`; + } + return `linear-gradient(90deg, ${colorStrings.join(', ')})`; +} + +// the theme does not make the full palette available to us, and we +// don't want transparent colors which our grays usually have. +const GRAY_05 = '#111217'; +const GRAY_90 = '#fbfbfb'; +const CONTRAST_THRESHOLD_MAX = 4.5; +const getGuideDotColor = (color: string): string => { + const darkColor = GRAY_05; + const lightColor = GRAY_90; + return colorManipulator.getContrastRatio(darkColor, color) >= CONTRAST_THRESHOLD_MAX ? darkColor : lightColor; +}; + +export function getEndpointMarkerColors(gradientStops: GradientStop[], percent = 1): [string, string] { + const [startColor, endColor] = getBarEndcapColors(gradientStops, percent); + return [getGuideDotColor(startColor), getGuideDotColor(endColor)]; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/effects.tsx b/packages/grafana-ui/src/components/RadialGauge/effects.tsx index 354a68a25ba..c48307f177e 100644 --- a/packages/grafana-ui/src/components/RadialGauge/effects.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/effects.tsx @@ -1,15 +1,18 @@ import { GrafanaTheme2 } from '@grafana/data'; -import { GaugeDimensions } from './utils'; +import { RadialGaugeDimensions } from './types'; export interface GlowGradientProps { id: string; barWidth: number; } +const MIN_GLOW_SIZE = 0.75; +const GLOW_FACTOR = 0.08; + export function GlowGradient({ id, barWidth }: GlowGradientProps) { // 0.75 is the minimum glow size, and it scales with bar width - const glowSize = 0.75 + barWidth * 0.08; + const glowSize = MIN_GLOW_SIZE + barWidth * GLOW_FACTOR; return ( @@ -22,56 +25,19 @@ export function GlowGradient({ id, barWidth }: GlowGradientProps) { ); } -export function SpotlightGradient({ - id, - dimensions, - roundedBars, - angle, - theme, -}: { - id: string; - dimensions: GaugeDimensions; - angle: number; - roundedBars: boolean; - theme: GrafanaTheme2; -}) { - const angleRadian = ((angle - 90) * Math.PI) / 180; - - let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2); - let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2); - let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian); - let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian); - - if (theme.isLight) { - return ( - - - - - - ); - } - - return ( - - - - {roundedBars && } - - ); -} +const CENTER_GLOW_OPACITY = 0.15; export function CenterGlowGradient({ gaugeId, color }: { gaugeId: string; color: string }) { return ( - - + + ); } export interface CenterGlowProps { - dimensions: GaugeDimensions; + dimensions: RadialGaugeDimensions; gaugeId: string; color?: string; } @@ -82,8 +48,8 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps return ( <> - - + + @@ -93,3 +59,36 @@ export function MiddleCircleGlow({ dimensions, gaugeId, color }: CenterGlowProps ); } + +export function SpotlightGradient({ + id, + dimensions, + roundedBars, + angle, + theme, +}: { + id: string; + dimensions: RadialGaugeDimensions; + angle: number; + roundedBars: boolean; + theme: GrafanaTheme2; +}) { + if (theme.isLight) { + return null; + } + + const angleRadian = ((angle - 90) * Math.PI) / 180; + + let x1 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian - 0.2); + let y1 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian - 0.2); + let x2 = dimensions.centerX + dimensions.radius * Math.cos(angleRadian); + let y2 = dimensions.centerY + dimensions.radius * Math.sin(angleRadian); + + return ( + + + + {roundedBars && } + + ); +} diff --git a/packages/grafana-ui/src/components/RadialGauge/types.ts b/packages/grafana-ui/src/components/RadialGauge/types.ts new file mode 100644 index 00000000000..cc233dd524c --- /dev/null +++ b/packages/grafana-ui/src/components/RadialGauge/types.ts @@ -0,0 +1,25 @@ +export type RadialTextMode = 'auto' | 'value_and_name' | 'value' | 'name' | 'none'; +export type RadialShape = 'circle' | 'gauge'; + +export interface RadialGaugeDimensions { + margin: number; + radius: number; + centerX: number; + centerY: number; + barWidth: number; + endAngle?: number; + barIndex: number; + thresholdsBarRadius: number; + thresholdsBarWidth: number; + thresholdsBarSpacing: number; + scaleLabelsFontSize: number; + scaleLabelsSpacing: number; + scaleLabelsRadius: number; + gaugeBottomY: number; +} + +/** @alpha - perhaps this should go in @grafana/data */ +export interface GradientStop { + color: string; + percent: number; +} diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts index 5e3f34d62cd..b9b2e4ad8f3 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.test.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.test.ts @@ -1,24 +1,111 @@ -import { FieldDisplay } from '@grafana/data'; +import { DataFrameView, FieldDisplay } from '@grafana/data'; import type { RadialGaugeProps } from './RadialGauge'; -import { calculateDimensions, toRad, getValueAngleForValue } from './utils'; +import { RadialGaugeDimensions } from './types'; +import { + calculateDimensions, + toRad, + getValueAngleForValue, + drawRadialArcPath, + getFieldConfigMinMax, + getFieldDisplayProcessor, + getAngleBetweenSegments, + getOptimalSegmentCount, +} from './utils'; describe('RadialGauge utils', () => { - function calc(overrides: Partial = {}) { - return calculateDimensions( - overrides.width ?? 200, - overrides.height ?? 200, - overrides.shape === 'gauge' ? 110 : 360, - overrides.glowBar ?? false, - overrides.roundedBars ?? false, - overrides.barWidthFactor ?? 0.4, - overrides.barIndex ?? 0, - overrides.thresholdsBar ?? false, - overrides.showScaleLabels ?? false - ); - } + describe('getFieldDisplayProcessor', () => { + it('should return display processor from view when available', () => { + const mockProcessor = jest.fn(); + const mockView = { + getFieldDisplayProcessor: jest.fn().mockReturnValue(mockProcessor), + } as unknown as DataFrameView; + + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: mockView, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBe(mockProcessor); + expect(mockView.getFieldDisplayProcessor).toHaveBeenCalledWith(0); + }); + + it('should return default display processor when view is not available', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const dp = getFieldDisplayProcessor(fieldDisplay); + expect(dp).toBeDefined(); + expect(typeof dp).toBe('function'); + }); + }); + + describe('getFieldConfigMinMax', () => { + it('should return min and max from field config when defined', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: { min: 10, max: 90 }, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(10); + expect(max).toBe(90); + }); + + it('should return default min and max when not defined in field config', () => { + const fieldDisplay: FieldDisplay = { + display: { numeric: 50, text: '50', color: 'blue' }, + field: {}, + view: undefined, + colIndex: 0, + rowIndex: 0, + name: 'test', + getLinks: () => [], + hasLinks: false, + }; + + const [min, max] = getFieldConfigMinMax(fieldDisplay); + expect(min).toBe(0); + expect(max).toBe(100); + }); + }); describe('calculateDimensions', () => { + function calc(overrides: Partial = {}) { + return calculateDimensions( + overrides.width ?? 200, + overrides.height ?? 200, + overrides.shape === 'gauge' ? 110 : 360, + overrides.glowBar ?? false, + overrides.roundedBars ?? false, + overrides.barWidthFactor ?? 0.4, + overrides.barIndex ?? 0, + overrides.thresholdsBar ?? false, + overrides.showScaleLabels ?? false + ); + } + it('should calculate basic dimensions for a square gauge', () => { const result = calc(); @@ -194,4 +281,84 @@ describe('RadialGauge utils', () => { expect(result.angle).toBe(240); }); }); + + describe('drawRadialArcPath', () => { + const defaultDims: RadialGaugeDimensions = Object.freeze({ + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }); + + it.each([ + { description: 'quarter arc', startAngle: 0, endAngle: 90 }, + { description: 'half arc', startAngle: 0, endAngle: 180 }, + { description: 'three quarter arc', startAngle: 0, endAngle: 270 }, + { description: 'rounded bars', startAngle: 0, endAngle: 270, roundedBars: true }, + { description: 'wide bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 50 } }, + { description: 'narrow bar width', startAngle: 0, endAngle: 180, dimensions: { barWidth: 5 } }, + { description: 'narrow radius', startAngle: 0, endAngle: 180, dimensions: { radius: 50 } }, + { + description: 'center x and y', + startAngle: 0, + endAngle: 360, + roundedBars: true, + dimensions: { centerX: 150, centerY: 200 }, + }, + ])(`should draw correct path for $description`, ({ startAngle, endAngle, dimensions, roundedBars }) => { + const path = drawRadialArcPath(startAngle, endAngle, { ...defaultDims, ...dimensions }, roundedBars); + expect(path).toMatchSnapshot(); + }); + + describe('edge cases', () => { + it('should adjust 360deg or greater arcs to avoid SVG rendering issues', () => { + expect(drawRadialArcPath(0, 360, defaultDims)).toEqual(drawRadialArcPath(0, 359.99, defaultDims)); + expect(drawRadialArcPath(0, 380, defaultDims)).toEqual(drawRadialArcPath(0, 380, defaultDims)); + }); + + it('should return empty string if inner radius collapses to zero or below', () => { + const smallRadiusDims = { ...defaultDims, radius: 5, barWidth: 20 }; + expect(drawRadialArcPath(0, 180, smallRadiusDims)).toBe(''); + }); + }); + }); + + describe('getAngleBetweenSegments', () => { + it('should calculate angle between segments based on spacing and count', () => { + expect(getAngleBetweenSegments(2, 10, 360)).toBe(48); + expect(getAngleBetweenSegments(5, 15, 180)).toBe(40); + }); + }); + + describe('getOptimalSegmentCount', () => { + it('should adjust segment count based on dimensions and spacing', () => { + const dimensions: RadialGaugeDimensions = { + centerX: 100, + centerY: 100, + radius: 80, + barWidth: 20, + margin: 0, + barIndex: 0, + thresholdsBarWidth: 0, + thresholdsBarSpacing: 0, + thresholdsBarRadius: 0, + scaleLabelsFontSize: 0, + scaleLabelsSpacing: 0, + scaleLabelsRadius: 0, + gaugeBottomY: 0, + }; + + expect(getOptimalSegmentCount(dimensions, 2, 10, 360)).toBe(8); + expect(getOptimalSegmentCount(dimensions, 1, 5, 360)).toBe(5); + }); + }); }); diff --git a/packages/grafana-ui/src/components/RadialGauge/utils.ts b/packages/grafana-ui/src/components/RadialGauge/utils.ts index 44f767d89b2..e26cf5eed2a 100644 --- a/packages/grafana-ui/src/components/RadialGauge/utils.ts +++ b/packages/grafana-ui/src/components/RadialGauge/utils.ts @@ -1,11 +1,38 @@ -import { FieldDisplay } from '@grafana/data'; +import { FieldDisplay, getDisplayProcessor } from '@grafana/data'; -export function getValueAngleForValue(fieldDisplay: FieldDisplay, startAngle: number, endAngle: number) { - const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; +import { RadialGaugeDimensions } from './types'; + +export function getFieldDisplayProcessor(displayValue: FieldDisplay) { + if (displayValue.view && displayValue.colIndex != null) { + const dp = displayValue.view.getFieldDisplayProcessor(displayValue.colIndex); + if (dp) { + return dp; + } + } + + return getDisplayProcessor(); +} + +export function getFieldConfigMinMax(fieldDisplay: FieldDisplay) { const min = fieldDisplay.field.min ?? 0; const max = fieldDisplay.field.max ?? 100; + return [min, max]; +} - let angle = ((fieldDisplay.display.numeric - min) / (max - min)) * angleRange; +export function getValuePercentageForValue(fieldDisplay: FieldDisplay, value = fieldDisplay.display.numeric) { + const [min, max] = getFieldConfigMinMax(fieldDisplay); + return (value - min) / (max - min); +} + +export function getValueAngleForValue( + fieldDisplay: FieldDisplay, + startAngle: number, + endAngle: number, + value = fieldDisplay.display.numeric +) { + const angleRange = (360 % (startAngle === 0 ? 1 : startAngle)) + endAngle; + + let angle = getValuePercentageForValue(fieldDisplay, value) * angleRange; if (angle > angleRange) { angle = angleRange; @@ -26,24 +53,19 @@ export function toRad(angle: number) { return ((angle - 90) * Math.PI) / 180; } -export interface GaugeDimensions { - margin: number; - radius: number; - centerX: number; - centerY: number; - barWidth: number; - endAngle?: number; - barIndex: number; - thresholdsBarRadius: number; - thresholdsBarWidth: number; - thresholdsBarSpacing: number; - showScaleLabels?: boolean; - scaleLabelsFontSize: number; - scaleLabelsSpacing: number; - scaleLabelsRadius: number; - gaugeBottomY: number; -} - +/** + * returns the calculated dimensions for the radial gauge + * @param width + * @param height + * @param endAngle + * @param glow + * @param roundedBars + * @param barWidthFactor + * @param barIndex + * @param thresholdBar + * @param showScaleLabels + * @returns {RadialGaugeDimensions} + */ export function calculateDimensions( width: number, height: number, @@ -54,7 +76,7 @@ export function calculateDimensions( barIndex: number, thresholdBar?: boolean, showScaleLabels?: boolean -): GaugeDimensions { +): RadialGaugeDimensions { const yMaxAngle = endAngle > 180 ? 180 : endAngle; let margin = 0; @@ -97,6 +119,7 @@ export function calculateDimensions( maxRadiusW -= labelsSize; maxRadiusH -= labelsSize; + // FIXME: needs coverage // For gauges the max label needs a bit more vertical space so that it does not get clipped if (maxRadiusIsLimitedByHeight && endAngle < 180) { const amount = outerRadius * 0.07; @@ -155,3 +178,105 @@ export function toCartesian(centerX: number, centerY: number, radius: number, an y: centerY + radius * Math.sin(radian), }; } + +export function drawRadialArcPath( + startAngle: number, + endAngle: number, + dimensions: RadialGaugeDimensions, + roundedBars?: boolean +): string { + const { radius, centerX, centerY, barWidth } = dimensions; + + // For some reason a 100% full arc cannot be rendered + if (endAngle >= 360) { + endAngle = 359.99; + } + + const startRadians = toRad(startAngle); + const endRadians = toRad(startAngle + endAngle); + + const largeArc = endAngle > 180 ? 1 : 0; + + const outerR = radius + barWidth / 2; + const innerR = Math.max(0, radius - barWidth / 2); + if (innerR <= 0) { + return ''; // cannot draw arc with 0 inner radius + } + + // get points for both an inner and outer arc. we draw + // the arc entirely with a path's fill instead of using stroke + // so that it can be used as a clip-path. + const ox1 = centerX + outerR * Math.cos(startRadians); + const oy1 = centerY + outerR * Math.sin(startRadians); + const ox2 = centerX + outerR * Math.cos(endRadians); + const oy2 = centerY + outerR * Math.sin(endRadians); + + const ix1 = centerX + innerR * Math.cos(startRadians); + const iy1 = centerY + innerR * Math.sin(startRadians); + const ix2 = centerX + innerR * Math.cos(endRadians); + const iy2 = centerY + innerR * Math.sin(endRadians); + + // calculate the cap width in case we're drawing rounded bars + const capR = barWidth / 2; + + const pathParts = [ + // start at outer start + 'M', + ox1, + oy1, + // outer arc from start to end (clockwise) + 'A', + outerR, + outerR, + 0, + largeArc, + 1, + ox2, + oy2, + ]; + + if (roundedBars) { + // rounded end cap: small arc connecting outer end to inner end + pathParts.push('A', capR, capR, 0, 0, 1, ix2, iy2); + } else { + // straight line to inner end (square butt) + pathParts.push('L', ix2, iy2); + } + + // inner arc from end back to start (counter-clockwise) + pathParts.push('A', innerR, innerR, 0, largeArc, 0, ix1, iy1); + + if (roundedBars) { + // rounded start cap: small arc connecting inner start back to outer start + pathParts.push('A', capR, capR, 0, 0, 1, ox1, oy1); + } else { + // straight line back to outer start (square butt) + pathParts.push('L', ox1, oy1); + } + + pathParts.push('Z'); + + return pathParts.join(' '); +} + +export function getAngleBetweenSegments(segmentSpacing: number, segmentCount: number, range: number) { + // Max spacing is 8 degrees between segments + // Changing this constant could be considered a breaking change + const maxAngleBetweenSegments = Math.max(range / 1.5 / segmentCount, 2); + return segmentSpacing * maxAngleBetweenSegments; +} + +export function getOptimalSegmentCount( + dimensions: RadialGaugeDimensions, + segmentSpacing: number, + segmentCount: number, + range: number +) { + const angleBetweenSegments = getAngleBetweenSegments(segmentSpacing, segmentCount, range); + + const innerRadius = dimensions.radius - dimensions.barWidth / 2; + const circumference = Math.PI * innerRadius * 2 * (range / 360); + const maxSegments = Math.floor(circumference / (angleBetweenSegments + 3)); + + return Math.min(maxSegments, segmentCount); +} diff --git a/public/app/plugins/panel/radialbar/EffectsEditor.tsx b/public/app/plugins/panel/radialbar/EffectsEditor.tsx index d7a0f03cbc7..a3c26beca90 100644 --- a/public/app/plugins/panel/radialbar/EffectsEditor.tsx +++ b/public/app/plugins/panel/radialbar/EffectsEditor.tsx @@ -44,11 +44,6 @@ export function EffectsEditor(props: StandardEditorProps) { value={!!props.value?.gradient} onChange={(e) => props.onChange({ ...props.value, gradient: e.currentTarget.checked })} /> - props.onChange({ ...props.value, rounded: e.currentTarget.checked })} - /> ) { value={!!props.value?.centerGlow} onChange={(e) => props.onChange({ ...props.value, centerGlow: e.currentTarget.checked })} /> - props.onChange({ ...props.value, spotlight: e.currentTarget.checked })} - /> ); } diff --git a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx index 86235a3bf68..03232406463 100644 --- a/public/app/plugins/panel/radialbar/RadialBarPanel.tsx +++ b/public/app/plugins/panel/radialbar/RadialBarPanel.tsx @@ -37,11 +37,10 @@ export function RadialBarPanel({ width={width} height={height} barWidthFactor={options.barWidthFactor} - gradient={options.effects?.gradient ? 'auto' : 'none'} - spotlight={options.effects?.spotlight} + gradient={options.effects?.gradient} glowBar={options.effects?.barGlow} glowCenter={options.effects?.centerGlow} - roundedBars={options.effects?.rounded} + roundedBars={options.barShape === 'rounded'} vizCount={valueProps.count} shape={options.shape} segmentCount={options.segmentCount} @@ -51,6 +50,7 @@ export function RadialBarPanel({ alignmentFactors={valueProps.alignmentFactors} valueManualFontSize={options.text?.valueSize} nameManualFontSize={options.text?.titleSize} + endpointMarker={options.endpointMarker !== 'none' ? options.endpointMarker : undefined} onClick={menuProps.openMenu} /> ); diff --git a/public/app/plugins/panel/radialbar/module.tsx b/public/app/plugins/panel/radialbar/module.tsx index 12aff4966f7..3dfcb3e3d5f 100644 --- a/public/app/plugins/panel/radialbar/module.tsx +++ b/public/app/plugins/panel/radialbar/module.tsx @@ -69,6 +69,36 @@ export const plugin = new PanelPlugin(RadialBarPanel) }, }); + builder.addRadio({ + path: 'barShape', + name: t('radialbar.config.bar-shape', 'Bar Style'), + category, + defaultValue: defaultOptions.barShape, + settings: { + options: [ + { value: 'flat', label: t('radialbar.config.bar-shape-flat', 'Flat') }, + { value: 'rounded', label: t('radialbar.config.bar-shape-rounded', 'Rounded') }, + ], + }, + showIf: (options) => options.segmentCount === 1, + }); + + builder.addRadio({ + path: 'endpointMarker', + name: t('radialbar.config.endpoint-marker', 'Endpoint marker'), + description: t('radialbar.config.endpoint-marker-description', 'Glow is only supported in dark mode'), + category, + defaultValue: defaultOptions.endpointMarker, + settings: { + options: [ + { value: 'point', label: t('radialbar.config.endpoint-marker-point', 'Point') }, + { value: 'glow', label: t('radialbar.config.endpoint-marker-glow', 'Glow') }, + { value: 'none', label: t('radialbar.config.endpoint-marker-none', 'None') }, + ], + }, + showIf: (options) => options.barShape === 'rounded' && options.segmentCount === 1, + }); + builder.addBooleanSwitch({ path: 'sparkline', name: t('radialbar.config.sparkline', 'Show sparkline'), diff --git a/public/app/plugins/panel/radialbar/panelcfg.cue b/public/app/plugins/panel/radialbar/panelcfg.cue index a37982dcfe1..c959512c0b8 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.cue +++ b/public/app/plugins/panel/radialbar/panelcfg.cue @@ -27,10 +27,8 @@ composableKinds: PanelCfg: { schema: { GaugePanelEffects: { barGlow?: bool | *false - spotlight?: bool | *false - rounded?: bool | *false centerGlow?: bool | *false - gradient?: bool | *true + gradient?: bool | *true } @cuetsy(kind="interface") Options: { @@ -42,6 +40,8 @@ composableKinds: PanelCfg: { sparkline?: bool | *true shape: "circle" | *"gauge" barWidthFactor: number | *0.5 + barShape: "flat" | "rounded" | *"flat" + endpointMarker?: "point" | "glow" | "none" | *"point" effects: GaugePanelEffects | *{} } @cuetsy(kind="interface") } diff --git a/public/app/plugins/panel/radialbar/panelcfg.gen.ts b/public/app/plugins/panel/radialbar/panelcfg.gen.ts index 24915c62ef1..e050a044f77 100644 --- a/public/app/plugins/panel/radialbar/panelcfg.gen.ts +++ b/public/app/plugins/panel/radialbar/panelcfg.gen.ts @@ -14,21 +14,19 @@ export interface GaugePanelEffects { barGlow?: boolean; centerGlow?: boolean; gradient?: boolean; - rounded?: boolean; - spotlight?: boolean; } export const defaultGaugePanelEffects: Partial = { barGlow: false, centerGlow: false, gradient: true, - rounded: false, - spotlight: false, }; export interface Options extends common.SingleStatBaseOptions { + barShape: ('flat' | 'rounded'); barWidthFactor: number; effects: GaugePanelEffects; + endpointMarker?: ('point' | 'glow' | 'none'); segmentCount: number; segmentSpacing: number; shape: ('circle' | 'gauge'); @@ -38,8 +36,10 @@ export interface Options extends common.SingleStatBaseOptions { } export const defaultOptions: Partial = { + barShape: 'flat', barWidthFactor: 0.5, effects: {}, + endpointMarker: 'point', segmentCount: 1, segmentSpacing: 0.3, shape: 'gauge', diff --git a/public/app/plugins/panel/radialbar/suggestions.ts b/public/app/plugins/panel/radialbar/suggestions.ts index 00896ae8458..eab5334ef40 100644 --- a/public/app/plugins/panel/radialbar/suggestions.ts +++ b/public/app/plugins/panel/radialbar/suggestions.ts @@ -18,19 +18,6 @@ const withDefaults = ( } }, }, - // 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); const MAX_GAUGES = 10; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a4e39d534a3..d97ce1128ba 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7963,11 +7963,7 @@ "suggestions": { "arc": "Gauge", "circular": "Circular gauge", - "no-thresholds": "Gauge - no thresholds", - "style": { - "circular": "Glowing", - "simple": "Simple" - } + "no-thresholds": "Gauge - no thresholds" }, "threshold": "Threshold {{value}}" }, @@ -12493,16 +12489,21 @@ }, "radialbar": { "config": { + "bar-shape": "Bar Style", + "bar-shape-flat": "Flat", + "bar-shape-rounded": "Rounded", "bar-width": "Bar width", "effects": { "bar-glow": "Bar glow", "center-glow": "Center glow", "gradient": "Gradient", - "label": "Effects", - "rounded-bars": "Rounded bars", - "spotlight": "Spotlight", - "spotlight-tooltip": "Only visible in dark themes" + "label": "Effects" }, + "endpoint-marker": "Endpoint marker", + "endpoint-marker-description": "Glow is only supported in dark mode", + "endpoint-marker-glow": "Glow", + "endpoint-marker-none": "None", + "endpoint-marker-point": "Point", "segment-count": "Segments", "segment-spacing": "Segment spacing", "shape": "Style", From 6daa7ff72911e5fc3938adf500db7bafec5f5ef6 Mon Sep 17 00:00:00 2001 From: Sean Griffin Date: Fri, 19 Dec 2025 16:05:46 -0500 Subject: [PATCH 15/80] Clean up Schema Inspector feature code (#115514) Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> --- .../SchemaInspector/SchemaInspectorPanel.tsx | 80 ++-- .../SqlExpressions/SqlExpr.test.tsx | 90 +---- .../components/SqlExpressions/SqlExpr.tsx | 344 ++++++------------ .../SqlExpressions/SqlExprContext.test.tsx | 88 +++++ .../SqlExpressions/SqlExprContext.tsx | 38 ++ .../SqlExpressions/SqlQueryActions.test.tsx | 137 +++++++ .../SqlExpressions/SqlQueryActions.tsx | 91 +++++ public/locales/en-US/grafana.json | 9 +- 8 files changed, 498 insertions(+), 379 deletions(-) create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx create mode 100644 public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx diff --git a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx index c84bf0d4223..a065bbc779f 100644 --- a/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SchemaInspector/SchemaInspectorPanel.tsx @@ -1,25 +1,24 @@ import { css } from '@emotion/css'; -import { useState, useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t, Trans } from '@grafana/i18n'; import { - Stack, - Tab, - TabsBar, - TabContent, - Icon, + Alert, Badge, - Text, - useStyles2, + Icon, InteractiveTable, ScrollContainer, - Alert, Spinner, - IconButton, + Stack, + Tab, + TabContent, + TabsBar, + Text, + useStyles2, } from '@grafana/ui'; -import { SQLSchemas, SQLSchemaField, SQLSchemaData } from '../hooks/useSQLSchemas'; +import { SQLSchemaData, SQLSchemaField, SQLSchemas } from '../hooks/useSQLSchemas'; import { getFieldTypeIcon } from './utils'; @@ -33,10 +32,9 @@ interface SchemaInspectorPanelProps { schemas: SQLSchemas | null; loading: boolean; error: Error | null; - onClose: () => void; } -export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: SchemaInspectorPanelProps) => { +export const SchemaInspectorPanel = ({ schemas, loading, error }: SchemaInspectorPanelProps) => { const styles = useStyles2(getStyles); const schemaResponse: SQLSchemas = schemas ?? {}; @@ -192,32 +190,21 @@ export const SchemaInspectorPanel = ({ schemas, loading, error, onClose }: Schem }; return ( -
-
- {refIds.length > 0 && ( - - {refIds.map((refId) => ( - setSelectedTab(refId)} - /> - ))} - - )} - -
+ <> + {refIds.length > 0 && ( + + {refIds.map((refId) => ( + setSelectedTab(refId)} + /> + ))} + + )} {renderContent()} -
+ ); }; @@ -225,21 +212,6 @@ const getStyles = (theme: GrafanaTheme2) => ({ schemaInfoContainer: css({ padding: theme.spacing(1), }), - schemaInspector: css({ - height: '100%', - display: 'flex', - flexDirection: 'column', - }), - // Unfortunate hack to get the close button to align with the tabs since we need to - // override the default styles of the TabsBar component. - tabsBarWrapper: css({ - flexShrink: 0, - display: 'flex', - alignItems: 'center', - justifyContent: 'space-between', - width: '100%', - padding: `0 ${theme.spacing(1)}`, - }), tableCell: css({ fontSize: theme.typography.bodySmall.fontSize, fontWeight: theme.typography.fontWeightMedium, @@ -247,10 +219,8 @@ const getStyles = (theme: GrafanaTheme2) => ({ }), tableContainer: css({ margin: theme.spacing(1), - flex: 1, overflowY: 'auto', overflowX: 'auto', - minHeight: 0, // Allow flex child to shrink border: `1px solid ${theme.colors.border.medium}`, borderRadius: theme.shape.radius.default, backgroundColor: theme.colors.background.primary, diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx index b7acc586447..9e2cfd50b75 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor, fireEvent, act, testWithFeatureToggles } from 'test/test-utils'; +import { act, fireEvent, render, testWithFeatureToggles } from 'test/test-utils'; import { ExpressionQuery, ExpressionQueryType } from '../../types'; @@ -127,78 +127,6 @@ describe('SqlExpr with GenAI features', () => { queries: [], }; - it('renders GenAI buttons with empty expression', async () => { - const customProps = { ...defaultProps, query: { ...defaultProps.query, expression: '' } }; - const { findByText } = render(); - expect(await findByText('Generate suggestion')).toBeInTheDocument(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders GenAI buttons with non-empty expression', async () => { - const { findByText } = render(); - expect(await findByText('Improve query')).toBeInTheDocument(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders "Improve query" when currentQuery differs from initialQuery', async () => { - const customProps = { - ...defaultProps, - query: { ...defaultProps.query, expression: 'SELECT * FROM A WHERE value > 10' }, - }; - const { findByText } = render(); - expect(await findByText('Improve query')).toBeInTheDocument(); - }); - - it('renders View explanation button when shouldShowViewExplanation is true', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation((currentExpression: string) => ({ - shouldShowViewExplanation: true, - })); - - const { findByText } = render(); - expect(await findByText('View explanation')).toBeInTheDocument(); - }); - - it('renders Explain query button when shouldShowViewExplanation is false', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - useSQLExplanations.mockImplementation((currentExpression: string) => ({ - shouldShowViewExplanation: false, - })); - - const { findByText } = render(); - expect(await findByText('Explain query')).toBeInTheDocument(); - }); - - it('renders SuggestionsDrawerButton when there are suggestions', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ suggestions: ['suggestion1', 'suggestion2'] })); - - const { findByTestId } = render(); - expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); - }); - - it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { - const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); - useSQLSuggestions.mockImplementation(() => ({ suggestions: [] })); - - const { queryByTestId } = render(); - expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); - }); - - it('calls handleOpenExplanation when View explanation is clicked', async () => { - const { useSQLExplanations } = require('./GenAI/hooks/useSQLExplanations'); - const mockHandleOpen = jest.fn(); - useSQLExplanations.mockImplementation(() => ({ - shouldShowViewExplanation: true, - handleOpenExplanation: mockHandleOpen, - })); - - const { findByText } = render(); - const button = await findByText('View explanation'); - fireEvent.click(button); - expect(mockHandleOpen).toHaveBeenCalled(); - }); - it('renders suggestions drawer when isDrawerOpen is true', async () => { const { useSQLSuggestions } = require('./GenAI/hooks/useSQLSuggestions'); useSQLSuggestions.mockImplementation(() => ({ @@ -245,26 +173,26 @@ describe('Schema Inspector feature toggle', () => { }); it('closes panel and shows reopen button when close button clicked', async () => { - const { queryByText, getByLabelText, findByText } = render(); + const { queryByText, getByText, findByText } = render(); expect(queryByText('No schema information available')).toBeInTheDocument(); - const closeButton = getByLabelText('Close schema inspector'); + const closeButton = getByText('Schema inspector'); await act(async () => fireEvent.click(closeButton)); expect(queryByText('No schema information available')).not.toBeInTheDocument(); - expect(await findByText('Inspect schema')).toBeInTheDocument(); + expect(await findByText('Schema inspector')).toBeInTheDocument(); }); - it('reopens panel when inspect schema button clicked after closing', async () => { - const { queryByText, getByLabelText, getByText } = render(); + it('reopens panel when Open schema inspector button clicked after closing', async () => { + const { queryByText, getByText } = render(); - const closeButton = getByLabelText('Close schema inspector'); + const closeButton = getByText('Schema inspector'); await act(async () => fireEvent.click(closeButton)); expect(queryByText('No schema information available')).not.toBeInTheDocument(); - const reopenButton = getByText('Inspect schema'); + const reopenButton = getByText('Schema inspector'); await act(async () => fireEvent.click(reopenButton)); expect(queryByText('No schema information available')).toBeInTheDocument(); @@ -300,7 +228,7 @@ describe('Schema Inspector feature toggle', () => { it('does not render panel or button', () => { const { queryByText } = render(); - expect(queryByText('Inspect schema')).not.toBeInTheDocument(); + expect(queryByText('Schema inspector')).not.toBeInTheDocument(); expect(queryByText('No schema information available')).not.toBeInTheDocument(); }); }); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx index 361d748b716..a59b46b232b 100644 --- a/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx +++ b/public/app/features/expressions/components/SqlExpressions/SqlExpr.tsx @@ -1,15 +1,15 @@ import { css, cx } from '@emotion/css'; -import { useMemo, useRef, useEffect, useState, lazy, Suspense, useCallback } from 'react'; +import { lazy, Suspense, useCallback, useEffect, useMemo, useState } from 'react'; import { useMeasure } from 'react-use'; import AutoSizer from 'react-virtualized-auto-sizer'; -import { SelectableValue, GrafanaTheme2 } from '@grafana/data'; -import { t, Trans } from '@grafana/i18n'; -import { SQLEditor, CompletionItemKind, LanguageDefinition, TableIdentifier } from '@grafana/plugin-ui'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; +import { Trans } from '@grafana/i18n'; +import { CompletionItemKind, LanguageDefinition, SQLEditor, TableIdentifier } from '@grafana/plugin-ui'; import { reportInteraction } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema/dist/esm/index'; import { formatSQL } from '@grafana/sql'; -import { useStyles2, Stack, Button, Modal } from '@grafana/ui'; +import { Button, Stack, useStyles2 } from '@grafana/ui'; import { ExpressionQueryEditorProps } from '../../ExpressionQueryEditor'; import { SqlExpressionQuery } from '../../types'; @@ -20,27 +20,10 @@ import { getSqlCompletionProvider } from './CompletionProvider/sqlCompletionProv import { useSQLExplanations } from './GenAI/hooks/useSQLExplanations'; import { useSQLSuggestions } from './GenAI/hooks/useSQLSuggestions'; import { SchemaInspectorPanel } from './SchemaInspector/SchemaInspectorPanel'; +import { SqlExprContextValue, SqlExprProvider } from './SqlExprContext'; +import { SqlQueryActions } from './SqlQueryActions'; import { useSQLSchemas } from './hooks/useSQLSchemas'; -// Lazy load the GenAI components to avoid circular dependencies -const GenAISQLSuggestionsButton = lazy(() => - import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({ - default: module.GenAISQLSuggestionsButton, - })) -); - -const GenAISQLExplainButton = lazy(() => - import('./GenAI/GenAISQLExplainButton').then((module) => ({ - default: module.GenAISQLExplainButton, - })) -); - -const SuggestionsDrawerButton = lazy(() => - import('./GenAI/SuggestionsDrawerButton').then((module) => ({ - default: module.SuggestionsDrawerButton, - })) -); - const GenAISuggestionsDrawer = lazy(() => import('./GenAI/GenAISuggestionsDrawer').then((module) => ({ default: module.GenAISuggestionsDrawer, @@ -55,7 +38,6 @@ const GenAIExplanationDrawer = lazy(() => // Account for Monaco editor's border to prevent clipping const EDITOR_BORDER_ADJUSTMENT = 2; // 1px border on top and bottom -const EDITOR_HEIGHT = 300; export interface SqlExprProps { refIds: Array>; @@ -93,14 +75,10 @@ FROM LIMIT 10`; - const [dimensions, setDimensions] = useState({ height: 0 }); - const styles = useStyles2((theme) => getStyles(theme, dimensions.height || EDITOR_HEIGHT)); - const containerRef = useRef(null); const [toolboxRef, toolboxMeasure] = useMeasure(); - const [isExpanded, setIsExpanded] = useState(false); const [isSchemaInspectorOpen, setIsSchemaInspectorOpen] = useState(true); - - const { handleApplySuggestion, handleHistoryUpdate, handleCloseDrawer, handleOpenDrawer, isDrawerOpen, suggestions } = + const styles = useStyles2((theme) => getStyles(theme)); + const { handleApplySuggestion, handleCloseDrawer, handleHistoryUpdate, handleOpenDrawer, isDrawerOpen, suggestions } = useSQLSuggestions(); const { @@ -195,21 +173,6 @@ LIMIT } }, [onRunQuery, refetchSchemas, isSchemaInspectorOpen]); - // Set up resize observer to handle container resizing - useEffect(() => { - if (!containerRef.current) { - return; - } - - const resizeObserver = new ResizeObserver((entries) => { - const { height } = entries[0].contentRect; - setDimensions({ height }); - }); - - resizeObserver.observe(containerRef.current); - return () => resizeObserver.disconnect(); - }, []); - useEffect(() => { // Call the onChange method once so we have access to the initial query in consuming components // But only if expression is empty @@ -236,168 +199,122 @@ LIMIT return () => document.removeEventListener('keydown', handleKeyDown, true); }, [executeQuery]); - const renderToolbox = (formatQuery: () => void) => ( -
- -
- ); + const contextValue: SqlExprContextValue = { + // Explanations + explanation, + isExplanationOpen, + shouldShowViewExplanation, + handleExplain, + handleOpenExplanation, + handleCloseExplanation, + // Suggestions + suggestions, + isDrawerOpen, + handleHistoryUpdate, + handleApplySuggestion, + handleOpenDrawer, + handleCloseDrawer, + }; - const renderSQLButtons = () => ( -
- - {isSchemasFeatureEnabled && !isSchemaInspectorOpen && ( - - )} - - - {shouldShowViewExplanation ? ( - - ) : ( - - )} - - - {}} // Noop - history is managed via onHistoryUpdate - onHistoryUpdate={handleHistoryUpdate} - queryContext={queryContext} - refIds={vars} - errorContext={errorContext} // Will be added when error tracking is implemented - // schemas={schemas} // Will be added when schema extraction is implemented - /> - - - {suggestions.length > 0 && ( - - - - )} -
- ); - - const renderSQLEditor = (width?: number, height?: number) => ( - <> -
- {renderSQLButtons()} -
( + + + {isSchemasFeatureEnabled && ( + + )} + + ); + + const renderMainContent = () => ( +
+
+ + {({ width, height }) => ( - {({ formatQuery }) => renderToolbox(formatQuery)} + {({ formatQuery }) => ( +
+ +
+ )}
-
- {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( -
- setIsSchemaInspectorOpen(false)} - /> -
)} -
+
- - - - - - - + {isSchemaInspectorOpen && isSchemasFeatureEnabled && ( +
+ +
+ )} +
); - const renderStandaloneEditor = () => ( - - {({ width, height }) => ( - - {({ formatQuery }) => renderToolbox(formatQuery)} - - )} - + const renderSQLEditor = () => ( + + {renderButtons()} + {renderMainContent()} + ); return ( - <> - {renderSQLEditor()} - {isExpanded && ( - setIsExpanded(false)} - > - {renderStandaloneEditor()} - - )} - + +
+ {renderSQLEditor()} + + + + + + +
+
); }; -const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ - sqlContainer: css({ - display: 'grid', - gap: theme.spacing(1), - gridTemplateRows: 'auto 1fr', - gridTemplateAreas: ` - "buttons" - "content" - `, +const getStyles = (theme: GrafanaTheme2) => ({ + mainContainer: css({ + marginTop: theme.spacing(0.5), }), - contentContainer: css({ - gridArea: 'content', + minHeight: '250px', + height: '100%', + resize: 'vertical', + overflow: 'hidden', + display: 'grid', - gap: theme.spacing(1), gridTemplateColumns: '1fr 0fr', gridTemplateAreas: '"editor schema"', [theme.transitions.handleMotion('no-preference')]: { @@ -408,67 +325,22 @@ const getStyles = (theme: GrafanaTheme2, editorHeight: number) => ({ }), contentContainerWithSchema: css({ gridTemplateColumns: '1fr 1fr', + gap: theme.spacing(1), }), editorContainer: css({ gridArea: 'editor', - height: editorHeight, // Use dynamic height from ResizeObserver - resize: 'vertical', - overflow: 'auto', - minHeight: '100px', - }), - modal: css({ - width: '95vw', - height: '95vh', - }), - modalContent: css({ height: '100%', - paddingTop: 0, - }), - // This is NOT ideal. The alternative is to expose SQL buttons as a separate component, - // Then consume them in ExpressionQueryEditor. This requires a lot of refactoring and - // can be prioritized later. - sqlButtons: css({ - gridArea: 'buttons', - justifySelf: 'end', - transform: `translateY(${theme.spacing(-4)})`, - marginBottom: theme.spacing(-4), // Prevent affecting editor position - zIndex: 10, // Ensure buttons appear above other elements - position: 'relative', // Required for z-index to work - display: 'flex', - alignItems: 'center', - gap: theme.spacing(1), + width: '100%', + overflow: 'auto', }), schemaInspector: css({ gridArea: 'schema', - height: editorHeight, + height: '100%', overflow: 'hidden', minWidth: 0, - }), - schemaInspectorOpen: css({ border: `1px solid ${theme.colors.border.weak}`, borderRadius: theme.shape.radius.default, }), - schemaFields: css({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(1), - padding: theme.spacing(1), - maxHeight: '120px', - overflowY: 'auto', - }), - fieldItem: css({ - display: 'flex', - alignItems: 'center', - gap: theme.spacing(0.5), - padding: theme.spacing(1), - backgroundColor: theme.colors.background.secondary, - borderRadius: theme.shape.radius.default, - border: `1px solid ${theme.colors.border.weak}`, - fontSize: theme.typography.bodySmall.fontSize, - }), - responseContainer: css({ - padding: theme.spacing(2), - }), }); async function fetchFields(identifier: TableIdentifier, queries: DataQuery[]) { diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx new file mode 100644 index 00000000000..f95128b5af6 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.test.tsx @@ -0,0 +1,88 @@ +import { render, screen } from 'test/test-utils'; + +import { SqlExprContextValue, SqlExprProvider, useSqlExprContext } from './SqlExprContext'; + +describe('SqlExprContext', () => { + const mockContextValue: SqlExprContextValue = { + explanation: 'Test explanation', + isExplanationOpen: false, + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleOpenExplanation: jest.fn(), + handleCloseExplanation: jest.fn(), + suggestions: ['suggestion1', 'suggestion2'], + isDrawerOpen: false, + handleHistoryUpdate: jest.fn(), + handleApplySuggestion: jest.fn(), + handleOpenDrawer: jest.fn(), + handleCloseDrawer: jest.fn(), + }; + + describe('SqlExprProvider', () => { + it('renders children correctly', () => { + render( + +
Test Child
+
+ ); + + expect(screen.getByText('Test Child')).toBeInTheDocument(); + }); + + it('provides context value to children', () => { + const TestConsumer = () => { + const context = useSqlExprContext(); + return
{context.explanation}
; + }; + + render( + + + + ); + + expect(screen.getByText('Test explanation')).toBeInTheDocument(); + }); + }); + + describe('useSqlExprContext', () => { + it('throws error when used outside provider', () => { + const TestComponent = () => { + useSqlExprContext(); + return
Should not render
; + }; + + // Suppress console.error for this test + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + + expect(() => { + render(); + }).toThrow('useSqlExprContext must be used within SqlExprProvider'); + + consoleSpy.mockRestore(); + }); + + it('returns context value when used inside provider', () => { + const TestComponent = () => { + const context = useSqlExprContext(); + return ( +
+ Explanation: {context.explanation} + Suggestions: {context.suggestions.length} + Is Drawer Open: {context.isDrawerOpen.toString()} +
+ ); + }; + + render( + + + + ); + + expect(screen.getByText('Explanation: Test explanation')).toBeInTheDocument(); + expect(screen.getByText('Suggestions: 2')).toBeInTheDocument(); + expect(screen.getByText('Is Drawer Open: false')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx new file mode 100644 index 00000000000..e05ccff80b6 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlExprContext.tsx @@ -0,0 +1,38 @@ +import { createContext, useContext, ReactNode } from 'react'; + +export interface SqlExprContextValue { + // Explanations + explanation: string; + isExplanationOpen: boolean; + shouldShowViewExplanation: boolean; + handleExplain: (explanation: string) => void; + handleOpenExplanation: () => void; + handleCloseExplanation: () => void; + + // Suggestions + suggestions: string[]; + isDrawerOpen: boolean; + handleHistoryUpdate: (suggestions: string[]) => void; + handleApplySuggestion: (suggestion: string) => string; + handleOpenDrawer: () => void; + handleCloseDrawer: () => void; +} + +const SqlExprContext = createContext(null); + +export const useSqlExprContext = () => { + const context = useContext(SqlExprContext); + if (!context) { + throw new Error('useSqlExprContext must be used within SqlExprProvider'); + } + return context; +}; + +interface SqlExprProviderProps { + children: ReactNode; + value: SqlExprContextValue; +} + +export const SqlExprProvider = ({ children, value }: SqlExprProviderProps) => { + return {children}; +}; diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx new file mode 100644 index 00000000000..93e3285ed83 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.test.tsx @@ -0,0 +1,137 @@ +import { fireEvent, render, waitFor } from 'test/test-utils'; + +import { SqlExprContextValue } from './SqlExprContext'; +import { SqlQueryActions, SqlQueryActionsProps } from './SqlQueryActions'; + +jest.mock('@grafana/ui', () => ({ + ...jest.requireActual('@grafana/ui'), + useStyles2: jest.fn().mockImplementation(() => ({})), +})); + +// Mock lazy loaded GenAI components +jest.mock('./GenAI/GenAISQLSuggestionsButton', () => ({ + GenAISQLSuggestionsButton: ({ currentQuery, initialQuery }: { currentQuery: string; initialQuery: string }) => { + const text = !currentQuery || currentQuery === initialQuery ? 'Generate suggestion' : 'Improve query'; + return
{text}
; + }, +})); + +jest.mock('./GenAI/GenAISQLExplainButton', () => ({ + GenAISQLExplainButton: () =>
Explain query
, +})); + +jest.mock('./GenAI/SuggestionsDrawerButton', () => ({ + SuggestionsDrawerButton: () =>
Suggestions Badge
, +})); + +// Mock SqlExprContext +const mockContextValue: SqlExprContextValue = { + handleOpenExplanation: jest.fn(), + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleOpenDrawer: jest.fn(), + suggestions: [], + explanation: '', + isExplanationOpen: false, + isDrawerOpen: false, + handleApplySuggestion: jest.fn(), + handleCloseDrawer: jest.fn(), + handleCloseExplanation: jest.fn(), +}; + +jest.mock('./SqlExprContext', () => ({ + useSqlExprContext: () => mockContextValue, + SqlExprProvider: ({ children }: { children: React.ReactNode }) => <>{children}, +})); + +describe('SqlQueryActions', () => { + const defaultProps: SqlQueryActionsProps = { + executeQuery: jest.fn(), + currentQuery: `SELECT * FROM A LIMIT 10`, + queryContext: {}, + refIds: ['A'], + initialQuery: `SELECT * FROM A LIMIT 10`, + errorContext: [], + }; + + beforeEach(() => { + jest.clearAllMocks(); + // Reset mock context to default values + Object.assign(mockContextValue, { + handleOpenExplanation: jest.fn(), + shouldShowViewExplanation: false, + handleExplain: jest.fn(), + handleHistoryUpdate: jest.fn(), + handleOpenDrawer: jest.fn(), + suggestions: [], + explanation: '', + isExplanationOpen: false, + isDrawerOpen: false, + handleApplySuggestion: jest.fn(), + handleCloseDrawer: jest.fn(), + handleCloseExplanation: jest.fn(), + }); + }); + + it('renders GenAI buttons with empty expression', async () => { + const customProps = { ...defaultProps, currentQuery: '' }; + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders GenAI buttons with non-empty expression', async () => { + const { findByText } = render(); + expect(await findByText('Generate suggestion')).toBeInTheDocument(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders "Improve query" when currentQuery differs from initialQuery', async () => { + const customProps = { + ...defaultProps, + currentQuery: 'SELECT * FROM A WHERE value > 10', + }; + const { findByText } = render(); + expect(await findByText('Improve query')).toBeInTheDocument(); + }); + + it('renders View explanation button when shouldShowViewExplanation is true', async () => { + mockContextValue.shouldShowViewExplanation = true; + + const { findByText } = render(); + expect(await findByText('View explanation')).toBeInTheDocument(); + }); + + it('renders Explain query button when shouldShowViewExplanation is false', async () => { + mockContextValue.shouldShowViewExplanation = false; + + const { findByText } = render(); + expect(await findByText('Explain query')).toBeInTheDocument(); + }); + + it('renders SuggestionsDrawerButton when there are suggestions', async () => { + mockContextValue.suggestions = ['suggestion1', 'suggestion2']; + + const { findByTestId } = render(); + expect(await findByTestId('suggestions-badge')).toBeInTheDocument(); + }); + + it('does not render SuggestionsDrawerButton when there are no suggestions', async () => { + mockContextValue.suggestions = []; + + const { queryByTestId } = render(); + expect(await waitFor(() => queryByTestId('suggestions-badge'))).not.toBeInTheDocument(); + }); + + it('calls handleOpenExplanation when View explanation is clicked', async () => { + const mockHandleOpen = jest.fn(); + mockContextValue.shouldShowViewExplanation = true; + mockContextValue.handleOpenExplanation = mockHandleOpen; + + const { findByText } = render(); + const button = await findByText('View explanation'); + fireEvent.click(button); + expect(mockHandleOpen).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx new file mode 100644 index 00000000000..a96a82a4e72 --- /dev/null +++ b/public/app/features/expressions/components/SqlExpressions/SqlQueryActions.tsx @@ -0,0 +1,91 @@ +import { lazy, Suspense } from 'react'; + +import { t, Trans } from '@grafana/i18n'; +import { Button, Stack } from '@grafana/ui'; + +import { useSqlExprContext } from './SqlExprContext'; + +// Lazy load the GenAI components to avoid circular dependencies +const GenAISQLSuggestionsButton = lazy(() => + import('./GenAI/GenAISQLSuggestionsButton').then((module) => ({ + default: module.GenAISQLSuggestionsButton, + })) +); + +const GenAISQLExplainButton = lazy(() => + import('./GenAI/GenAISQLExplainButton').then((module) => ({ + default: module.GenAISQLExplainButton, + })) +); + +const SuggestionsDrawerButton = lazy(() => + import('./GenAI/SuggestionsDrawerButton').then((module) => ({ + default: module.SuggestionsDrawerButton, + })) +); + +export interface SqlQueryActionsProps { + executeQuery: () => void; + currentQuery: string; + queryContext: Record; + refIds: string[]; + initialQuery: string; + errorContext: string[]; +} + +export const SqlQueryActions = ({ + executeQuery, + currentQuery, + queryContext, + refIds, + initialQuery, + errorContext, +}: SqlQueryActionsProps) => { + const { + handleOpenExplanation, + shouldShowViewExplanation, + handleExplain, + handleHistoryUpdate, + handleOpenDrawer, + suggestions, + } = useSqlExprContext(); + return ( + + + + {shouldShowViewExplanation ? ( + + ) : ( + + )} + + + {}} // Noop - history is managed via onHistoryUpdate + onHistoryUpdate={handleHistoryUpdate} + queryContext={queryContext} + refIds={refIds} + errorContext={errorContext} // Will be added when error tracking is implemented + // schemas={schemas} // Will be added when schema extraction is implemented + /> + + {suggestions.length > 0 && ( + + + + )} + + ); +}; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index d97ce1128ba..807d6fbfa4b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7872,23 +7872,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "Close schema inspector" - }, "sql-expr": { "button-run-query": "Run query", - "modal-title": "SQL Editor", "tooltip-experimental": "SQL Expressions LLM integration is experimental. Please report any issues to the Grafana team." }, "sql-schema": { - "close-schema-inspector": "Close schema inspector", "error-title": "Error", - "inspect-button": "Inspect schema", "loading": "Loading schema information...", "no-data-title": "No schema information available", "no-fields-desc": "This query returned no schema information.", "no-fields-title": "No schema information", - "query-error-title": "Query error" + "query-error-title": "Query error", + "schema-inspector": "Schema inspector" }, "threshold": { "label-input": "Input" From 5585595c16f633a2a1e6ac8efefc6f7ff0e22013 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Sat, 20 Dec 2025 00:41:06 +0000 Subject: [PATCH 16/80] I18n: Download translations from Crowdin (#115604) 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 | 135 +++++++++++++++++----------- public/locales/de-DE/grafana.json | 135 +++++++++++++++++----------- public/locales/es-ES/grafana.json | 135 +++++++++++++++++----------- public/locales/fr-FR/grafana.json | 135 +++++++++++++++++----------- public/locales/hu-HU/grafana.json | 135 +++++++++++++++++----------- public/locales/id-ID/grafana.json | 135 +++++++++++++++++----------- public/locales/it-IT/grafana.json | 135 +++++++++++++++++----------- public/locales/ja-JP/grafana.json | 135 +++++++++++++++++----------- public/locales/ko-KR/grafana.json | 135 +++++++++++++++++----------- public/locales/nl-NL/grafana.json | 135 +++++++++++++++++----------- public/locales/pl-PL/grafana.json | 135 +++++++++++++++++----------- public/locales/pt-BR/grafana.json | 135 +++++++++++++++++----------- public/locales/pt-PT/grafana.json | 135 +++++++++++++++++----------- public/locales/ru-RU/grafana.json | 135 +++++++++++++++++----------- public/locales/sv-SE/grafana.json | 135 +++++++++++++++++----------- public/locales/tr-TR/grafana.json | 135 +++++++++++++++++----------- public/locales/zh-Hans/grafana.json | 135 +++++++++++++++++----------- public/locales/zh-Hant/grafana.json | 135 +++++++++++++++++----------- 18 files changed, 1476 insertions(+), 954 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 575bba19ca1..84cc597980b 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -2688,6 +2688,35 @@ "text-federated": "Federované", "text-provisioned": "Zajištěno" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Zdroj dat", @@ -3749,6 +3778,7 @@ "text": "Nebyly nalezeny žádné výsledky pro váš dotaz" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6222,7 +6252,7 @@ "title-someone-else-has-updated-this-dashboard": "Tuto nástěnku aktualizoval jiný uživatel", "would-still-dashboard": "Chcete přesto tuto nástěnku uložit?" }, - "save-and-overwrite": "Uložit a přepsat" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7241,9 @@ "time-range-label": "Zamknout časový rozsah" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Pro tip: {{proTip}}" }, @@ -7691,39 +7724,6 @@ }, "share-span": "Sdílet" }, - "span-filters": { - "aria-label-select-max-span-operator": "Vyberte operátor max. rozsahu", - "aria-label-select-min-span-operator": "Vyberte operátor min. rozsahu", - "aria-label-select-service-name": "Vyberte název služby", - "aria-label-select-service-name-operator": "Vyberte operátor názvu služby", - "aria-label-select-span-name": "Vyberte název rozsahu", - "aria-label-select-span-name-operator": "Vyberte operátor názvu rozsahu", - "ariaLabel-select-max-span-duration": "Vyberte maximální dobu trvání", - "ariaLabel-select-min-span-duration": "Vyberte minimální dobu trvání rozsahu", - "label-collapse": "Filtry rozsahu", - "label-duration": "Doba trvání", - "label-service-name": "Název služby", - "label-span-name": "Název rozsahu", - "label-tags": "Tagy", - "placeholder-all-service-names": "Všechny názvy služby", - "placeholder-all-span-names": "Všechny názvy rozsahu", - "tooltip-collapse": "Filtrujte svá rozpětí níže. Filtry můžete používat tak dlouho, dokud nezúžíte výsledná rozpětí na několik vybraných, které vás nejvíce zajímají.", - "tooltip-duration": "Filtrovat podle doby trvání. Akceptované jednotky jsou {{units}}", - "tooltip-tags": "Filtrujte podle tagů, tagů procesů nebo polí protokolu ve vybraném rozpětí." - }, - "span-filters-tags": { - "aria-label-add-tag": "Přidat tag", - "aria-label-input-tag-value": "Vstupní hodnota tagu", - "aria-label-remove-tag": "Odebrat tag", - "aria-label-select-tag-key": "Vyberte klíč tagu", - "aria-label-select-tag-operator": "Vyberte operátor tagu", - "aria-label-select-tag-value": "Vyberte hodnotu tagu", - "placeholder-select-tag": "Vyberte tag", - "placeholder-select-value": "Vyberte hodnotu", - "placeholder-tag-value": "Hodnota tagu", - "tooltip-add-tag": "Přidat tag", - "tooltip-remove-tag": "Odebrat tag" - }, "span-flame-graph": { "flame-graph": "Graf plamene" }, @@ -7922,23 +7922,18 @@ "label-upsample": "Zvýšit vzorkovací frekvenci", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Spustit dotaz", - "modal-title": "Editor SQL", "tooltip-experimental": "Integrace LLM pro výrazy jazyka SQL je experimentální. Jakékoli problémy nahlaste týmu Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Vstup" @@ -8013,11 +8008,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9454,7 @@ "name-unit": "Jednotka", "name-value-name": "Název hodnoty", "name-y-axis-scale": "Měřítko osy Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9492,6 +9484,18 @@ "label-all": "Vše", "label-hidden": "Skryté", "label-single": "Jednorázový" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11299,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12083,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Zobrazit podrobnosti", "loading-finished-job": "Načítání dokončené úlohy…", @@ -12180,6 +12192,9 @@ "webhook-last-event": "Poslední událost:", "webhook-url": "Zobrazit webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Zpět na úložiště", "cleaning-up-resources": "Čištění zdrojů úložiště", @@ -12284,6 +12299,9 @@ "tooltip-unhealthy-repository": "Nelze stáhnout nezdravé úložiště" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12321,15 @@ }, "warning-title-default": "Varování", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Doba trvání tohoto procesu závisí na počtu zapojených zdrojů.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12348,7 @@ "step-finish": "Vybrat další nastavení", "step-synchronize": "Synchronizovat s externím úložištěm", "sync-description": "Synchronizujte zdroje s externím úložištěm. Po tomto jednorázovém kroku budou všechny budoucí aktualizace automaticky uloženy do úložiště a zajištěny zpět do instance.", + "sync-option-migrate-resources": "", "title-bootstrap": "Vyberte, co chcete synchronizovat", "title-connect": "Připojit k externímu úložišti", "title-finish": "Vybrat další nastavení", @@ -12564,16 +12588,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 9df0dd8a2da..ef5f649123d 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Verbunden", "text-provisioned": "Bereitgestellt" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Datenquelle", @@ -3717,6 +3746,7 @@ "text": "Keine Ergebnisse für deine Abfrage gefunden" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Eine andere Person hat dieses Dashboard aktualisiert", "would-still-dashboard": "Möchten Sie dieses Dashboard trotzdem speichern?" }, - "save-and-overwrite": "'Speichern und überschreiben'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Zeitbereich sperren" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Profitipp: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Teilen" }, - "span-filters": { - "aria-label-select-max-span-operator": "Höchstspannen-Operator auswählen", - "aria-label-select-min-span-operator": "Mindestspannen-Operator auswählen", - "aria-label-select-service-name": "Dienstname auswählen", - "aria-label-select-service-name-operator": "Dienstnamen-Operator auswählen", - "aria-label-select-span-name": "Spannen-Name auswählen", - "aria-label-select-span-name-operator": "Spannen-Namen-Operator auswählen", - "ariaLabel-select-max-span-duration": "Dauer der max. Spanne auswählen", - "ariaLabel-select-min-span-duration": "Dauer der min. Spanne auswählen", - "label-collapse": "Spannenfilter", - "label-duration": "Dauer", - "label-service-name": "Dienstname", - "label-span-name": "Spannen-Name", - "label-tags": "Tags", - "placeholder-all-service-names": "Alle Dienstnamen", - "placeholder-all-span-names": "Alle Spannen-Namen", - "tooltip-collapse": "Filtern Sie unten Ihre Spannen. Sie können weiterhin Filter anwenden, bis Sie Ihre resultierenden Spannen auf die wenigen eingegrenzt haben, die für Sie am meisten von Interesse sind.", - "tooltip-duration": "Nach Dauer filtern. Zulässige Einheiten sind {{units}}", - "tooltip-tags": "Filtern Sie in Ihren Spannen nach Tags, Prozess-Tags oder Log-Feldern." - }, - "span-filters-tags": { - "aria-label-add-tag": "Tag hinzufügen", - "aria-label-input-tag-value": "Tag-Wert eingeben", - "aria-label-remove-tag": "Tag entfernen", - "aria-label-select-tag-key": "Tag-Key auswählen", - "aria-label-select-tag-operator": "Tag-Operator auswählen", - "aria-label-select-tag-value": "Tag-Wert auswählen", - "placeholder-select-tag": "Tag auswählen", - "placeholder-select-value": "Wert auswählen", - "placeholder-tag-value": "Tag-Wert", - "tooltip-add-tag": "Tag hinzufügen", - "tooltip-remove-tag": "Tag entfernen" - }, "span-flame-graph": { "flame-graph": "Flammendiagramm" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10 s, 1 min., 30 min., 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Abfrage ausführen", - "modal-title": "SQL Editor", "tooltip-experimental": "Die Integration des SQL Expressions LLM ist experimentell. Bitte melden Sie Probleme dem Grafana-Team." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Eingabe" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Einheit", "name-value-name": "Wertname", "name-y-axis-scale": "Y-Achsenskala", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Alles", "label-hidden": "Ausgeblendet", "label-single": "Einzeln" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Details anzeigen", "loading-finished-job": "Fertiger Auftrag wird geladen …", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Letztes Ereignis:", "webhook-url": "Webhook anzeigen" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Zurück zu den Repositorys", "cleaning-up-resources": "Bereinigen von Repository-Ressourcen", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Ein fehlerhaftes Repository kann nicht abgerufen werden" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Warnung", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Die Dauer dieses Prozesses hängt von der Anzahl der betroffenen Ressourcen ab.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Zusätzliche Einstellungen auswählen", "step-synchronize": "Mit externem Speicher synchronisieren", "sync-description": "Synchronisieren Sie Ressourcen mit externem Speicher. Nach diesem einmaligen Schritt werden alle zukünftigen Updates automatisch im Repository gespeichert und wieder in der Instanz bereitgestellt.", + "sync-option-migrate-resources": "", "title-bootstrap": "Wählen Sie aus, was synchronisiert wird", "title-connect": "Mit externem Speicher verbinden", "title-finish": "Zusätzliche Einstellungen auswählen", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 1c8c641cfad..443dbefbc39 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federado", "text-provisioned": "Provisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Fuente de datos", @@ -3717,6 +3746,7 @@ "text": "No se han encontrado resultados para tu consulta" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Otra persona ha actualizado este dashboard", "would-still-dashboard": "¿Seguro que quieres guardar este dashboard?" }, - "save-and-overwrite": "«Guardar y sobrescribir»" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Bloquear el intervalo de tiempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Consejo profesional: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Compartir" }, - "span-filters": { - "aria-label-select-max-span-operator": "Seleccionar operador de intervalo máximo", - "aria-label-select-min-span-operator": "Seleccionar operador de intervalo mínimo", - "aria-label-select-service-name": "Seleccionar nombre de servicio", - "aria-label-select-service-name-operator": "Seleccionar operador de nombre de servicio", - "aria-label-select-span-name": "Seleccionar nombre de intervalo", - "aria-label-select-span-name-operator": "Seleccionar operador de nombre de intervalo", - "ariaLabel-select-max-span-duration": "Seleccionar duración máxima del intervalo", - "ariaLabel-select-min-span-duration": "Seleccionar duración mínima del intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duración", - "label-service-name": "Nombre de servicio", - "label-span-name": "Nombre del intervalo", - "label-tags": "Etiquetas", - "placeholder-all-service-names": "Todos los nombres de servicios", - "placeholder-all-span-names": "Todos los nombres de intervalo", - "tooltip-collapse": "Filtre sus intervalos a continuación. Puede seguir aplicando filtros hasta que haya reducido los intervalos resultantes a los que más le interesen.", - "tooltip-duration": "Filtra por duración. Las unidades aceptadas son {{units}}", - "tooltip-tags": "Filtra por etiquetas, etiquetas de proceso o campos de logs en tus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Añadir etiqueta", - "aria-label-input-tag-value": "Introducir valor de la etiqueta", - "aria-label-remove-tag": "Quitar etiqueta", - "aria-label-select-tag-key": "Seleccionar clave de etiqueta", - "aria-label-select-tag-operator": "Seleccionar operador de etiqueta", - "aria-label-select-tag-value": "Seleccionar valor de etiqueta", - "placeholder-select-tag": "Seleccionar etiqueta", - "placeholder-select-value": "Seleccionar valor", - "placeholder-tag-value": "Valor de etiqueta", - "tooltip-add-tag": "Añadir etiqueta", - "tooltip-remove-tag": "Quitar etiqueta" - }, "span-flame-graph": { "flame-graph": "Gráfico de llama" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumentar tamaño", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Ejecutar consulta", - "modal-title": "Editor de SQL", "tooltip-experimental": "La integración de LLM de expresiones SQL es experimental. Avisa de cualquier problema al equipo de Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unidad", "name-value-name": "Nombre del valor", "name-y-axis-scale": "Escala del eje Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Todo", "label-hidden": "Oculto", "label-single": "Único" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ver detalles", "loading-finished-job": "Cargando trabajo finalizado...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Ver webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Volver a los repositorios", "cleaning-up-resources": "Limpiando los recursos del repositorio", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "No se puede extraer un repositorio que no está en buen estado" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Advertencia", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La duración de este proceso depende del número de recursos involucrados.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Elegir ajustes adicionales", "step-synchronize": "Sincronizar con un almacenamiento externo", "sync-description": "Sincroniza los recursos con un almacenamiento externo. Después de este paso único, todas las actualizaciones futuras se guardarán automáticamente en el repositorio y se aprovisionarán de nuevo en la instancia.", + "sync-option-migrate-resources": "", "title-bootstrap": "Elegir qué sincronizar", "title-connect": "Conectar a un almacenamiento externo", "title-finish": "Elegir ajustes adicionales", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index d2faa93f8e5..1d98e007593 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Fédéré", "text-provisioned": "Mis en service" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Source de données", @@ -3717,6 +3746,7 @@ "text": "Aucun résultat n'a été trouvé pour votre requête" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Quelqu’un d’autre a mis à jour ce tableau de bord", "would-still-dashboard": "Voulez-vous toujours enregistrer ce tableau de bord ?" }, - "save-and-overwrite": "« Enregistrer et écraser »" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Verrouiller la période temporelle" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Conseil de pro : {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Partager" }, - "span-filters": { - "aria-label-select-max-span-operator": "Sélectionner l’opérateur de la durée maximale", - "aria-label-select-min-span-operator": "Sélectionner l’opérateur de la durée minimale", - "aria-label-select-service-name": "Sélectionner le nom de service", - "aria-label-select-service-name-operator": "Sélectionner l’opérateur de nom de service", - "aria-label-select-span-name": "Sélectionner le nom de la durée", - "aria-label-select-span-name-operator": "Sélectionner l’opérateur de nom de la durée", - "ariaLabel-select-max-span-duration": "Sélectionner la durée maximale de segment", - "ariaLabel-select-min-span-duration": "Sélectionner la durée minimale de segment", - "label-collapse": "Filtres de durée", - "label-duration": "Durée", - "label-service-name": "Nom du service", - "label-span-name": "Nom de la durée", - "label-tags": "Étiquettes", - "placeholder-all-service-names": "Tous les noms de service", - "placeholder-all-span-names": "Tous les noms de durée", - "tooltip-collapse": "Filtrez vos plages ci-dessous. Vous pouvez continuer à appliquer des filtres jusqu’à ce que vous ayez réduit votre plage de résultats à ceux qui vous intéressent le plus.", - "tooltip-duration": "Filtrer par durée. Les unités acceptées sont {{units}}", - "tooltip-tags": "Filtrez par balises, balises de processus ou champs de journal dans vos durées." - }, - "span-filters-tags": { - "aria-label-add-tag": "Ajouter une balise", - "aria-label-input-tag-value": "Saisir la valeur de la balise", - "aria-label-remove-tag": "Supprimer la balise", - "aria-label-select-tag-key": "Sélectionner la clé de la balise", - "aria-label-select-tag-operator": "Sélectionner l’opérateur de la balise", - "aria-label-select-tag-value": "Sélectionner la valeur de la balise", - "placeholder-select-tag": "Choisir une balise", - "placeholder-select-value": "Sélectionner une valeur", - "placeholder-tag-value": "Valeur de la balise", - "tooltip-add-tag": "Ajouter une balise", - "tooltip-remove-tag": "Supprimer la balise" - }, "span-flame-graph": { "flame-graph": "Graphique de flamme" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Sur-échantillonner", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Exécuter la requête", - "modal-title": "Éditeur SQL", "tooltip-experimental": "L’intégration des expressions SQL avec les LLM est expérimentale. Merci de signaler tout problème à l’équipe Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrée" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unité", "name-value-name": "Nom de la valeur", "name-y-axis-scale": "Échelle de l’axe Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Tous", "label-hidden": "Masqué", "label-single": "Unique" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Afficher les détails", "loading-finished-job": "Chargement de mission terminée…", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Dernier événement :", "webhook-url": "Voir le webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Retour aux référentiels", "cleaning-up-resources": "Nettoyage des ressources du référentiel", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Impossible de fusionner un référentiel en mauvais état" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Avertissement", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La durée de ce processus dépend du nombre de ressources impliquées.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Choisir des paramètres supplémentaires", "step-synchronize": "Synchroniser avec un stockage externe", "sync-description": "Synchronisez les ressources avec un stockage externe. Après cette étape unique, toutes les futures mises à jour seront automatiquement enregistrées dans le référentiel et mises en service dans l’instance.", + "sync-option-migrate-resources": "", "title-bootstrap": "Choisir ce qui doit être synchronisé", "title-connect": "Se connecter à un stockage externe", "title-finish": "Choisir des paramètres supplémentaires", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 0c8c164742b..bfe9d3e9542 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Összevont", "text-provisioned": "Kiépítve" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Adatforrás", @@ -3717,6 +3746,7 @@ "text": "Nincs találat a lekérdezésre" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Valaki más frissítette ezt az irányítópultot", "would-still-dashboard": "Biztosan menti ezt az irányítópultot?" }, - "save-and-overwrite": "„Mentés és felülírás”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Időtartomány zárolása" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Megosztás" }, - "span-filters": { - "aria-label-select-max-span-operator": "Max. terjedelem operátor kijelölése", - "aria-label-select-min-span-operator": "Válasszon operátort a min. terjedelemhez", - "aria-label-select-service-name": "Válasszon szolgáltatásnevet", - "aria-label-select-service-name-operator": "Válasszon operátort a szolgáltatásnévhez", - "aria-label-select-span-name": "Válasszon terjedelemnevet", - "aria-label-select-span-name-operator": "Terjedelemnév operátor kijelölése", - "ariaLabel-select-max-span-duration": "Maximális időtartam kiválasztása", - "ariaLabel-select-min-span-duration": "Minimális időtartam kiválasztása", - "label-collapse": "Terjedelemszűrők", - "label-duration": "Időtartam", - "label-service-name": "Szolgáltatásnév", - "label-span-name": "Terjedelemnév", - "label-tags": "Címkék", - "placeholder-all-service-names": "Összes szolgáltatásnév", - "placeholder-all-span-names": "Összes terjedelemnév", - "tooltip-collapse": "Alább szűrheti a terjedelmeket. Folytathatja a szűrők alkalmazását, amíg a kapott terjedelmeket a leginkább keresett néhány terjedelemre nem szűkíti.", - "tooltip-duration": "Szűrés időtartam szerint. Elfogadott mértékegységek: {{units}}", - "tooltip-tags": "Szűrés címkék, folyamatcímkék vagy naplómezők alapján a terjedelmeiben." - }, - "span-filters-tags": { - "aria-label-add-tag": "Címke hozzáadása", - "aria-label-input-tag-value": "Bemeneti címke értéke", - "aria-label-remove-tag": "Címke eltávolítása", - "aria-label-select-tag-key": "Címkekulcs kijelölése", - "aria-label-select-tag-operator": "Címkeoperátor kijelölése", - "aria-label-select-tag-value": "Címkeérték kijelölése", - "placeholder-select-tag": "Címke kijelölése", - "placeholder-select-value": "Érték kijelölése", - "placeholder-tag-value": "Címke értéke", - "tooltip-add-tag": "Címke hozzáadása", - "tooltip-remove-tag": "Címke eltávolítása" - }, "span-flame-graph": { "flame-graph": "Lángdiagram" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Mintasűrűség növelése", "tooltip-s-m-h": "10 mp., 1 p., 30 p., 1 ó." }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Lekérdezés futtatása", - "modal-title": "SQL-szerkesztő", "tooltip-experimental": "Az SQL-kifejezések LLM-integrációja kísérleti jellegű. Kérjük, jelentse az esetleges problémákat a Grafana csapatának." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Bemenet" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Egység", "name-value-name": "Érték neve", "name-y-axis-scale": "Y tengely skálája", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatikus", "placeholder-axis-width": "Automatikus", "placeholder-decimals": "Automatikus", @@ -9422,6 +9414,18 @@ "label-all": "Összes", "label-hidden": "Rejtett", "label-single": "Különálló" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Részletek megtekintése", "loading-finished-job": "Befejezett feladat betöltése…", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Legutóbbi esemény:", "webhook-url": "Webkapocs megtekintése" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Vissza az adattárakhoz", "cleaning-up-resources": "Adattári erőforrások tisztítása", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Nem lehet beolvasni egy nem megfelelő állapotú adattárat" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Figyelmeztetés", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A folyamat időtartama az érintett erőforrások számától függ.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Válassza ki a további beállításokat", "step-synchronize": "Szinkronizálás külső tárolóval", "sync-description": "Erőforrások szinkronizálása külső tárolóval. Ezután az egyszeri lépés után az összes jövőbeli frissítés automatikusan mentve lesz az adattárba, és vissza lesz építve a példányba.", + "sync-option-migrate-resources": "", "title-bootstrap": "Válassza ki, mit szeretne szinkronizálni", "title-connect": "Csatlakozás külső tárolóhoz", "title-finish": "Válassza ki a további beállításokat", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 66ad5574fb6..86b18767abb 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "Federasi", "text-provisioned": "Disediakan" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Sumber data", @@ -3701,6 +3730,7 @@ "text": "Hasil untuk kueri Anda tidak ditemukan" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "Orang lain telah memperbarui dasbor ini", "would-still-dashboard": "Ingin tetap menyimpan dasbor ini?" }, - "save-and-overwrite": "'Simpan dan timpa'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "Kunci rentang waktu" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Kiat Pro: {{proTip}}" }, @@ -7616,39 +7649,6 @@ }, "share-span": "Bagikan" }, - "span-filters": { - "aria-label-select-max-span-operator": "Pilih operator rentang maksimum", - "aria-label-select-min-span-operator": "Pilih operator rentang min", - "aria-label-select-service-name": "Pilih nama layanan", - "aria-label-select-service-name-operator": "Pilih operator nama layanan", - "aria-label-select-span-name": "Pilih nama rentang", - "aria-label-select-span-name-operator": "Pilih operator nama rentang", - "ariaLabel-select-max-span-duration": "Pilih durasi rentang maksimum", - "ariaLabel-select-min-span-duration": "Pilih durasi rentang minimum", - "label-collapse": "Filter Rentang", - "label-duration": "Durasi", - "label-service-name": "Nama layanan", - "label-span-name": "Nama rentang", - "label-tags": "Tag", - "placeholder-all-service-names": "Semua nama layanan", - "placeholder-all-span-names": "Semua nama rentang", - "tooltip-collapse": "Filter rentang Anda di bawah ini. Anda dapat terus menerapkan filter hingga Anda mempersempit rentang hasil Anda menjadi beberapa pilihan yang paling Anda minati.", - "tooltip-duration": "Filter menurut durasi. Unit yang diterima adalah {{units}}", - "tooltip-tags": "Filter berdasarkan tag, tag proses, atau bidang log di rentang Anda." - }, - "span-filters-tags": { - "aria-label-add-tag": "Tambah tag", - "aria-label-input-tag-value": "Masukkan nilai tag", - "aria-label-remove-tag": "Hapus tag", - "aria-label-select-tag-key": "Pilih kunci tag", - "aria-label-select-tag-operator": "Pilih operator tag", - "aria-label-select-tag-value": "Pilih nilai tag", - "placeholder-select-tag": "Pilih tag", - "placeholder-select-value": "Pilih nilai", - "placeholder-tag-value": "Nilai tag", - "tooltip-add-tag": "Tambah tag", - "tooltip-remove-tag": "Hapus tag" - }, "span-flame-graph": { "flame-graph": "Grafik api" }, @@ -7847,23 +7847,18 @@ "label-upsample": "Tingkatkan sample", "tooltip-s-m-h": "10 dtk, 1 mnt, 30 mnt, 1 jm" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Jalankan kueri", - "modal-title": "Editor SQL", "tooltip-experimental": "Integrasi LLM Ekspresi SQL bersifat eksperimental. Harap laporkan masalah apa pun kepada tim Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Input" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "Unit", "name-value-name": "Nama nilai", "name-y-axis-scale": "Skala sumbu Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Otomatis", "placeholder-axis-width": "Otomatis", "placeholder-decimals": "Otomatis", @@ -9387,6 +9379,18 @@ "label-all": "Semua", "label-hidden": "Tersembunyi", "label-single": "Tunggal" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Lihat detail", "loading-finished-job": "Memuat pekerjaan yang sudah selesai...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "Peristiwa Terakhir:", "webhook-url": "Lihat Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Kembali ke repositori", "cleaning-up-resources": "Membersihkan sumber daya repositori", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "Tidak dapat menerapkan pull pada repositori yang tidak sehat" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "Peringatan", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Durasi proses ini bergantung pada jumlah sumber daya yang terlibat.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "Pilih pengaturan tambahan", "step-synchronize": "Sinkronkan dengan penyimpanan eksternal", "sync-description": "Sinkronkan sumber daya dengan penyimpanan eksternal. Setelah langkah satu kali ini, semua pembaruan mendatang akan disimpan secara otomatis ke repositori dan disediakan kembali ke instans.", + "sync-option-migrate-resources": "", "title-bootstrap": "Pilih item yang akan disinkronkan", "title-connect": "Hubungkan ke penyimpanan eksternal", "title-finish": "Pilih pengaturan tambahan", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 76898afd674..976d81b2f37 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federato", "text-provisioned": "Fornito" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Sorgente dati", @@ -3717,6 +3746,7 @@ "text": "Nessun risultato trovato per la ricerca" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Qualcun altro ha aggiornato questa dashboard", "would-still-dashboard": "Desideri comunque salvare questa dashboard?" }, - "save-and-overwrite": "\"Salva e sovrascrivi\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Blocca intervallo di tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Suggerimento pro: {{proTip}} " }, @@ -7641,39 +7674,6 @@ }, "share-span": "Condividi" }, - "span-filters": { - "aria-label-select-max-span-operator": "Seleziona l'operatore di intervallo massimo", - "aria-label-select-min-span-operator": "Seleziona operatore di intervallo minimo", - "aria-label-select-service-name": "Seleziona nome servizio", - "aria-label-select-service-name-operator": "Seleziona operatore nome servizio", - "aria-label-select-span-name": "Seleziona nome intervallo", - "aria-label-select-span-name-operator": "Seleziona l'operatore del nome dell'intervallo", - "ariaLabel-select-max-span-duration": "Seleziona la durata dell'intervallo massimo", - "ariaLabel-select-min-span-duration": "Seleziona la durata dell'intervallo minimo", - "label-collapse": "Filtri intervallo", - "label-duration": "Durata", - "label-service-name": "Nome del servizio", - "label-span-name": "Nome intervallo", - "label-tags": "Tag", - "placeholder-all-service-names": "Tutti i nomi dei servizi", - "placeholder-all-span-names": "Tutti i nomi degli intervalli", - "tooltip-collapse": "Filtra i tuoi intervalli qui sotto. Puoi continuare ad applicare i filtri fino a quando non avrai ristretto gli intervalli risultanti ai pochi selezionati a cui sei più interessato.", - "tooltip-duration": "Filtra per durata. Le unità accettate sono {{units}}", - "tooltip-tags": "Filtra per tag, tag di processo o campi di registro nei tuoi intervalli." - }, - "span-filters-tags": { - "aria-label-add-tag": "Aggiungi tag", - "aria-label-input-tag-value": "Valore del tag di inserimento", - "aria-label-remove-tag": "Rimuovi tag", - "aria-label-select-tag-key": "Seleziona chiave tag", - "aria-label-select-tag-operator": "Seleziona operatore tag", - "aria-label-select-tag-value": "Seleziona valore tag", - "placeholder-select-tag": "Seleziona tag", - "placeholder-select-value": "Seleziona valore", - "placeholder-tag-value": "Valore del tag", - "tooltip-add-tag": "Aggiungi tag", - "tooltip-remove-tag": "Rimuovi tag" - }, "span-flame-graph": { "flame-graph": "Grafico a fiamma" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumenta campionamento", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Esegui query", - "modal-title": "Editor SQL", "tooltip-experimental": "L'integrazione LLM delle Espressioni SQL è sperimentale. Segnala eventuali problemi al team Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Inserisci" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unità", "name-value-name": "Nome del valore", "name-y-axis-scale": "Scala asse Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatico", "placeholder-axis-width": "Automatico", "placeholder-decimals": "Automatico", @@ -9422,6 +9414,18 @@ "label-all": "Tutti", "label-hidden": "Nascosto", "label-single": "Singolo" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Visualizza dettagli", "loading-finished-job": "Caricamento attività terminata in corso...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Ultimo evento:", "webhook-url": "Visualizza webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Torna ai repository", "cleaning-up-resources": "Pulizia delle risorse del repository", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Impossibile eseguire il pull di un repository non integro" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Attenzione", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "La durata di questo processo dipende dal numero di risorse coinvolte.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Scegli impostazioni aggiuntive", "step-synchronize": "Sincronizza con la memoria esterna", "sync-description": "Sincronizza le risorse con la memoria esterna. Dopo questo passaggio una tantum, tutti gli aggiornamenti futuri verranno salvati automaticamente nel repository e ripristinati nell'istanza.", + "sync-option-migrate-resources": "", "title-bootstrap": "Scegli cosa sincronizzare", "title-connect": "Connetti a una memoria esterna", "title-finish": "Scegli impostazioni aggiuntive", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index cebb4cd6bad..9bfbc78a21e 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "フェデレーション", "text-provisioned": "プロビジョニング済み" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "データソース", @@ -3701,6 +3730,7 @@ "text": "クエリに一致する結果が見つかりませんでした。" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "他のユーザーがこのダッシュボードを更新しました", "would-still-dashboard": "このダッシュボードの保存を続行しますか?" }, - "save-and-overwrite": "「保存して上書き」" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "時間範囲をロック" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "プロのヒント:{{proTip}} " }, @@ -7616,39 +7649,6 @@ }, "share-span": "共有" }, - "span-filters": { - "aria-label-select-max-span-operator": "最大スパン演算子を選択", - "aria-label-select-min-span-operator": "最小スパン演算子を選択", - "aria-label-select-service-name": "サービス名を選択", - "aria-label-select-service-name-operator": "サービス名演算子を選択", - "aria-label-select-span-name": "スパン名を選択", - "aria-label-select-span-name-operator": "スパン名演算子を選択", - "ariaLabel-select-max-span-duration": "最大スパン期間を選択", - "ariaLabel-select-min-span-duration": "最小スパン期間を選択", - "label-collapse": "スパンフィルター", - "label-duration": "継続時間", - "label-service-name": "サービス名", - "label-span-name": "スパン名", - "label-tags": "タグ", - "placeholder-all-service-names": "すべてのサービス名", - "placeholder-all-span-names": "すべてのスパン名", - "tooltip-collapse": "以下のスパンを絞り込みます。最も関心のある少数のスパンに絞り込むまで、フィルターを適用し続けることができます。", - "tooltip-duration": "期間で絞り込みます。使用可能な単位:{{units}}", - "tooltip-tags": "スパン内のタグ、プロセスタグ、またはログフィールドで絞り込みます。" - }, - "span-filters-tags": { - "aria-label-add-tag": "タグを追加", - "aria-label-input-tag-value": "タグ値を入力", - "aria-label-remove-tag": "タグを削除", - "aria-label-select-tag-key": "タグキーを選択", - "aria-label-select-tag-operator": "タグ演算子を選択", - "aria-label-select-tag-value": "タグ値を選択", - "placeholder-select-tag": "タグを選択", - "placeholder-select-value": "値を選択", - "placeholder-tag-value": "タグ値", - "tooltip-add-tag": "タグを追加", - "tooltip-remove-tag": "タグを削除" - }, "span-flame-graph": { "flame-graph": "フレームグラフ" }, @@ -7847,23 +7847,18 @@ "label-upsample": "アップサンプリング", "tooltip-s-m-h": "10秒、1分、30分、1時間" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "クエリを実行", - "modal-title": "SQLエディター", "tooltip-experimental": "SQL Expressions LLMの統合は実験的です。問題が発生した場合は、Grafanaチームに報告してください。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "入力" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "単位", "name-value-name": "値の名前", "name-y-axis-scale": "Y軸スケール", + "name-y-bucket-scale": "", "placeholder-axis-label": "自動", "placeholder-axis-width": "自動", "placeholder-decimals": "自動", @@ -9387,6 +9379,18 @@ "label-all": "すべて", "label-hidden": "非表示", "label-single": "単体" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "詳細を表示", "loading-finished-job": "完了したジョブを読み込み中...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "最新イベント:", "webhook-url": "Webhookを表示" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "リポジトリに戻る", "cleaning-up-resources": "リポジトリリソースのクリーンアップ中", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "問題のあるリポジトリをプルできません" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "このプロセスの所要時間は、関連するリソースの数によって異なります。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "追加設定を選択", "step-synchronize": "外部ストレージと同期", "sync-description": "リソースを外部ストレージと同期します。この一度限りの手順が完了すると、その後のすべての更新は自動的にリポジトリに保存され、インスタンスにプロビジョニングされます。", + "sync-option-migrate-resources": "", "title-bootstrap": "同期する内容を選択", "title-connect": "外部ストレージに接続", "title-finish": "追加設定を選択", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 07e410e67e1..bd4103a1de1 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "연합됨", "text-provisioned": "프로비저닝됨" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "데이터 소스", @@ -3701,6 +3730,7 @@ "text": "쿼리에 대해 찾은 결과 없음" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "다른 사람이 이 대시보드를 업데이트했습니다", "would-still-dashboard": "그래도 이 대시보드를 저장하시겠어요?" }, - "save-and-overwrite": "'저장 및 덮어쓰기'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "시간 범위 잠그기" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "유용한 팁: {{proTip}} " }, @@ -7616,39 +7649,6 @@ }, "share-span": "공유" }, - "span-filters": { - "aria-label-select-max-span-operator": "최대 스팬 연산자 선택", - "aria-label-select-min-span-operator": "최소 스팬 연산자 선택", - "aria-label-select-service-name": "서비스 이름 선택", - "aria-label-select-service-name-operator": "서비스 이름 연산자 선택", - "aria-label-select-span-name": "스팬 이름 선택", - "aria-label-select-span-name-operator": "스팬 이름 연산자 선택", - "ariaLabel-select-max-span-duration": "최대 스팬 기간 선택", - "ariaLabel-select-min-span-duration": "최소 스팬 기간 선택", - "label-collapse": "스팬 필터", - "label-duration": "지속 시간", - "label-service-name": "서비스 이름", - "label-span-name": "스팬 이름", - "label-tags": "태그", - "placeholder-all-service-names": "모든 서비스 이름", - "placeholder-all-span-names": "모든 스팬 이름", - "tooltip-collapse": "아래에서 스팬을 필터링합니다. 결과 스팬을 가장 관심 있는 몇 가지로 좁힐 때까지 필터를 계속 적용할 수 있습니다.", - "tooltip-duration": "지속 시간을 기준으로 필터링합니다. 허용되는 단위는 {{units}}입니다", - "tooltip-tags": "스팬의 태그, 프로세스 태그 또는 로그 필드를 기준으로 필터링합니다." - }, - "span-filters-tags": { - "aria-label-add-tag": "태그 추가", - "aria-label-input-tag-value": "태그 값 입력", - "aria-label-remove-tag": "태그 제거", - "aria-label-select-tag-key": "태그 키 선택", - "aria-label-select-tag-operator": "태그 연산자 선택", - "aria-label-select-tag-value": "태그 값 선택", - "placeholder-select-tag": "태그 선택", - "placeholder-select-value": "값 선택", - "placeholder-tag-value": "태그 값", - "tooltip-add-tag": "태그 추가", - "tooltip-remove-tag": "태그 제거" - }, "span-flame-graph": { "flame-graph": "불꽃 그래프" }, @@ -7847,23 +7847,18 @@ "label-upsample": "업샘플", "tooltip-s-m-h": "10초, 1분, 30분, 1시간" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "쿼리 실행", - "modal-title": "SQL 편집기", "tooltip-experimental": "SQL 표현식 LLM 통합 기능은 실험 단계입니다. 문제가 발생하면 Grafana 팀에 보고해 주세요." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "입력" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "단위", "name-value-name": "값 이름", "name-y-axis-scale": "Y축 스케일", + "name-y-bucket-scale": "", "placeholder-axis-label": "자동", "placeholder-axis-width": "자동", "placeholder-decimals": "자동", @@ -9387,6 +9379,18 @@ "label-all": "전체", "label-hidden": "숨김", "label-single": "단일" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "세부 정보 보기", "loading-finished-job": "완료된 작업 로딩 중...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "마지막 이벤트:", "webhook-url": "웹훅 보기" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "리포지토리로 돌아가기", "cleaning-up-resources": "리포지토리 리소스 정리 및 삭제 중", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "상태가 좋지 않은 리포지토리를 가져올 수 없습니다" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "경고", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "이 프로세스의 지속 시간은 관련된 리소스 수에 따라 달라집니다.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "추가 설정 선택", "step-synchronize": "외부 스토리지와 동기화", "sync-description": "외부 스토리지와 리소스를 동기화합니다. 이 일회성 단계 후에는 향후 모든 업데이트가 자동으로 리포지토리에 저장되고 인스턴스에 다시 프로비저닝됩니다.", + "sync-option-migrate-resources": "", "title-bootstrap": "동기화할 항목 선택", "title-connect": "외부 스토리지에 연결", "title-finish": "추가 설정 선택", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index 237fbb74953..d5386284647 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federatief", "text-provisioned": "Provisioned" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Gegevensbron", @@ -3717,6 +3746,7 @@ "text": "Geen resultaten gevonden voor je zoekopdracht" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Iemand anders heeft dit dashboard bijgewerkt", "would-still-dashboard": "Wil je dit dashboard nog steeds opslaan?" }, - "save-and-overwrite": "'Opslaan en overschrijven'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Tijdsbereik vergrendelen" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}} " }, @@ -7641,39 +7674,6 @@ }, "share-span": "Delen" }, - "span-filters": { - "aria-label-select-max-span-operator": "Operator voor maximale span selecteren", - "aria-label-select-min-span-operator": "Selecteer een operator voor een minimumspan", - "aria-label-select-service-name": "Servicenaam selecteren", - "aria-label-select-service-name-operator": "Servicenaam operator selecteren", - "aria-label-select-span-name": "Selecteer een spannaam", - "aria-label-select-span-name-operator": " Operator voor een spannaam selecteren", - "ariaLabel-select-max-span-duration": "Selecteer een maximale spanduur", - "ariaLabel-select-min-span-duration": "Selecteer een minimale spanduur", - "label-collapse": "Spanfilters", - "label-duration": "Duur", - "label-service-name": "Servicenaam", - "label-span-name": "Spannaam", - "label-tags": "Labels", - "placeholder-all-service-names": "Alle servicenamen", - "placeholder-all-span-names": "Alle spannamen", - "tooltip-collapse": "Filter je bereik hieronder. Je kunt filters blijven toepassen totdat je het resulterende bereik hebt beperkt tot de resultaten die het meest interessant voor je zijn.", - "tooltip-duration": "Filteren op duur. Geaccepteerde eenheden zijn {{units}}", - "tooltip-tags": "Filteren op labels, labels verwerken of logvelden in je spans." - }, - "span-filters-tags": { - "aria-label-add-tag": "Label toevoegen", - "aria-label-input-tag-value": "Labelwaarde invoeren", - "aria-label-remove-tag": "Label verwijderen", - "aria-label-select-tag-key": "Labelsleutel selecteren", - "aria-label-select-tag-operator": "Labeloperator selecteren", - "aria-label-select-tag-value": "Labelwaarde selecteren", - "placeholder-select-tag": "Label selecteren", - "placeholder-select-value": "Waarde selecteren", - "placeholder-tag-value": "Labelwaarde", - "tooltip-add-tag": "Label toevoegen", - "tooltip-remove-tag": "Label verwijderen" - }, "span-flame-graph": { "flame-graph": "Vlamgrafiek" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Upsample", "tooltip-s-m-h": "10s, 1m, 30m, 1u" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Query uitvoeren", - "modal-title": "SQL-editor", "tooltip-experimental": "SQL Expressions LLM-integratie is experimenteel. Meld eventuele problemen aan het Grafana-team." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Invoer" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Eenheid", "name-value-name": "Naam van waarde:", "name-y-axis-scale": "Y-as schaal", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatisch", "placeholder-axis-width": "Automatisch", "placeholder-decimals": "Automatisch", @@ -9422,6 +9414,18 @@ "label-all": "Alle", "label-hidden": "Verborgen", "label-single": "Enkel" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Details bekijken", "loading-finished-job": "Voltooide taak laden...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Laatste gebeurtenis:", "webhook-url": "Webhook bekijken" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Terug naar repositories", "cleaning-up-resources": "Bronnen van repository opschonen", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Kan geen ongezonde repository ophalen" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Waarschuwing", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "De duur van dit proces is afhankelijk van het aantal betrokken bronnen.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Kies aanvullende instellingen", "step-synchronize": "Synchroniseren met externe opslag", "sync-description": "Bronnen synchroniseren met externe opslag. Na deze eenmalige stap worden alle toekomstige updates automatisch opgeslagen in de repository en opnieuw ingericht in de instantie.", + "sync-option-migrate-resources": "", "title-bootstrap": "Kies wat je wilt synchroniseren", "title-connect": "Verbinden met externe opslag", "title-finish": "Kies aanvullende instellingen", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index 6ff40c3ab1f..ad8f9b19b6a 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -2688,6 +2688,35 @@ "text-federated": "Federacja", "text-provisioned": "Po aprowizacji" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Źródło danych", @@ -3749,6 +3778,7 @@ "text": "Nie znaleziono wyników dla tego zapytania" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6222,7 +6252,7 @@ "title-someone-else-has-updated-this-dashboard": "Ktoś inny zaktualizował ten pulpit", "would-still-dashboard": "Czy nadal chcesz zapisać ten pulpit?" }, - "save-and-overwrite": "„Zapisz i zastąp”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7241,9 @@ "time-range-label": "Zablokuj zakres czasu" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Wskazówka: {{proTip}}" }, @@ -7691,39 +7724,6 @@ }, "share-span": "Udostępnij" }, - "span-filters": { - "aria-label-select-max-span-operator": "Wybierz operator maksymalnego zakresu", - "aria-label-select-min-span-operator": "Wybierz operator minimalnego zakresu", - "aria-label-select-service-name": "Wybierz nazwę usługi", - "aria-label-select-service-name-operator": "Wybierz operator nazwy usługi", - "aria-label-select-span-name": "Wybierz nazwę zakresu", - "aria-label-select-span-name-operator": "Wybierz operator nazwy zakresu", - "ariaLabel-select-max-span-duration": "Wybierz maksymalny czas trwania zakresu", - "ariaLabel-select-min-span-duration": "Wybierz minimalny czas trwania zakresu", - "label-collapse": "Filtry zakresu", - "label-duration": "Czas trwania", - "label-service-name": "Nazwa usługi", - "label-span-name": "Nazwa zakresu", - "label-tags": "Znaczniki", - "placeholder-all-service-names": "Wszystkie nazwy usług", - "placeholder-all-span-names": "Wszystkie nazwy zakresów", - "tooltip-collapse": "Odfiltruj zakresy poniżej. Możesz dodawać filtry, aż zawęzisz zakres wyników do kilku najbardziej interesujących.", - "tooltip-duration": "Filtrowanie według czasu trwania. Akceptowane jednostki: {{units}}", - "tooltip-tags": "Filtruj według tagów, tagów procesu lub pól logów w swoich zakresach." - }, - "span-filters-tags": { - "aria-label-add-tag": "Dodaj tag", - "aria-label-input-tag-value": "Wpisz wartość tagu", - "aria-label-remove-tag": "Usuń tag", - "aria-label-select-tag-key": "Wybierz klucz tagu", - "aria-label-select-tag-operator": "Wybierz operator tagu", - "aria-label-select-tag-value": "Wybierz wartość tagu", - "placeholder-select-tag": "Wybierz tag", - "placeholder-select-value": "Wybierz wartość", - "placeholder-tag-value": "Wartość tagu", - "tooltip-add-tag": "Dodaj tag", - "tooltip-remove-tag": "Usuń tag" - }, "span-flame-graph": { "flame-graph": "Wykres płomienia" }, @@ -7922,23 +7922,18 @@ "label-upsample": "Zwiększ próbkowanie", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Uruchom zapytanie", - "modal-title": "Edytor SQL", "tooltip-experimental": "Integracja LLM z wyrażeniami SQL jest eksperymentalna. Wszelkie problemy zgłaszaj zespołowi Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Wejście" @@ -8013,11 +8008,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9454,7 @@ "name-unit": "Jednostka", "name-value-name": "Nazwa wartości", "name-y-axis-scale": "Skala osi Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automatycznie", "placeholder-axis-width": "Automatycznie", "placeholder-decimals": "Automatycznie", @@ -9492,6 +9484,18 @@ "label-all": "Wszystkie", "label-hidden": "Ukryte", "label-single": "Pojedyncza" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11299,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12083,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Zobacz szczegóły", "loading-finished-job": "Wczytywanie zakończonego zadania…", @@ -12180,6 +12192,9 @@ "webhook-last-event": "Ostatnie zdarzenie:", "webhook-url": "Wyświetl element webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Wróć do repozytoriów", "cleaning-up-resources": "Sprzątanie zasobów repozytorium", @@ -12284,6 +12299,9 @@ "tooltip-unhealthy-repository": "Nie można pobrać danych z niesprawnego repozytorium" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12321,15 @@ }, "warning-title-default": "Ostrzeżenie", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Czas trwania tego procesu zależy od liczby zasobów.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12348,7 @@ "step-finish": "Wybierz dodatkowe ustawienia", "step-synchronize": "Synchronizuj z zewnętrzną pamięcią masową", "sync-description": "Zsynchronizuj zasoby z zewnętrzną pamięcią masową. Po tym jednorazowym kroku wszystkie przyszłe aktualizacje zostaną automatycznie zapisane w repozytorium i ponownie aprowizowane w instancji.", + "sync-option-migrate-resources": "", "title-bootstrap": "Wybierz, co chcesz zsynchronizować", "title-connect": "Połączenie z zewnętrzną pamięcią masową", "title-finish": "Wybierz dodatkowe ustawienia", @@ -12564,16 +12588,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index cbaee920be0..8ad480fc30d 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federado", "text-provisioned": "Provisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Fonte de dados", @@ -3717,6 +3746,7 @@ "text": "Nenhum resultado encontrado para sua consulta" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Outra pessoa atualizou este painel", "would-still-dashboard": "Deseja salvar este painel mesmo assim?" }, - "save-and-overwrite": "\"Salvar e substituir\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Bloquear intervalo de tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Dica de especialistas: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Compartilhar" }, - "span-filters": { - "aria-label-select-max-span-operator": "Selecionar operador de intervalo máximo", - "aria-label-select-min-span-operator": "Selecionar operador de intervalo mínimo", - "aria-label-select-service-name": "Selecionar nome do serviço", - "aria-label-select-service-name-operator": "Selecionar operador de nome do serviço", - "aria-label-select-span-name": "Selecionar nome do intervalo", - "aria-label-select-span-name-operator": "Selecione o operador de nome de intervalo", - "ariaLabel-select-max-span-duration": "Selecionar duração máxima do intervalo", - "ariaLabel-select-min-span-duration": "Selecionar duração mínima do intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duração", - "label-service-name": "Nome do serviço", - "label-span-name": "Nome do intervalo", - "label-tags": "Tags", - "placeholder-all-service-names": "Todos os nomes de serviço", - "placeholder-all-span-names": "Todos os nomes de intervalo", - "tooltip-collapse": "Filtre seus intervalos abaixo. Você pode continuar aplicando filtros até restringir seus intervalos resultantes a um grupo pequeno que contenha aqueles que forem mais pertinentes para você.", - "tooltip-duration": "Filtrar por duração. As unidades aceitas são {{units}}", - "tooltip-tags": "Filtrar por tags, tags de processo ou campos de logs nos seus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Adicionar tag", - "aria-label-input-tag-value": "Valor da tag de entrada", - "aria-label-remove-tag": "Remover tag", - "aria-label-select-tag-key": "Selecionar chave de tag", - "aria-label-select-tag-operator": "Selecionar operador de tag", - "aria-label-select-tag-value": "Selecionar valor da tag", - "placeholder-select-tag": "Selecionar tag", - "placeholder-select-value": "Selecionar valor", - "placeholder-tag-value": "Valor da tag", - "tooltip-add-tag": "Adicionar tag", - "tooltip-remove-tag": "Remover tag" - }, "span-flame-graph": { "flame-graph": "Gráfico de chama" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumentar taxa de amostragem", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Executar consulta", - "modal-title": "Editor de SQL", "tooltip-experimental": "A integração do LLM de expressões SQL está em fase de testes. Informe à equipe do Grafana se surgir algum problema." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unidade", "name-value-name": "Nome do valor", "name-y-axis-scale": "Escala do eixo Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automático", "placeholder-axis-width": "Automático", "placeholder-decimals": "Automático", @@ -9422,6 +9414,18 @@ "label-all": "Tudo", "label-hidden": "Oculto", "label-single": "Única" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Veja os detalhes", "loading-finished-job": "Carregando tarefa concluída…", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Voltar para os repositórios", "cleaning-up-resources": "Limpando recursos do repositório", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Não é possível fazer extração de um repositório instável" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Aviso", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A duração deste processo depende da quantidade de recursos envolvidos.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Escolha configurações adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com armazenamento externo. Após esta única etapa, todas as atualizações futuras serão salvas automaticamente no repositório e provisionadas de volta para a instância.", + "sync-option-migrate-resources": "", "title-bootstrap": "Escolha o que será sincronizado", "title-connect": "Conectar ao armazenamento externo", "title-finish": "Escolha configurações adicionais", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 4e99ebc24f7..3fed004bfdf 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federado", "text-provisioned": "Aprovisionado" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Origem dos dados", @@ -3717,6 +3746,7 @@ "text": "Não foram encontrados resultados para a sua consulta" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Outra pessoa atualizou este painel de controlo", "would-still-dashboard": "Ainda pretende guardar este painel de controlo?" }, - "save-and-overwrite": "\"Guardar e substituir\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Bloquear intervalo de tempo" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Dica Pro: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Partilhar" }, - "span-filters": { - "aria-label-select-max-span-operator": "Selecionar o operador de intervalo máximo", - "aria-label-select-min-span-operator": "Selecionar o operador de intervalo mínimo", - "aria-label-select-service-name": "Selecione o nome do serviço", - "aria-label-select-service-name-operator": "Selecionar o operador do nome do serviço", - "aria-label-select-span-name": "Selecionar o nome do intervalo", - "aria-label-select-span-name-operator": "Selecionar o operador do nome do intervalo", - "ariaLabel-select-max-span-duration": "Selecionar a duração máxima do intervalo", - "ariaLabel-select-min-span-duration": "Selecionar a duração mínima do intervalo", - "label-collapse": "Filtros de intervalo", - "label-duration": "Duração", - "label-service-name": "Nome do serviço", - "label-span-name": "Nome do intervalo", - "label-tags": "Etiquetas", - "placeholder-all-service-names": "Todos os nomes de serviços", - "placeholder-all-span-names": "Todos os nomes de intervalos", - "tooltip-collapse": "Filtre os seus intervalos abaixo. Pode continuar a aplicar filtros até ter limitado os seus períodos resultantes para apenas alguns que lhe interessem mais.", - "tooltip-duration": "Filtrar por duração. As unidades aceites são {{units}}", - "tooltip-tags": "Filtrar por etiquetas, etiquetas de processo ou campos de registo nos seus intervalos." - }, - "span-filters-tags": { - "aria-label-add-tag": "Adicionar etiqueta", - "aria-label-input-tag-value": "Valor da etiqueta de entrada", - "aria-label-remove-tag": "Remover controlo", - "aria-label-select-tag-key": "Selecionar a chave da etiqueta", - "aria-label-select-tag-operator": "Selecionar o operador da etiqueta", - "aria-label-select-tag-value": "Selecionar o valor da etiqueta", - "placeholder-select-tag": "Selecionar a etiqueta", - "placeholder-select-value": "Selecionar valor", - "placeholder-tag-value": "Valor da etiqueta", - "tooltip-add-tag": "Adicionar etiqueta", - "tooltip-remove-tag": "Remover controlo" - }, "span-flame-graph": { "flame-graph": "Gráfico de chama" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Aumentar a quantidade de amostras", "tooltip-s-m-h": "10s, 1m, 30m, 1h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Executar consulta", - "modal-title": "Editor SQL", "tooltip-experimental": "A integração de LLM de expressões SQL é experimental. Comunique quaisquer problemas à equipa da Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Entrada" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Unidade", "name-value-name": "Nome do valor", "name-y-axis-scale": "Escala do eixo Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Automático", "placeholder-axis-width": "Automático", "placeholder-decimals": "Automático", @@ -9422,6 +9414,18 @@ "label-all": "Tudo", "label-hidden": "Oculto", "label-single": "Único" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ver detalhes", "loading-finished-job": "A carregar o trabalho concluído...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Último evento:", "webhook-url": "Visualizar Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Voltar aos repositórios", "cleaning-up-resources": "A limpar recursos do repositório", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Não foi possível obter um repositório que não está em bom estado" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Aviso", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "A duração deste processo depende do número de recursos envolvidos.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Escolha as definições adicionais", "step-synchronize": "Sincronizar com armazenamento externo", "sync-description": "Sincronize recursos com um armazenamento externo. Após este passo único, todas as atualizações futuras serão guardadas automaticamente no repositório e aprovisionadas novamente para a instância.", + "sync-option-migrate-resources": "", "title-bootstrap": "Escolha o que sincronizar", "title-connect": "Ligar a um armazenamento externo", "title-finish": "Escolha as definições adicionais", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 3d29410a1bf..10cd0cdd7bb 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -2688,6 +2688,35 @@ "text-federated": "Федеративная", "text-provisioned": "Подготовлено" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Источник данных", @@ -3749,6 +3778,7 @@ "text": "По вашему запросу ничего не найдено" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6222,7 +6252,7 @@ "title-someone-else-has-updated-this-dashboard": "Дашборд обновлен другим пользователем", "would-still-dashboard": "Все равно сохранить дашборд?" }, - "save-and-overwrite": "'Сохранить и перезаписать'" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7211,6 +7241,9 @@ "time-range-label": "Заблокировать временной диапазон" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Совет: {{proTip}}" }, @@ -7691,39 +7724,6 @@ }, "share-span": "Поделиться" }, - "span-filters": { - "aria-label-select-max-span-operator": "Выбрать оператора макс. диапазона", - "aria-label-select-min-span-operator": "Выбрать оператор мин. диапазона", - "aria-label-select-service-name": "Выбрать название службы", - "aria-label-select-service-name-operator": "Выбрать оператор названия службы", - "aria-label-select-span-name": "Выбрать название диапазона", - "aria-label-select-span-name-operator": "Выбрать оператора названия диапазона", - "ariaLabel-select-max-span-duration": "Выбрать макс. продолжительность интервала", - "ariaLabel-select-min-span-duration": "Выбрать мин. продолжительность интервала", - "label-collapse": "Фильтры диапазонов", - "label-duration": "Длительность", - "label-service-name": "Название службы", - "label-span-name": "Название диапазона", - "label-tags": "Теги", - "placeholder-all-service-names": "Все названия служб", - "placeholder-all-span-names": "Все названия диапазонов", - "tooltip-collapse": "Выполните фильтрацию своих диапазонов ниже. Вы можете продолжать применять фильтры, пока не сузите полученные диапазоны до нескольких наиболее важных для вас вариантов.", - "tooltip-duration": "Фильтр по длительности. Допустимые единицы измерения: {{units}}", - "tooltip-tags": "Фильтр по тегам, тегам процессов или полям журнала в ваших диапазонах." - }, - "span-filters-tags": { - "aria-label-add-tag": "Добавить тег", - "aria-label-input-tag-value": "Ввести значение тега", - "aria-label-remove-tag": "Удалить тег", - "aria-label-select-tag-key": "Выбрать ключ тега", - "aria-label-select-tag-operator": "Выбрать оператор тега", - "aria-label-select-tag-value": "Выбрать значение тега", - "placeholder-select-tag": "Выбрать тег", - "placeholder-select-value": "Выбрать значение", - "placeholder-tag-value": "Значение тега", - "tooltip-add-tag": "Добавить тег", - "tooltip-remove-tag": "Удалить тег" - }, "span-flame-graph": { "flame-graph": "Flame-график" }, @@ -7922,23 +7922,18 @@ "label-upsample": "Увеличить частоту выборки", "tooltip-s-m-h": "10 с, 1 мин, 30 мин, 1 ч" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Выполнить запрос", - "modal-title": "Редактор SQL", "tooltip-experimental": "Интеграция LLM с SQL-выражениями является экспериментальной. При обнаружении проблем свяжитесь с командой Grafana." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Ввод" @@ -8013,11 +8008,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9463,6 +9454,7 @@ "name-unit": "Единица", "name-value-name": "Имя значения", "name-y-axis-scale": "Шкала оси Y", + "name-y-bucket-scale": "", "placeholder-axis-label": "Авто", "placeholder-axis-width": "Авто", "placeholder-decimals": "Авто", @@ -9492,6 +9484,18 @@ "label-all": "Все", "label-hidden": "Скрыты", "label-single": "Один" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11295,9 +11299,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -12074,7 +12083,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Подробнее", "loading-finished-job": "Загрузка завершенного задания...", @@ -12180,6 +12192,9 @@ "webhook-last-event": "Последнее событие:", "webhook-url": "Просмотр веб-перехватчика" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Назад к репозиториям", "cleaning-up-resources": "Очистка ресурсов репозитория", @@ -12284,6 +12299,9 @@ "tooltip-unhealthy-repository": "Невозможно внести изменения в неисправный репозиторий" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12303,10 +12321,15 @@ }, "warning-title-default": "Предупреждение", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Длительность процесса зависит от количества задействованных ресурсов.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12325,6 +12348,7 @@ "step-finish": "Выберите дополнительные параметры", "step-synchronize": "Синхронизировать с внешним хранилищем", "sync-description": "Синхронизируйте ресурсы с внешним хранилищем. После этого разового шага все будущие обновления будут автоматически сохраняться в репозитории и загружаться обратно в экземпляр.", + "sync-option-migrate-resources": "", "title-bootstrap": "Выберите, что синхронизировать", "title-connect": "Подключение к внешнему хранилищу", "title-finish": "Выбор дополнительных параметров", @@ -12564,16 +12588,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index 38943db06ed..faca6a40afc 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Federerade", "text-provisioned": "Provisionerad" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Datakälla", @@ -3717,6 +3746,7 @@ "text": "Inga resultat hittades för din fråga" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Någon annan har uppdaterat denna instrumentpanel", "would-still-dashboard": "Vill du fortfarande spara denna instrumentpanel?" }, - "save-and-overwrite": "”Spara och skriv över”" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Lås tidsintervall" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "ProTip: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Dela" }, - "span-filters": { - "aria-label-select-max-span-operator": "Välj operator för maximalt intervall", - "aria-label-select-min-span-operator": "Välj operator för minsta intervall", - "aria-label-select-service-name": "Välj namn för tjänst", - "aria-label-select-service-name-operator": "Välj operator för tjänstnamn", - "aria-label-select-span-name": "Välj namn på intervall", - "aria-label-select-span-name-operator": "Välj operator för intervallnamn", - "ariaLabel-select-max-span-duration": "Välj maximal varaktighet för tidsspann", - "ariaLabel-select-min-span-duration": "Välj minimal varaktighet för tidsspann", - "label-collapse": "Intervallfilter", - "label-duration": "Varaktighet", - "label-service-name": "Namn på tjänst", - "label-span-name": "Intervallnamn", - "label-tags": "Taggar", - "placeholder-all-service-names": "Alla servicenamn", - "placeholder-all-span-names": "Alla namn på intervall", - "tooltip-collapse": "Filtrera dina spann nedan. Du kan fortsätta att tillämpa filter tills du har begränsat dina resulterande spann till det fåtal som du är mest intresserad av.", - "tooltip-duration": "Filtrera per varaktighet. Accepterade enheter är {{units}}", - "tooltip-tags": "Filtrera efter taggar, processtaggar eller loggfält i dina spår." - }, - "span-filters-tags": { - "aria-label-add-tag": "Lägg till etikett", - "aria-label-input-tag-value": "Ange taggvärde", - "aria-label-remove-tag": "Ta bort tagg", - "aria-label-select-tag-key": "Välj etikettnyckel", - "aria-label-select-tag-operator": "Välj etikettoperator", - "aria-label-select-tag-value": "Välj etikettvärde", - "placeholder-select-tag": "Välj etikett", - "placeholder-select-value": "Välj värde", - "placeholder-tag-value": "Etikettvärde", - "tooltip-add-tag": "Lägg till etikett", - "tooltip-remove-tag": "Ta bort tagg" - }, "span-flame-graph": { "flame-graph": "Flamgraf" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Sampla upp", "tooltip-s-m-h": "10 s, 1 m, 30 m, 1 h" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "Kör fråga", - "modal-title": "SQL-redigerare", "tooltip-experimental": "Integreringen för SQL Expressions LLM är i ett experimentellt skede. Rapportera alla problem du stöter på till Grafana-teamet." }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Ingång" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Enhet", "name-value-name": "Värdenamn", "name-y-axis-scale": "Y-axelns skala", + "name-y-bucket-scale": "", "placeholder-axis-label": "Auto", "placeholder-axis-width": "Auto", "placeholder-decimals": "Auto", @@ -9422,6 +9414,18 @@ "label-all": "Alla", "label-hidden": "Dolt", "label-single": "Singel" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Visa detaljer", "loading-finished-job": "Läser in färdigt jobb …", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Senaste händelsen:", "webhook-url": "Visa webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Tillbaka till lagringsplatserna", "cleaning-up-resources": "Rensa lagringsplatsresurser", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "Det gick inte att hämta en ohälsosam lagringsplats" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Varning", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Hur lång den här processen är beror på hur många resurser som är inblandade.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Välj ytterligare inställningar", "step-synchronize": "Synkronisera med extern lagring", "sync-description": "Synkronisera resurser med extern lagring. Efter det här engångssteget sparas alla framtida uppdateringar automatiskt på lagringsplatsen och provisioneras tillbaka till instansen.", + "sync-option-migrate-resources": "", "title-bootstrap": "Välj vad du vill synkronisera", "title-connect": "Anslut till extern lagring", "title-finish": "Välj ytterligare inställningar", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 07a0b6673ac..ad957bd271f 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -2666,6 +2666,35 @@ "text-federated": "Şirket dışı", "text-provisioned": "Sağlanan" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "Veri kaynağı", @@ -3717,6 +3746,7 @@ "text": "Sorgunuz için sonuç bulunamadı" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6180,7 +6210,7 @@ "title-someone-else-has-updated-this-dashboard": "Başka biri bu panoyu güncelledi", "would-still-dashboard": "Yine de bu panoyu kaydetmek istiyor musunuz?" }, - "save-and-overwrite": "\"Kaydet ve üzerine yaz\"" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7165,6 +7195,9 @@ "time-range-label": "Zaman aralığını kilitle" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "Uzman ipucu: {{proTip}}" }, @@ -7641,39 +7674,6 @@ }, "share-span": "Paylaş" }, - "span-filters": { - "aria-label-select-max-span-operator": "Maksimum zaman aralığı işlecini seçin", - "aria-label-select-min-span-operator": "Minimum zaman aralığı işlecini seçin", - "aria-label-select-service-name": "Hizmet adını seç", - "aria-label-select-service-name-operator": "Hizmet adını işlecini seç", - "aria-label-select-span-name": "Zaman aralığı adı seç", - "aria-label-select-span-name-operator": "Zaman aralığı adı işleci seçin", - "ariaLabel-select-max-span-duration": "Maksimum zaman aralığı süresini seçin", - "ariaLabel-select-min-span-duration": "Minimum zaman aralığı süresini seçin", - "label-collapse": "Zaman aralığı filtreleri", - "label-duration": "Süre", - "label-service-name": "Hizmet adı", - "label-span-name": "Zaman aralığı adı", - "label-tags": "Etiketler", - "placeholder-all-service-names": "Tüm hizmet adları", - "placeholder-all-span-names": "Tüm zaman aralığı adları", - "tooltip-collapse": "Aşağıdaki zaman aralıklarınızı filtreleyin. İlginizi en çok çeken birkaç sonucu daraltana kadar filtre uygulamaya devam edebilirsiniz.", - "tooltip-duration": "Süreye göre filtreleyin. Kabul edilen birimler: {{units}}", - "tooltip-tags": "Zamanlardaki etiketlere, işlem etiketlerine veya günlük kaydı alanlarına göre filtreleyin." - }, - "span-filters-tags": { - "aria-label-add-tag": "Etiket ekle", - "aria-label-input-tag-value": "Etiket değeri girin", - "aria-label-remove-tag": "Etiketi kaldır", - "aria-label-select-tag-key": "Etiket anahtarını seçin", - "aria-label-select-tag-operator": "Etiket işlecini seçin", - "aria-label-select-tag-value": "Etiket değerini seçin", - "placeholder-select-tag": "Etiket seçin", - "placeholder-select-value": "Değer seçin", - "placeholder-tag-value": "Etiket değeri", - "tooltip-add-tag": "Etiket ekle", - "tooltip-remove-tag": "Etiketi kaldır" - }, "span-flame-graph": { "flame-graph": "Alev grafiği" }, @@ -7872,23 +7872,18 @@ "label-upsample": "Örneklemeyi artır", "tooltip-s-m-h": "10 sn, 1 dk, 30 dk, 1 sa" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "", - "modal-title": "", "tooltip-experimental": "" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "Girdi" @@ -7963,11 +7958,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9393,6 +9384,7 @@ "name-unit": "Birim", "name-value-name": "Değer adı", "name-y-axis-scale": "Y ekseni ölçeği", + "name-y-bucket-scale": "", "placeholder-axis-label": "Otomatik", "placeholder-axis-width": "Otomatik", "placeholder-decimals": "Otomatik", @@ -9422,6 +9414,18 @@ "label-all": "Tümü", "label-hidden": "Gizli", "label-single": "Tek" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11203,9 +11207,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11972,7 +11981,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "Ayrıntıları görüntüleyin", "loading-finished-job": "Tamamlanan iş yükleniyor...", @@ -12078,6 +12090,9 @@ "webhook-last-event": "Son Olay:", "webhook-url": "Web Kancasını Görüntüle" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "Depolara geri dön", "cleaning-up-resources": "Depo kaynakları temizleniyor", @@ -12182,6 +12197,9 @@ "tooltip-unhealthy-repository": "İyi durumda olmayan bir depo çekilemedi" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12201,10 +12219,15 @@ }, "warning-title-default": "Uyarı", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "Bu işlemin süresi dâhil olan kaynakların sayısına bağlıdır.", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12223,6 +12246,7 @@ "step-finish": "Ek ayarlar seçin", "step-synchronize": "Harici depolama ile senkronize et", "sync-description": "Kaynakları harici depolama ile senkronize edin. Bu tek seferlik adımdan sonra tüm gelecekteki güncellemeler otomatik olarak depoya kaydedilecek ve örneğe geri sağlanacaktır.", + "sync-option-migrate-resources": "", "title-bootstrap": "Nelerin senkronize edileceğini seçin", "title-connect": "Harici depolamaya bağlan", "title-finish": "Ek ayarlar seçin", @@ -12460,16 +12484,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index a1eb5ea1fae..f02bcda5189 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "联合", "text-provisioned": "已预置" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "数据源", @@ -3701,6 +3730,7 @@ "text": "未找到与您的查询相关的结果" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "其他人已更新此数据面板", "would-still-dashboard": "您仍然要保存此数据面板吗?" }, - "save-and-overwrite": "‘保存并覆盖’" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "锁定时间范围" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "专业提示:{{proTip}}" }, @@ -7616,39 +7649,6 @@ }, "share-span": "共享" }, - "span-filters": { - "aria-label-select-max-span-operator": "选择最大跨度运算符", - "aria-label-select-min-span-operator": "选择最小跨度运算符", - "aria-label-select-service-name": "选择服务名称", - "aria-label-select-service-name-operator": "选择服务名称运算符", - "aria-label-select-span-name": "选择跨度名称", - "aria-label-select-span-name-operator": "选择跨度名称运算符", - "ariaLabel-select-max-span-duration": "选择最大跨度持续时间", - "ariaLabel-select-min-span-duration": "选择最小跨度持续时间", - "label-collapse": "跨度筛选器", - "label-duration": "持续时间", - "label-service-name": "服务名称", - "label-span-name": "跨度名称", - "label-tags": "标签", - "placeholder-all-service-names": "所有服务名称", - "placeholder-all-span-names": "所有跨度名称", - "tooltip-collapse": "在下方筛选您的跨度。您可以继续应用筛选条件,直到将结果跨度缩小到您最感兴趣的少数几个。", - "tooltip-duration": "按持续时间筛选。可接受的单位是 {{units}}", - "tooltip-tags": "根据跨度中的标记、流程标记或日志字段进行筛选。" - }, - "span-filters-tags": { - "aria-label-add-tag": "添加标记", - "aria-label-input-tag-value": "输入标记值", - "aria-label-remove-tag": "移除标记", - "aria-label-select-tag-key": "选择标记键", - "aria-label-select-tag-operator": "选择标记运算符", - "aria-label-select-tag-value": "选择标记值", - "placeholder-select-tag": "选择标记", - "placeholder-select-value": "选择值", - "placeholder-tag-value": "标记值", - "tooltip-add-tag": "添加标记", - "tooltip-remove-tag": "移除标记" - }, "span-flame-graph": { "flame-graph": "火焰图" }, @@ -7847,23 +7847,18 @@ "label-upsample": "升高采样", "tooltip-s-m-h": "10 秒、1 分钟、30 分钟、1 小时" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "运行查询", - "modal-title": "SQL 编辑器", "tooltip-experimental": "SQL 表达式 LLM 集成是实验性的。若有任何问题,请向 Grafana 团队报告。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "输入" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "单位", "name-value-name": "值名称", "name-y-axis-scale": "Y 轴比例", + "name-y-bucket-scale": "", "placeholder-axis-label": "自动", "placeholder-axis-width": "自动", "placeholder-decimals": "自动", @@ -9387,6 +9379,18 @@ "label-all": "全部", "label-hidden": "隐藏", "label-single": "单一" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "查看详情", "loading-finished-job": "正在加载已完成的作业...", @@ -12027,6 +12039,9 @@ "webhook-last-event": "最后一个事件:", "webhook-url": "查看 Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "回到存储库", "cleaning-up-resources": "清理存储库资源", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "无法拉取状态不良的存储库" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "此过程的持续时间取决于所涉及资源的数量。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "选择附加设置", "step-synchronize": "与外部存储同步", "sync-description": "将资源与外部存储同步。完成此一次性步骤后,所有后续更新都将自动保存到存储库中,并预配回实例。", + "sync-option-migrate-resources": "", "title-bootstrap": "选择要同步的内容", "title-connect": "连接到外部存储", "title-finish": "选择附加设置", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index df0a64f1c1e..dc05987a2e6 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -2655,6 +2655,35 @@ "text-federated": "聯合", "text-provisioned": "已佈建" }, + "saved-searches": { + "actions-aria-label": "", + "apply-aria-label": "", + "apply-tooltip": "", + "button-label": "", + "cancel": "", + "default-indicator": "", + "delete": "", + "delete-button": "", + "dropdown-aria-label": "", + "empty-state": "", + "error-load-description": "", + "error-load-title": "", + "error-name-duplicate": "", + "error-name-required": "", + "error-rename-description": "", + "error-rename-title": "", + "error-save-description": "", + "error-save-title": "", + "list-aria-label": "", + "name-placeholder": "", + "remove-default": "", + "rename": "", + "rename-button": "", + "save-button": "", + "save-current-search": "", + "save-disabled-tooltip": "", + "set-default": "" + }, "search": { "property": { "data-source": "資料來源", @@ -3701,6 +3730,7 @@ "text": "未找到您的查詢結果" }, "recently-viewed": { + "clear": "", "empty": "", "title": "" }, @@ -6159,7 +6189,7 @@ "title-someone-else-has-updated-this-dashboard": "其他人已更新此儀表板", "would-still-dashboard": "仍要儲存此儀表板嗎?" }, - "save-and-overwrite": "「儲存並覆寫」" + "save-and-overwrite": "" }, "library-viz-panel-info": { "last-edited": "", @@ -7142,6 +7172,9 @@ "time-range-label": "鎖定時間範圍" } }, + "embedded-panel": { + "powered-by": "" + }, "empty-list-cta": { "pro-tip": "專業提示:{{proTip}}" }, @@ -7616,39 +7649,6 @@ }, "share-span": "分享" }, - "span-filters": { - "aria-label-select-max-span-operator": "選擇最大範圍的運算子", - "aria-label-select-min-span-operator": "選取最小範圍的運算子", - "aria-label-select-service-name": "選取服務名稱", - "aria-label-select-service-name-operator": "選取服務名稱運算子", - "aria-label-select-span-name": "選取範圍名稱", - "aria-label-select-span-name-operator": "選擇範圍名稱運算子", - "ariaLabel-select-max-span-duration": "選擇最大跨度持續時間", - "ariaLabel-select-min-span-duration": "選取最小跨度持續時間", - "label-collapse": "範圍篩選器", - "label-duration": "持續時間", - "label-service-name": "服務名稱", - "label-span-name": "範圍名稱", - "label-tags": "標籤", - "placeholder-all-service-names": "所有服務名稱", - "placeholder-all-span-names": "所有範圍名稱", - "tooltip-collapse": "篩選下方的範圍。您可以繼續套用篩選條件,直到將結果範圍縮小到您最感興趣的幾個範圍。", - "tooltip-duration": "依持續時間篩選。可接受的單元為 {{units}}", - "tooltip-tags": "按範圍中的標記、流程標記或紀錄欄位篩選。" - }, - "span-filters-tags": { - "aria-label-add-tag": "新增標記", - "aria-label-input-tag-value": "輸入標記值", - "aria-label-remove-tag": "移除標記", - "aria-label-select-tag-key": "選擇標記鍵", - "aria-label-select-tag-operator": "選擇標記運算子", - "aria-label-select-tag-value": "選擇標記值", - "placeholder-select-tag": "選擇標記", - "placeholder-select-value": "選擇值", - "placeholder-tag-value": "標記值", - "tooltip-add-tag": "新增標記", - "tooltip-remove-tag": "移除標記" - }, "span-flame-graph": { "flame-graph": "火焰圖" }, @@ -7847,23 +7847,18 @@ "label-upsample": "向上取樣", "tooltip-s-m-h": "10 秒、1 分鐘、30 分鐘、1 小時" }, - "schema-inspector-panel": { - "aria-label-close-schema-inspector": "" - }, "sql-expr": { "button-run-query": "執行查詢", - "modal-title": "SQL 編輯器", "tooltip-experimental": "SQL 運算式 LLM 整合為實驗性功能。如有任何問題,請向 Grafana 團隊回報。" }, "sql-schema": { - "close-schema-inspector": "", "error-title": "", - "inspect-button": "", "loading": "", "no-data-title": "", "no-fields-desc": "", "no-fields-title": "", - "query-error-title": "" + "query-error-title": "", + "schema-inspector": "" }, "threshold": { "label-input": "輸入" @@ -7938,11 +7933,7 @@ "suggestions": { "arc": "", "circular": "", - "no-thresholds": "", - "style": { - "circular": "", - "simple": "" - } + "no-thresholds": "" }, "threshold": "" }, @@ -9358,6 +9349,7 @@ "name-unit": "單位", "name-value-name": "值名稱", "name-y-axis-scale": "Y 軸刻度", + "name-y-bucket-scale": "", "placeholder-axis-label": "自動", "placeholder-axis-width": "自動", "placeholder-decimals": "自動", @@ -9387,6 +9379,18 @@ "label-all": "全部", "label-hidden": "隱藏", "label-single": "單一" + }, + "y-bucket-scale-editor": { + "linear-threshold-description": "", + "linear-threshold-label": "", + "linear-threshold-placeholder": "", + "log-base-label": "", + "scale-options": { + "label-auto": "", + "label-linear": "", + "label-log": "", + "label-symlog": "" + } } }, "help-modal": { @@ -11157,9 +11161,14 @@ }, "visualization-suggestions": { "apply-suggestion-aria-label": "", + "error-loading-some-suggestions": { + "message": "" + }, "error-loading-suggestions": { "message": "", - "title": "" + "plugin-failed": "", + "title": "", + "try-again-button": "" }, "unknown-viz-type": "", "use-this-suggestion": "" @@ -11921,7 +11930,10 @@ "resource-not-found": "", "unsupported-repository-type": "" }, - "inline-secure-values-warning": "", + "instance-sync-deprecation": { + "message": "", + "title": "" + }, "job-status": { "label-view-details": "檢視詳細資料", "loading-finished-job": "正在載入已完成的作業…", @@ -12027,6 +12039,9 @@ "webhook-last-event": "上次事件:", "webhook-url": "檢視 Webhook" }, + "repository-status": { + "error": "" + }, "repository-status-page": { "back-to-repositories": "返回至儲存庫", "cleaning-up-resources": "清理儲存庫資源", @@ -12131,6 +12146,9 @@ "tooltip-unhealthy-repository": "無法拉取狀態不佳的儲存庫" }, "synchronize-step": { + "instance-migrate-resources-description": "", + "migrate-resources-description": "", + "options": "", "repository-error": "", "repository-error-message": "", "repository-unhealthy": "" @@ -12150,10 +12168,15 @@ }, "warning-title-default": "警告", "wizard": { + "alert-intro": "", "alert-point-1": "", - "alert-point-2": "", "alert-point-3": "此流程的持續時間取決於所涉及的資源數量。", "alert-point-4": "", + "alert-point-folder-cleanup": "", + "alert-point-folder-structure": "", + "alert-point-instance-alerts": "", + "alert-point-permissions": "", + "alert-point-unsupported": "", "alert-title": "", "button-cancel": "", "button-cancelling": "", @@ -12172,6 +12195,7 @@ "step-finish": "選擇附加設定", "step-synchronize": "與外部儲存空間同步", "sync-description": "將資源與外部儲存空間同步。在此一次性步驟之後,未來的所有更新都將自動儲存到儲存庫中,並佈建回執行個體。", + "sync-option-migrate-resources": "", "title-bootstrap": "選擇要同步的內容", "title-connect": "連接到外部儲存空間", "title-finish": "選擇附加設定", @@ -12408,16 +12432,21 @@ }, "radialbar": { "config": { + "bar-shape": "", + "bar-shape-flat": "", + "bar-shape-rounded": "", "bar-width": "", "effects": { "bar-glow": "", "center-glow": "", "gradient": "", - "label": "", - "rounded-bars": "", - "spotlight": "", - "spotlight-tooltip": "" + "label": "" }, + "endpoint-marker": "", + "endpoint-marker-description": "", + "endpoint-marker-glow": "", + "endpoint-marker-none": "", + "endpoint-marker-point": "", "segment-count": "", "segment-spacing": "", "shape": "", From 15b5dcda806bbea6e01d7fdc056c3af5facd0eb9 Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon, 22 Dec 2025 04:00:37 -0700 Subject: [PATCH 17/80] Dashboard V1->V2 Conversion: Default multi to true in GroupBy when multi is not defined in v1 (#115656) default multi to true when multi is not defined in v1 --- .../testdata/input/v1beta1.groupby.json | 166 +++++++++++++ .../output/v1beta1.groupby.v0alpha1.json | 172 +++++++++++++ .../output/v1beta1.groupby.v2alpha1.json | 229 +++++++++++++++++ .../output/v1beta1.groupby.v2beta1.json | 232 ++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 4 +- 5 files changed, 802 insertions(+), 1 deletion(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json new file mode 100644 index 00000000000..88527ef6036 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.groupby.json @@ -0,0 +1,166 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "works with group by var", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "name": "Group by", + "type": "groupby" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "groupby test", + "weekStart": "" + } + } \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json new file mode 100644 index 00000000000..463d1864dce --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v0alpha1.json @@ -0,0 +1,172 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": 0 + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "pluginVersion": "12.4.0-pre", + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true, + "refId": "A" + } + ], + "title": "works with group by var", + "type": "timeseries" + } + ], + "preload": false, + "schemaVersion": 42, + "tags": [], + "templating": { + "list": [ + { + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "name": "Group by", + "type": "groupby" + } + ] + }, + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": {}, + "timezone": "browser", + "title": "groupby test", + "weekStart": "" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json new file mode 100644 index 00000000000..58bf555354c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2alpha1.json @@ -0,0 +1,229 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "datasource": { + "type": "grafana", + "uid": "-- Grafana --" + }, + "query": { + "kind": "grafana", + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "works with group by var", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": { + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true + } + }, + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "12.4.0-pre", + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "groupby test", + "variables": [ + { + "kind": "GroupByVariable", + "spec": { + "name": "Group by", + "datasource": { + "type": "prometheus", + "uid": "test-uid" + }, + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "options": [], + "multi": true, + "hide": "dontHide", + "skipUrlSync": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json new file mode 100644 index 00000000000..c2ac16a874c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.groupby.v2beta1.json @@ -0,0 +1,232 @@ +{ + "kind": "DashboardWithAccessInfo", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "groupby-test" + }, + "spec": { + "annotations": [ + { + "kind": "AnnotationQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "grafana", + "version": "v0", + "datasource": { + "name": "-- Grafana --" + }, + "spec": {} + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations \u0026 Alerts", + "builtIn": true, + "legacyOptions": { + "type": "dashboard" + } + } + } + ], + "cursorSync": "Off", + "editable": true, + "elements": { + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "works with group by var", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-uid" + }, + "spec": { + "editorMode": "code", + "expr": "sum(counters_requests)", + "legendFormat": "__auto", + "range": true + } + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "12.4.0-pre", + "spec": { + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "hideZeros": false, + "mode": "single", + "sort": "none" + } + }, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": 0, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + }, + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "barWidthFactor": 0.6, + "drawStyle": "line", + "fillOpacity": 0, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "auto", + "showValues": false, + "spanNulls": false, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + } + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [], + "liveNow": false, + "preload": false, + "tags": [], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "groupby test", + "variables": [ + { + "kind": "GroupByVariable", + "group": "prometheus", + "datasource": { + "name": "test-uid" + }, + "spec": { + "name": "Group by", + "current": { + "text": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ], + "value": [ + "a_legacy_label", + "app", + "exported_instance", + "exported_job" + ] + }, + "options": [], + "multi": true, + "hide": "dontHide", + "skipUrlSync": false + } + } + ] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 92c5d937fe7..224f222ae33 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -1734,7 +1734,9 @@ func buildGroupByVariable(ctx context.Context, varMap map[string]interface{}, co Hide: commonProps.Hide, SkipUrlSync: commonProps.SkipUrlSync, Current: buildVariableCurrent(varMap["current"]), - Multi: getBoolField(varMap, "multi", false), + // We set it to true by default because GroupByVariable + // constructor defaults to multi: true + Multi: getBoolField(varMap, "multi", true), }, } From dd1edf7f16a6d9ff132002eea175a5c1765777e3 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Mon, 22 Dec 2025 21:23:59 +0100 Subject: [PATCH 18/80] Alerting: Fix database-based filtering by labels when rules have no labels (#115657) Alerting: Fix database-based filtering by labels when rules have no labels at all --- pkg/services/ngalert/store/alert_rule_labels_test.go | 12 ++++++------ pkg/services/ngalert/store/alert_rule_test.go | 12 +++++++++--- pkg/services/ngalert/store/json.go | 12 ++++++------ pkg/services/ngalert/store/json_test.go | 12 ++++++------ 4 files changed, 27 insertions(+), 21 deletions(-) diff --git a/pkg/services/ngalert/store/alert_rule_labels_test.go b/pkg/services/ngalert/store/alert_rule_labels_test.go index 9b72d8f00f9..2006c49fd7a 100644 --- a/pkg/services/ngalert/store/alert_rule_labels_test.go +++ b/pkg/services/ngalert/store/alert_rule_labels_test.go @@ -73,42 +73,42 @@ func TestBuildLabelMatcherJSON(t *testing.T) { name: "MySQL MatchEqual with non-empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", wantArgs: []any{"team", "alerting"}, }, { name: "MySQL MatchEqual with empty value", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ? OR JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ? OR JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL)", wantArgs: []any{"team", "", "team"}, }, { name: "MySQL MatchNotEqual", dialect: migrator.NewMysqlDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { name: "PostgreSQL MatchEqual with non-empty value", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: "alerting"}, - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?", wantArgs: []any{"team", "alerting"}, }, { name: "PostgreSQL MatchEqual with empty value", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchEqual, Name: "team", Value: ""}, - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) = ? OR jsonb_extract_path_text(labels::jsonb, ?) IS NULL)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ? OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL)", wantArgs: []any{"team", "", "team"}, }, { name: "PostgreSQL MatchNotEqual", dialect: migrator.NewPostgresDialect(), matcher: &labels.Matcher{Type: labels.MatchNotEqual, Name: "team", Value: "alerting"}, - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { diff --git a/pkg/services/ngalert/store/alert_rule_test.go b/pkg/services/ngalert/store/alert_rule_test.go index 29b82f943ba..f7497c7b05a 100644 --- a/pkg/services/ngalert/store/alert_rule_test.go +++ b/pkg/services/ngalert/store/alert_rule_test.go @@ -2462,6 +2462,12 @@ func TestIntegration_ListAlertRules(t *testing.T) { ruleNonempty := createRule(t, store, ruleGen.With( ruleGen.WithLabels(map[string]string{"empty": "nonempty"}), ruleGen.WithTitle("rule_nonempty"))) + // include a rule with no labels at all, + // to ensure we handle that case correctly. + // JSON functions need to be able to handle null and empty string values. + ruleNoLabels := createRule(t, store, ruleGen.With( + ruleGen.WithLabels(map[string]string{}), + ruleGen.WithTitle("rule_no_labels"))) tc := []struct { name string @@ -2487,7 +2493,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { labelMatchers: labels.Matchers{ func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchNotEqual, "team", "alerting"); return m }(), }, - expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + expectedRules: []*models.AlertRule{ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels}, }, { name: "special characters in labels are handled correctly", @@ -2536,7 +2542,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { labelMatchers: labels.Matchers{ func() *labels.Matcher { m, _ := labels.NewMatcher(labels.MatchEqual, "empty", ""); return m }(), }, - expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty}, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNoLabels}, }, { name: "inequality matcher on non-existent label matches all rules", @@ -2546,7 +2552,7 @@ func TestIntegration_ListAlertRules(t *testing.T) { return m }(), }, - expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty}, + expectedRules: []*models.AlertRule{ruleLower, ruleUpper, ruleSpecial, ruleGlob, ruleSpecialChars, ruleEmpty, ruleNonempty, ruleNoLabels}, }, } diff --git a/pkg/services/ngalert/store/json.go b/pkg/services/ngalert/store/json.go index 7b634246951..e0c71975a76 100644 --- a/pkg/services/ngalert/store/json.go +++ b/pkg/services/ngalert/store/json.go @@ -13,9 +13,9 @@ import ( func jsonEquals(dialect migrator.Dialect, column, key, value string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?))) = ?", column), []any{key, value} + return fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?))) = ?", column), []any{key, value} case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) = ?", column), []any{key, value} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) = ?", column), []any{key, value} default: return "", nil } @@ -25,9 +25,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, var jx string switch dialect.DriverName() { case migrator.MySQL: - jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(%s, CONCAT('$.', ?)))", column) + jx = fmt.Sprintf("JSON_UNQUOTE(JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)))", column) case migrator.Postgres: - jx = fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?)", column) + jx = fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?)", column) default: return "", nil } @@ -37,9 +37,9 @@ func jsonNotEquals(dialect migrator.Dialect, column, key, value string) (string, func jsonKeyMissing(dialect migrator.Dialect, column, key string) (string, []any) { switch dialect.DriverName() { case migrator.MySQL: - return fmt.Sprintf("JSON_EXTRACT(%s, CONCAT('$.', ?)) IS NULL", column), []any{key} + return fmt.Sprintf("JSON_EXTRACT(NULLIF(%s, ''), CONCAT('$.', ?)) IS NULL", column), []any{key} case migrator.Postgres: - return fmt.Sprintf("jsonb_extract_path_text(%s::jsonb, ?) IS NULL", column), []any{key} + return fmt.Sprintf("jsonb_extract_path_text(NULLIF(%s, '')::jsonb, ?) IS NULL", column), []any{key} default: return "", nil } diff --git a/pkg/services/ngalert/store/json_test.go b/pkg/services/ngalert/store/json_test.go index 89f85a027a6..d09d3741c4b 100644 --- a/pkg/services/ngalert/store/json_test.go +++ b/pkg/services/ngalert/store/json_test.go @@ -23,7 +23,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) = ?", + wantSQL: "JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) = ?", wantArgs: []any{"team", "alerting"}, }, { @@ -32,7 +32,7 @@ func TestJsonEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) = ?", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) = ?", wantArgs: []any{"team", "alerting"}, }, } @@ -62,7 +62,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(labels, CONCAT('$.', ?))) != ?)", + wantSQL: "(JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) IS NULL OR JSON_UNQUOTE(JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?))) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, { @@ -71,7 +71,7 @@ func TestJsonNotEquals(t *testing.T) { column: "labels", key: "team", value: "alerting", - wantSQL: "(jsonb_extract_path_text(labels::jsonb, ?) IS NULL OR jsonb_extract_path_text(labels::jsonb, ?) != ?)", + wantSQL: "(jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL OR jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) != ?)", wantArgs: []any{"team", "team", "alerting"}, }, } @@ -99,7 +99,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewMysqlDialect(), column: "labels", key: "team", - wantSQL: "JSON_EXTRACT(labels, CONCAT('$.', ?)) IS NULL", + wantSQL: "JSON_EXTRACT(NULLIF(labels, ''), CONCAT('$.', ?)) IS NULL", wantArgs: []any{"team"}, }, { @@ -107,7 +107,7 @@ func TestJsonKeyMissing(t *testing.T) { dialect: migrator.NewPostgresDialect(), column: "labels", key: "team", - wantSQL: "jsonb_extract_path_text(labels::jsonb, ?) IS NULL", + wantSQL: "jsonb_extract_path_text(NULLIF(labels, '')::jsonb, ?) IS NULL", wantArgs: []any{"team"}, }, } From 096208202ebab02f8842ae08803b1df724dcedfc Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 23 Dec 2025 11:23:16 +0100 Subject: [PATCH 19/80] Alerting: Fix a race condition panic in ResetStateByRuleUID (#115662) --- pkg/services/ngalert/schedule/registry.go | 16 ++++++----- pkg/services/ngalert/state/manager.go | 10 ++++--- pkg/services/ngalert/state/manager_test.go | 33 ++++++++++++++++++++++ 3 files changed, 48 insertions(+), 11 deletions(-) diff --git a/pkg/services/ngalert/schedule/registry.go b/pkg/services/ngalert/schedule/registry.go index 892e0af8235..6ce09d49169 100644 --- a/pkg/services/ngalert/schedule/registry.go +++ b/pkg/services/ngalert/schedule/registry.go @@ -101,13 +101,13 @@ func (e *Evaluation) Fingerprint() fingerprint { type alertRulesRegistry struct { rules map[models.AlertRuleKey]*models.AlertRule folderTitles map[models.FolderKey]string - mu sync.Mutex + mu sync.RWMutex } // all returns all rules in the registry. func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]string) { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() result := make([]*models.AlertRule, 0, len(r.rules)) for _, rule := range r.rules { result = append(result, rule) @@ -116,8 +116,8 @@ func (r *alertRulesRegistry) all() ([]*models.AlertRule, map[models.FolderKey]st } func (r *alertRulesRegistry) get(k models.AlertRuleKey) *models.AlertRule { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() return r.rules[k] } @@ -157,12 +157,14 @@ func (r *alertRulesRegistry) del(k models.AlertRuleKey) (*models.AlertRule, bool } func (r *alertRulesRegistry) isEmpty() bool { - r.mu.Lock() - defer r.mu.Unlock() + r.mu.RLock() + defer r.mu.RUnlock() return len(r.rules) == 0 } func (r *alertRulesRegistry) needsUpdate(keys []models.AlertRuleKeyWithVersion) bool { + r.mu.RLock() + defer r.mu.RUnlock() if len(r.rules) != len(keys) { return true } diff --git a/pkg/services/ngalert/state/manager.go b/pkg/services/ngalert/state/manager.go index 4d6e066f2bf..45c29f9d684 100644 --- a/pkg/services/ngalert/state/manager.go +++ b/pkg/services/ngalert/state/manager.go @@ -232,9 +232,7 @@ func (st *Manager) Get(orgID int64, alertRuleUID string, stateId data.Fingerprin return st.cache.get(orgID, alertRuleUID, stateId) } -// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. A closed channel is returned to be able -// to gracefully handle the clear state step in scheduler in case we do not need to use the historian to save state -// history. +// DeleteStateByRuleUID removes the rule instances from cache and instanceStore. func (st *Manager) DeleteStateByRuleUID(ctx context.Context, ruleKey ngModels.AlertRuleKeyWithGroup, reason string) []StateTransition { logger := st.log.FromContext(ctx) logger.Debug("Resetting state of the rule") @@ -292,10 +290,14 @@ func (st *Manager) ForgetStateByRuleUID(ctx context.Context, ruleKey ngModels.Al // ResetStateByRuleUID removes the rule instances from cache and instanceStore and saves state history. If the state // history has to be saved, rule must not be nil. func (st *Manager) ResetStateByRuleUID(ctx context.Context, rule *ngModels.AlertRule, reason string) []StateTransition { + if rule == nil { + return nil + } + ruleKey := rule.GetKeyWithGroup() transitions := st.DeleteStateByRuleUID(ctx, ruleKey, reason) - if rule == nil || st.historian == nil || len(transitions) == 0 { + if st.historian == nil || len(transitions) == 0 { return transitions } diff --git a/pkg/services/ngalert/state/manager_test.go b/pkg/services/ngalert/state/manager_test.go index 9dccbe52d2d..b8b366ac619 100644 --- a/pkg/services/ngalert/state/manager_test.go +++ b/pkg/services/ngalert/state/manager_test.go @@ -2051,6 +2051,39 @@ func TestIntegrationResetStateByRuleUID(t *testing.T) { } } +func TestResetStateByRuleUID(t *testing.T) { + ctx := context.Background() + + setupManager := func(historian state.Historian) *state.Manager { + cfg := state.ManagerCfg{ + Metrics: metrics.NewNGAlert(prometheus.NewPedanticRegistry()).GetStateMetrics(), + ExternalURL: nil, + InstanceStore: &state.FakeInstanceStore{}, + Images: &state.NoopImageService{}, + Clock: clock.NewMock(), + Historian: historian, + Tracer: tracing.InitializeTracerForTest(), + Log: log.New("ngalert.state.manager"), + } + + return state.NewManager(cfg, state.NewNoopPersister()) + } + + t.Run("with nil historian", func(t *testing.T) { + manager := setupManager(nil) + + transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason") + require.Empty(t, transitions) + }) + + t.Run("with historian", func(t *testing.T) { + manager := setupManager(&state.FakeHistorian{}) + + transitions := manager.ResetStateByRuleUID(ctx, nil, "test reason") + require.Empty(t, transitions) + }) +} + func setCacheID(s *state.State) *state.State { if s.CacheID != 0 { return s From 84120fb2107267701ba3f4f22ccb24fac943e9c9 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 23 Dec 2025 11:26:15 +0100 Subject: [PATCH 20/80] Alerting: Fix file import/export of recording rules with target datasource uid (#115663) Alerting: Fix export of recording rules with target datasource uid --- .../test-data/post-rulegroup-101-export.hcl | 45 ++++++++++ .../test-data/post-rulegroup-101-export.json | 61 ++++++++++++++ .../test-data/post-rulegroup-101-export.yaml | 45 ++++++++++ .../api/test-data/post-rulegroup-101.json | 65 +++++++++++++++ .../alerting/config_reader_test.go | 40 +++++++++ .../provisioning/alerting/rules_types.go | 10 ++- .../provisioning/alerting/rules_types_test.go | 82 ++++++------------- .../with-target-datasource.yml | 26 ++++++ .../without-target-datasource.yml | 25 ++++++ .../test-data/rulegroup-1-export.json | 27 ++++++ .../alerting/test-data/rulegroup-1-get.json | 38 +++++++++ .../alerting/test-data/rulegroup-1-post.json | 20 +++++ pkg/tests/api/alerting/testing.go | 1 + 13 files changed, 426 insertions(+), 59 deletions(-) create mode 100644 pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml create mode 100644 pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl index 3a5882e0a49..5daa4dce406 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.hcl @@ -173,4 +173,49 @@ resource "grafana_rule_group" "rule_group_d3e8424bfbf66bc3" { from = "condition" } } + rule { + name = "recording rule with target" + + data { + ref_id = "query" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "000000002" + model = "{\"expr\":\"rate(http_requests_total[5m])\",\"hide\":false,\"interval\":\"\",\"intervalMs\":1000,\"legendFormat\":\"\",\"maxDataPoints\":100,\"refId\":\"query\"}" + } + data { + ref_id = "reduced" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "__expr__" + model = "{\"expression\":\"query\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"reducer\":\"mean\",\"refId\":\"reduced\",\"type\":\"reduce\"}" + } + data { + ref_id = "condition" + + relative_time_range { + from = 18000 + to = 10800 + } + + datasource_uid = "__expr__" + model = "{\"expression\":\"$reduced > 5\",\"hide\":false,\"intervalMs\":1000,\"maxDataPoints\":100,\"refId\":\"condition\",\"type\":\"math\"}" + } + + is_paused = false + + record { + metric = "http_requests_rate" + from = "condition" + target_datasource_uid = "000000003" + } + } } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json index bde4bb31f57..4fe9a2dc75a 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.json @@ -233,6 +233,67 @@ "metric": "test_metric", "from": "condition" } + }, + { + "title": "recording rule with target", + "data": [ + { + "refId": "query", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "000000002", + "model": { + "expr": "rate(http_requests_total[5m])", + "hide": false, + "interval": "", + "intervalMs": 1000, + "legendFormat": "", + "maxDataPoints": 100, + "refId": "query" + } + }, + { + "refId": "reduced", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "query", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "reducer": "mean", + "refId": "reduced", + "type": "reduce" + } + }, + { + "refId": "condition", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "$reduced \u003e 5", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "refId": "condition", + "type": "math" + } + } + ], + "isPaused": false, + "record": { + "metric": "http_requests_rate", + "from": "condition", + "targetDatasourceUid": "000000003" + } } ] } diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml index bdb6e9e8362..242a2204823 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101-export.yaml @@ -187,3 +187,48 @@ groups: record: metric: test_metric from: condition + - title: recording rule with target + data: + - refId: query + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: "000000002" + model: + expr: rate(http_requests_total[5m]) + hide: false + interval: "" + intervalMs: 1000 + legendFormat: "" + maxDataPoints: 100 + refId: query + - refId: reduced + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: __expr__ + model: + expression: query + hide: false + intervalMs: 1000 + maxDataPoints: 100 + reducer: mean + refId: reduced + type: reduce + - refId: condition + relativeTimeRange: + from: 18000 + to: 10800 + datasourceUid: __expr__ + model: + expression: $reduced > 5 + hide: false + intervalMs: 1000 + maxDataPoints: 100 + refId: condition + type: math + isPaused: false + record: + metric: http_requests_rate + from: condition + targetDatasourceUid: "000000003" diff --git a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json index f0eaa36d40d..e9936041979 100644 --- a/pkg/services/ngalert/api/test-data/post-rulegroup-101.json +++ b/pkg/services/ngalert/api/test-data/post-rulegroup-101.json @@ -240,6 +240,71 @@ "from": "condition" } } + }, + { + "grafana_alert": { + "title": "recording rule with target", + "data": [ + { + "refId": "query", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "000000002", + "model": { + "expr": "rate(http_requests_total[5m])", + "hide": false, + "interval": "", + "intervalMs": 1000, + "legendFormat": "", + "maxDataPoints": 100, + "refId": "query" + } + }, + { + "refId": "reduced", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "query", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "reducer": "mean", + "refId": "reduced", + "type": "reduce" + } + }, + { + "refId": "condition", + "queryType": "", + "relativeTimeRange": { + "from": 18000, + "to": 10800 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "$reduced > 5", + "hide": false, + "intervalMs": 1000, + "maxDataPoints": 100, + "refId": "condition", + "type": "math" + } + } + ], + "record": { + "metric": "http_requests_rate", + "from": "condition", + "target_datasource_uid": "000000003" + } + } } ] } diff --git a/pkg/services/provisioning/alerting/config_reader_test.go b/pkg/services/provisioning/alerting/config_reader_test.go index c13780633c3..9cb71b4950f 100644 --- a/pkg/services/provisioning/alerting/config_reader_test.go +++ b/pkg/services/provisioning/alerting/config_reader_test.go @@ -19,6 +19,7 @@ const ( testFileDasboardTypoSupport = "./testdata/alert_rules/dasboard-typo-support" testFileMultipleRules = "./testdata/alert_rules/multiple-rules" testFileMultipleFiles = "./testdata/alert_rules/multiple-files" + testFileRecordingRules = "./testdata/alert_rules/recording-rules" testFileCorrectProperties_cp = "./testdata/contact_points/correct-properties" testFileCorrectPropertiesWithOrg_cp = "./testdata/contact_points/correct-properties-with-org" testFileEmptyUID = "./testdata/contact_points/empty-uid" @@ -188,4 +189,43 @@ func TestConfigReader(t *testing.T) { } }) }) + + t.Run("recording rules should parse correctly", func(t *testing.T) { + ruleFiles, err := configReader.readConfig(ctx, testFileRecordingRules) + require.NoError(t, err) + require.Len(t, ruleFiles, 2) + + findRule := func(title string) *AlertingFile { + for _, rf := range ruleFiles { + if rf.Groups[0].Title == title { + return rf + } + } + return nil + } + + ruleWithTarget := findRule("recording_rules_group") + require.NotNil(t, ruleWithTarget) + + require.Len(t, ruleWithTarget.Groups, 1) + require.Len(t, ruleWithTarget.Groups[0].Rules, 1) + + ruleWith := ruleWithTarget.Groups[0].Rules[0] + require.NotNil(t, ruleWith.Record) + require.Equal(t, "my_recorded_metric", ruleWith.Record.Metric) + require.Equal(t, "A", ruleWith.Record.From) + require.Equal(t, "mimir-uid", ruleWith.Record.TargetDatasourceUID) + + ruleWithoutTarget := findRule("recording_rules_group_no_target") + require.NotNil(t, ruleWithoutTarget) + + require.Len(t, ruleWithoutTarget.Groups, 1) + require.Len(t, ruleWithoutTarget.Groups[0].Rules, 1) + + ruleWithout := ruleWithoutTarget.Groups[0].Rules[0] + require.NotNil(t, ruleWithout.Record) + require.Equal(t, "http_requests_rate", ruleWithout.Record.Metric) + require.Equal(t, "A", ruleWithout.Record.From) + require.Equal(t, "", ruleWithout.Record.TargetDatasourceUID) + }) } diff --git a/pkg/services/provisioning/alerting/rules_types.go b/pkg/services/provisioning/alerting/rules_types.go index bac091ababa..46dbbbd6342 100644 --- a/pkg/services/provisioning/alerting/rules_types.go +++ b/pkg/services/provisioning/alerting/rules_types.go @@ -303,13 +303,15 @@ func (nsV1 *NotificationSettingsV1) mapToModel() (models.NotificationSettings, e } type RecordV1 struct { - Metric values.StringValue `json:"metric" yaml:"metric"` - From values.StringValue `json:"from" yaml:"from"` + Metric values.StringValue `json:"metric" yaml:"metric"` + From values.StringValue `json:"from" yaml:"from"` + TargetDatasourceUID values.StringValue `json:"targetDatasourceUid" yaml:"targetDatasourceUid"` } func (record *RecordV1) mapToModel() (models.Record, error) { return models.Record{ - Metric: record.Metric.Value(), - From: record.From.Value(), + Metric: record.Metric.Value(), + From: record.From.Value(), + TargetDatasourceUID: record.TargetDatasourceUID.Value(), }, nil } diff --git a/pkg/services/provisioning/alerting/rules_types_test.go b/pkg/services/provisioning/alerting/rules_types_test.go index c2cff56e419..51db78097ad 100644 --- a/pkg/services/provisioning/alerting/rules_types_test.go +++ b/pkg/services/provisioning/alerting/rules_types_test.go @@ -208,6 +208,15 @@ func TestRecordingRules(t *testing.T) { _, err := rule.mapToModel(1) require.NoError(t, err) }) + + t.Run("a valid rule with empty targetDatasourceUid should not error", func(t *testing.T) { + rule := validRecordingRuleV1(t) + rule.Record.TargetDatasourceUID = stringToStringValue("") + model, err := rule.mapToModel(1) + require.NoError(t, err) + require.NotNil(t, model.Record) + require.Equal(t, "", model.Record.TargetDatasourceUID) + }) } func TestNotificationsSettingsV1MapToModel(t *testing.T) { @@ -307,80 +316,43 @@ func TestNotificationsSettingsV1MapToModel(t *testing.T) { func validRuleGroupV1(t *testing.T) AlertRuleGroupV1 { t.Helper() - var ( - orgID values.Int64Value - name values.StringValue - folder values.StringValue - interval values.StringValue - ) + + var orgID values.Int64Value err := yaml.Unmarshal([]byte("1"), &orgID) require.NoError(t, err) - err = yaml.Unmarshal([]byte("Test"), &name) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("Test"), &folder) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &interval) - require.NoError(t, err) + return AlertRuleGroupV1{ OrgID: orgID, - Name: name, - Folder: folder, - Interval: interval, + Name: stringToStringValue("Test"), + Folder: stringToStringValue("Test"), + Interval: stringToStringValue("10s"), Rules: []AlertRuleV1{}, } } func validRuleV1(t *testing.T) AlertRuleV1 { t.Helper() - var ( - title values.StringValue - uid values.StringValue - forDuration values.StringValue - condition values.StringValue - ) - err := yaml.Unmarshal([]byte("test"), &title) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_uid"), &uid) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &forDuration) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("A"), &condition) - require.NoError(t, err) + return AlertRuleV1{ - Title: title, - UID: uid, - For: forDuration, - Condition: condition, + Title: stringToStringValue("test"), + UID: stringToStringValue("test_uid"), + For: stringToStringValue("10s"), + Condition: stringToStringValue("A"), Data: []QueryV1{{}}, } } func validRecordingRuleV1(t *testing.T) AlertRuleV1 { t.Helper() - var ( - title values.StringValue - uid values.StringValue - forDuration values.StringValue - metric values.StringValue - from values.StringValue - ) - err := yaml.Unmarshal([]byte("test"), &title) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_uid"), &uid) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("10s"), &forDuration) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("test_metric"), &metric) - require.NoError(t, err) - err = yaml.Unmarshal([]byte("A"), &from) - require.NoError(t, err) + return AlertRuleV1{ - Title: title, - UID: uid, - For: forDuration, + Title: stringToStringValue("test"), + UID: stringToStringValue("test_uid"), + For: stringToStringValue("10s"), Record: &RecordV1{ - Metric: metric, - From: from, + Metric: stringToStringValue("test_metric"), + From: stringToStringValue("A"), + TargetDatasourceUID: stringToStringValue("test_target_datasource"), }, Data: []QueryV1{{}}, } diff --git a/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml new file mode 100644 index 00000000000..582ddf730ae --- /dev/null +++ b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/with-target-datasource.yml @@ -0,0 +1,26 @@ +apiVersion: 1 +groups: + - name: recording_rules_group + folder: my_folder + interval: 1m + rules: + - uid: recording_rule_with_target + title: my_recording_rule_with_target + condition: A + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus-uid + model: + expr: up{instance="localhost:9090"} + instant: true + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + record: + metric: my_recorded_metric + from: A + targetDatasourceUid: mimir-uid diff --git a/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml new file mode 100644 index 00000000000..0b8b2760988 --- /dev/null +++ b/pkg/services/provisioning/alerting/testdata/alert_rules/recording-rules/without-target-datasource.yml @@ -0,0 +1,25 @@ +apiVersion: 1 +groups: + - name: recording_rules_group_no_target + folder: my_folder + interval: 1m + rules: + - uid: recording_rule_without_target + title: my_recording_rule_without_target + condition: A + data: + - refId: A + queryType: '' + relativeTimeRange: + from: 600 + to: 0 + datasourceUid: prometheus-uid + model: + expr: rate(http_requests_total[5m]) + instant: true + intervalMs: 1000 + maxDataPoints: 43200 + refId: A + record: + metric: http_requests_rate + from: A diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json index 18d8b8cea40..f8c01a3d883 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-export.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-export.json @@ -72,6 +72,33 @@ }, "isPaused": false, "missing_series_evals_to_resolve": 2 + }, + { + "uid": "", + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "refId": "A", + "type": "math" + } + } + ], + "isPaused": false, + "record": { + "metric": "test_metric", + "from": "A", + "targetDatasourceUid": "test-datasource-uid" + } } ] } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json index 333bd32c0d1..d392254d756 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-get.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-get.json @@ -97,6 +97,44 @@ }, "missing_series_evals_to_resolve": 2 } + }, + { + "expr": "", + "for": "0s", + "keep_firing_for": "0s", + "grafana_alert": { + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "queryType": "", + "relativeTimeRange": { + "from": 0, + "to": 0 + }, + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "intervalMs": 1000, + "maxDataPoints": 43200, + "type": "math" + } + } + ], + "updated": "2023-09-29T17:37:19Z", + "intervalSeconds": 60, + "version": 1, + "uid": "", + "namespace_uid": "", + "rule_group": "Group1", + "is_paused": false, + "record": { + "metric": "test_metric", + "from": "A", + "target_datasource_uid": "test-datasource-uid" + }, + "metadata": {} + } } ] } diff --git a/pkg/tests/api/alerting/test-data/rulegroup-1-post.json b/pkg/tests/api/alerting/test-data/rulegroup-1-post.json index f9f1441eb18..e020299fc42 100644 --- a/pkg/tests/api/alerting/test-data/rulegroup-1-post.json +++ b/pkg/tests/api/alerting/test-data/rulegroup-1-post.json @@ -53,6 +53,26 @@ "exec_err_state": "Alerting", "missing_series_evals_to_resolve": 2 } + }, + { + "grafana_alert": { + "title": "RecordingRule1", + "data": [ + { + "refId": "A", + "datasourceUid": "__expr__", + "model": { + "expression": "1 + 1", + "type": "math" + } + } + ], + "record": { + "metric": "test_metric", + "from": "A", + "target_datasource_uid": "test-datasource-uid" + } + } } ] } diff --git a/pkg/tests/api/alerting/testing.go b/pkg/tests/api/alerting/testing.go index b49e61d3a4e..3bae37f209a 100644 --- a/pkg/tests/api/alerting/testing.go +++ b/pkg/tests/api/alerting/testing.go @@ -249,6 +249,7 @@ func convertGettableGrafanaRuleToPostable(gettable *apimodels.GettableGrafanaRul ExecErrState: gettable.ExecErrState, IsPaused: &gettable.IsPaused, NotificationSettings: gettable.NotificationSettings, + Record: gettable.Record, Metadata: gettable.Metadata, } } From 521cc11994369c21feb265203bfeda3d3f9d5583 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Tue, 23 Dec 2025 05:41:49 -0500 Subject: [PATCH 21/80] Dashboard Outline: Differentiate hover styles between edit and view modes (#115646) --- .../dashboard-scene/edit-pane/DashboardOutline.tsx | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 10bb180a4b1..b543b04537c 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -94,7 +94,11 @@ function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index } // eslint-disable-next-line @typescript-eslint/consistent-type-assertions style={{ '--depth': depth } as React.CSSProperties} > -
+
{isContainer && ( + + )} {loading && } {/* TODO: Better empty state https://github.com/grafana/grafana/issues/114804 */} {!loading && recentDashboards.length === 0 && ( diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 807d6fbfa4b..85563c43905 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "Clear history", "empty": "Nothing viewed yet", + "error": "Recently viewed dashboards couldn’t be loaded.", + "retry": "Retry", "title": "Recently viewed" }, "restore": { From 47436a3eebb64f45d2be85181585f503a68613f3 Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Tue, 23 Dec 2025 16:16:48 +0200 Subject: [PATCH 24/80] Provisioning: Fix settings error loop (#115677) --- .../NestedFolderPicker/FolderRepo.tsx | 35 +++++++++++-------- .../components/BrowseView.tsx | 2 +- .../hooks/useGetResourceRepositoryView.ts | 7 ++-- .../hooks/useIsProvisionedInstance.ts | 19 +++++++--- 4 files changed, 40 insertions(+), 23 deletions(-) diff --git a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx index 153becab1b9..9de19c4fcfe 100644 --- a/public/app/core/components/NestedFolderPicker/FolderRepo.tsx +++ b/public/app/core/components/NestedFolderPicker/FolderRepo.tsx @@ -14,13 +14,12 @@ export interface Props { } export const FolderRepo = memo(function FolderRepo({ folder }: Props) { - // skip rendering if: - // folder is not present - // folder have parentUID - // folder is not managed - // if whole instance is provisioned - const isProvisionedInstance = useIsProvisionedInstance(); - const skipRender = getShouldSkipRender(folder, isProvisionedInstance); + // Check if we can skip early without needing the useIsProvisionedInstance query + // This reduces RTK Query subscriptions and prevents re-render loops on API errors + const canSkipEarly = getCanSkipEarly(folder); + + const isProvisionedInstance = useIsProvisionedInstance({ skip: canSkipEarly }); + const skipRender = canSkipEarly || isProvisionedInstance; const { isReadOnlyRepo, repoType } = useGetResourceRepositoryView({ folderName: skipRender ? undefined : folder?.uid, @@ -51,11 +50,19 @@ export const FolderRepo = memo(function FolderRepo({ folder }: Props) { ); }); -function getShouldSkipRender(folder: FolderDTO | DashboardViewItem | undefined, isProvisionedInstance?: boolean) { - // Skip render if parentUID is present, then we should skip rendering. we only display icon for root folders - const hasParent = folder && Boolean('parentUID' in folder && folder.parentUID); - // Skip render if folder is not managed by Repo - const isNotManaged = folder && folder.managedBy !== ManagerKind.Repo; - - return !folder || hasParent || isNotManaged || isProvisionedInstance; +// Check conditions that don't require the useIsProvisionedInstance hook +function getCanSkipEarly(folder: FolderDTO | DashboardViewItem | undefined): boolean { + if (!folder) { + return true; + } + // Skip render if parentUID is present - we only display icon for root folders + const hasParent = Boolean('parentUID' in folder && folder.parentUID); + if (hasParent) { + return true; + } + const isNotManaged = folder.managedBy !== ManagerKind.Repo; + if (isNotManaged) { + return true; + } + return false; } diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index b28204f4fe2..0b42b79f9ae 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -43,10 +43,10 @@ export function BrowseView({ folderUID, width, height, permissions, isReadOnlyRe const selectedItems = useCheckboxSelectionState(); const childrenByParentUID = useChildrenByParentUIDState(); const canSelect = canSelectItems(permissions); - const isProvisionedInstance = useIsProvisionedInstance(); const provisioningEnabled = config.featureToggles.provisioning; const hasNoRole = contextSrv.user.orgRole === OrgRole.None; const { data: settingsData } = useGetFrontendSettingsQuery(!provisioningEnabled || hasNoRole ? skipToken : undefined); + const isProvisionedInstance = useIsProvisionedInstance({ settings: settingsData }); const rootItems = useSelector(rootItemsSelector); const [, stateManager] = useSearchStateManager(); diff --git a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts index d612be620dc..9dc8e2b51c1 100644 --- a/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts +++ b/public/app/features/provisioning/hooks/useGetResourceRepositoryView.ts @@ -34,9 +34,10 @@ export const useGetResourceRepositoryView = ({ const hasNoRole = contextSrv.user.orgRole === OrgRole.None; const provisioningEnabled = config.featureToggles.provisioning; - const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery( - !provisioningEnabled || skipQuery || hasNoRole ? skipToken : undefined - ); + const shouldSkipSettings = !provisioningEnabled || skipQuery || hasNoRole || (!name && !folderName); + const settingsQueryArg = shouldSkipSettings ? skipToken : undefined; + + const { data: settingsData, isLoading: isSettingsLoading } = useGetFrontendSettingsQuery(settingsQueryArg); const skipFolderQuery = !folderName || !provisioningEnabled || skipQuery || hasNoRole; const { data: folder, isLoading: isFolderLoading } = useGetFolderQuery( diff --git a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts index 1c4ba12b8e4..61fcbb73da6 100644 --- a/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts +++ b/public/app/features/provisioning/hooks/useIsProvisionedInstance.ts @@ -5,13 +5,22 @@ import { config } from '@grafana/runtime'; import { RepositoryViewList, useGetFrontendSettingsQuery } from 'app/api/clients/provisioning/v0alpha1'; import { contextSrv } from 'app/core/services/context_srv'; -export function useIsProvisionedInstance(settings?: RepositoryViewList) { +interface UseIsProvisionedInstanceOptions { + settings?: RepositoryViewList; + skip?: boolean; +} + +export function useIsProvisionedInstance(options: UseIsProvisionedInstanceOptions = {}) { + const { settings, skip: skipQuery } = options; const hasNoRole = contextSrv.user.orgRole === OrgRole.None; - const skip = !config.featureToggles.provisioning || hasNoRole; + const skip = !config.featureToggles.provisioning || hasNoRole || skipQuery; const settingsQuery = useGetFrontendSettingsQuery(settings || skip ? skipToken : undefined); - if (!settings) { - settings = settingsQuery.data; + + if (settingsQuery.isError) { + return false; } - return settings?.items?.some((item) => item.target === 'instance'); + + const effectiveSettings = settings ?? settingsQuery.data; + return effectiveSettings?.items?.some((item) => item.target === 'instance'); } From 45f665d203530292c038406a953913f92064be92 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Tue, 23 Dec 2025 15:24:53 +0100 Subject: [PATCH 25/80] Alerting: Config option to set default datasource in Prometheus rule import (#115665) What is this feature? Add a config option to set data source to imported rules when X-Grafana-Alerting-Datasource-UID is not present. Why do we need this feature? Currently mimirtool requires passing --extra-headers 'X-Grafana-Alerting-Datasource-UID: {uid}' when used with Grafana. This config option allows to specify a default, which is used when the header is missing, making it easier to use and more similar to the case when it's used with Mimir. --- conf/defaults.ini | 5 ++ conf/sample.ini | 5 ++ .../alerting-rules/alerting-migration.md | 2 + .../setup-grafana/configure-grafana/_index.md | 4 ++ .../ngalert/api/api_convert_prometheus.go | 3 + .../api/api_convert_prometheus_test.go | 60 +++++++++++++++++++ pkg/setting/setting_unified_alerting.go | 5 +- 7 files changed, 83 insertions(+), 1 deletion(-) diff --git a/conf/defaults.ini b/conf/defaults.ini index 28cd0420eb2..363ca39d0c4 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1653,6 +1653,11 @@ loki_basic_auth_password = # Accepts duration formats like: 30s, 1m, 1h. rule_query_offset = 1m +# Default data source UID to use for query execution when importing Prometheus rules. +# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided. +# If not set, the header becomes required. +default_datasource_uid = + [recording_rules] # Enable recording rules. enabled = true diff --git a/conf/sample.ini b/conf/sample.ini index 0bb5b82fdc9..530b14c87ac 100644 --- a/conf/sample.ini +++ b/conf/sample.ini @@ -1615,6 +1615,11 @@ max_annotations_to_keep = # Accepts duration formats like: 30s, 1m, 1h. rule_query_offset = 1m +# Default data source UID to use for query execution when importing Prometheus rules. +# This default is used when the X-Grafana-Alerting-Datasource-UID header is not provided. +# If not set, the header becomes required. +default_datasource_uid = + #################################### Recording Rules ##################### [recording_rules] # Enable recording rules. diff --git a/docs/sources/alerting/alerting-rules/alerting-migration.md b/docs/sources/alerting/alerting-rules/alerting-migration.md index 3afa3aec453..ab2a5e995cd 100644 --- a/docs/sources/alerting/alerting-rules/alerting-migration.md +++ b/docs/sources/alerting/alerting-rules/alerting-migration.md @@ -242,6 +242,8 @@ Set to `true` to import recording rules in paused state. The UID of the data source to use for alert rule queries. +If not specified in the header, Grafana uses the configured default from `unified_alerting.prometheus_conversion.default_datasource_uid`. If neither the header nor the configuration option is provided, the request fails. + #### `X-Grafana-Alerting-Target-Datasource-UID` The UID of the target data source for recording rules. If not specified, the value from `X-Grafana-Alerting-Datasource-UID` is used. diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 05d9cb66228..67c361b2bdc 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -2052,6 +2052,10 @@ This section applies only to rules imported as Grafana-managed rules. For more i Set the query offset to imported Grafana-managed rules when `query_offset` is not defined in the original rule group configuration. The default value is `1m`. +#### `default_datasource_uid` + +Set the default data source UID to use for query execution when importing Prometheus rules. Grafana uses this default when the `X-Grafana-Alerting-Datasource-UID` header isn't provided during import. If this option isn't set, the header becomes required. The default value is empty. +
### `[annotations]` diff --git a/pkg/services/ngalert/api/api_convert_prometheus.go b/pkg/services/ngalert/api/api_convert_prometheus.go index 79198ababf1..b6849f55dfc 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus.go +++ b/pkg/services/ngalert/api/api_convert_prometheus.go @@ -375,6 +375,9 @@ func (srv *ConvertPrometheusSrv) RouteConvertPrometheusPostRuleGroups(c *context } datasourceUID := strings.TrimSpace(c.Req.Header.Get(datasourceUIDHeader)) + if datasourceUID == "" { + datasourceUID = srv.cfg.PrometheusConversion.DefaultDatasourceUID + } if datasourceUID == "" { return response.Err(errDatasourceUIDHeaderMissing) } diff --git a/pkg/services/ngalert/api/api_convert_prometheus_test.go b/pkg/services/ngalert/api/api_convert_prometheus_test.go index b4377431249..74e851ed195 100644 --- a/pkg/services/ngalert/api/api_convert_prometheus_test.go +++ b/pkg/services/ngalert/api/api_convert_prometheus_test.go @@ -75,6 +75,46 @@ func TestRouteConvertPrometheusPostRuleGroup(t *testing.T) { require.Contains(t, string(response.Body()), "Missing datasource UID header") }) + t.Run("without datasource UID header but with config default should succeed", func(t *testing.T) { + srv, _, ruleStore := createConvertPrometheusSrv(t) + // Set the config default + srv.cfg.PrometheusConversion.DefaultDatasourceUID = existingDSUID + + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, "") + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify that the config default datasource was used + assertRulesUseDatasource(t, ruleStore, existingDSUID, 2) + }) + + t.Run("header should take precedence over config default", func(t *testing.T) { + srv, dsCache, ruleStore := createConvertPrometheusSrv(t) + // Add another datasource + anotherDS := &datasources.DataSource{ + UID: "another-ds", + Type: datasources.DS_PROMETHEUS, + } + dsCache.DataSources = append(dsCache.DataSources, anotherDS) + + // Set the config default to one DS + srv.cfg.PrometheusConversion.DefaultDatasourceUID = "another-ds" + + // But use the header to specify a different one + rc := createRequestCtx() + rc.Req.Header.Set(datasourceUIDHeader, existingDSUID) + + response := srv.RouteConvertPrometheusPostRuleGroup(rc, "test", simpleGroup) + + require.Equal(t, http.StatusAccepted, response.Status()) + + // Verify that the header datasource was used, not the config default + assertRulesUseDatasource(t, ruleStore, existingDSUID, 2) + }) + t.Run("with invalid datasource should return error", func(t *testing.T) { srv, _, _ := createConvertPrometheusSrv(t) rc := createRequestCtx() @@ -1761,6 +1801,26 @@ func createRequestCtx() *contextmodel.ReqContext { } } +// assertRulesUseDatasource retrieves all alert rules from the store and verifies they use the expected datasource +func assertRulesUseDatasource(t *testing.T, ruleStore *fakes.RuleStore, expectedDatasourceUID string, expectedRuleCount int) { + t.Helper() + + rules, err := ruleStore.ListAlertRules(context.Background(), &models.ListAlertRulesQuery{ + OrgID: 1, + }) + require.NoError(t, err) + require.Len(t, rules, expectedRuleCount) + + for _, rule := range rules { + if rule.Record == nil { + require.NotEmpty(t, rule.Data) + require.Equal(t, expectedDatasourceUID, rule.Data[0].DatasourceUID, rule.Title, expectedDatasourceUID) + } else { + require.Equal(t, expectedDatasourceUID, rule.Record.TargetDatasourceUID) + } + } +} + // Test parseBooleanHeader function which handles boolean header values func TestParseBooleanHeader(t *testing.T) { headerName := "X-Test-Header" diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 0733e8241e6..743f386ff52 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -190,6 +190,8 @@ type UnifiedAlertingReservedLabelSettings struct { type UnifiedAlertingPrometheusConversionSettings struct { // RuleQueryOffset defines a time offset to apply to rule queries during conversion from Prometheus to Grafana format RuleQueryOffset time.Duration + // DefaultDatasourceUID is the default datasource UID to use when converting Prometheus rules if not specified via header + DefaultDatasourceUID string } type UnifiedAlertingLokiSettings struct { @@ -536,7 +538,8 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { prometheusConversion := iniFile.Section("unified_alerting.prometheus_conversion") uaCfg.PrometheusConversion = UnifiedAlertingPrometheusConversionSettings{ - RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + RuleQueryOffset: prometheusConversion.Key("rule_query_offset").MustDuration(time.Minute), + DefaultDatasourceUID: prometheusConversion.Key("default_datasource_uid").MustString(""), } rr := iniFile.Section("recording_rules") From 0a0f92e85ea6d319c8e7501e435672608e3e3884 Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:52:50 +0000 Subject: [PATCH 26/80] InspectJsonTab: Force render the layout after change to reflect new gridPos (#115688) force render the layout after inspect panel change to account for gridPos change --- .../inspect/InspectJsonTab.test.tsx | 47 ++++++++++++++++++- .../inspect/InspectJsonTab.tsx | 7 +++ 2 files changed, 53 insertions(+), 1 deletion(-) diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx index 36337a6ddef..786ff98e236 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.test.tsx @@ -12,7 +12,7 @@ import { } from '@grafana/data'; import { getPanelPlugin } from '@grafana/data/test'; import { setPluginImportUtils, setRunRequest } from '@grafana/runtime'; -import { SceneCanvasText, SceneDataTransformer, SceneQueryRunner, VizPanel } from '@grafana/scenes'; +import { SceneCanvasText, SceneDataTransformer, SceneGridLayout, SceneQueryRunner, VizPanel } from '@grafana/scenes'; import * as libpanels from 'app/features/library-panels/state/api'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; @@ -183,6 +183,51 @@ describe('InspectJsonTab', () => { expect(tab.state.onClose).toHaveBeenCalled(); }); + it('Can update gridPos and forces layout re-render', async () => { + const { tab, panel, scene } = await buildTestScene(); + + // Get the layout manager and spy on the grid's forceRender + const layoutManager = scene.state.body as DefaultGridLayoutManager; + const grid = layoutManager.state.grid as SceneGridLayout; + const forceRenderSpy = jest.spyOn(grid, 'forceRender'); + + const originalGridItem = panel.parent as DashboardGridItem; + expect(originalGridItem.state.x).toBe(0); + expect(originalGridItem.state.y).toBe(0); + expect(originalGridItem.state.width).toBe(8); + expect(originalGridItem.state.height).toBe(10); + + tab.onCodeEditorBlur(`{ + "id": 12, + "type": "table", + "title": "Panel A", + "gridPos": { + "x": 5, + "y": 10, + "w": 12, + "h": 8 + }, + "options": {}, + "fieldConfig": {}, + "transformations": [], + "transparent": false + }`); + + tab.onApplyChange(); + + const panel2 = findVizPanelByKey(scene, panel.state.key)!; + const gridItem = panel2.parent as DashboardGridItem; + + // Verify all gridPos properties are updated + expect(gridItem.state.x).toBe(5); + expect(gridItem.state.y).toBe(10); + expect(gridItem.state.width).toBe(12); + expect(gridItem.state.height).toBe(8); + + // Verify forceRender was called on the layout to apply position changes + expect(forceRenderSpy).toHaveBeenCalled(); + }); + it('Can show panel json for V2 dashboard specification', async () => { const { tab } = await buildTestSceneWithV2Spec(); diff --git a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx index 4fd9bc8b9a6..60f3073dea1 100644 --- a/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx +++ b/public/app/features/dashboard-scene/inspect/InspectJsonTab.tsx @@ -9,6 +9,7 @@ import { SceneDataTransformer, sceneGraph, SceneGridItemStateLike, + SceneGridLayout, SceneObjectBase, SceneObjectRef, SceneObjectState, @@ -168,6 +169,12 @@ export class InspectJsonTab extends SceneObjectBase { panel.parent.setState(newState); + // Force the grid layout to re-render with the new positions + const layout = sceneGraph.getLayout(panel); + if (layout instanceof SceneGridLayout) { + layout.forceRender(); + } + //Report relevant updates reportPanelInspectInteraction(InspectTab.JSON, 'apply', { panel_type_changed: panel.state.pluginId !== panelModel.type, From a1389bc17319a4d1069d75ebc315eaeee3703ff2 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Tue, 23 Dec 2025 14:46:44 -0500 Subject: [PATCH 27/80] Alerting: Update alerting module to 77a1e2f35be87bebc41a0bf634f336282f0b9b53 (#115498) * [create-pull-request] automated change * Remove IsProtectedField and temp structure * Fix alerting historian * make update-workspace --------- Co-authored-by: yuri-tceretian <25988953+yuri-tceretian@users.noreply.github.com> Co-authored-by: Yuri Tseretyan Co-authored-by: Alexander Akhmetov --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 +- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 +- .../pkg/app/notification/lokireader.go | 31 ++--- .../pkg/app/notification/lokireader_test.go | 106 ++++++++++-------- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 +- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 +- go.mod | 2 +- go.sum | 4 +- go.work.sum | 22 ++-- pkg/api/alerting.go | 64 ++--------- pkg/services/ngalert/models/receivers_diff.go | 55 +-------- .../alert-notifiers-v2-snapshot.json | 20 ++++ 16 files changed, 131 insertions(+), 197 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index c1b2c7acd25..efc9ed4d500 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,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-20251212143239-491433b332b7 // indirect + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // 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 760641a8857..07730457d60 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index 0524e1a3852..9a83b79c0f6 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 6e82a1dea7b..17beef468f0 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/alerting/historian/pkg/app/notification/lokireader.go b/apps/alerting/historian/pkg/app/notification/lokireader.go index c26519e59b4..636ae083d91 100644 --- a/apps/alerting/historian/pkg/app/notification/lokireader.go +++ b/apps/alerting/historian/pkg/app/notification/lokireader.go @@ -31,6 +31,10 @@ const ( maxLimit = 1000 Namespace = "grafana" Subsystem = "alerting" + + // LogQL field path for alert rule UID after JSON parsing. + // Loki flattens nested JSON fields with underscores: alert.labels.__alert_rule_uid__ -> alert_labels___alert_rule_uid__ + lokiAlertRuleUIDField = "alert_labels___alert_rule_uid__" ) var ( @@ -111,13 +115,13 @@ func buildQuery(query Query) (string, error) { fmt.Sprintf(`%s=%q`, historian.LabelFrom, historian.LabelFromValue), } - if query.RuleUID != nil { - selectors = append(selectors, - fmt.Sprintf(`%s=%q`, historian.LabelRuleUID, *query.RuleUID)) - } - logql := fmt.Sprintf(`{%s} | json`, strings.Join(selectors, `,`)) + // Add ruleUID filter as JSON line filter if specified. + if query.RuleUID != nil && *query.RuleUID != "" { + logql += fmt.Sprintf(` | %s = %q`, lokiAlertRuleUIDField, *query.RuleUID) + } + // Add receiver filter if specified. if query.Receiver != nil && *query.Receiver != "" { logql += fmt.Sprintf(` | receiver = %q`, *query.Receiver) @@ -211,16 +215,13 @@ func parseLokiEntry(s lokiclient.Sample) (Entry, error) { groupLabels = make(map[string]string) } - alerts := make([]EntryAlert, len(lokiEntry.Alerts)) - for i, a := range lokiEntry.Alerts { - alerts[i] = EntryAlert{ - Status: a.Status, - Labels: a.Labels, - Annotations: a.Annotations, - StartsAt: a.StartsAt, - EndsAt: a.EndsAt, - } - } + alerts := []EntryAlert{{ + Status: lokiEntry.Alert.Status, + Labels: lokiEntry.Alert.Labels, + Annotations: lokiEntry.Alert.Annotations, + StartsAt: lokiEntry.Alert.StartsAt, + EndsAt: lokiEntry.Alert.EndsAt, + }} return Entry{ Timestamp: s.T, diff --git a/apps/alerting/historian/pkg/app/notification/lokireader_test.go b/apps/alerting/historian/pkg/app/notification/lokireader_test.go index 708c9d10df1..c9c35cb1e62 100644 --- a/apps/alerting/historian/pkg/app/notification/lokireader_test.go +++ b/apps/alerting/historian/pkg/app/notification/lokireader_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/grafana/alerting/models" "github.com/grafana/alerting/notify/historian" "github.com/grafana/alerting/notify/historian/lokiclient" "github.com/grafana/grafana-app-sdk/logging" @@ -133,9 +134,8 @@ func TestBuildQuery(t *testing.T) { query: Query{ RuleUID: stringPtr("test-rule-uid"), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with receiver filter", @@ -143,9 +143,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Receiver: stringPtr("email-receiver"), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver"`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with status filter", @@ -153,9 +152,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusFiring), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | status = "firing"`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | status = "firing"`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with success outcome filter", @@ -163,9 +161,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error = ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error = ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with error outcome filter", @@ -173,9 +170,8 @@ func TestBuildQuery(t *testing.T) { RuleUID: stringPtr("test-rule-uid"), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeError), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | error != ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | error != ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with many filters", @@ -185,9 +181,8 @@ func TestBuildQuery(t *testing.T) { Status: createStatusPtr(v0alpha1.CreateNotificationqueryRequestNotificationStatusResolved), Outcome: outcomePtr(v0alpha1.CreateNotificationqueryRequestNotificationOutcomeSuccess), }, - expected: fmt.Sprintf(`{%s=%q,%s=%q} | json | receiver = "email-receiver" | status = "resolved" | error = ""`, - historian.LabelFrom, historian.LabelFromValue, - historian.LabelRuleUID, "test-rule-uid"), + expected: fmt.Sprintf(`{%s=%q} | json | alert_labels___alert_rule_uid__ = "test-rule-uid" | receiver = "email-receiver" | status = "resolved" | error = ""`, + historian.LabelFrom, historian.LabelFromValue), }, { name: "query with group label matcher", @@ -277,19 +272,19 @@ func TestParseLokiEntry(t *testing.T) { GroupLabels: map[string]string{ "alertname": "test-alert", }, - Alerts: []historian.NotificationHistoryLokiEntryAlert{ - { - Status: "firing", - Labels: map[string]string{ - "severity": "critical", - }, - Annotations: map[string]string{ - "summary": "Test alert", - }, - StartsAt: now, - EndsAt: now.Add(1 * time.Hour), + Alert: historian.NotificationHistoryLokiEntryAlert{ + Status: "firing", + Labels: map[string]string{ + "severity": "critical", }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + StartsAt: now, + EndsAt: now.Add(1 * time.Hour), }, + AlertIndex: 0, + AlertCount: 1, Retry: false, Duration: 100, PipelineTime: now, @@ -335,7 +330,9 @@ func TestParseLokiEntry(t *testing.T) { Error: "notification failed", GroupKey: "key:thing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -347,7 +344,7 @@ func TestParseLokiEntry(t *testing.T) { Outcome: OutcomeError, GroupKey: "key:thing", GroupLabels: map[string]string{}, - Alerts: []EntryAlert{}, + Alerts: []EntryAlert{{}}, Error: stringPtr("notification failed"), PipelineTime: now, }, @@ -365,7 +362,7 @@ func TestParseLokiEntry(t *testing.T) { Status: Status("firing"), Outcome: OutcomeSuccess, GroupLabels: map[string]string{}, - Alerts: []EntryAlert{}, + Alerts: []EntryAlert{{}}, PipelineTime: now, }, }, @@ -448,7 +445,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-1", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -459,7 +458,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-3", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -474,7 +475,9 @@ func TestLokiReader_RunQuery(t *testing.T) { Receiver: "receiver-2", Status: "firing", GroupLabels: map[string]string{}, - Alerts: []historian.NotificationHistoryLokiEntryAlert{}, + Alert: historian.NotificationHistoryLokiEntryAlert{}, + AlertIndex: 0, + AlertCount: 1, PipelineTime: now, }), }, @@ -546,19 +549,19 @@ func createMockLokiResponse(timestamp time.Time) lokiclient.QueryRes { GroupLabels: map[string]string{ "alertname": "test-alert", }, - Alerts: []historian.NotificationHistoryLokiEntryAlert{ - { - Status: "firing", - Labels: map[string]string{ - "severity": "critical", - }, - Annotations: map[string]string{ - "summary": "Test alert", - }, - StartsAt: timestamp, - EndsAt: timestamp.Add(1 * time.Hour), + Alert: historian.NotificationHistoryLokiEntryAlert{ + Status: "firing", + Labels: map[string]string{ + "severity": "critical", }, + Annotations: map[string]string{ + "summary": "Test alert", + }, + StartsAt: timestamp, + EndsAt: timestamp.Add(1 * time.Hour), }, + AlertIndex: 0, + AlertCount: 1, Retry: false, Duration: 100, PipelineTime: timestamp, @@ -587,10 +590,19 @@ func createLokiEntryJSONWithNilLabels(t *testing.T, timestamp time.Time) string "status": "firing", "error": "", "groupLabels": null, - "alerts": [], + "alert": {}, + "alertIndex": 0, + "alertCount": 1, "retry": false, "duration": 0, "pipelineTime": "%s" }`, timestamp.Format(time.RFC3339Nano)) return jsonStr } + +func TestRuleUIDLabelConstant(t *testing.T) { + // Verify that models.RuleUIDLabel has the expected value. + // If this changes in the alerting module, our LogQL field path constant will be incorrect + // and filtering for a single alert rule by its UID will break. + assert.Equal(t, "__alert_rule_uid__", models.RuleUIDLabel) +} diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 24769ca825f..8a6cec152cd 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 4584bbd9cc1..00d85d14de4 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index edcf18ea3e3..62f8f4edf0f 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // indirect + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index c5fbc7a39a5..f2dbfce834a 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,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/alerting v0.0.0-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.mod b/go.mod index fb1ab1ce189..492087be19f 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,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-20251212143239-491433b332b7 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @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 diff --git a/go.sum b/go.sum index c1a8d8ad808..9d7d7380b71 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,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-20251212143239-491433b332b7 h1:ZzG/gCclEit9w0QUfQt9GURcOycAIGcsQAhY1u0AEX0= -github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= +github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.work.sum b/go.work.sum index 73813d12650..ca22b546c86 100644 --- a/go.work.sum +++ b/go.work.sum @@ -793,7 +793,15 @@ github.com/go-openapi/loads v0.22.0/go.mod h1:yLsaTCS92mnSAZX5WWoxszLj0u+Ojl+Zs5 github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= github.com/go-openapi/strfmt v0.23.0/go.mod h1:NrtIpfKtWIygRkKVsxh7XQMDQW5HKQl6S5ik2elW+K4= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= -github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-openapi/swag v0.23.1/go.mod h1:STZs8TbRvEQQKUA+JZNAm3EWlgaOBGpyFDqQnDHMef0= +github.com/go-openapi/swag/conv v0.25.1/go.mod h1:Z1mFEGPfyIKPu0806khI3zF+/EUXde+fdeksUl2NiDs= +github.com/go-openapi/swag/fileutils v0.25.1/go.mod h1:+NXtt5xNZZqmpIpjqcujqojGFek9/w55b3ecmOdtg8M= +github.com/go-openapi/swag/jsonutils v0.25.1/go.mod h1:JpEkAjxQXpiaHmRO04N1zE4qbUEg3b7Udll7AMGTNOo= +github.com/go-openapi/swag/loading v0.25.1/go.mod h1:xoIe2EG32NOYYbqxvXgPzne989bWvSNoWoyQVWEZicc= +github.com/go-openapi/swag/mangling v0.25.1/go.mod h1:CdiMQ6pnfAgyQGSOIYnZkXvqhnnwOn997uXZMAd/7mQ= +github.com/go-openapi/swag/stringutils v0.25.1/go.mod h1:JLdSAq5169HaiDUbTvArA2yQxmgn4D6h4A+4HqVvAYg= +github.com/go-openapi/swag/typeutils v0.25.1/go.mod h1:9McMC/oCdS4BKwk2shEB7x17P6HmMmA6dQRtAkSnNb8= +github.com/go-openapi/swag/yamlutils v0.25.1/go.mod h1:cm9ywbzncy3y6uPm/97ysW8+wZ09qsks+9RS8fLWKqg= github.com/go-openapi/validate v0.24.0/go.mod h1:iyeX1sEufmv3nPbBdX3ieNviWnOZaJ1+zquzJEf2BAQ= github.com/go-pdf/fpdf v0.6.0 h1:MlgtGIfsdMEEQJr2le6b/HNr1ZlQwxyWr77r2aj2U/8= github.com/go-playground/assert/v2 v2.0.1 h1:MsBgLAaY856+nPRTKrp3/OZK38U/wa0CcBYNjji3q3A= @@ -982,7 +990,6 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K github.com/grpc-ecosystem/grpc-gateway/v2 v2.19.1/go.mod h1:5SN9VR2LTsRFsrEC6FHgRbTWrTHu6tqPeKxEQv15giM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2/go.mod h1:pkJQ2tZHJ0aFOVEEot6oZmaVEZcRme73eIFmhiVuRWs= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645 h1:MJG/KsmcqMwFAkh8mTnAwhyKoB+sTAnY4CACC110tbU= github.com/grpc-ecosystem/grpc-opentracing v0.0.0-20180507213350-8e809c8a8645/go.mod h1:6iZfnjpejD4L/4DwD7NryNaJyCQdzwWwH2MWhCA90Kw= github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8= @@ -1404,7 +1411,6 @@ github.com/richardartoul/molecule v1.0.0/go.mod h1:uvX/8buq8uVeiZiFht+0lqSLBHF+u github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/fastuuid v1.2.0 h1:Ppwyp6VYCF1nvBTXL3trRso7mXMlRrw9ooo375wvi2s= github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= -github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= @@ -1623,7 +1629,6 @@ go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5queth go.mongodb.org/mongo-driver v1.14.0/go.mod h1:Vzb0Mk/pa7e6cWw85R4F/endUC3u0U9jGcNU603k65c= go.mongodb.org/mongo-driver v1.17.3/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/collector v0.121.0/go.mod h1:M4TlnmkjIgishm2DNCk9K3hMKTmAsY9w8cNFsp9EchM= go.opentelemetry.io/collector v0.124.0/go.mod h1:QzERYfmHUedawjr8Ph/CBEEkVqWS8IlxRLAZt+KHlCg= go.opentelemetry.io/collector/client v1.29.0/go.mod h1:LCUoEV2KCTKA1i+/txZaGsSPVWUcqeOV6wCfNsAippE= @@ -1839,6 +1844,7 @@ go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.62.0/go.mod h1:NfchwuyNoMcZ5MLHwPrODwUF1HWCXWrL31s8gSAdIKY= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/contrib/otelconf v0.15.0 h1:BLNiIUsrNcqhSKpsa6CnhE6LdrpY1A8X0szMVsu99eo= go.opentelemetry.io/contrib/otelconf v0.15.0/go.mod h1:OPH1seO5z9dp1P26gnLtoM9ht7JDvh3Ws6XRHuXqImY= go.opentelemetry.io/contrib/propagators/aws v1.37.0 h1:cp8AFiM/qjBm10C/ATIRnEDXpD5MBknrA0ANw4T2/ss= @@ -1910,7 +1916,6 @@ go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v8 go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc= go.opentelemetry.io/proto/otlp v1.7.0/go.mod h1:fSKjH6YJ7HDlwzltzyMj036AJ3ejJLCgCSHGj4efDDo= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= @@ -2118,8 +2123,8 @@ google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go. google.golang.org/genproto/googleapis/api v0.0.0-20250804133106-a7a43d27e69b/go.mod h1:oDOGiMSXHL4sDTJvFvIB9nRQCGdLP1o/iVaqQK8zB+M= google.golang.org/genproto/googleapis/api v0.0.0-20250818200422-3122310a409c/go.mod h1:ea2MjsO70ssTfCjiwHgI0ZFqcw45Ksuk2ckf9G468GA= google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= -google.golang.org/genproto/googleapis/api v0.0.0-20250908214217-97024824d090/go.mod h1:U8EXRNSd8sUYyDfs/It7KVWodQr+Hf9xtxyxWudSwEw= google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk= +google.golang.org/genproto/googleapis/api v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:G5IanEx8/PgI9w6CFcYQf7jMtHQhZruvfM1i3qOqk5U= google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822 h1:zWFRixYR5QlotL+Uv3YfsPRENIrQFXiGs+iwqel6fOQ= google.golang.org/genproto/googleapis/bytestream v0.0.0-20250603155806-513f23925822/go.mod h1:h6yxum/C2qRb4txaZRLDHK8RyS0H/o2oEDeKY4onY/Y= @@ -2150,10 +2155,9 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go. google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251002232023-7c0ddcbb5797/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251014184007-4626949a642f/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251103181224-f26f9409b101/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251111163417-95abcf5c77ba/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= google.golang.org/grpc v1.23.1/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -2177,7 +2181,6 @@ google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7E google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= -google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -2299,7 +2302,6 @@ sigs.k8s.io/structured-merge-diff/v4 v4.2.3/go.mod h1:qjx8mGObPmV2aSZepjQjbmb2ih sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/structured-merge-diff/v6 v6.2.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= -sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= sigs.k8s.io/yaml v1.3.0/go.mod h1:GeOyir5tyXNByN85N/dRIT9es5UQNerPYEKK56eTBm8= sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= sigs.k8s.io/yaml v1.5.0/go.mod h1:wZs27Rbxoai4C0f8/9urLZtZtF3avA3gKvGyPdDqTO4= diff --git a/pkg/api/alerting.go b/pkg/api/alerting.go index 8fb2f366bf2..27abb6ebbfc 100644 --- a/pkg/api/alerting.go +++ b/pkg/api/alerting.go @@ -10,7 +10,6 @@ import ( "github.com/grafana/grafana/pkg/api/response" contextmodel "github.com/grafana/grafana/pkg/services/contexthandler/model" - "github.com/grafana/grafana/pkg/services/ngalert/models" ) func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) response.Response { @@ -24,13 +23,13 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons } type NotifierPlugin struct { - Type string `json:"type"` - TypeAlias string `json:"typeAlias,omitempty"` - Name string `json:"name"` - Heading string `json:"heading"` - Description string `json:"description"` - Info string `json:"info"` - Options []Field `json:"options"` + Type string `json:"type"` + TypeAlias string `json:"typeAlias,omitempty"` + Name string `json:"name"` + Heading string `json:"heading"` + Description string `json:"description"` + Info string `json:"info"` + Options []schema.Field `json:"options"` } result := make([]*NotifierPlugin, 0, len(v2)) @@ -45,56 +44,9 @@ func (hs *HTTPServer) GetAlertNotifiers() func(*contextmodel.ReqContext) respons Description: s.Description, Heading: s.Heading, Info: s.Info, - Options: schemaFieldsToFields(s.Type, nil, v1.Options), + Options: v1.Options, }) } return response.JSON(http.StatusOK, result) } } - -type Field struct { - Element schema.ElementType `json:"element"` - InputType schema.InputType `json:"inputType"` - Label string `json:"label"` - Description string `json:"description"` - Placeholder string `json:"placeholder"` - PropertyName string `json:"propertyName"` - SelectOptions []schema.SelectOption `json:"selectOptions"` - ShowWhen schema.ShowWhen `json:"showWhen"` - Required bool `json:"required"` - Protected bool `json:"protected,omitempty"` - ValidationRule string `json:"validationRule"` - Secure bool `json:"secure"` - DependsOn string `json:"dependsOn"` - SubformOptions []Field `json:"subformOptions"` -} - -func schemaFieldsToFields(iType schema.IntegrationType, parent schema.IntegrationFieldPath, fields []schema.Field) []Field { - if fields == nil { - return nil - } - result := make([]Field, 0, len(fields)) - for _, f := range fields { - result = append(result, schemaFieldToField(iType, parent, f)) - } - return result -} - -func schemaFieldToField(iType schema.IntegrationType, parent schema.IntegrationFieldPath, f schema.Field) Field { - return Field{ - Element: f.Element, - InputType: f.InputType, - Label: f.Label, - Description: f.Description, - Placeholder: f.Placeholder, - PropertyName: f.PropertyName, - SelectOptions: f.SelectOptions, - ShowWhen: f.ShowWhen, - Required: f.Required, - ValidationRule: f.ValidationRule, - Secure: f.Secure, - DependsOn: f.DependsOn, - SubformOptions: schemaFieldsToFields(iType, append(parent, f.PropertyName), f.SubformOptions), - Protected: models.IsProtectedField(iType, append(parent, f.PropertyName)), - } -} diff --git a/pkg/services/ngalert/models/receivers_diff.go b/pkg/services/ngalert/models/receivers_diff.go index bfe9328542f..681f07601d9 100644 --- a/pkg/services/ngalert/models/receivers_diff.go +++ b/pkg/services/ngalert/models/receivers_diff.go @@ -169,62 +169,9 @@ func HasIntegrationsDifferentProtectedFields(existing, incoming *Integration) [] var result []schema.IntegrationFieldPath settingsDiff := diff.GetSettingsPaths() for _, path := range settingsDiff { - if IsProtectedField(incoming.Config.Type(), path) { + if incoming.Config.IsProtectedField(path) { result = append(result, path) } } return result } - -// IsProtectedField returns true if the field at the given path is existing protected one. -// This includes: -// 1. URL fields marked as secure in the schema (e.g., webhook URLs with credentials) -// 2. URL fields NOT marked as secure but could contain credentials (e.g., API endpoints) -func IsProtectedField(integrationType schema.IntegrationType, path schema.IntegrationFieldPath) bool { - str := strings.ToLower(string(integrationType)) - pathStr := path.String() - - switch str { - case "prometheus-alertmanager": - return pathStr == "url" - case "dingding": - return pathStr == "url" // marked as secure - case "discord": - return pathStr == "url" // marked as secure (webhook URL) - case "googlechat": - return pathStr == "url" // marked as secure - case "jira": - return pathStr == "api_url" - case "kafka": - return pathStr == "kafkaRestProxy" - case "line": - return false - case "mqtt": - return pathStr == "brokerUrl" - case "oncall": - return pathStr == "url" - case "opsgenie": - return pathStr == "apiUrl" - case "pagerduty": - return pathStr == "url" - case "sensugo": - return pathStr == "url" - case "slack": - return pathStr == "url" || pathStr == "endpointUrl" - case "teams": - return pathStr == "url" - case "victorops": - return pathStr == "url" // marked as secure - case "webex": - return pathStr == "api_url" - case "webhook": - return pathStr == "url" || - pathStr == "http_config.oauth2.token_url" || - pathStr == "http_config.oauth2.proxy_config.proxy_url" - case "wecom": - return pathStr == "url" || // marked as secure - pathStr == "endpointUrl" - default: - return false - } -} 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 d942144ef9c..50c92e4d069 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 @@ -93,6 +93,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -225,6 +226,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -1300,6 +1302,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -1405,6 +1408,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2476,6 +2480,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2645,6 +2650,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -2935,6 +2941,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -3139,6 +3146,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -4405,6 +4413,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -5334,6 +5343,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -6630,6 +6640,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -6928,6 +6939,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "token", @@ -6946,6 +6958,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -9237,6 +9250,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -11515,6 +11529,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "", @@ -12308,6 +12323,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13001,6 +13017,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13443,6 +13460,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -13641,6 +13659,7 @@ "is": "" }, "required": false, + "protected": true, "validationRule": "", "secure": false, "dependsOn": "", @@ -15072,6 +15091,7 @@ "is": "" }, "required": true, + "protected": true, "validationRule": "", "secure": true, "dependsOn": "secret", From f5218b5eb826f1ea8561c24abf679e67a6a9bd88 Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 23 Dec 2025 16:39:30 -0500 Subject: [PATCH 28/80] Sparkline: Add point annotations for some common calcs (#115595) --- .../src/field/fieldDisplay.test.ts | 77 +++++++++- .../grafana-data/src/field/fieldDisplay.ts | 137 +++++++++++------- .../RadialGauge/RadialSparkline.tsx | 2 +- .../src/components/Sparkline/Sparkline.tsx | 10 +- .../src/components/Sparkline/utils.test.ts | 135 ++++++++++++++++- .../src/components/Sparkline/utils.ts | 56 +++++-- 6 files changed, 338 insertions(+), 79 deletions(-) diff --git a/packages/grafana-data/src/field/fieldDisplay.test.ts b/packages/grafana-data/src/field/fieldDisplay.test.ts index 718c0e54430..5ec3ed7ba4f 100644 --- a/packages/grafana-data/src/field/fieldDisplay.test.ts +++ b/packages/grafana-data/src/field/fieldDisplay.test.ts @@ -3,11 +3,18 @@ import { merge } from 'lodash'; import { toDataFrame } from '../dataframe/processDataFrame'; import { createTheme } from '../themes/createTheme'; import { ReducerID } from '../transformations/fieldReducer'; +import { FieldType } from '../types/dataFrame'; import { FieldConfigPropertyItem } from '../types/fieldOverrides'; import { MappingType, SpecialValueMatch, ValueMapping } from '../types/valueMapping'; import { getDisplayProcessor } from './displayProcessor'; -import { fixCellTemplateExpressions, getFieldDisplayValues, GetFieldDisplayValuesOptions } from './fieldDisplay'; +import { + FieldSparkline, + fixCellTemplateExpressions, + getFieldDisplayValues, + GetFieldDisplayValuesOptions, + getSparklineHighlight, +} from './fieldDisplay'; import { standardFieldConfigEditorRegistry } from './standardFieldConfigEditorRegistry'; describe('FieldDisplay', () => { @@ -556,3 +563,71 @@ describe('fixCellTemplateExpressions', () => { ); }); }); + +describe('getSparklineHighlight', () => { + const sparkline: FieldSparkline = { + y: { name: 'A', type: FieldType.number, values: [null, 2, 3, 4, 10, 8, 8, 8, 9, null], config: {} }, + }; + + it.each([ + { + calc: ReducerID.last, + expected: { + type: 'point', + xIdx: 9, + }, + }, + { + calc: ReducerID.max, + expected: { + type: 'point', + xIdx: 4, + }, + }, + { + calc: ReducerID.min, + expected: { + type: 'point', + xIdx: 1, + }, + }, + { + calc: ReducerID.first, + expected: { + type: 'point', + xIdx: 0, + }, + }, + { + calc: ReducerID.firstNotNull, + expected: { + type: 'point', + xIdx: 1, + }, + }, + { + calc: ReducerID.lastNotNull, + expected: { + type: 'point', + xIdx: 8, + }, + }, + { + calc: ReducerID.mean, + expected: { + type: 'line', + y: 6.5, + }, + }, + { + calc: ReducerID.median, + expected: { + type: 'line', + y: 8, + }, + }, + ])('it calculates the correct highlight for the $calc', ({ calc, expected }) => { + const result = getSparklineHighlight(sparkline, calc); + expect(result).toEqual(expected); + }); +}); diff --git a/packages/grafana-data/src/field/fieldDisplay.ts b/packages/grafana-data/src/field/fieldDisplay.ts index 3d82f571926..3496f419395 100644 --- a/packages/grafana-data/src/field/fieldDisplay.ts +++ b/packages/grafana-data/src/field/fieldDisplay.ts @@ -3,7 +3,7 @@ import { isEmpty } from 'lodash'; import { DataFrameView } from '../dataframe/DataFrameView'; import { getTimeField } from '../dataframe/processDataFrame'; import { GrafanaTheme2 } from '../themes/types'; -import { reduceField, ReducerID } from '../transformations/fieldReducer'; +import { isReducerID, reduceField, ReducerID } from '../transformations/fieldReducer'; import { getFieldMatcher } from '../transformations/matchers'; import { FieldMatcherID } from '../transformations/matchers/ids'; import { ScopedVars } from '../types/ScopedVars'; @@ -43,6 +43,7 @@ export interface FieldSparkline { x?: Field; // if this does not exist, use the index timeRange?: TimeRange; // Optionally force an absolute time highlightIndex?: number; + highlightLine?: number; } export interface FieldDisplay { @@ -72,6 +73,76 @@ export interface GetFieldDisplayValuesOptions { export const DEFAULT_FIELD_DISPLAY_VALUES_LIMIT = 25; +interface SparklineHighlightPoint { + type: 'point'; + xIdx: number; +} + +interface SparklineHighlightLine { + type: 'line'; + y: number; +} + +export function getSparklineHighlight( + sparkline: FieldSparkline, + calc: ReducerID +): SparklineHighlightPoint | SparklineHighlightLine | void { + switch (calc) { + case ReducerID.last: + return { type: 'point', xIdx: sparkline.y.values.length - 1 }; + case ReducerID.first: + return { type: 'point', xIdx: 0 }; + case ReducerID.lastNotNull: { + for (let k = sparkline.y.values.length - 1; k >= 0; k--) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v)) { + return { type: 'point', xIdx: k }; + } + } + return; + } + case ReducerID.firstNotNull: { + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v)) { + return { type: 'point', xIdx: k }; + } + } + return; + } + case ReducerID.min: { + let minIdx = -1; + let prevMin = Infinity; + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) { + prevMin = v; + minIdx = k; + } + } + return minIdx >= 0 ? { type: 'point', xIdx: minIdx } : undefined; + } + case ReducerID.max: { + let maxIdx = -1; + let prevMax = -Infinity; + for (let k = 0; k < sparkline.y.values.length; k++) { + const v = sparkline.y.values[k]; + if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) { + prevMax = v; + maxIdx = k; + } + } + return maxIdx >= 0 ? { type: 'point', xIdx: maxIdx } : undefined; + } + case ReducerID.mean: + return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.mean] }).mean }; + case ReducerID.median: + return { type: 'line', y: reduceField({ field: sparkline.y, reducers: [ReducerID.median] }).median }; + default: + return; + } +} + export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): FieldDisplay[] => { const { replaceVariables, reduceOptions, timeZone, theme } = options; const calcs = reduceOptions.calcs.length ? reduceOptions.calcs : [ReducerID.last]; @@ -190,62 +261,16 @@ export const getFieldDisplayValues = (options: GetFieldDisplayValuesOptions): Fi y: dataFrame.fields[i], x: timeField, }; - let highlightIdx: number | undefined = (() => { - switch (calc) { - case ReducerID.last: - return sparkline.y.values.length - 1; - case ReducerID.first: - return 0; - // TODO: #112977 enable more reducers for highlight index - // case ReducerID.lastNotNull: { - // for (let k = sparkline.y.values.length - 1; k >= 0; k--) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v)) { - // return k; - // } - // } - // return; - // } - // case ReducerID.firstNotNull: { - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v)) { - // return k; - // } - // } - // return; - // } - // case ReducerID.min: { - // let minIdx = -1; - // let prevMin = Infinity; - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v) && v < prevMin) { - // prevMin = v; - // minIdx = k; - // } - // } - // return minIdx >= 0 ? minIdx : undefined; - // } - // case ReducerID.max: { - // let maxIdx = -1; - // let prevMax = -Infinity; - // for (let k = 0; k < sparkline.y.values.length; k++) { - // const v = sparkline.y.values[k]; - // if (v !== null && v !== undefined && !Number.isNaN(v) && v > prevMax) { - // prevMax = v; - // maxIdx = k; - // } - // } - // return maxIdx >= 0 ? maxIdx : undefined; - // } - default: - return; + if (isReducerID(calc)) { + const sparklineHighlight = getSparklineHighlight(sparkline, calc); + switch (sparklineHighlight?.type) { + case 'point': + sparkline.highlightIndex = sparklineHighlight.xIdx; + break; + case 'line': + sparkline.highlightLine = sparklineHighlight.y; + break; } - })(); - - if (typeof highlightIdx === 'number') { - sparkline.highlightIndex = highlightIdx; } } diff --git a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx index 2d6c45a14bf..4a52d5241d5 100644 --- a/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx +++ b/packages/grafana-ui/src/components/RadialGauge/RadialSparkline.tsx @@ -67,7 +67,7 @@ export const RadialSparkline = memo( return (
- +
); } diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index c18b235e757..d1fb4f3b0e0 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -14,18 +14,18 @@ export interface SparklineProps extends Themeable2 { height: number; config?: FieldConfig; sparkline: FieldSparkline; + showHighlights?: boolean; } -const SparklineFn: React.FC = memo((props) => { - const { sparkline, config: fieldConfig, theme, width, height } = props; - - const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, fieldConfig); +export const SparklineFn: React.FC = memo((props) => { + const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; } const data = preparePlotData2(alignedDataFrame, getStackingGroups(alignedDataFrame)); - const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme); + const configBuilder = prepareConfig(sparkline, alignedDataFrame, theme, showHighlights); return ; }); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.test.ts b/packages/grafana-ui/src/components/Sparkline/utils.test.ts index ca49f6da512..0ec65515e0c 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.test.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.test.ts @@ -1,6 +1,6 @@ -import { Field, FieldSparkline, FieldType } from '@grafana/data'; +import { createTheme, Field, FieldSparkline, FieldType, toDataFrame } from '@grafana/data'; -import { getYRange, preparePlotFrame } from './utils'; +import { getYRange, prepareConfig, preparePlotFrame } from './utils'; describe('Prepare Sparkline plot frame', () => { it('should return sorted array if x-axis numeric', () => { @@ -201,3 +201,134 @@ describe('Get y range', () => { expect(actual[0]).toBeLessThan(actual[1]!); }); }); + +describe('prepareConfig', () => { + it('should not throw an error if there are multiple values', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should not throw an error if there is a single value', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should not throw an error if there are no values', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [], + type: FieldType.number, + config: {}, + }, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme()); + expect(config.series.length).toBe(1); + }); + + it('should set up highlight series if showHighlights is true and highlightIdx exists', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + highlightIndex: 2, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme(), true); + expect(config.series.length).toBe(1); + expect(config.series[0].getConfig().points).toEqual( + expect.objectContaining({ + show: true, + filter: [2], + }) + ); + }); + + it('should not set up highlight series if showHighlights is false even if highlightIdx exists', () => { + const sparkline: FieldSparkline = { + x: { + name: 'x', + values: [1679839200000, 1680444000000, 1681048800000, 1681653600000, 1682258400000], + type: FieldType.time, + config: {}, + }, + y: { + name: 'y', + values: [1, 2, 3, 4, 5], + type: FieldType.number, + config: {}, + }, + highlightIndex: 2, + }; + + const dataFrame = toDataFrame({ + fields: [sparkline.x, sparkline.y], + }); + + const config = prepareConfig(sparkline, dataFrame, createTheme(), false); + expect(config.series.length).toBe(1); + expect(config.series[0].getConfig().points?.show).not.toBe(true); + }); +}); diff --git a/packages/grafana-ui/src/components/Sparkline/utils.ts b/packages/grafana-ui/src/components/Sparkline/utils.ts index be24eb6c4e8..c1402c4da2d 100644 --- a/packages/grafana-ui/src/components/Sparkline/utils.ts +++ b/packages/grafana-ui/src/components/Sparkline/utils.ts @@ -2,6 +2,7 @@ import { Range } from 'uplot'; import { applyNullInsertThreshold, + // colorManipulator, DataFrame, FieldConfig, FieldSparkline, @@ -22,6 +23,7 @@ import { VisibilityMode, ScaleDirection, ScaleOrientation, + // FieldColorModeId, } from '@grafana/schema'; import { UPlotConfigBuilder } from '../uPlot/config/UPlotConfigBuilder'; @@ -112,8 +114,7 @@ export function getYRange(alignedFrame: DataFrame): Range.MinMax { return [roundedMin, roundedMax]; } -// TODO: #112977 enable highlight index -// const HIGHLIGHT_IDX_POINT_SIZE = 6; +const HIGHLIGHT_IDX_POINT_SIZE = 6; const defaultConfig: GraphFieldConfig = { drawStyle: GraphDrawStyle.Line, @@ -124,7 +125,9 @@ const defaultConfig: GraphFieldConfig = { export const prepareSeries = ( sparkline: FieldSparkline, - fieldConfig?: FieldConfig + _theme: GrafanaTheme2, + fieldConfig?: FieldConfig, + _showHighlights?: boolean ): { frame: DataFrame; warning?: string } => { const frame = nullToValue(preparePlotFrame(sparkline, fieldConfig)); if (frame.fields.some((f) => f.values.length <= 1)) { @@ -136,16 +139,41 @@ export const prepareSeries = ( frame, }; } + // TODO:rgb(24, 24, 24) will address this. + // if (showHighlights && typeof sparkline.highlightLine === 'number') { + // const highlightY = sparkline.highlightLine; + // const colorMode = getFieldColorModeForField(sparkline.y); + // const seriesColor = colorMode.getCalculator(sparkline.y, theme)(highlightY, 0); + // frame.fields.push({ + // name: 'highlightLine', + // type: FieldType.number, + // values: new Array(frame.length).fill(highlightY), + // config: { + // color: { + // mode: FieldColorModeId.Fixed, + // fixedColor: colorManipulator.lighten(seriesColor, 0.5), + // }, + // custom: { + // lineStyle: { + // fill: 'dash', + // dash: [5, 2], + // }, + // }, + // }, + // state: {}, + // }); + // } return { frame }; }; export const prepareConfig = ( sparkline: FieldSparkline, dataFrame: DataFrame, - theme: GrafanaTheme2 + theme: GrafanaTheme2, + showHighlights?: boolean ): UPlotConfigBuilder => { const builder = new UPlotConfigBuilder(); - // const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2; + const rangePad = HIGHLIGHT_IDX_POINT_SIZE / 2; builder.setCursor({ show: false, @@ -206,13 +234,14 @@ export const prepareConfig = ( const colorMode = getFieldColorModeForField(field); const seriesColor = colorMode.getCalculator(field, theme)(0, 0); - // TODO: #112977 enable highlight index and adjust padding accordingly - // const hasHighlightIndex = typeof sparkline.highlightIndex === 'number'; - // if (hasHighlightIndex) { - // builder.setPadding([rangePad, rangePad, rangePad, rangePad]); - // } + + const hasHighlightIndex = showHighlights && typeof sparkline.highlightIndex === 'number'; + if (hasHighlightIndex) { + builder.setPadding([rangePad, rangePad, rangePad, rangePad]); + } + const pointsMode = - customConfig.drawStyle === GraphDrawStyle.Points // || hasHighlightIndex + customConfig.drawStyle === GraphDrawStyle.Points || hasHighlightIndex ? VisibilityMode.Always : customConfig.showPoints; @@ -227,9 +256,8 @@ export const prepareConfig = ( lineWidth: customConfig.lineWidth, lineInterpolation: customConfig.lineInterpolation, showPoints: pointsMode, - // TODO: #112977 enable highlight index - pointSize: /* hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : */ customConfig.pointSize, - // pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined, + pointSize: hasHighlightIndex ? HIGHLIGHT_IDX_POINT_SIZE : customConfig.pointSize, + pointsFilter: hasHighlightIndex ? [sparkline.highlightIndex!] : undefined, fillOpacity: customConfig.fillOpacity, fillColor: customConfig.fillColor, lineStyle: customConfig.lineStyle, From 5e4e6c1172826351bd7c9aa689679d2b87bf61f2 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 00:42:01 +0000 Subject: [PATCH 29/80] I18n: Download translations from Crowdin (#115705) 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 | 2 ++ public/locales/de-DE/grafana.json | 2 ++ public/locales/es-ES/grafana.json | 2 ++ public/locales/fr-FR/grafana.json | 2 ++ public/locales/hu-HU/grafana.json | 2 ++ public/locales/id-ID/grafana.json | 2 ++ public/locales/it-IT/grafana.json | 2 ++ public/locales/ja-JP/grafana.json | 2 ++ public/locales/ko-KR/grafana.json | 2 ++ public/locales/nl-NL/grafana.json | 2 ++ public/locales/pl-PL/grafana.json | 2 ++ public/locales/pt-BR/grafana.json | 2 ++ public/locales/pt-PT/grafana.json | 2 ++ public/locales/ru-RU/grafana.json | 2 ++ public/locales/sv-SE/grafana.json | 2 ++ public/locales/tr-TR/grafana.json | 2 ++ public/locales/zh-Hans/grafana.json | 2 ++ public/locales/zh-Hant/grafana.json | 2 ++ 18 files changed, 36 insertions(+) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 84cc597980b..2fb06738b36 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -3780,6 +3780,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index ef5f649123d..6c09564ae2f 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 443dbefbc39..45d955ba66c 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 1d98e007593..33bf748fbc0 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index bfe9d3e9542..3704f01de9a 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 86b18767abb..602acd8813a 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 976d81b2f37..4832c6c744c 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index 9bfbc78a21e..c87617b2161 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index bd4103a1de1..65d967807ea 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index d5386284647..a1d9ba17c5b 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index ad8f9b19b6a..c7d04e0cd8b 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -3780,6 +3780,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index 8ad480fc30d..eee46fc8344 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 3fed004bfdf..415075e65ab 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index 10cd0cdd7bb..a8aab23f3d0 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -3780,6 +3780,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index faca6a40afc..f3c4effc8b3 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index ad957bd271f..7cd8b7b5939 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -3748,6 +3748,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index f02bcda5189..b36e525f676 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index dc05987a2e6..0302a7ffb6f 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -3732,6 +3732,8 @@ "recently-viewed": { "clear": "", "empty": "", + "error": "", + "retry": "", "title": "" }, "restore": { From 3f5f0f783b5219243d04cad1de20e58bb4533b47 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 08:48:06 +0100 Subject: [PATCH 30/80] Alerting: Update alerting module to 926c7491019668286c423cad9d2a65f419b14944 (#115704) [create-pull-request] automated change Co-authored-by: alexander-akhmetov <1875873+alexander-akhmetov@users.noreply.github.com> --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 ++-- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 ++-- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- 10 files changed, 15 insertions(+), 15 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index efc9ed4d500..646ceed9a86 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,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-20251217141753-77a1e2f35be8 // indirect + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // 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 07730457d60..750d9f97fc5 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index 9a83b79c0f6..fb624d65db3 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 17beef468f0..0835100976a 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 8a6cec152cd..54689bc54f3 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 00d85d14de4..28bf1486774 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 62f8f4edf0f..a2657edda7a 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8 // indirect + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index f2dbfce834a..f0c923083af 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,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/alerting v0.0.0-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.mod b/go.mod index 492087be19f..f22d410c51f 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,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-20251217141753-77a1e2f35be8 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @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 diff --git a/go.sum b/go.sum index 9d7d7380b71..069d53dd5e9 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,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-20251217141753-77a1e2f35be8 h1:m5VqerdocNDh1vTi8itJmX9DwonGqj+SfkO0JxPHt0E= -github.com/grafana/alerting v0.0.0-20251217141753-77a1e2f35be8/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= +github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= From 4f57ebe4ad636cbfc50b220bf0ce18ea6b4ac18d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Wed, 24 Dec 2025 09:33:24 +0100 Subject: [PATCH 31/80] fix: bump default facet search limit for unified search (#115690) * fix: bump limit * feat: add facetLimit query parameter to search API * fix: set to 500 * fix: update snapshot * fix: yarn generate-apis --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 3 ++ pkg/registry/apis/dashboard/search.go | 20 +++++++++++- pkg/registry/apis/dashboard/search_test.go | 32 +++++++++++++++++++ .../dashboard.grafana.app-v0alpha1.json | 9 ++++++ public/app/features/search/service/unified.ts | 2 +- 5 files changed, 64 insertions(+), 2 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index 5d3e72b13aa..b50a074e4a2 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -243,6 +243,7 @@ const injectedRtkApi = api type: queryArg['type'], folder: queryArg.folder, facet: queryArg.facet, + facetLimit: queryArg.facetLimit, tags: queryArg.tags, libraryPanel: queryArg.libraryPanel, permission: queryArg.permission, @@ -663,6 +664,8 @@ export type SearchDashboardsAndFoldersApiArg = { folder?: string; /** count distinct terms for selected fields */ facet?: string[]; + /** maximum number of terms to return per facet (default 50, max 1000) */ + facetLimit?: number; /** tag query filter */ tags?: string[]; /** find dashboards that reference a given libraryPanel */ diff --git a/pkg/registry/apis/dashboard/search.go b/pkg/registry/apis/dashboard/search.go index e28eeedcecc..08a943b8da6 100644 --- a/pkg/registry/apis/dashboard/search.go +++ b/pkg/registry/apis/dashboard/search.go @@ -115,6 +115,15 @@ func (s *SearchHandler) GetAPIRoutes(defs map[string]common.OpenAPIDefinition) * Schema: spec.ArrayProperty(spec.StringProperty()), }, }, + { + ParameterProps: spec3.ParameterProps{ + Name: "facetLimit", + In: "query", + Description: "maximum number of terms to return per facet (default 50, max 1000)", + Required: false, + Schema: spec.Int64Property(), + }, + }, { ParameterProps: spec3.ParameterProps{ Name: "tags", @@ -340,6 +349,7 @@ func (s *SearchHandler) DoSearch(w http.ResponseWriter, r *http.Request) { func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, user identity.Requester, getDashboardsUIDsSharedWithUser func() ([]string, error)) (*resourcepb.ResourceSearchRequest, error) { // get limit and offset from query params limit := 50 + facetLimit := 50 offset := 0 page := 1 if queryParams.Has("limit") { @@ -422,11 +432,19 @@ func convertHttpSearchRequestToResourceSearchRequest(queryParams url.Values, use // The facet term fields if facets, ok := queryParams["facet"]; ok { + if queryParams.Has("facetLimit") { + if parsed, err := strconv.Atoi(queryParams.Get("facetLimit")); err == nil && parsed > 0 { + facetLimit = parsed + if facetLimit > 1000 { + facetLimit = 1000 + } + } + } searchRequest.Facet = make(map[string]*resourcepb.ResourceSearchRequest_Facet) for _, v := range facets { searchRequest.Facet[v] = &resourcepb.ResourceSearchRequest_Facet{ Field: v, - Limit: 50, + Limit: int64(facetLimit), } } } diff --git a/pkg/registry/apis/dashboard/search_test.go b/pkg/registry/apis/dashboard/search_test.go index 406494b9d36..3b9935f8247 100644 --- a/pkg/registry/apis/dashboard/search_test.go +++ b/pkg/registry/apis/dashboard/search_test.go @@ -818,6 +818,38 @@ func TestConvertHttpSearchRequestToResourceSearchRequest(t *testing.T) { Federated: []*resourcepb.ResourceKey{folderKey}, }, }, + "facet fields with custom limit": { + queryString: "facet=tags&facetLimit=500", + 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: 500}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, + "facet fields with limit exceeding max": { + queryString: "facet=tags&facetLimit=5000", + 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: 1000}, + }, + Federated: []*resourcepb.ResourceKey{folderKey}, + }, + }, "tag filter": { queryString: "tag=tag1&tag=tag2", expected: &resourcepb.ResourceSearchRequest{ diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index b65fa2ad0d7..61834093866 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -1802,6 +1802,15 @@ } } }, + { + "name": "facetLimit", + "in": "query", + "description": "maximum number of terms to return per facet (default 50, max 1000)", + "schema": { + "type": "integer", + "format": "int64" + } + }, { "name": "tags", "in": "query", diff --git a/public/app/features/search/service/unified.ts b/public/app/features/search/service/unified.ts index 8d1af58f9d9..146a54d295d 100644 --- a/public/app/features/search/service/unified.ts +++ b/public/app/features/search/service/unified.ts @@ -106,7 +106,7 @@ export class UnifiedSearcher implements GrafanaSearcher { async tags(query: SearchQuery): Promise { const qry = query.query ?? '*'; - let uri = `${searchURI}?facet=tags&query=${qry}&limit=1`; + let uri = `${searchURI}?facet=tags&facetLimit=1000&query=${qry}&limit=1`; const resp = await getBackendSrv().get(uri); return resp.facets?.tags?.terms || []; } From c38e515dec1608c5072c6684a80e1f9ef96dc064 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 24 Dec 2025 09:49:38 +0100 Subject: [PATCH 32/80] Alerting: Fix export of imported Prometheus-style recording rules to terraform (#115661) Alerting: Fix export imported Prometheus-style recording rules to terraform --- .../ngalert/api/api_ruler_validation_test.go | 2 + pkg/services/ngalert/api/compat/compat.go | 84 +++++++++------- .../ngalert/api/compat/compat_test.go | 97 +++++++++++++++++++ .../api/validation/api_ruler_validation.go | 1 + pkg/services/ngalert/prom/convert.go | 10 +- pkg/services/ngalert/prom/convert_test.go | 7 +- 6 files changed, 158 insertions(+), 43 deletions(-) diff --git a/pkg/services/ngalert/api/api_ruler_validation_test.go b/pkg/services/ngalert/api/api_ruler_validation_test.go index 98fbb20cf12..553a02cff30 100644 --- a/pkg/services/ngalert/api/api_ruler_validation_test.go +++ b/pkg/services/ngalert/api/api_ruler_validation_test.go @@ -493,6 +493,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { r.GrafanaManagedAlert.NoDataState = apimodels.OK r.GrafanaManagedAlert.ExecErrState = apimodels.AlertingErrState r.GrafanaManagedAlert.NotificationSettings = &apimodels.AlertRuleNotificationSettings{} + r.GrafanaManagedAlert.MissingSeriesEvalsToResolve = util.Pointer[int64](1) r.For = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }() r.KeepFiringFor = func() *model.Duration { five := model.Duration(time.Second * 5); return &five }() return &r @@ -502,6 +503,7 @@ func TestValidateRuleNode_NoUID(t *testing.T) { require.Empty(t, alert.NoDataState) require.Empty(t, alert.ExecErrState) require.Nil(t, alert.NotificationSettings) + require.Nil(t, alert.MissingSeriesEvalsToResolve) require.Zero(t, alert.For) require.Zero(t, alert.KeepFiringFor) }, diff --git a/pkg/services/ngalert/api/compat/compat.go b/pkg/services/ngalert/api/compat/compat.go index 5fda13672ba..37c9a8db6ed 100644 --- a/pkg/services/ngalert/api/compat/compat.go +++ b/pkg/services/ngalert/api/compat/compat.go @@ -189,42 +189,11 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE data = append(data, query) } - cPtr := &rule.Condition - if rule.Condition == "" { - cPtr = nil - } - - noDataState := definitions.NoDataState(rule.NoDataState) - ndsPtr := &noDataState - if noDataState == "" { - ndsPtr = nil - } - execErrorState := definitions.ExecutionErrorState(rule.ExecErrState) - eesPtr := &execErrorState - if execErrorState == "" { - eesPtr = nil - } - result := definitions.AlertRuleExport{ - UID: rule.UID, - Title: rule.Title, - For: model.Duration(rule.For), - KeepFiringFor: model.Duration(rule.KeepFiringFor), - Condition: cPtr, - Data: data, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - NoDataState: ndsPtr, - ExecErrState: eesPtr, - IsPaused: rule.IsPaused, - NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings), - Record: AlertRuleRecordExportFromRecord(rule.Record), - } - if rule.For.Seconds() > 0 { - result.ForString = util.Pointer(model.Duration(rule.For).String()) - } - if rule.KeepFiringFor.Seconds() > 0 { - result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String()) + UID: rule.UID, + Title: rule.Title, + Data: data, + IsPaused: rule.IsPaused, } if rule.Annotations != nil { result.Annotations = &rule.Annotations @@ -232,13 +201,54 @@ func AlertRuleExportFromAlertRule(rule models.AlertRule) (definitions.AlertRuleE if rule.Labels != nil { result.Labels = &rule.Labels } - if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 { - result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve + + if rule.Type() == models.RuleTypeRecording { + populateRecordingRuleExportFields(rule, &result) + } else { + populateAlertingRuleExportFields(rule, &result) } return result, nil } +func populateRecordingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) { + result.Record = AlertRuleRecordExportFromRecord(rule.Record) +} + +func populateAlertingRuleExportFields(rule models.AlertRule, result *definitions.AlertRuleExport) { + result.DashboardUID = rule.DashboardUID + result.PanelID = rule.PanelID + result.NotificationSettings = AlertRuleNotificationSettingsExportFromNotificationSettings(rule.NotificationSettings) + + if rule.Condition != "" { + result.Condition = &rule.Condition + } + + if rule.NoDataState != "" { + noDataState := definitions.NoDataState(rule.NoDataState) + result.NoDataState = &noDataState + } + + if rule.ExecErrState != "" { + execErrorState := definitions.ExecutionErrorState(rule.ExecErrState) + result.ExecErrState = &execErrorState + } + + result.For = model.Duration(rule.For) + if rule.For > 0 { + result.ForString = util.Pointer(model.Duration(rule.For).String()) + } + + result.KeepFiringFor = model.Duration(rule.KeepFiringFor) + if rule.KeepFiringFor > 0 { + result.KeepFiringForString = util.Pointer(model.Duration(rule.KeepFiringFor).String()) + } + + if rule.MissingSeriesEvalsToResolve != nil && *rule.MissingSeriesEvalsToResolve != -1 { + result.MissingSeriesEvalsToResolve = rule.MissingSeriesEvalsToResolve + } +} + func encodeQueryModel(m map[string]any) (string, error) { var buf bytes.Buffer enc := json.NewEncoder(&buf) diff --git a/pkg/services/ngalert/api/compat/compat_test.go b/pkg/services/ngalert/api/compat/compat_test.go index 4a107335945..8b50c609559 100644 --- a/pkg/services/ngalert/api/compat/compat_test.go +++ b/pkg/services/ngalert/api/compat/compat_test.go @@ -9,6 +9,7 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/api/tooling/definitions" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/util" ) func TestToModel(t *testing.T) { @@ -115,6 +116,102 @@ func TestToModel(t *testing.T) { }) } +func TestAlertRuleExportFromAlertRule(t *testing.T) { + alertingRule := models.RuleGen.With( + models.RuleGen.WithNotEmptyLabels(2, "lbl-"), + models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}), + models.RuleGen.WithFor(2*time.Minute), + models.RuleGen.WithKeepFiringFor(5*time.Minute), + models.RuleGen.WithNotificationSettingsGen(models.NotificationSettingsGen()), + ).Generate() + recordingRule := models.RuleGen.With( + models.RuleGen.WithAllRecordingRules(), + models.RuleGen.WithNotEmptyLabels(2, "lbl-"), + models.RuleGen.WithAnnotations(map[string]string{"ann-key": "ann-value"}), + ).Generate() + + // Build expected exported recording rule + recordingRuleData, err := AlertQueryExportFromAlertQuery(recordingRule.Data[0]) + require.NoError(t, err) + expectedRecordingRuleExport := definitions.AlertRuleExport{ + UID: recordingRule.UID, + Title: recordingRule.Title, + Data: []definitions.AlertQueryExport{recordingRuleData}, + Annotations: &recordingRule.Annotations, + Labels: &recordingRule.Labels, + Record: &definitions.AlertRuleRecordExport{ + Metric: recordingRule.Record.Metric, + From: recordingRule.Record.From, + TargetDatasourceUID: util.Pointer(recordingRule.Record.TargetDatasourceUID), + }, + } + + // Build expected exported alerting rule + alertingRuleData, err := AlertQueryExportFromAlertQuery(alertingRule.Data[0]) + require.NoError(t, err) + noDataState := definitions.NoDataState(alertingRule.NoDataState) + execErrState := definitions.ExecutionErrorState(alertingRule.ExecErrState) + expectedAlertingRuleExport := definitions.AlertRuleExport{ + UID: alertingRule.UID, + Title: alertingRule.Title, + Condition: &alertingRule.Condition, + Data: []definitions.AlertQueryExport{alertingRuleData}, + DashboardUID: alertingRule.DashboardUID, + PanelID: alertingRule.PanelID, + NoDataState: &noDataState, + ExecErrState: &execErrState, + For: prommodel.Duration(alertingRule.For), + KeepFiringFor: prommodel.Duration(alertingRule.KeepFiringFor), + ForString: util.Pointer(prommodel.Duration(alertingRule.For).String()), + KeepFiringForString: util.Pointer(prommodel.Duration(alertingRule.KeepFiringFor).String()), + Annotations: &alertingRule.Annotations, + Labels: &alertingRule.Labels, + NotificationSettings: AlertRuleNotificationSettingsExportFromNotificationSettings(alertingRule.NotificationSettings), + MissingSeriesEvalsToResolve: alertingRule.MissingSeriesEvalsToResolve, + } + + testCases := []struct { + name string + rule models.AlertRule + expected definitions.AlertRuleExport + }{ + { + name: "export recording rule", + rule: recordingRule, + expected: expectedRecordingRuleExport, + }, + { + name: "export alerting rule", + rule: alertingRule, + expected: expectedAlertingRuleExport, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + exported, err := AlertRuleExportFromAlertRule(tc.rule) + require.NoError(t, err) + require.Equal(t, tc.expected, exported) + }) + } +} + +func TestAlertQueryExportFromAlertQuery(t *testing.T) { + query := models.RuleGen.GenerateQuery() + + exported, err := AlertQueryExportFromAlertQuery(query) + require.NoError(t, err) + + require.Equal(t, query.RefID, exported.RefID) + require.Equal(t, query.DatasourceUID, exported.DatasourceUID) + require.Equal(t, int64(time.Duration(query.RelativeTimeRange.From).Seconds()), exported.RelativeTimeRange.FromSeconds) + require.Equal(t, int64(time.Duration(query.RelativeTimeRange.To).Seconds()), exported.RelativeTimeRange.ToSeconds) + require.NotNil(t, exported.QueryType) + require.Equal(t, query.QueryType, *exported.QueryType) + require.NotNil(t, exported.Model) + require.NotEmpty(t, exported.ModelString) +} + func TestAlertRuleMetadataFromModelMetadata(t *testing.T) { t.Run("should convert model metadata to api metadata", func(t *testing.T) { modelMetadata := models.AlertRuleMetadata{ diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index c2baf8108ba..5a74c58f90e 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -193,6 +193,7 @@ func validateRecordingRuleFields(in *apimodels.PostableExtendedRuleNode, newRule newRule.For = 0 newRule.KeepFiringFor = 0 newRule.NotificationSettings = nil + newRule.MissingSeriesEvalsToResolve = nil return newRule, nil } diff --git a/pkg/services/ngalert/prom/convert.go b/pkg/services/ngalert/prom/convert.go index 13c95e70aa4..0e8ebf647d4 100644 --- a/pkg/services/ngalert/prom/convert.go +++ b/pkg/services/ngalert/prom/convert.go @@ -272,16 +272,16 @@ func (p *Converter) convertRule(orgID int64, namespaceUID string, promGroup Prom RuleGroup: promGroup.Name, IsPaused: isPaused, Record: record, + } + + if !isRecordingRule { + result.NotificationSettings = p.cfg.NotificationSettings // MissingSeriesEvalsToResolve is set to 1 to match the Prometheus behaviour. // Prometheus resolves alerts as soon as the series disappears. // By setting this value to 1 we ensure that the alert is resolved on the first evaluation // that doesn't have the series. - MissingSeriesEvalsToResolve: util.Pointer[int64](1), - } - - if !isRecordingRule { - result.NotificationSettings = p.cfg.NotificationSettings + result.MissingSeriesEvalsToResolve = util.Pointer[int64](1) } if p.cfg.KeepOriginalRuleDefinition != nil && *p.cfg.KeepOriginalRuleDefinition { diff --git a/pkg/services/ngalert/prom/convert_test.go b/pkg/services/ngalert/prom/convert_test.go index 503cd76dd64..d9542e24c5e 100644 --- a/pkg/services/ngalert/prom/convert_test.go +++ b/pkg/services/ngalert/prom/convert_test.go @@ -358,7 +358,12 @@ func TestPrometheusRulesToGrafana(t *testing.T) { require.Equal(t, models.Duration(evalOffset), grafanaRule.Data[0].RelativeTimeRange.To) require.Equal(t, models.Duration(10*time.Minute+evalOffset), grafanaRule.Data[0].RelativeTimeRange.From) - require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) + + if promRule.Record != "" { + require.Nil(t, grafanaRule.MissingSeriesEvalsToResolve) + } else { + require.Equal(t, util.Pointer(int64(1)), grafanaRule.MissingSeriesEvalsToResolve) + } require.Equal(t, models.OkErrState, grafanaRule.ExecErrState) require.Equal(t, models.OK, grafanaRule.NoDataState) From e38f007d305fc73beb4ad7c66697cd71a42a6071 Mon Sep 17 00:00:00 2001 From: Alexander Akhmetov Date: Wed, 24 Dec 2025 13:41:46 +0100 Subject: [PATCH 33/80] Alerting: Fetch alert rule provenances for a page of rules only (#115643) * Alerting: Fetch alert rule provenances for a page of rules only * error when failed to fetch provenance --- .../ngalert/api/api_prometheus_test.go | 134 ++++++++++++++++++ .../ngalert/api/prometheus/api_prometheus.go | 48 +++++-- .../ngalert/notifier/alertmanager_config.go | 1 + pkg/services/ngalert/provisioning/persist.go | 1 + .../provisioning/provisioning_store_mock.go | 61 ++++++++ .../ngalert/store/provisioning_store.go | 24 ++++ .../ngalert/store/provisioning_store_test.go | 49 +++++++ .../ngalert/tests/fakes/provisioning.go | 30 +++- 8 files changed, 331 insertions(+), 17 deletions(-) diff --git a/pkg/services/ngalert/api/api_prometheus_test.go b/pkg/services/ngalert/api/api_prometheus_test.go index 75ec6c901fd..dc0f6d1f13d 100644 --- a/pkg/services/ngalert/api/api_prometheus_test.go +++ b/pkg/services/ngalert/api/api_prometheus_test.go @@ -2369,6 +2369,140 @@ func TestRouteGetRuleStatuses(t *testing.T) { } }) + t.Run("multi-page pagination loads provenance correctly", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + // Create 3 groups with 1 rule each: groups 1 and 3 firing, group 2 normal + for i := 1; i <= 3; i++ { + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = fmt.Sprintf("group-%d", i) + r.UID = fmt.Sprintf("rule-%d", i) + }, withClassicConditionSingleQuery()).GenerateRef() + + alertState := eval.Normal + if i != 2 { + alertState = eval.Alerting + } + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = alertState + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + } + + // Set provenance for all rules + err := fakeProvisioning.SetProvenance(context.Background(), + &ngmodels.AlertRule{UID: "rule-1", OrgID: orgID}, orgID, ngmodels.ProvenanceAPI) + require.NoError(t, err) + err = fakeProvisioning.SetProvenance(context.Background(), + &ngmodels.AlertRule{UID: "rule-3", OrgID: orgID}, orgID, ngmodels.ProvenanceFile) + require.NoError(t, err) + + // Request firing groups with group_limit=2 - fetches multiple pages, skipping group 2 + req, err := http.NewRequest("GET", "/api/v1/rules?state=firing&group_limit=2", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusOK, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + + // Should return 2 firing groups + require.Len(t, res.Data.RuleGroups, 2) + require.Equal(t, "group-1", res.Data.RuleGroups[0].Name) + require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceAPI), res.Data.RuleGroups[0].Rules[0].Provenance) + require.Equal(t, "group-3", res.Data.RuleGroups[1].Name) + require.Equal(t, apimodels.Provenance(ngmodels.ProvenanceFile), res.Data.RuleGroups[1].Rules[0].Provenance) + }) + + t.Run("provenance fetch error returns error response in paginated mode", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = "group-1" + r.UID = "rule-1" + }, withClassicConditionSingleQuery()).GenerateRef() + + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = eval.Alerting + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + + fakeProvisioning.GetProvenancesByUIDsFunc = func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) { + return nil, errors.New("database connection failed") + } + + req, err := http.NewRequest("GET", "/api/v1/rules?group_limit=10", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusInternalServerError, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "failed to load provenance") + }) + + t.Run("provenance fetch error returns error response in non-paginated mode", func(t *testing.T) { + fakeStore, fakeAIM, api, fakeProvisioning := setupAPIFull(t) + + rule := gen.With(gen.WithOrgID(orgID), func(r *ngmodels.AlertRule) { + r.NamespaceUID = "ns-1" + r.RuleGroup = "group-1" + r.UID = "rule-1" + }, withClassicConditionSingleQuery()).GenerateRef() + + fakeAIM.GenerateAlertInstances(orgID, rule.UID, 1, func(s *state.State) *state.State { + s.State = eval.Alerting + s.Labels = data.Labels{"test": "label"} + return s + }) + fakeStore.PutRule(context.Background(), rule) + + fakeProvisioning.GetProvenancesFunc = func(ctx context.Context, orgID int64, resourceType string) (map[string]ngmodels.Provenance, error) { + return nil, errors.New("database connection failed") + } + + req, err := http.NewRequest("GET", "/api/v1/rules", nil) + require.NoError(t, err) + c := &contextmodel.ReqContext{ + Context: &web.Context{Req: req}, + SignedInUser: &user.SignedInUser{ + OrgID: orgID, + Permissions: queryPermissions, + }, + } + + resp := api.RouteGetRuleStatuses(c) + require.Equal(t, http.StatusInternalServerError, resp.Status()) + + var res apimodels.RuleResponse + require.NoError(t, json.Unmarshal(resp.Body(), &res)) + require.Equal(t, "error", res.Status) + require.Contains(t, res.Error, "failed to load provenance") + }) + t.Run("state filter continues when first page has no matches", func(t *testing.T) { fakeStore, fakeAIM, api := setupAPI(t) diff --git a/pkg/services/ngalert/api/prometheus/api_prometheus.go b/pkg/services/ngalert/api/prometheus/api_prometheus.go index b4e14a66cfe..077761d0caf 100644 --- a/pkg/services/ngalert/api/prometheus/api_prometheus.go +++ b/pkg/services/ngalert/api/prometheus/api_prometheus.go @@ -54,6 +54,7 @@ type StatusReader interface { type ProvenanceStore interface { GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]ngmodels.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]ngmodels.Provenance, error) } type PrometheusSrv struct { @@ -328,14 +329,6 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon span.AddEvent("User permissions checked") span.SetAttributes(attribute.Int("allowedNamespaces", len(allowedNamespaces))) - provenanceRecords, err := srv.provenanceStore.GetProvenances(c.Req.Context(), c.GetOrgID(), (&ngmodels.AlertRule{}).ResourceType()) - if err != nil { - ruleResponse.Status = "error" - ruleResponse.Error = fmt.Sprintf("failed to get provenances visible to the user: %s", err.Error()) - ruleResponse.ErrorType = apiv1.ErrServer - return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) - } - ruleResponse = PrepareRuleGroupStatusesV2( srv.log, srv.store, @@ -347,7 +340,7 @@ func (srv PrometheusSrv) RouteGetRuleStatuses(c *contextmodel.ReqContext) respon }, RuleStatusMutatorGenerator(srv.status), RuleAlertStateMutatorGenerator(srv.manager), - provenanceRecords, + srv.provenanceStore, ) return response.JSON(ruleResponse.HTTPStatusCode(), ruleResponse) @@ -454,6 +447,7 @@ func RuleAlertStateMutatorGenerator(manager state.AlertInstanceManager) RuleAler type paginationContext struct { opts RuleGroupStatusesOptions provenanceRecords map[string]ngmodels.Provenance + provenanceStore ProvenanceStore ruleStatusMutator RuleStatusMutator alertStateMutator RuleAlertStateMutator @@ -532,6 +526,37 @@ func (ctx *paginationContext) fetchAndFilterPage(log log.Logger, store ListAlert ) span.AddEvent("Alert rules retrieved from store") + // Load provenance for this page's rules + if ctx.provenanceStore != nil { + maxGroups := getInt64WithDefault(ctx.opts.Query, "group_limit", -1) + maxRules := getInt64WithDefault(ctx.opts.Query, "rule_limit", -1) + + if maxGroups > 0 || maxRules > 0 { + // Paginated, fetch and merge provenances for this page + uids := make([]string, 0, len(ruleList)) + for _, rule := range ruleList { + uids = append(uids, rule.UID) + } + pageProvenances, err := ctx.provenanceStore.GetProvenancesByUIDs(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType(), uids) + if err != nil { + return pageResult{}, fmt.Errorf("failed to load provenance: %w", err) + } + if ctx.provenanceRecords == nil { + ctx.provenanceRecords = pageProvenances + } else { + maps.Copy(ctx.provenanceRecords, pageProvenances) + } + } else if ctx.provenanceRecords == nil { + // Not paginated, fetch all once + var err error + ctx.provenanceRecords, err = ctx.provenanceStore.GetProvenances(ctx.opts.Ctx, ctx.opts.OrgID, (&ngmodels.AlertRule{}).ResourceType()) + if err != nil { + return pageResult{}, fmt.Errorf("failed to load provenance: %w", err) + } + } + } + span.AddEvent("Provenances retrieved from store") + groupedRules := getGroupedRules(log, ruleList, ctx.ruleNamesSet, ctx.opts.AllowedNamespaces) result := pageResult{ @@ -643,7 +668,7 @@ func paginateRuleGroups(log log.Logger, store ListAlertRulesStoreV2, ctx *pagina return allGroups, rulesTotals, continueToken, nil } -func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceRecords map[string]ngmodels.Provenance) apimodels.RuleResponse { +func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opts RuleGroupStatusesOptions, ruleStatusMutator RuleStatusMutator, alertStateMutator RuleAlertStateMutator, provenanceStore ProvenanceStore) apimodels.RuleResponse { ctx, span := tracer.Start(opts.Ctx, "api.prometheus.PrepareRuleGroupStatusesV2") defer span.End() opts.Ctx = ctx @@ -835,7 +860,8 @@ func PrepareRuleGroupStatusesV2(log log.Logger, store ListAlertRulesStoreV2, opt span.SetAttributes(attribute.Bool("compact", compact)) pagCtx := &paginationContext{ opts: opts, - provenanceRecords: provenanceRecords, + provenanceRecords: nil, + provenanceStore: provenanceStore, ruleStatusMutator: ruleStatusMutator, alertStateMutator: alertStateMutator, namespaceUIDs: namespaceUIDs, diff --git a/pkg/services/ngalert/notifier/alertmanager_config.go b/pkg/services/ngalert/notifier/alertmanager_config.go index 7e755903791..ba8d37809b1 100644 --- a/pkg/services/ngalert/notifier/alertmanager_config.go +++ b/pkg/services/ngalert/notifier/alertmanager_config.go @@ -485,6 +485,7 @@ func assignReceiverConfigsUIDs(c []*definitions.PostableApiReceiver) error { type provisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error } diff --git a/pkg/services/ngalert/provisioning/persist.go b/pkg/services/ngalert/provisioning/persist.go index 914a3644984..e6d6b37fc24 100644 --- a/pkg/services/ngalert/provisioning/persist.go +++ b/pkg/services/ngalert/provisioning/persist.go @@ -19,6 +19,7 @@ type alertmanagerConfigStore interface { type ProvisioningStore interface { GetProvenance(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) GetProvenances(ctx context.Context, org int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error DeleteProvenance(ctx context.Context, o models.Provisionable, org int64) error } diff --git a/pkg/services/ngalert/provisioning/provisioning_store_mock.go b/pkg/services/ngalert/provisioning/provisioning_store_mock.go index 31cc77e26a6..bbc115d87c1 100644 --- a/pkg/services/ngalert/provisioning/provisioning_store_mock.go +++ b/pkg/services/ngalert/provisioning/provisioning_store_mock.go @@ -188,6 +188,67 @@ func (_c *MockProvisioningStore_GetProvenances_Call) RunAndReturn(run func(conte return _c } +// GetProvenancesByUIDs provides a mock function with given fields: ctx, org, resourceType, uids +func (_m *MockProvisioningStore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + ret := _m.Called(ctx, org, resourceType, uids) + + if len(ret) == 0 { + panic("no return value specified for GetProvenancesByUIDs") + } + + var r0 map[string]models.Provenance + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) (map[string]models.Provenance, error)); ok { + return rf(ctx, org, resourceType, uids) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, string, []string) map[string]models.Provenance); ok { + r0 = rf(ctx, org, resourceType, uids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]models.Provenance) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, string, []string) error); ok { + r1 = rf(ctx, org, resourceType, uids) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockProvisioningStore_GetProvenancesByUIDs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetProvenancesByUIDs' +type MockProvisioningStore_GetProvenancesByUIDs_Call struct { + *mock.Call +} + +// GetProvenancesByUIDs is a helper method to define mock.On call +// - ctx context.Context +// - org int64 +// - resourceType string +// - uids []string +func (_e *MockProvisioningStore_Expecter) GetProvenancesByUIDs(ctx interface{}, org interface{}, resourceType interface{}, uids interface{}) *MockProvisioningStore_GetProvenancesByUIDs_Call { + return &MockProvisioningStore_GetProvenancesByUIDs_Call{Call: _e.mock.On("GetProvenancesByUIDs", ctx, org, resourceType, uids)} +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Run(run func(ctx context.Context, org int64, resourceType string, uids []string)) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(int64), args[2].(string), args[3].([]string)) + }) + return _c +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) Return(_a0 map[string]models.Provenance, _a1 error) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockProvisioningStore_GetProvenancesByUIDs_Call) RunAndReturn(run func(context.Context, int64, string, []string) (map[string]models.Provenance, error)) *MockProvisioningStore_GetProvenancesByUIDs_Call { + _c.Call.Return(run) + return _c +} + // SetProvenance provides a mock function with given fields: ctx, o, org, p func (_m *MockProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { ret := _m.Called(ctx, o, org, p) diff --git a/pkg/services/ngalert/store/provisioning_store.go b/pkg/services/ngalert/store/provisioning_store.go index 27f03143c98..df5d7dc80c6 100644 --- a/pkg/services/ngalert/store/provisioning_store.go +++ b/pkg/services/ngalert/store/provisioning_store.go @@ -62,6 +62,30 @@ func (st DBstore) GetProvenances(ctx context.Context, org int64, resourceType st return resultMap, err } +// GetProvenancesByUIDs gets the provenance status for specific UIDs. +func (st DBstore) GetProvenancesByUIDs(ctx context.Context, org int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + if len(uids) == 0 { + return map[string]models.Provenance{}, nil + } + + result := make(map[string]models.Provenance, len(uids)) + err := st.SQLStore.WithDbSession(ctx, func(sess *db.Session) error { + rawData, err := sess.Table(provenanceRecord{}). + Where("record_type = ? AND org_id = ?", resourceType, org). + In("record_key", uids). + Cols("record_key", "provenance"). + QueryString() + if err != nil { + return fmt.Errorf("failed to query for existing provenance status: %w", err) + } + for _, data := range rawData { + result[data["record_key"]] = models.Provenance(data["provenance"]) + } + return nil + }) + return result, err +} + // SetProvenance changes the provenance status for a provisionable object. func (st DBstore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { recordType := o.ResourceType() diff --git a/pkg/services/ngalert/store/provisioning_store_test.go b/pkg/services/ngalert/store/provisioning_store_test.go index b6f8b8fe5cd..359d594da0a 100644 --- a/pkg/services/ngalert/store/provisioning_store_test.go +++ b/pkg/services/ngalert/store/provisioning_store_test.go @@ -133,6 +133,55 @@ func TestIntegrationProvisioningStore(t *testing.T) { require.Equal(t, models.ProvenanceAPI, p[rule2.UID]) }) + t.Run("Store should return provenances by UIDs", func(t *testing.T) { + const orgID = 124 + rule1 := models.AlertRule{UID: "uid-1", OrgID: orgID} + rule2 := models.AlertRule{UID: "uid-2", OrgID: orgID} + rule3 := models.AlertRule{UID: "uid-3", OrgID: orgID} + + err := store.SetProvenance(context.Background(), &rule1, orgID, models.ProvenanceFile) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &rule2, orgID, models.ProvenanceAPI) + require.NoError(t, err) + err = store.SetProvenance(context.Background(), &rule3, orgID, models.ProvenanceFile) + require.NoError(t, err) + + // Fetch only rule1 and rule2 + p, err := store.GetProvenancesByUIDs(context.Background(), orgID, rule1.ResourceType(), []string{rule1.UID, rule2.UID}) + require.NoError(t, err) + require.Len(t, p, 2) + require.Equal(t, models.ProvenanceFile, p[rule1.UID]) + require.Equal(t, models.ProvenanceAPI, p[rule2.UID]) + _, exists := p[rule3.UID] + require.False(t, exists) + }) + + t.Run("GetProvenancesByUIDs returns empty map for empty UIDs", func(t *testing.T) { + p, err := store.GetProvenancesByUIDs(context.Background(), 1, "alertRule", []string{}) + require.NoError(t, err) + require.Empty(t, p) + }) + + t.Run("GetProvenancesByUIDs respects org ID", func(t *testing.T) { + const orgID1 = 125 + const orgID2 = 126 + rule := models.AlertRule{UID: "cross-org-uid"} + + err := store.SetProvenance(context.Background(), &rule, orgID1, models.ProvenanceFile) + require.NoError(t, err) + + // Should not find in different org + p, err := store.GetProvenancesByUIDs(context.Background(), orgID2, rule.ResourceType(), []string{rule.UID}) + require.NoError(t, err) + require.Empty(t, p) + + // Should find in correct org + p, err = store.GetProvenancesByUIDs(context.Background(), orgID1, rule.ResourceType(), []string{rule.UID}) + require.NoError(t, err) + require.Len(t, p, 1) + require.Equal(t, models.ProvenanceFile, p[rule.UID]) + }) + t.Run("Store should delete provenance correctly", func(t *testing.T) { const orgID = 1234 ruleOrg := models.AlertRule{ diff --git a/pkg/services/ngalert/tests/fakes/provisioning.go b/pkg/services/ngalert/tests/fakes/provisioning.go index 43de0a6dc68..fce2586f120 100644 --- a/pkg/services/ngalert/tests/fakes/provisioning.go +++ b/pkg/services/ngalert/tests/fakes/provisioning.go @@ -8,12 +8,13 @@ import ( ) type FakeProvisioningStore struct { - Calls []Call - Records map[int64]map[string]models.Provenance - GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) - GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) - SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error - DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error + Calls []Call + Records map[int64]map[string]models.Provenance + GetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) (models.Provenance, error) + GetProvenancesFunc func(ctx context.Context, orgID int64, resourceType string) (map[string]models.Provenance, error) + GetProvenancesByUIDsFunc func(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) + SetProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error + DeleteProvenanceFunc func(ctx context.Context, o models.Provisionable, org int64) error } func NewFakeProvisioningStore() *FakeProvisioningStore { @@ -51,6 +52,23 @@ func (f *FakeProvisioningStore) GetProvenances(ctx context.Context, orgID int64, return results, nil } +func (f *FakeProvisioningStore) GetProvenancesByUIDs(ctx context.Context, orgID int64, resourceType string, uids []string) (map[string]models.Provenance, error) { + f.Calls = append(f.Calls, Call{MethodName: "GetProvenancesByUIDs", Arguments: []any{ctx, orgID, resourceType, uids}}) + if f.GetProvenancesByUIDsFunc != nil { + return f.GetProvenancesByUIDsFunc(ctx, orgID, resourceType, uids) + } + results := make(map[string]models.Provenance) + if val, ok := f.Records[orgID]; ok { + for _, uid := range uids { + key := uid + resourceType + if prov, ok := val[key]; ok { + results[uid] = prov + } + } + } + return results, nil +} + func (f *FakeProvisioningStore) SetProvenance(ctx context.Context, o models.Provisionable, org int64, p models.Provenance) error { f.Calls = append(f.Calls, Call{MethodName: "SetProvenance", Arguments: []any{ctx, o, org, p}}) if f.SetProvenanceFunc != nil { From fa1e6cce5e217f01c93a8cdedc344bc8122b4eea Mon Sep 17 00:00:00 2001 From: Yuri Tseretyan Date: Fri, 26 Dec 2025 16:55:57 -0500 Subject: [PATCH 34/80] Alerting: Rule backtesting with experimental UI (#115525) * add function to convert StateTransition to LokiEntry * add QueryResultBuilder * update backtesting to produce result similar to historian * make shouldRecord public * filter out noop transitions * add experimental front-end * add new fields * move conversion of api model to AlertRule to validation * add extra labels * calculate tick timestamp using the same logic as in scheduler * implement correct logic of calculating first evaluation timestamp * add uid, group and folder uid they are needed for jitter strategy * add JitterOffsetInDuration and JitterStrategy.String() * add config `backtesting_max_evaluations` to [unified_alerting] (not documented for now) * remove obsolete tests * elevate permisisons for backtesting endpoint * move backtesting to separate dir --- pkg/services/ngalert/api/api.go | 2 +- pkg/services/ngalert/api/api_testing.go | 60 +--- pkg/services/ngalert/api/authorization.go | 10 +- .../api/tooling/definitions/testing.go | 20 +- .../api/validation/api_ruler_validation.go | 57 ++- pkg/services/ngalert/backtesting/engine.go | 235 +++++++++--- .../ngalert/backtesting/engine_test.go | 338 ++++++++++-------- pkg/services/ngalert/backtesting/eval_data.go | 5 +- .../ngalert/backtesting/eval_data_test.go | 35 +- .../ngalert/backtesting/eval_query.go | 5 +- .../ngalert/backtesting/eval_query_test.go | 33 +- pkg/services/ngalert/models/alert_rule.go | 4 + pkg/services/ngalert/schedule/jitter.go | 10 + .../ngalert/schedule/ticker/ticker.go | 6 +- pkg/services/ngalert/state/historian/core.go | 6 +- .../ngalert/state/historian/core_test.go | 2 +- pkg/services/ngalert/state/historian/loki.go | 132 ++++--- pkg/setting/setting_unified_alerting.go | 7 + .../api/alerting/api_backtesting_test.go | 3 +- .../test-data/api_backtesting_data.json | 6 + .../alerting/unified/api/backtestApi.ts | 50 +++ .../backtesting/BacktestDropdownButton.tsx | 63 ++++ .../components/backtesting/BacktestPanel.tsx | 200 +++++++++++ .../alert-rule-form/AlertRuleForm.tsx | 3 + public/locales/en-US/grafana.json | 11 +- 25 files changed, 964 insertions(+), 339 deletions(-) create mode 100644 public/app/features/alerting/unified/api/backtestApi.ts create mode 100644 public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx create mode 100644 public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx diff --git a/pkg/services/ngalert/api/api.go b/pkg/services/ngalert/api/api.go index eefbb6dea30..e2b60ad6e39 100644 --- a/pkg/services/ngalert/api/api.go +++ b/pkg/services/ngalert/api/api.go @@ -161,7 +161,7 @@ func (api *API) RegisterAPIEndpoints(m *metrics.API) { authz: ruleAuthzService, evaluator: api.EvaluatorFactory, cfg: &api.Cfg.UnifiedAlerting, - backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer), + backtesting: backtesting.NewEngine(api.AppUrl, api.EvaluatorFactory, api.Tracer, api.Cfg.UnifiedAlerting, api.FeatureManager), featureManager: api.FeatureManager, appUrl: api.AppUrl, tracer: api.Tracer, diff --git a/pkg/services/ngalert/api/api_testing.go b/pkg/services/ngalert/api/api_testing.go index 3bda2e3f28f..13bc1a96c24 100644 --- a/pkg/services/ngalert/api/api_testing.go +++ b/pkg/services/ngalert/api/api_testing.go @@ -34,7 +34,6 @@ import ( "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/services/ngalert/store" "github.com/grafana/grafana/pkg/setting" - "github.com/grafana/grafana/pkg/util" ) type folderService interface { @@ -230,54 +229,27 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(http.StatusNotFound, nil, "Backgtesting API is not enabled") } - if cmd.From.After(cmd.To) { - return ErrResp(400, nil, "From cannot be greater than To") - } - - noDataState, err := ngmodels.NoDataStateFromString(string(cmd.NoDataState)) - + rule, err := apivalidation.ValidateBacktestConfig(c.GetOrgID(), cmd, apivalidation.RuleLimitsFromConfig(srv.cfg, srv.featureManager)) if err != nil { - return ErrResp(400, err, "") - } - forInterval := time.Duration(cmd.For) - if forInterval < 0 { - return ErrResp(400, nil, "Bad For interval") + return ErrResp(http.StatusBadRequest, err, "") } - intervalSeconds, err := apivalidation.ValidateInterval(time.Duration(cmd.Interval), srv.cfg.BaseInterval) - if err != nil { - return ErrResp(400, err, "") - } - - queries := AlertQueriesFromApiAlertQueries(cmd.Data) - if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, &ngmodels.AlertRule{Data: queries}); err != nil { + if err := srv.authz.AuthorizeDatasourceAccessForRule(c.Req.Context(), c.SignedInUser, rule); err != nil { return errorToResponse(err) } - rule := &ngmodels.AlertRule{ - // ID: 0, - // Updated: time.Time{}, - // Version: 0, - // NamespaceUID: "", - // DashboardUID: nil, - // PanelID: nil, - // RuleGroup: "", - // RuleGroupIndex: 0, - // ExecErrState: "", - Title: cmd.Title, - // prefix backtesting- is to distinguish between executions of regular rule and backtesting in logs (like expression engine, evaluator, state manager etc) - UID: "backtesting-" + util.GenerateShortUID(), - OrgID: c.GetOrgID(), - Condition: cmd.Condition, - Data: queries, - IntervalSeconds: intervalSeconds, - NoDataState: noDataState, - For: forInterval, - Annotations: cmd.Annotations, - Labels: cmd.Labels, + // Fetch folder path for alert labels, fallback to "Backtesting" if not available + var folderTitle string + if cmd.NamespaceUID != "" { + f, err := srv.folderService.GetNamespaceByUID(c.Req.Context(), cmd.NamespaceUID, c.OrgID, c.SignedInUser) + if err != nil { + srv.log.FromContext(c.Req.Context()).Warn("Failed to fetch folder path for alert labels", "error", err) + } else { + folderTitle = f.Fullpath + } } - result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To) + result, err := srv.backtesting.Test(c.Req.Context(), c.SignedInUser, rule, cmd.From, cmd.To, folderTitle) if err != nil { if errors.Is(err, backtesting.ErrInvalidInputData) { return ErrResp(400, err, "Failed to evaluate") @@ -285,9 +257,5 @@ func (srv TestingApiSrv) BacktestAlertRule(c *contextmodel.ReqContext, cmd apimo return ErrResp(500, err, "Failed to evaluate") } - body, err := data.FrameToJSON(result, data.IncludeAll) - if err != nil { - return ErrResp(500, err, "Failed to convert frame to JSON") - } - return response.JSON(http.StatusOK, body) + return response.JSONStreaming(http.StatusOK, result) } diff --git a/pkg/services/ngalert/api/authorization.go b/pkg/services/ngalert/api/authorization.go index 1c107db22c6..7f8b42bb3a4 100644 --- a/pkg/services/ngalert/api/authorization.go +++ b/pkg/services/ngalert/api/authorization.go @@ -81,9 +81,15 @@ func (api *API) authorize(method, path string) web.Handler { // additional authorization is done in the request handler eval = ac.EvalPermission(ac.ActionAlertingRuleRead) // Grafana Rules Testing Paths - case http.MethodPost + "/api/v1/rule/backtest": + case http.MethodPost + "/api/v1/rule/backtest": // TODO (yuri) this should be protected by dedicated permission // additional authorization is done in the request handler - eval = ac.EvalPermission(ac.ActionAlertingRuleRead) + eval = ac.EvalAll( + ac.EvalPermission(ac.ActionAlertingRuleRead), + ac.EvalAny( + ac.EvalPermission(ac.ActionAlertingRuleUpdate), + ac.EvalPermission(ac.ActionAlertingRuleCreate), + ), + ) case http.MethodPost + "/api/v1/eval": // additional authorization is done in the request handler eval = ac.EvalPermission(ac.ActionAlertingRuleRead) diff --git a/pkg/services/ngalert/api/tooling/definitions/testing.go b/pkg/services/ngalert/api/tooling/definitions/testing.go index 2c228e94758..e094c8515b3 100644 --- a/pkg/services/ngalert/api/tooling/definitions/testing.go +++ b/pkg/services/ngalert/api/tooling/definitions/testing.go @@ -221,15 +221,21 @@ type BacktestConfig struct { To time.Time `json:"to"` Interval model.Duration `json:"interval,omitempty"` - Condition string `json:"condition"` - Data []AlertQuery `json:"data"` - For model.Duration `json:"for,omitempty"` + Condition string `json:"condition"` + Data []AlertQuery `json:"data"` + For *model.Duration `json:"for,omitempty"` + KeepFiringFor *model.Duration `json:"keep_firing_for,omitempty"` - Title string `json:"title"` - Labels map[string]string `json:"labels,omitempty"` - Annotations map[string]string `json:"annotations,omitempty"` + Title string `json:"title"` + Labels map[string]string `json:"labels,omitempty"` - NoDataState NoDataState `json:"no_data_state"` + NoDataState NoDataState `json:"no_data_state"` + ExecErrState ExecutionErrorState `json:"exec_err_state"` + MissingSeriesEvalsToResolve *int64 `json:"missing_series_evals_to_resolve,omitempty"` + + UID string `json:"uid,omitempty"` + RuleGroup string `json:"rule_group,omitempty"` + NamespaceUID string `json:"namespace_uid,omitempty"` } // swagger:model diff --git a/pkg/services/ngalert/api/validation/api_ruler_validation.go b/pkg/services/ngalert/api/validation/api_ruler_validation.go index 5a74c58f90e..09e601a4711 100644 --- a/pkg/services/ngalert/api/validation/api_ruler_validation.go +++ b/pkg/services/ngalert/api/validation/api_ruler_validation.go @@ -249,6 +249,21 @@ func ValidateCondition(condition string, queries []apimodels.AlertQuery, canPatc return nil } +func validateGroupInterval(incoming prommodels.Duration, limits RuleLimits) (time.Duration, error) { + interval := time.Duration(incoming) + if interval == 0 { + // if group interval is 0 (undefined) then we automatically fall back to the default interval + interval = limits.DefaultRuleEvaluationInterval + } + + if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { + return 0, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) + } + + // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval + return interval, nil +} + func ValidateInterval(interval, baseInterval time.Duration) (int64, error) { intervalSeconds := int64(interval.Seconds()) @@ -336,18 +351,11 @@ func ValidateRuleGroup( return nil, fmt.Errorf("rule group name is too long. Max length is %d", store.AlertRuleMaxRuleGroupNameLength) } - interval := time.Duration(ruleGroupConfig.Interval) - if interval == 0 { - // if group interval is 0 (undefined) then we automatically fall back to the default interval - interval = limits.DefaultRuleEvaluationInterval + interval, err := validateGroupInterval(ruleGroupConfig.Interval, limits) + if err != nil { + return nil, err } - if interval < 0 || int64(interval.Seconds())%int64(limits.BaseInterval.Seconds()) != 0 { - return nil, fmt.Errorf("rule evaluation interval (%d second) should be positive number that is multiple of the base interval of %d seconds", int64(interval.Seconds()), int64(limits.BaseInterval.Seconds())) - } - - // TODO should we validate that interval is >= cfg.MinInterval? Currently, we allow to save but fix the specified interval if it is < cfg.MinInterval - // If the rule group is reserved for no-group rules, we cannot have multiple rules in it. if isNoGroupRuleGroup && len(ruleGroupConfig.Rules) > 1 { return nil, fmt.Errorf("rule group %s is reserved for no-group rules and cannot be used for rule groups with multiple rules", ruleGroupConfig.Name) @@ -410,3 +418,32 @@ func ValidateNotificationSettings(n *apimodels.AlertRuleNotificationSettings) ([ s, }, nil } + +func ValidateBacktestConfig(orgId int64, config apimodels.BacktestConfig, limits RuleLimits) (*ngmodels.AlertRule, error) { + if config.From.After(config.To) { + return nil, fmt.Errorf("invalid testing range: from %s must be before to %s", config.From, config.To) + } + + interval, err := validateGroupInterval(config.Interval, limits) + if err != nil { + return nil, err + } + + return ValidateRuleNode(&apimodels.PostableExtendedRuleNode{ + ApiRuleNode: &apimodels.ApiRuleNode{ + For: config.For, + KeepFiringFor: config.KeepFiringFor, + Labels: config.Labels, + Annotations: nil, + }, + GrafanaManagedAlert: &apimodels.PostableGrafanaRule{ + Title: config.Title, + Condition: config.Condition, + Data: config.Data, + UID: config.UID, + NoDataState: config.NoDataState, + ExecErrState: config.ExecErrState, + MissingSeriesEvalsToResolve: config.MissingSeriesEvalsToResolve, + }, + }, config.RuleGroup, interval, orgId, config.NamespaceUID, limits) +} diff --git a/pkg/services/ngalert/backtesting/engine.go b/pkg/services/ngalert/backtesting/engine.go index 31c968eb234..b4a534fe134 100644 --- a/pkg/services/ngalert/backtesting/engine.go +++ b/pkg/services/ngalert/backtesting/engine.go @@ -15,10 +15,16 @@ import ( "github.com/grafana/grafana/pkg/apimachinery/identity" "github.com/grafana/grafana/pkg/infra/log" "github.com/grafana/grafana/pkg/infra/tracing" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/services/ngalert/schedule" + "github.com/grafana/grafana/pkg/services/ngalert/schedule/ticker" "github.com/grafana/grafana/pkg/services/ngalert/state" + "github.com/grafana/grafana/pkg/services/ngalert/state/historian" + history_model "github.com/grafana/grafana/pkg/services/ngalert/state/historian/model" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/util" ) var ( @@ -28,7 +34,7 @@ var ( backtestingEvaluatorFactory = newBacktestingEvaluator ) -type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) error +type callbackFunc = func(evaluationIndex int, now time.Time, results eval.Results) (bool, error) type backtestingEvaluator interface { Eval(ctx context.Context, from time.Time, interval time.Duration, evaluations int, callback callbackFunc) error @@ -40,11 +46,17 @@ type stateManager interface { } type Engine struct { - evalFactory eval.EvaluatorFactory - createStateManager func() stateManager + evalFactory eval.EvaluatorFactory + createStateManager func() stateManager + disableGrafanaFolder bool + featureToggles featuremgmt.FeatureToggles + minInterval time.Duration + baseInterval time.Duration + jitterStrategy schedule.JitterStrategy + maxEvaluations int } -func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer) *Engine { +func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracing.Tracer, cfg setting.UnifiedAlertingSettings, toggles featuremgmt.FeatureToggles) *Engine { return &Engine{ evalFactory: evalFactory, createStateManager: func() stateManager { @@ -60,74 +72,139 @@ func NewEngine(appUrl *url.URL, evalFactory eval.EvaluatorFactory, tracer tracin } return state.NewManager(cfg, state.NewNoopPersister()) }, + disableGrafanaFolder: false, + featureToggles: toggles, + minInterval: cfg.MinInterval, + baseInterval: cfg.BaseInterval, + maxEvaluations: cfg.BacktestingMaxEvaluations, + jitterStrategy: schedule.JitterStrategyFrom(cfg, toggles), } } -func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time) (*data.Frame, error) { - ruleCtx := models.WithRuleKey(ctx, rule.GetKey()) - logger := logger.FromContext(ctx) - +func (e *Engine) Test(ctx context.Context, user identity.Requester, rule *models.AlertRule, from, to time.Time, folderTitle string) (res *data.Frame, err error) { + if rule == nil { + return nil, fmt.Errorf("%w: rule is not defined", ErrInvalidInputData) + } if !from.Before(to) { - return nil, fmt.Errorf("%w: invalid interval of the backtesting [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix()) + return nil, fmt.Errorf("%w: invalid interval [%d,%d]", ErrInvalidInputData, from.Unix(), to.Unix()) } - if to.Sub(from).Seconds() < float64(rule.IntervalSeconds) { - return nil, fmt.Errorf("%w: interval of the backtesting [%d,%d] is less than evaluation interval [%ds]", ErrInvalidInputData, from.Unix(), to.Unix(), rule.IntervalSeconds) + + ruleCtx := models.WithRuleKey(ctx, rule.GetKey()) + logger := logger.FromContext(ruleCtx).New("backtesting", util.GenerateShortUID()) + + var warns []string + if rule.GetInterval() < e.minInterval { + logger.Warn("Interval adjusted to minimal interval", "originalInterval", rule.GetInterval(), "adjustedInterval", e.minInterval) + rule = rule.Copy() + rule.IntervalSeconds = int64(e.minInterval.Seconds()) + warns = append(warns, fmt.Sprintf("Interval adjusted to minimal interval %ds", rule.IntervalSeconds)) } - length := int(to.Sub(from).Seconds()) / int(rule.IntervalSeconds) - stateManager := e.createStateManager() + effectiveStrategy := e.jitterStrategy + if e.jitterStrategy == schedule.JitterByGroup && (rule.RuleGroup == "" || rule.NamespaceUID == "") || + e.jitterStrategy == schedule.JitterByRule && rule.UID == "" { + logger.Warn(fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter", e.jitterStrategy)) + warns = append(warns, fmt.Sprintf("Jitter strategy is set to %s, but rule group or namespace is not set. Ignore jitter. The results of testing will be different than real evaluations", e.jitterStrategy)) + effectiveStrategy = schedule.JitterNever + } + jitterOffset := schedule.JitterOffsetInDuration(rule, e.baseInterval, effectiveStrategy) + firstEval, err := getFirstEvaluationTime(from, rule, e.baseInterval, jitterOffset) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrInvalidInputData, err) + } - evaluator, err := backtestingEvaluatorFactory(ruleCtx, e.evalFactory, user, rule.GetEvalCondition().WithSource("backtesting"), &schedule.AlertingResultsFromRuleState{ - Manager: stateManager, - Rule: rule, - }) + evaluations := calculateNumberOfEvaluations(firstEval, to, rule.GetInterval()) + if e.maxEvaluations > 0 && evaluations > e.maxEvaluations { + logger.Warn("Evaluations adjusted to maximal number", "originalEvaluations", evaluations, "adjustedEvaluations", e.maxEvaluations) + warns = append(warns, fmt.Sprintf("Number of evaluations are adjusted to the limit of %d evaluations. Requested: %d", e.maxEvaluations, evaluations)) + evaluations = e.maxEvaluations + } + + start := time.Now() + defer func() { + if err == nil { + logger.Info("Rule testing finished successfully", "duration", time.Since(start)) + } else { + logger.Error("Rule testing finished with error", "duration", time.Since(start), "error", err) + } + }() + + stateMgr := e.createStateManager() + + evaluator, err := backtestingEvaluatorFactory(ruleCtx, + e.evalFactory, + user, + rule.GetEvalCondition().WithSource("backtesting"), + &schedule.AlertingResultsFromRuleState{ + Manager: stateMgr, + Rule: rule, + }, + ) if err != nil { return nil, errors.Join(ErrInvalidInputData, err) } - logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluations", length) + logger.Info("Start testing alert rule", "from", from, "to", to, "interval", rule.GetInterval(), "firstTick", firstEval, "evaluations", evaluations, "jitterOffset", jitterOffset, "jitterStrategy", effectiveStrategy) - start := time.Now() + var builder *historian.QueryResultBuilder - tsField := data.NewField("Time", nil, make([]time.Time, length)) - valueFields := make(map[data.Fingerprint]*data.Field) - - err = evaluator.Eval(ruleCtx, from, time.Duration(rule.IntervalSeconds)*time.Second, length, func(idx int, currentTime time.Time, results eval.Results) error { - if idx >= length { - logger.Info("Unexpected evaluation. Skipping", "from", from, "to", to, "interval", rule.IntervalSeconds, "evaluationTime", currentTime, "evaluationIndex", idx, "expectedEvaluations", length) - return nil - } - states := stateManager.ProcessEvalResults(ruleCtx, currentTime, rule, results, nil, nil) - tsField.Set(idx, currentTime) - for _, s := range states { - field, ok := valueFields[s.CacheID] - if !ok { - field = data.NewField("", s.Labels, make([]*string, length)) - valueFields[s.CacheID] = field - } - if s.State.State != eval.NoData { // set nil if NoData - value := s.State.State.String() - if s.StateReason != "" { - value += " (" + s.StateReason + ")" - } - field.Set(idx, &value) - continue - } - } - return nil - }) - fields := make([]*data.Field, 0, len(valueFields)+1) - fields = append(fields, tsField) - for _, f := range valueFields { - fields = append(fields, f) + ruleMeta := history_model.RuleMeta{ + ID: rule.ID, + OrgID: rule.OrgID, + UID: rule.UID, + Title: rule.Title, + Group: rule.RuleGroup, + NamespaceUID: rule.NamespaceUID, + // DashboardUID: "", + // PanelID: 0, + Condition: rule.Condition, } - result := data.NewFrame("Testing results", fields...) - + labels := map[string]string{ + historian.OrgIDLabel: fmt.Sprint(ruleMeta.OrgID), + historian.GroupLabel: fmt.Sprint(ruleMeta.Group), + historian.FolderUIDLabel: fmt.Sprint(rule.NamespaceUID), + } + labelsBytes, err := json.Marshal(labels) if err != nil { return nil, err } - logger.Info("Rule testing finished successfully", "duration", time.Since(start)) - return result, nil + + // Ensure fallback if empty string is passed + if folderTitle == "" { + folderTitle = "Backtesting" + } + extraLabels := state.GetRuleExtraLabels(logger, rule, folderTitle, !e.disableGrafanaFolder, e.featureToggles) + + processFn := func(idx int, currentTime time.Time, results eval.Results) (bool, error) { + // init the builder. Do the best guess for the size of the result + if builder == nil { + builder = historian.NewQueryResultBuilder(evaluations * len(results)) + for _, warn := range warns { + builder.AddWarn(warn) + } + } + states := stateMgr.ProcessEvalResults(ruleCtx, currentTime, rule, results, extraLabels, nil) + for _, s := range states { + if !historian.ShouldRecord(s) { + continue + } + entry := historian.StateTransitionToLokiEntry(ruleMeta, s) + err := builder.AddRow(currentTime, entry, labelsBytes) + if err != nil { + return false, err + } + } + return idx <= evaluations, nil + } + + err = evaluator.Eval(ruleCtx, firstEval, rule.GetInterval(), evaluations, processFn) + if err != nil { + return nil, err + } + if builder == nil { + return nil, errors.New("no results were produced") + } + return builder.ToFrame(), nil } func newBacktestingEvaluator(ctx context.Context, evalFactory eval.EvaluatorFactory, user identity.Requester, condition models.Condition, reader eval.AlertingResultsReader) (backtestingEvaluator, error) { @@ -173,3 +250,53 @@ type NoopImageService struct{} func (s *NoopImageService) NewImage(_ context.Context, _ *models.AlertRule) (*models.Image, error) { return &models.Image{}, nil } + +func getNextEvaluationTime(currentTime time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) { + if rule.IntervalSeconds%int64(baseInterval.Seconds()) != 0 { + return time.Time{}, fmt.Errorf("interval %ds is not divisible by base interval %ds", rule.IntervalSeconds, int64(baseInterval.Seconds())) + } + + freq := rule.IntervalSeconds / int64(baseInterval.Seconds()) + + firstTickNum := currentTime.Unix() / int64(baseInterval.Seconds()) + + jitterOffsetTicks := int64(jitterOffset / baseInterval) + + firstEvalTickNum := firstTickNum + (jitterOffsetTicks-(firstTickNum%freq)+freq)%freq + + return time.Unix(firstEvalTickNum*int64(baseInterval.Seconds()), 0), nil +} + +func getFirstEvaluationTime(from time.Time, rule *models.AlertRule, baseInterval time.Duration, jitterOffset time.Duration) (time.Time, error) { + // Now calculate the time of the tick the same way as in the scheduler + firstTick := ticker.GetStartTick(from, baseInterval) + + // calculate time of the first evaluation that is at or after the first tick + firstEval, err := getNextEvaluationTime(firstTick, rule, baseInterval, jitterOffset) + if err != nil { + return time.Time{}, err + } + + // Ensure firstEval is at or after from + // Calculate how many intervals to skip to get past 'from' + if firstEval.Before(from) { + diff := from.Sub(firstEval) + interval := rule.GetInterval() + // Ceiling division: how many intervals needed to cover the difference + intervalsToAdd := (diff + interval - 1) / interval + firstEval = firstEval.Add(interval * intervalsToAdd) + } + + return firstEval, nil +} + +func calculateNumberOfEvaluations(firstEval, to time.Time, interval time.Duration) int { + var evaluations int + if to.After(firstEval) { + evaluations = int(to.Sub(firstEval).Seconds()) / int(interval.Seconds()) + } + if evaluations == 0 { + evaluations = 1 + } + return evaluations +} diff --git a/pkg/services/ngalert/backtesting/engine_test.go b/pkg/services/ngalert/backtesting/engine_test.go index d2685e71535..33441d73f32 100644 --- a/pkg/services/ngalert/backtesting/engine_test.go +++ b/pkg/services/ngalert/backtesting/engine_test.go @@ -4,7 +4,6 @@ import ( "context" "encoding/json" "errors" - "fmt" "math/rand" "testing" "time" @@ -14,9 +13,11 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/data" "github.com/grafana/grafana/pkg/apimachinery/identity" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/ngalert/eval" "github.com/grafana/grafana/pkg/services/ngalert/eval/eval_mocks" "github.com/grafana/grafana/pkg/services/ngalert/models" + "github.com/grafana/grafana/pkg/services/ngalert/schedule" "github.com/grafana/grafana/pkg/services/ngalert/state" "github.com/grafana/grafana/pkg/util" ) @@ -158,16 +159,6 @@ func TestNewBacktestingEvaluator(t *testing.T) { } func TestEvaluatorTest(t *testing.T) { - states := []eval.State{eval.Normal, eval.Alerting, eval.Pending} - generateState := func(prefix string) *state.State { - labels := models.GenerateAlertLabels(rand.Intn(5)+1, prefix+"-") - return &state.State{ - CacheID: labels.Fingerprint(), - Labels: labels, - State: states[rand.Intn(len(states))], - } - } - randomResultCallback := func(now time.Time) (eval.Results, error) { return eval.GenerateResults(rand.Intn(5)+1, eval.ResultGen()), nil } @@ -189,84 +180,17 @@ func TestEvaluatorTest(t *testing.T) { createStateManager: func() stateManager { return manager }, + disableGrafanaFolder: false, + featureToggles: featuremgmt.WithFeatures(), + minInterval: 1 * time.Second, + baseInterval: 1 * time.Second, + jitterStrategy: schedule.JitterNever, + maxEvaluations: 10000, } gen := models.RuleGen rule := gen.With(gen.WithInterval(time.Second)).GenerateRef() ruleInterval := time.Duration(rule.IntervalSeconds) * time.Second - t.Run("should return data frame in specific format", func(t *testing.T) { - from := time.Unix(0, 0) - to := from.Add(5 * ruleInterval) - allStates := [...]eval.State{eval.Normal, eval.Alerting, eval.Pending, eval.NoData, eval.Error} - - var states []state.StateTransition - - for _, s := range allStates { - labels := models.GenerateAlertLabels(rand.Intn(5)+1, s.String()+"-") - states = append(states, state.StateTransition{ - State: &state.State{ - CacheID: labels.Fingerprint(), - Labels: labels, - State: s, - StateReason: util.GenerateShortUID(), - }, - }) - } - - manager.stateCallback = func(now time.Time) []state.StateTransition { - return states - } - - frame, err := engine.Test(context.Background(), nil, rule, from, to) - - require.NoError(t, err) - require.Len(t, frame.Fields, len(states)+1) // +1 - timestamp - - t.Run("should contain field Time", func(t *testing.T) { - timestampField, _ := frame.FieldByName("Time") - require.NotNil(t, timestampField, "frame does not contain field 'Time'") - require.Equal(t, data.FieldTypeTime, timestampField.Type()) - }) - - fieldByState := make(map[data.Fingerprint]*data.Field, len(states)) - - t.Run("should contain a field per state", func(t *testing.T) { - for _, s := range states { - var f *data.Field - for _, field := range frame.Fields { - if field.Labels.String() == s.Labels.String() { - f = field - break - } - } - require.NotNilf(t, f, "Cannot find a field by state labels") - fieldByState[s.CacheID] = f - } - }) - - t.Run("should be populated with correct values", func(t *testing.T) { - timestampField, _ := frame.FieldByName("Time") - expectedLength := timestampField.Len() - for _, field := range frame.Fields { - require.Equalf(t, expectedLength, field.Len(), "Field %s should have the size %d", field.Name, expectedLength) - } - for i := 0; i < expectedLength; i++ { - expectedTime := from.Add(time.Duration(int64(i)*rule.IntervalSeconds) * time.Second) - require.Equal(t, expectedTime, timestampField.At(i).(time.Time)) - for _, s := range states { - f := fieldByState[s.CacheID] - if s.State.State == eval.NoData { - require.Nil(t, f.At(i)) - } else { - v := f.At(i).(*string) - require.NotNilf(t, v, "Field [%s] value at index %d should not be nil", s.CacheID, i) - require.Equal(t, fmt.Sprintf("%s (%s)", s.State.State, s.StateReason), *v) - } - } - } - }) - }) - t.Run("should not fail if 'to-from' is not times of interval", func(t *testing.T) { from := time.Unix(0, 0) to := from.Add(5 * ruleInterval) @@ -287,84 +211,26 @@ func TestEvaluatorTest(t *testing.T) { return states } - frame, err := engine.Test(context.Background(), nil, rule, from, to) + frame, err := engine.Test(context.Background(), nil, rule, from, to, "") require.NoError(t, err) expectedLen := frame.Rows() for i := 0; i < 100; i++ { jitter := time.Duration(rand.Int63n(ruleInterval.Milliseconds())) * time.Millisecond - frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter)) + frame, err = engine.Test(context.Background(), nil, rule, from, to.Add(jitter), "") require.NoError(t, err) require.Equalf(t, expectedLen, frame.Rows(), "jitter %v caused result to be different that base-line", jitter) } }) - t.Run("should backfill field with nulls if a new dimension created in the middle", func(t *testing.T) { - from := time.Unix(0, 0) - - state1 := state.StateTransition{ - State: generateState("1"), - } - state2 := state.StateTransition{ - State: generateState("2"), - } - state3 := state.StateTransition{ - State: generateState("3"), - } - stateByTime := map[time.Time][]state.StateTransition{ - from: {state1, state2}, - from.Add(1 * ruleInterval): {state1, state2}, - from.Add(2 * ruleInterval): {state1, state2}, - from.Add(3 * ruleInterval): {state1, state2, state3}, - from.Add(4 * ruleInterval): {state1, state2, state3}, - } - to := from.Add(time.Duration(len(stateByTime)) * ruleInterval) - - manager.stateCallback = func(now time.Time) []state.StateTransition { - return stateByTime[now] - } - - frame, err := engine.Test(context.Background(), nil, rule, from, to) - require.NoError(t, err) - - var field3 *data.Field - for _, field := range frame.Fields { - if field.Labels.String() == state3.Labels.String() { - field3 = field - break - } - } - require.NotNilf(t, field3, "Result for state 3 was not found") - require.Equalf(t, len(stateByTime), field3.Len(), "State3 result has unexpected number of values") - - idx := 0 - for curTime, states := range stateByTime { - value := field3.At(idx).(*string) - if len(states) == 2 { - require.Nilf(t, value, "The result should be nil if state3 was not available for time %v", curTime) - } - } - }) - t.Run("should fail", func(t *testing.T) { manager.stateCallback = func(now time.Time) []state.StateTransition { return nil } - t.Run("when interval is not correct", func(t *testing.T) { from := time.Now() - t.Run("when from=to", func(t *testing.T) { - to := from - _, err := engine.Test(context.Background(), nil, rule, from, to) - require.ErrorIs(t, err, ErrInvalidInputData) - }) t.Run("when from > to", func(t *testing.T) { to := from.Add(-ruleInterval) - _, err := engine.Test(context.Background(), nil, rule, from, to) - require.ErrorIs(t, err, ErrInvalidInputData) - }) - t.Run("when to-from < interval", func(t *testing.T) { - to := from.Add(ruleInterval).Add(-time.Millisecond) - _, err := engine.Test(context.Background(), nil, rule, from, to) + _, err := engine.Test(context.Background(), nil, rule, from, to, "") require.ErrorIs(t, err, ErrInvalidInputData) }) }) @@ -376,7 +242,7 @@ func TestEvaluatorTest(t *testing.T) { } from := time.Now() to := from.Add(ruleInterval) - _, err := engine.Test(context.Background(), nil, rule, from, to) + _, err := engine.Test(context.Background(), nil, rule, from, to, "") require.ErrorIs(t, err, expectedError) }) }) @@ -404,10 +270,188 @@ func (f *fakeBacktestingEvaluator) Eval(_ context.Context, from time.Time, inter if err != nil { return err } - err = callback(idx, now, results) + c, err := callback(idx, now, results) if err != nil { return err } + if !c { + break + } } return nil } + +func TestGetNextEvaluationTime(t *testing.T) { + baseInterval := 10 * time.Second + + testCases := []struct { + name string + ruleInterval int64 + currentTimestamp int64 + jitterOffset time.Duration + expectError bool + expectedNext int64 + }{ + { + name: "interval not divisible by base interval", + ruleInterval: 15, + currentTimestamp: 0, + jitterOffset: 0, + expectError: true, + }, + { + name: "no jitter - from tick 0", + ruleInterval: 20, + currentTimestamp: 0, + jitterOffset: 0, + expectedNext: 0, + }, + { + name: "no jitter - from tick 1", + ruleInterval: 20, + currentTimestamp: 10, + jitterOffset: 0, + expectedNext: 20, + }, + { + name: "no jitter - from tick 2", + ruleInterval: 20, + currentTimestamp: 20, + jitterOffset: 0, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 0", + ruleInterval: 60, + currentTimestamp: 0, + jitterOffset: 20 * time.Second, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 2", + ruleInterval: 60, + currentTimestamp: 20, + jitterOffset: 20 * time.Second, + expectedNext: 20, + }, + { + name: "with 20s jitter - from tick 3", + ruleInterval: 60, + currentTimestamp: 30, + jitterOffset: 20 * time.Second, + expectedNext: 80, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval} + currentTime := time.Unix(tc.currentTimestamp, 0) + result, err := getNextEvaluationTime(currentTime, rule, baseInterval, tc.jitterOffset) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "is not divisible by base interval") + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedNext, result.Unix()) + }) + } +} + +func TestGetFirstEvaluationTime(t *testing.T) { + baseInterval := 10 * time.Second + + testCases := []struct { + name string + ruleInterval int64 + fromUnix int64 + jitterOffset time.Duration + expectError bool + expectedUnix int64 + }{ + { + name: "interval not divisible by base interval", + ruleInterval: 15, + fromUnix: 0, + jitterOffset: 0, + expectError: true, + }, + { + name: "no jitter - from at tick 0", + ruleInterval: 20, + fromUnix: 0, + jitterOffset: 0, + expectedUnix: 0, + }, + { + name: "no jitter - from at tick 1", + ruleInterval: 20, + fromUnix: 10, + jitterOffset: 0, + expectedUnix: 20, + }, + { + name: "no jitter - from before first tick", + ruleInterval: 20, + fromUnix: 5, + jitterOffset: 0, + expectedUnix: 20, + }, + { + name: "no jitter - from after first aligned tick", + ruleInterval: 20, + fromUnix: 25, + jitterOffset: 0, + expectedUnix: 40, + }, + { + name: "no jitter - from at tick boundary", + ruleInterval: 10, + fromUnix: 10, + jitterOffset: 0, + expectedUnix: 10, + }, + { + name: "with 20s jitter - from epoch", + ruleInterval: 60, + fromUnix: 0, + jitterOffset: 20 * time.Second, + expectedUnix: 20, + }, + { + name: "with 20s jitter - from 70s", + ruleInterval: 60, + fromUnix: 70, + jitterOffset: 20 * time.Second, + expectedUnix: 80, + }, + { + name: "with 50s jitter - from 25s", + ruleInterval: 60, + fromUnix: 25, + jitterOffset: 50 * time.Second, + expectedUnix: 50, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + rule := &models.AlertRule{IntervalSeconds: tc.ruleInterval} + from := time.Unix(tc.fromUnix, 0) + result, err := getFirstEvaluationTime(from, rule, baseInterval, tc.jitterOffset) + + if tc.expectError { + require.Error(t, err) + require.Contains(t, err.Error(), "is not divisible by base interval") + return + } + + require.NoError(t, err) + require.Equal(t, tc.expectedUnix, result.Unix()) + require.GreaterOrEqual(t, result.Unix(), from.Unix(), "first eval should be at or after from") + }) + } +} diff --git a/pkg/services/ngalert/backtesting/eval_data.go b/pkg/services/ngalert/backtesting/eval_data.go index 999c0bc6302..13827e6e757 100644 --- a/pkg/services/ngalert/backtesting/eval_data.go +++ b/pkg/services/ngalert/backtesting/eval_data.go @@ -85,10 +85,13 @@ func (d *dataEvaluator) Eval(_ context.Context, from time.Time, interval time.Du EvaluatedAt: now, }) } - err := callback(i, now, result) + cont, err := callback(i, now, result) if err != nil { return err } + if !cont { + break + } } return nil } diff --git a/pkg/services/ngalert/backtesting/eval_data_test.go b/pkg/services/ngalert/backtesting/eval_data_test.go index 864229b777c..3d80fa9337a 100644 --- a/pkg/services/ngalert/backtesting/eval_data_test.go +++ b/pkg/services/ngalert/backtesting/eval_data_test.go @@ -100,11 +100,11 @@ func TestDataEvaluator_Eval(t *testing.T) { resultsCount := int(to.Sub(from).Seconds() / interval.Seconds()) - err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, resultsCount, func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) require.NoError(t, err) @@ -164,11 +164,11 @@ func TestDataEvaluator_Eval(t *testing.T) { size := to.Sub(from).Milliseconds() / interval.Milliseconds() r := make([]results, 0, size) - err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, interval, int(size), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) currentRowIdx := 0 @@ -195,11 +195,11 @@ func TestDataEvaluator_Eval(t *testing.T) { size := int(to.Sub(from).Seconds() / interval.Seconds()) r := make([]results, 0, size) - err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, interval, size, func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) currentRowIdx := 0 @@ -230,11 +230,11 @@ func TestDataEvaluator_Eval(t *testing.T) { t.Run("should be noData until the frame interval", func(t *testing.T) { newFrom := from.Add(-10 * time.Second) r := make([]results, 0, int(to.Sub(newFrom).Seconds())) - err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), newFrom, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) rowIdx := 0 @@ -258,11 +258,11 @@ func TestDataEvaluator_Eval(t *testing.T) { t.Run("should be the last value after the frame interval", func(t *testing.T) { newTo := to.Add(10 * time.Second) r := make([]results, 0, int(newTo.Sub(from).Seconds())) - err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, cap(r), func(idx int, now time.Time, res eval.Results) (bool, error) { r = append(r, results{ now, res, }) - return nil + return true, nil }) rowIdx := 0 @@ -282,12 +282,21 @@ func TestDataEvaluator_Eval(t *testing.T) { }) t.Run("should stop if callback error", func(t *testing.T) { expectedError := errors.New("error") - err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) error { + err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) { if idx == 5 { - return expectedError + return false, expectedError } - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) }) + t.Run("should stop if callback does not want to continue", func(t *testing.T) { + evaluated := 0 + err = evaluator.Eval(context.Background(), from, time.Second, 6, func(idx int, now time.Time, res eval.Results) (bool, error) { + evaluated++ + return evaluated < 2, nil + }) + require.NoError(t, err) + require.Equal(t, 2, evaluated) + }) } diff --git a/pkg/services/ngalert/backtesting/eval_query.go b/pkg/services/ngalert/backtesting/eval_query.go index f53e3de86cb..07720f4f265 100644 --- a/pkg/services/ngalert/backtesting/eval_query.go +++ b/pkg/services/ngalert/backtesting/eval_query.go @@ -18,10 +18,13 @@ func (d *queryEvaluator) Eval(ctx context.Context, from time.Time, interval time if err != nil { return err } - err = callback(idx, now, results) + cont, err := callback(idx, now, results) if err != nil { return err } + if !cont { + break + } } return nil } diff --git a/pkg/services/ngalert/backtesting/eval_query_test.go b/pkg/services/ngalert/backtesting/eval_query_test.go index e88948971f0..4c9df9d25b1 100644 --- a/pkg/services/ngalert/backtesting/eval_query_test.go +++ b/pkg/services/ngalert/backtesting/eval_query_test.go @@ -31,9 +31,9 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { intervals[idx] = now - return nil + return true, nil }) require.NoError(t, err) require.Len(t, intervals, times) @@ -49,7 +49,7 @@ func TestQueryEvaluator_Eval(t *testing.T) { } }) - t.Run("should stop evaluation if error", func(t *testing.T) { + t.Run("should stop evaluation", func(t *testing.T) { t.Run("when evaluation fails", func(t *testing.T) { m := &eval_mocks.ConditionEvaluatorMock{} expectedResults := eval.Results{} @@ -62,9 +62,9 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, 0, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { intervals = append(intervals, now) - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) require.Len(t, intervals, 3) @@ -81,14 +81,31 @@ func TestQueryEvaluator_Eval(t *testing.T) { intervals := make([]time.Time, 0, times) - err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) error { + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { if len(intervals) > 3 { - return expectedError + return false, expectedError } intervals = append(intervals, now) - return nil + return true, nil }) require.ErrorIs(t, err, expectedError) }) + + t.Run("when callback does not want to continue", func(t *testing.T) { + m := &eval_mocks.ConditionEvaluatorMock{} + expectedResults := eval.Results{} + m.EXPECT().Evaluate(mock.Anything, mock.Anything).Return(expectedResults, nil) + evaluator := queryEvaluator{ + eval: m, + } + + evaluated := 0 + err := evaluator.Eval(ctx, from, interval, times, func(idx int, now time.Time, results eval.Results) (bool, error) { + evaluated++ + return evaluated <= 2, nil + }) + require.NoError(t, err, nil) + require.Equal(t, 3, evaluated) + }) }) } diff --git a/pkg/services/ngalert/models/alert_rule.go b/pkg/services/ngalert/models/alert_rule.go index f7bb3d9fcfd..7d6e8a1fab5 100644 --- a/pkg/services/ngalert/models/alert_rule.go +++ b/pkg/services/ngalert/models/alert_rule.go @@ -480,6 +480,10 @@ func (alertRule *AlertRule) GetPanelID() int64 { return -1 } +func (alertRule *AlertRule) GetInterval() time.Duration { + return time.Duration(alertRule.IntervalSeconds) * time.Second +} + type LabelOption func(map[string]string) func WithoutInternalLabels() LabelOption { diff --git a/pkg/services/ngalert/schedule/jitter.go b/pkg/services/ngalert/schedule/jitter.go index 3d6c839f372..a805ab9b0b3 100644 --- a/pkg/services/ngalert/schedule/jitter.go +++ b/pkg/services/ngalert/schedule/jitter.go @@ -5,6 +5,7 @@ import ( "time" "github.com/grafana/grafana-plugin-sdk-go/data" + "github.com/grafana/grafana/pkg/services/featuremgmt" ngmodels "github.com/grafana/grafana/pkg/services/ngalert/models" "github.com/grafana/grafana/pkg/setting" @@ -13,6 +14,10 @@ import ( // JitterStrategy represents a modifier to alert rule timing that affects how evaluations are distributed. type JitterStrategy int +func (s JitterStrategy) String() string { + return [...]string{"never", "by group", "by rule"}[s] +} + const ( JitterNever JitterStrategy = iota JitterByGroup @@ -57,6 +62,11 @@ func jitterOffsetInTicks(r *ngmodels.AlertRule, baseInterval time.Duration, stra return res } +// JitterOffsetInDuration gives the jitter offset for a rule, in terms of a duration relative to its interval and a base interval. +func JitterOffsetInDuration(r *ngmodels.AlertRule, baseInterval time.Duration, strategy JitterStrategy) time.Duration { + return time.Duration(jitterOffsetInTicks(r, baseInterval, strategy)) * baseInterval +} + func jitterHash(r *ngmodels.AlertRule, strategy JitterStrategy) uint64 { ls := data.Labels{ "name": r.RuleGroup, diff --git a/pkg/services/ngalert/schedule/ticker/ticker.go b/pkg/services/ngalert/schedule/ticker/ticker.go index a52b9c4e559..c24dc13798a 100644 --- a/pkg/services/ngalert/schedule/ticker/ticker.go +++ b/pkg/services/ngalert/schedule/ticker/ticker.go @@ -44,7 +44,11 @@ func New(c clock.Clock, interval time.Duration, metric *Metrics, logger log.Logg } func getStartTick(clk clock.Clock, interval time.Duration) time.Time { - nano := clk.Now().UnixNano() + return GetStartTick(clk.Now(), interval) +} + +func GetStartTick(t time.Time, interval time.Duration) time.Time { + nano := t.UnixNano() return time.Unix(0, nano-(nano%interval.Nanoseconds())) } diff --git a/pkg/services/ngalert/state/historian/core.go b/pkg/services/ngalert/state/historian/core.go index eabb5214a9e..daf415be25a 100644 --- a/pkg/services/ngalert/state/historian/core.go +++ b/pkg/services/ngalert/state/historian/core.go @@ -17,7 +17,7 @@ import ( const StateHistoryWriteTimeout = time.Minute -func shouldRecord(transition state.StateTransition) bool { +func ShouldRecord(transition state.StateTransition) bool { if !transition.Changed() { return false } @@ -35,9 +35,9 @@ func shouldRecord(transition state.StateTransition) bool { } // ShouldRecordAnnotation returns true if an annotation should be created for a given state transition. -// This is stricter than shouldRecord to avoid cluttering panels with state transitions. +// This is stricter than ShouldRecord to avoid cluttering panels with state transitions. func ShouldRecordAnnotation(t state.StateTransition) bool { - if !shouldRecord(t) { + if !ShouldRecord(t) { return false } diff --git a/pkg/services/ngalert/state/historian/core_test.go b/pkg/services/ngalert/state/historian/core_test.go index f798bd26cef..a5f0c55d816 100644 --- a/pkg/services/ngalert/state/historian/core_test.go +++ b/pkg/services/ngalert/state/historian/core_test.go @@ -92,7 +92,7 @@ func TestShouldRecord(t *testing.T) { } t.Run(fmt.Sprintf("%s -> %s should be %v", trans.PreviousFormatted(), trans.Formatted(), !ok), func(t *testing.T) { - require.Equal(t, !ok, shouldRecord(trans)) + require.Equal(t, !ok, ShouldRecord(trans)) }) } } diff --git a/pkg/services/ngalert/state/historian/loki.go b/pkg/services/ngalert/state/historian/loki.go index 76e3e9025bd..8e98d411c1e 100644 --- a/pkg/services/ngalert/state/historian/loki.go +++ b/pkg/services/ngalert/state/historian/loki.go @@ -41,6 +41,69 @@ const ( dfLabels = "labels" ) +// QueryResultBuilder is a builder for a data frame that represents query results from Loki. +// It contains three fields: time (timestamp), line (JSON data), and labels (JSON labels). +type QueryResultBuilder struct { + frame *data.Frame +} + +// NewQueryResultBuilder creates a new QueryResultBuilder with the specified capacity. +// The capacity is used to pre-allocate the underlying slices for better performance. +func NewQueryResultBuilder(capacity int) *QueryResultBuilder { + frame := data.NewFrame("states") + lbls := data.Labels(map[string]string{}) + + // We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly. + // The format is composed of the following vectors: + // 1. `time` - timestamp - when the transition happened + // 2. `line` - JSON - the full data of the transition + // 3. `labels` - JSON - the labels associated with that state transition + times := make([]time.Time, 0, capacity) + lines := make([]json.RawMessage, 0, capacity) + labels := make([]json.RawMessage, 0, capacity) + + frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times)) + frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines)) + frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels)) + + return &QueryResultBuilder{frame: frame} +} + +func (qr QueryResultBuilder) AddRowRaw(timestamp time.Time, line json.RawMessage, labels json.RawMessage) { + frame := qr.frame + frame.Fields[0].Append(timestamp) + frame.Fields[1].Append(line) + frame.Fields[2].Append(labels) +} + +func (qr QueryResultBuilder) AddRow(timestamp time.Time, line LokiEntry, labels json.RawMessage) error { + lineBytes, err := json.Marshal(line) + if err != nil { + return err + } + qr.AddRowRaw(timestamp, lineBytes, labels) + return nil +} + +// ToFrame converts the QueryResultBuilder back to a data.Frame. +func (qr QueryResultBuilder) ToFrame() *data.Frame { + return qr.frame +} + +func (qr QueryResultBuilder) AddWarn(s string) { + m := qr.frame.Meta + if m == nil { + m = &data.FrameMeta{} + qr.frame.SetMeta(m) + } + m.Notices = append(m.Notices, data.Notice{ + Severity: data.NoticeSeverityWarning, + Text: s, + Link: "", + Inspect: 0, + }) +} + const ( StateHistoryLabelKey = "from" StateHistoryLabelValue = "state-history" @@ -191,20 +254,7 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st totalLen += len(arr.Values) } - // Create a new slice to store the merged elements. - frame := data.NewFrame("states") - - // We merge all series into a single linear history. - lbls := data.Labels(map[string]string{}) - - // We represent state history as a single merged history, that roughly corresponds to what you get in the Grafana Explore tab when querying Loki directly. - // The format is composed of the following vectors: - // 1. `time` - timestamp - when the transition happened - // 2. `line` - JSON - the full data of the transition - // 3. `labels` - JSON - the labels associated with that state transition - times := make([]time.Time, 0, totalLen) - lines := make([]json.RawMessage, 0, totalLen) - labels := make([]json.RawMessage, 0, totalLen) + queryResult := NewQueryResultBuilder(totalLen) // Initialize a slice of pointers to the current position in each array. pointers := make([]int, len(res)) @@ -259,17 +309,10 @@ func (h RemoteLokiBackend) merge(res []lokiclient.Stream, folderUIDToFilter []st pointers[minElStreamIdx]++ continue } - times = append(times, time.Unix(0, tsNano)) - labels = append(labels, lblsJson) - lines = append(lines, json.RawMessage(entryBytes)) + queryResult.AddRowRaw(time.Unix(0, tsNano), entryBytes, lblsJson) pointers[minElStreamIdx]++ } - - frame.Fields = append(frame.Fields, data.NewField(dfTime, lbls, times)) - frame.Fields = append(frame.Fields, data.NewField(dfLine, lbls, lines)) - frame.Fields = append(frame.Fields, data.NewField(dfLabels, lbls, labels)) - - return frame, nil + return queryResult.ToFrame(), nil } func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, externalLabels map[string]string, logger log.Logger) lokiclient.Stream { @@ -282,28 +325,11 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, samples := make([]lokiclient.Sample, 0, len(states)) for _, state := range states { - if !shouldRecord(state) { + if !ShouldRecord(state) { continue } - sanitizedLabels := removePrivateLabels(state.Labels) - entry := LokiEntry{ - SchemaVersion: 1, - Previous: state.PreviousFormatted(), - Current: state.Formatted(), - Values: valuesAsDataBlob(state.State), - Condition: rule.Condition, - DashboardUID: rule.DashboardUID, - PanelID: rule.PanelID, - Fingerprint: labelFingerprint(sanitizedLabels), - RuleTitle: rule.Title, - RuleID: rule.ID, - RuleUID: rule.UID, - InstanceLabels: sanitizedLabels, - } - if state.State.State == eval.Error { - entry.Error = state.Error.Error() - } + entry := StateTransitionToLokiEntry(rule, state) jsn, err := json.Marshal(entry) if err != nil { @@ -324,6 +350,28 @@ func StatesToStream(rule history_model.RuleMeta, states []state.StateTransition, } } +func StateTransitionToLokiEntry(rule history_model.RuleMeta, state state.StateTransition) LokiEntry { + sanitizedLabels := removePrivateLabels(state.Labels) + entry := LokiEntry{ + SchemaVersion: 1, + Previous: state.PreviousFormatted(), + Current: state.Formatted(), + Values: valuesAsDataBlob(state.State), + Condition: rule.Condition, + DashboardUID: rule.DashboardUID, + PanelID: rule.PanelID, + Fingerprint: labelFingerprint(sanitizedLabels), + RuleTitle: rule.Title, + RuleID: rule.ID, + RuleUID: rule.UID, + InstanceLabels: sanitizedLabels, + } + if state.State.State == eval.Error && state.Error != nil { + entry.Error = state.Error.Error() + } + return entry +} + func (h *RemoteLokiBackend) recordStreams(ctx context.Context, stream lokiclient.Stream, logger log.Logger) error { if err := h.client.Push(ctx, []lokiclient.Stream{stream}); err != nil { return err diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 743f386ff52..6abaef8bc2e 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -156,6 +156,8 @@ type UnifiedAlertingSettings struct { // AlertmanagerMaxTemplateOutputSize specifies the maximum allowed size for rendered template output in bytes. AlertmanagerMaxTemplateOutputSize int64 + + BacktestingMaxEvaluations int } type RecordingRuleSettings struct { @@ -594,6 +596,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { return fmt.Errorf("setting 'alertmanager_max_template_output_bytes' is invalid, only 0 or a positive integer are allowed") } + uaCfg.BacktestingMaxEvaluations = ua.Key("backtesting_max_evaluations").MustInt(100) + if uaCfg.BacktestingMaxEvaluations < 0 { + uaCfg.BacktestingMaxEvaluations = 100 + } + cfg.UnifiedAlerting = uaCfg return nil } diff --git a/pkg/tests/api/alerting/api_backtesting_test.go b/pkg/tests/api/alerting/api_backtesting_test.go index f07be49ff01..faf50a60da2 100644 --- a/pkg/tests/api/alerting/api_backtesting_test.go +++ b/pkg/tests/api/alerting/api_backtesting_test.go @@ -68,7 +68,7 @@ func TestBacktesting(t *testing.T) { require.Truef(t, ok, "The data file does not contain a field `data`") status, body := apiCli.SubmitRuleForBacktesting(t, request) - require.Equal(t, http.StatusOK, status) + require.Equalf(t, http.StatusOK, status, "Response: %s", body) var result data.Frame require.NoErrorf(t, json.Unmarshal([]byte(body), &result), "cannot parse response to data frame") }) @@ -107,6 +107,7 @@ func TestBacktesting(t *testing.T) { resourcepermissions.SetResourcePermissionCommand{ Actions: []string{ accesscontrol.ActionAlertingRuleRead, + accesscontrol.ActionAlertingRuleUpdate, }, Resource: "folders", ResourceID: "*", diff --git a/pkg/tests/api/alerting/test-data/api_backtesting_data.json b/pkg/tests/api/alerting/test-data/api_backtesting_data.json index d02b6905f0b..5fe0f621126 100644 --- a/pkg/tests/api/alerting/test-data/api_backtesting_data.json +++ b/pkg/tests/api/alerting/test-data/api_backtesting_data.json @@ -12,6 +12,9 @@ }, "condition": "A", "no_data_state": "Alerting", + "title": "test-rule-backtesting-data", + "rule_group": "test-group", + "namespace_uid": "test-namespace", "data": [ { "refId": "A", @@ -193,6 +196,9 @@ }, "condition": "C", "no_data_state": "Alerting", + "title": "test-rule-backtesting-data", + "rule_group": "test-group", + "namespace_uid": "test-namespace", "data": [ { "refId": "A", diff --git a/public/app/features/alerting/unified/api/backtestApi.ts b/public/app/features/alerting/unified/api/backtestApi.ts new file mode 100644 index 00000000000..14a0827cb20 --- /dev/null +++ b/public/app/features/alerting/unified/api/backtestApi.ts @@ -0,0 +1,50 @@ +import { DataFrameJSON } from '@grafana/data'; +import { AlertQuery, GrafanaAlertStateDecision, Labels } from 'app/types/unified-alerting-dto'; + +import { alertingApi } from './alertingApi'; + +/** + * Request body for the backtest API matching the BacktestConfig struct in the backend + */ +export interface BacktestRequest { + // Required time range fields + from: string; // ISO 8601 timestamp + to: string; // ISO 8601 timestamp + interval: string; // e.g., "1m", "5m" + + // Required alert definition fields + condition: string; + data: AlertQuery[]; + title: string; + no_data_state?: GrafanaAlertStateDecision; + exec_err_state?: GrafanaAlertStateDecision; + + // Optional duration fields + for?: string; + keep_firing_for?: string; + + // Optional metadata fields + labels?: Labels; + missing_series_evals_to_resolve?: number; + + // Optional rule identification fields + uid?: string; + rule_group?: string; + namespace_uid?: string; +} + +export const BACKTEST_URL = '/api/v1/rule/backtest'; + +export const backtestApi = alertingApi.injectEndpoints({ + endpoints: (build) => ({ + runBacktest: build.mutation({ + query: (requestBody) => ({ + url: BACKTEST_URL, + method: 'POST', + body: requestBody, + }), + }), + }), +}); + +export const { useRunBacktestMutation } = backtestApi; diff --git a/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx b/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx new file mode 100644 index 00000000000..7d7c4467780 --- /dev/null +++ b/public/app/features/alerting/unified/components/backtesting/BacktestDropdownButton.tsx @@ -0,0 +1,63 @@ +import { useCallback, useState } from 'react'; + +import { TimeRange, rangeUtil } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { Button, Drawer, Dropdown, Menu, MenuItem } from '@grafana/ui'; + +import { RuleFormValues } from '../../types/rule-form'; + +import { BacktestPanel } from './BacktestPanel'; + +interface BacktestDropdownButtonProps { + ruleDefinition: RuleFormValues; +} + +export function BacktestDropdownButton({ ruleDefinition }: BacktestDropdownButtonProps) { + const [isBacktestPanelOpen, setIsBacktestPanelOpen] = useState(false); + const [backtestTimeRange, setBacktestTimeRange] = useState(); + + const handleTimeRangeSelect = useCallback((rawFrom: string) => { + const timeRange = rangeUtil.convertRawToRange({ from: rawFrom, to: 'now' }); + setBacktestTimeRange(timeRange); + setIsBacktestPanelOpen(true); + }, []); + + const handleCustomSelect = useCallback(() => { + setBacktestTimeRange(undefined); + setIsBacktestPanelOpen(true); + }, []); + + return ( + <> + + handleTimeRangeSelect('now-15m')} + /> + handleTimeRangeSelect('now-1h')} + /> + + + } + > + + + + {isBacktestPanelOpen && ( + setIsBacktestPanelOpen(false)} + size="md" + > + + + )} + + ); +} diff --git a/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx b/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx new file mode 100644 index 00000000000..1a3a4ae7706 --- /dev/null +++ b/public/app/features/alerting/unified/components/backtesting/BacktestPanel.tsx @@ -0,0 +1,200 @@ +import { css } from '@emotion/css'; +import { fromPairs, isEmpty, isEqual } from 'lodash'; +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { AlertLabels } from '@grafana/alerting/unstable'; +import { DataFrameJSON, GrafanaTheme2, TimeRange, rangeUtil } from '@grafana/data'; +import { Trans, t } from '@grafana/i18n'; +import { + Alert, + Icon, + LoadingPlaceholder, + RefreshPicker, + Stack, + Text, + TimeRangePicker, + Tooltip, + useStyles2, +} from '@grafana/ui'; + +import { useRunBacktestMutation } from '../../api/backtestApi'; +import { RuleFormValues } from '../../types/rule-form'; +import { combineMatcherStrings } from '../../utils/alertmanager'; +import { messageFromError } from '../../utils/redux'; +import { formValuesToRulerGrafanaRuleDTO } from '../../utils/rule-form'; +import { LogRecordViewerByTimestamp } from '../rules/state-history/LogRecordViewer'; +import { LogTimelineViewer } from '../rules/state-history/LogTimelineViewer'; +import { useFrameSubset } from '../rules/state-history/LokiStateHistory'; +import { useRuleHistoryRecords } from '../rules/state-history/useRuleHistoryRecords'; + +interface BacktestPanelProps { + ruleDefinition: RuleFormValues; + initialTimeRange?: TimeRange; +} + +export function BacktestPanel({ ruleDefinition, initialTimeRange }: BacktestPanelProps) { + const styles = useStyles2(getStyles); + const [timeRange, setTimeRange] = useState( + initialTimeRange || rangeUtil.convertRawToRange({ from: 'now-15m', to: 'now' }) + ); + const [stateHistory, setStateHistory] = useState(); + const [instancesFilter, setInstancesFilter] = useState(''); + const shouldRunInitialBacktest = useRef(!!initialTimeRange); + + const [runBacktest, { isLoading, error: mutationError }] = useRunBacktestMutation(); + + const handleRunBacktest = useCallback(async () => { + // Convert form values to the proper AlertRule format + const alertRule = formValuesToRulerGrafanaRuleDTO(ruleDefinition); + + // Build requestBody matching BacktestConfig struct + const requestBody = { + // Required time range fields + from: timeRange.from.toISOString(), + to: timeRange.to.toISOString(), + interval: ruleDefinition.evaluateEvery, + + // Required alert definition fields + condition: alertRule.grafana_alert.condition, + data: alertRule.grafana_alert.data, + title: alertRule.grafana_alert.title, + no_data_state: alertRule.grafana_alert.no_data_state, + exec_err_state: alertRule.grafana_alert.exec_err_state, + + // Optional duration fields + for: alertRule.for, + keep_firing_for: alertRule.keep_firing_for, + + // Optional metadata fields + labels: alertRule.labels, + missing_series_evals_to_resolve: alertRule.grafana_alert.missing_series_evals_to_resolve, + + // Optional rule identification fields + uid: alertRule.grafana_alert.uid, + rule_group: ruleDefinition.group, + namespace_uid: ruleDefinition.folder?.uid, + }; + + try { + const result = await runBacktest(requestBody).unwrap(); + setStateHistory(result); + } catch (err) { + // Error is handled by RTK Query and available via mutationError + } + }, [ruleDefinition, timeRange, runBacktest]); + + // Update time range when initialTimeRange prop changes + useEffect(() => { + if (initialTimeRange) { + setTimeRange(initialTimeRange); + } + }, [initialTimeRange]); + + // Run backtest once after initial mount when timeRange is synchronized with initialTimeRange + useEffect(() => { + if (shouldRunInitialBacktest.current && initialTimeRange && isEqual(timeRange, initialTimeRange)) { + shouldRunInitialBacktest.current = false; + handleRunBacktest(); + } + }, [initialTimeRange, timeRange, handleRunBacktest]); + + const { dataFrames, historyRecords, commonLabels } = useRuleHistoryRecords(stateHistory, instancesFilter); + + const { frameSubset, frameTimeRange } = useFrameSubset(dataFrames); + + const onLogRecordLabelClick = useCallback( + (label: string) => { + const matcherString = combineMatcherStrings(instancesFilter, label); + setInstancesFilter(matcherString); + }, + [instancesFilter] + ); + + const hasResults = stateHistory !== undefined; + + const notices = stateHistory?.schema?.meta?.notices || []; + const errorMessage = mutationError ? messageFromError(mutationError) : null; + + return ( +
+ + {}} + onMoveBackward={() => {}} + onMoveForward={() => {}} + onZoom={() => {}} + /> + {}} + isLoading={isLoading} + noIntervalPicker={true} + /> + +
+ {isLoading && } + + {errorMessage && ( + {errorMessage} + )} + + {!isLoading && !mutationError && hasResults && notices.length > 0 && ( + + {notices.map((notice, index) => ( + + {notice.text} + + ))} + + )} + + {!isLoading && !mutationError && hasResults && ( +
+ {!isEmpty(commonLabels) && ( + + + + Common labels + + + + + + + + )} + + +
+ )} +
+
+ ); +} +const getStyles = (theme: GrafanaTheme2) => ({ + scrollableContent: css({ + flex: 1, + display: 'flex', + flexDirection: 'column', + paddingTop: theme.spacing(2), + overflow: 'hidden', + }), + resultsContainer: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(2), + flex: 1, + overflow: 'hidden', + }), +}); diff --git a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx index 85e72cca2d8..ed05cd8823c 100644 --- a/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/alert-rule-form/AlertRuleForm.tsx @@ -60,6 +60,7 @@ import { formValuesToRulerRuleDTO, } from '../../../utils/rule-form'; import { fromRulerRule, fromRulerRuleAndRuleGroupIdentifier } from '../../../utils/rule-id'; +import { BacktestDropdownButton } from '../../backtesting/BacktestDropdownButton'; import { GrafanaRuleExporter } from '../../export/GrafanaRuleExporter'; import { AlertRuleNameAndMetric } from '../AlertRuleNameInput'; import AnnotationsStep from '../AnnotationsStep'; @@ -290,6 +291,8 @@ export const AlertRuleForm = ({ existing, prefill, isManualRestore }: Props) => Edit YAML )} + + {config.featureToggles.alertingBacktesting && }
diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 85563c43905..a51e48d0e7f 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "Enter a {{key}}...", "placeholder-value-input-default": "Enter custom annotation content..." }, + "backtest": { + "error-title": "Failed to run backtest", + "loading": "Running backtest...", + "panel-title": "Rule Retroactive Testing" + }, "bulk-actions": { "delete": { "success": "Rules successfully deleted from folder" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "Custom", "disableAdvancedOptions": { "text": "The selected queries and expressions cannot be converted to default. If you deactivate advanced options, your query and condition will be reset to default settings." }, + "last15m": "Last 15 minutes", + "last1h": "Last 1 hour", "preview": "Preview", - "previewCondition": "Preview alert rule condition" + "previewCondition": "Preview alert rule condition", + "testRule": "Test Rule" }, "receiver-filter": { "aria-label-contact-points": "Filter by contact points", From a345f78ae0faee08f0e51e9c04b46082ac3caec6 Mon Sep 17 00:00:00 2001 From: "grafana-pr-automation[bot]" <140550294+grafana-pr-automation[bot]@users.noreply.github.com> Date: Sun, 28 Dec 2025 00:34:24 +0000 Subject: [PATCH 35/80] I18n: Download translations from Crowdin (#115717) 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, 180 insertions(+), 18 deletions(-) diff --git a/public/locales/cs-CZ/grafana.json b/public/locales/cs-CZ/grafana.json index 2fb06738b36..7ed8c4bf808 100644 --- a/public/locales/cs-CZ/grafana.json +++ b/public/locales/cs-CZ/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Zadejte obsah vlastní vysvětlivky…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Pravidla byla úspěšně odstraněna ze složky" @@ -2219,11 +2224,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Vybrané dotazy a výrazy nelze převést na výchozí. Pokud deaktivujete pokročilé možnosti, váš dotaz a podmínka budou obnoveny do výchozího nastavení." }, + "last15m": "", + "last1h": "", "preview": "Náhled", - "previewCondition": "Podmínka pravidla náhledu výstrahy" + "previewCondition": "Podmínka pravidla náhledu výstrahy", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrovat podle kontaktních bodů", diff --git a/public/locales/de-DE/grafana.json b/public/locales/de-DE/grafana.json index 6c09564ae2f..a8d01ea13bb 100644 --- a/public/locales/de-DE/grafana.json +++ b/public/locales/de-DE/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Inhalt der benutzerdefinierten Anmerkung eingeben …" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Die Regeln wurden erfolgreich aus dem Ordner gelöscht" @@ -2203,11 +2208,15 @@ "min-interval": "Mind. Intervall = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Die ausgewählten Abfragen und Ausdrücke können nicht in die Standardeinstellung konvertiert werden. Wenn Sie die erweiterten Optionen deaktivieren, werden Ihre Abfrage und Bedingung auf die Standardeinstellungen zurückgesetzt." }, + "last15m": "", + "last1h": "", "preview": "Vorschau", - "previewCondition": "Vorschau der Warnregelbedingung" + "previewCondition": "Vorschau der Warnregelbedingung", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Nach Kontaktpunkten filtern", diff --git a/public/locales/es-ES/grafana.json b/public/locales/es-ES/grafana.json index 45d955ba66c..a6f418cc1be 100644 --- a/public/locales/es-ES/grafana.json +++ b/public/locales/es-ES/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Introduce el contenido de la anotación personalizada..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reglas eliminadas correctamente de la carpeta" @@ -2203,11 +2208,15 @@ "min-interval": "Tamaño min. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Las consultas y expresiones seleccionadas no se pueden convertir a predeterminadas. Si desactivas las opciones avanzadas, tu consulta y condición se restablecerán a la configuración predeterminada." }, + "last15m": "", + "last1h": "", "preview": "Vista previa", - "previewCondition": "Vista previa de la condición de la regla de alerta" + "previewCondition": "Vista previa de la condición de la regla de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por puntos de contacto", diff --git a/public/locales/fr-FR/grafana.json b/public/locales/fr-FR/grafana.json index 33bf748fbc0..b6e586e10db 100644 --- a/public/locales/fr-FR/grafana.json +++ b/public/locales/fr-FR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Saisir le contenu de l’annotation personnalisée..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Règles supprimées du dossier" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervalle = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Les requêtes et expressions sélectionnées ne peuvent pas être converties en valeurs par défaut. Si vous désactivez les options avancées, votre requête et votre condition seront réinitialisées aux valeurs par défaut." }, + "last15m": "", + "last1h": "", "preview": "Aperçu", - "previewCondition": "Aperçu de la condition de la règle d'alerte" + "previewCondition": "Aperçu de la condition de la règle d'alerte", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrer par points de contact", diff --git a/public/locales/hu-HU/grafana.json b/public/locales/hu-HU/grafana.json index 3704f01de9a..d51785fdb8a 100644 --- a/public/locales/hu-HU/grafana.json +++ b/public/locales/hu-HU/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Adja meg az egyéni jegyzet tartalmát…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "A szabályok sikeresen törlődtek a mappából" @@ -2203,11 +2208,15 @@ "min-interval": "Min. intervallum = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "A kijelölt lekérdezések és kifejezések nem konvertálhatók alapértelmezettre. Ha kikapcsolja a speciális beállításokat, a lekérdezés és a feltétel visszaáll az alapértelmezett beállításokra." }, + "last15m": "", + "last1h": "", "preview": "Előnézet", - "previewCondition": "Riasztási szabály előnézeti feltétele" + "previewCondition": "Riasztási szabály előnézeti feltétele", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Szűrés kapcsolattartási pontok szerint", diff --git a/public/locales/id-ID/grafana.json b/public/locales/id-ID/grafana.json index 602acd8813a..c000333c5c2 100644 --- a/public/locales/id-ID/grafana.json +++ b/public/locales/id-ID/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Masukkan konten anotasi kustom..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Aturan berhasil dihapus dari folder" @@ -2195,11 +2200,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Kueri dan ekspresi yang dipilih tidak dapat dikonversi ke default. Jika Anda menonaktifkan opsi lanjutan, kueri dan kondisi Anda akan diatur ulang ke pengaturan default." }, + "last15m": "", + "last1h": "", "preview": "Pratinjau", - "previewCondition": "Pratinjau kondisi aturan peringatan" + "previewCondition": "Pratinjau kondisi aturan peringatan", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filter berdasarkan titik kontak", diff --git a/public/locales/it-IT/grafana.json b/public/locales/it-IT/grafana.json index 4832c6c744c..5b5839ddf39 100644 --- a/public/locales/it-IT/grafana.json +++ b/public/locales/it-IT/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Inserisci il contenuto dell'annotazione personalizzata..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regole eliminate dalla cartella" @@ -2203,11 +2208,15 @@ "min-interval": "Min Intervallo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Le query e le espressioni selezionate non possono essere convertite in predefinite. Se disattivi le opzioni avanzate, la query e la condizione verranno ripristinate alle impostazioni predefinite." }, + "last15m": "", + "last1h": "", "preview": "Anteprima", - "previewCondition": "Anteprima condizione regola di avviso" + "previewCondition": "Anteprima condizione regola di avviso", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtra per punti di contatto", diff --git a/public/locales/ja-JP/grafana.json b/public/locales/ja-JP/grafana.json index c87617b2161..85597e5cff8 100644 --- a/public/locales/ja-JP/grafana.json +++ b/public/locales/ja-JP/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "カスタム注釈内容を入力..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "ルールがフォルダから正常に削除されました" @@ -2195,11 +2200,15 @@ "min-interval": "最小間隔= {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "選択したクエリと式はデフォルトに変換できません。高度なオプションを無効にすると、クエリと条件はデフォルト設定にリセットされます。" }, + "last15m": "", + "last1h": "", "preview": "プレビュー", - "previewCondition": "アラートルール条件をプレビューする" + "previewCondition": "アラートルール条件をプレビューする", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "コンタクトポイントで絞り込む", diff --git a/public/locales/ko-KR/grafana.json b/public/locales/ko-KR/grafana.json index 65d967807ea..25e6bea87a4 100644 --- a/public/locales/ko-KR/grafana.json +++ b/public/locales/ko-KR/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "사용자 지정 주석 내용을 입력하세요..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "폴더에서 규칙이 성공적으로 삭제되었습니다" @@ -2195,11 +2200,15 @@ "min-interval": "최소 간격 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "선택한 쿼리와 표현식을 기본값으로 변환할 수 없습니다. 고급 옵션을 비활성화하면 쿼리와 조건이 기본 설정으로 재설정됩니다." }, + "last15m": "", + "last1h": "", "preview": "미리보기", - "previewCondition": "경고 규칙 조건 미리보기" + "previewCondition": "경고 규칙 조건 미리보기", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "연락처로 필터링", diff --git a/public/locales/nl-NL/grafana.json b/public/locales/nl-NL/grafana.json index a1d9ba17c5b..b1f700e5957 100644 --- a/public/locales/nl-NL/grafana.json +++ b/public/locales/nl-NL/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Aangepaste annotatie-inhoud invoeren..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regels zijn verwijderd uit de map" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Interval = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "De geselecteerde query's en expressies kunnen niet worden geconverteerd naar standaard. Als je geavanceerde opties deactiveert, worden je query en voorwaarde teruggezet naar de standaardinstellingen." }, + "last15m": "", + "last1h": "", "preview": "Voorbeeld", - "previewCondition": "Voorbeeld waarschuwingsregel voorwaarde" + "previewCondition": "Voorbeeld waarschuwingsregel voorwaarde", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filteren op contactpunten", diff --git a/public/locales/pl-PL/grafana.json b/public/locales/pl-PL/grafana.json index c7d04e0cd8b..2705b11c9ae 100644 --- a/public/locales/pl-PL/grafana.json +++ b/public/locales/pl-PL/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Wpisz treść niestandardowej adnotacji…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reguły zostały usunięte z folderu" @@ -2219,11 +2224,15 @@ "min-interval": "Min. odstęp czasu = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Nie można przekonwertować wybranych zapytań i wyrażeń na domyślne. Jeśli wyłączysz opcje zaawansowane, zapytanie i warunek zostaną zresetowane do ustawień domyślnych." }, + "last15m": "", + "last1h": "", "preview": "Podgląd", - "previewCondition": "Podgląd warunku reguły alertu" + "previewCondition": "Podgląd warunku reguły alertu", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtruj według punktów kontaktu", diff --git a/public/locales/pt-BR/grafana.json b/public/locales/pt-BR/grafana.json index eee46fc8344..250376a959f 100644 --- a/public/locales/pt-BR/grafana.json +++ b/public/locales/pt-BR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Insira o conteúdo da anotação personalizada…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "As regras foram excluídas da pasta" @@ -2203,11 +2208,15 @@ "min-interval": "Mín. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "As consultas e expressões selecionadas não podem ser convertidas para o padrão. Se você desativar as opções avançadas, sua consulta e condição serão redefinidas para as configurações padrão." }, + "last15m": "", + "last1h": "", "preview": "Visualizar", - "previewCondition": "Visualizar condição de regra de alerta" + "previewCondition": "Visualizar condição de regra de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por pontos de contato", diff --git a/public/locales/pt-PT/grafana.json b/public/locales/pt-PT/grafana.json index 415075e65ab..beb5f7d3de8 100644 --- a/public/locales/pt-PT/grafana.json +++ b/public/locales/pt-PT/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Introduzir o conteúdo da anotação personalizada..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Regras eliminadas da pasta com sucesso" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervalo = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "As consultas e expressões selecionadas não podem ser convertidas para padrão. Se desativar as opções avançadas, a sua consulta e condição serão repostas para as definições padrão." }, + "last15m": "", + "last1h": "", "preview": "Pré-visualizar", - "previewCondition": "Pré-visualizar a condição da regra de alerta" + "previewCondition": "Pré-visualizar a condição da regra de alerta", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrar por pontos de contacto", diff --git a/public/locales/ru-RU/grafana.json b/public/locales/ru-RU/grafana.json index a8aab23f3d0..8655e70fba0 100644 --- a/public/locales/ru-RU/grafana.json +++ b/public/locales/ru-RU/grafana.json @@ -729,6 +729,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Ввести содержимое пользовательской аннотации..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Правила удалены из папки" @@ -2219,11 +2224,15 @@ "min-interval": "Мин. интервал = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Выбранные запросы и выражения не могут быть преобразованы в используемые по умолчанию. Если вы отключите расширенные параметры, ваш запрос и условие будут сброшены до настроек по умолчанию." }, + "last15m": "", + "last1h": "", "preview": "Предварительный просмотр", - "previewCondition": "Предварительный просмотр условия правила оповещения" + "previewCondition": "Предварительный просмотр условия правила оповещения", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Фильтр по точкам контакта", diff --git a/public/locales/sv-SE/grafana.json b/public/locales/sv-SE/grafana.json index f3c4effc8b3..4869152e5e8 100644 --- a/public/locales/sv-SE/grafana.json +++ b/public/locales/sv-SE/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Ange innehåll för anpassad kommentar …" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Reglerna har raderats från mappen" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Intervall = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "De valda frågorna och uttrycken kan inte konverteras till standard. Om du inaktiverar avancerade alternativ kommer din fråga och ditt villkor att återställas till standardinställningarna." }, + "last15m": "", + "last1h": "", "preview": "Förhandsgranska", - "previewCondition": "Förhandsgranska varningsregeltillstånd" + "previewCondition": "Förhandsgranska varningsregeltillstånd", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "Filtrera efter kontaktpunkter", diff --git a/public/locales/tr-TR/grafana.json b/public/locales/tr-TR/grafana.json index 7cd8b7b5939..ad54e0fd3e8 100644 --- a/public/locales/tr-TR/grafana.json +++ b/public/locales/tr-TR/grafana.json @@ -723,6 +723,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "Özel ek açıklama içeriği girin..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "Kurallar klasörden başarıyla silindi" @@ -2203,11 +2208,15 @@ "min-interval": "Min. Aralık = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "Seçilen sorgular ve ifadeler varsayılana dönüştürülemez. Gelişmiş seçenekleri devre dışı bırakırsanız sorgunuz ve koşulunuz varsayılan ayarlara sıfırlanır." }, + "last15m": "", + "last1h": "", "preview": "Ön izleme", - "previewCondition": "Uyarı kuralı koşulunu ön izle" + "previewCondition": "Uyarı kuralı koşulunu ön izle", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "", diff --git a/public/locales/zh-Hans/grafana.json b/public/locales/zh-Hans/grafana.json index b36e525f676..7a4b67ba8e7 100644 --- a/public/locales/zh-Hans/grafana.json +++ b/public/locales/zh-Hans/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "输入自定义注释内容..." }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "规则已成功从文件夹中删除" @@ -2195,11 +2200,15 @@ "min-interval": "最小间隔 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "无法将所选查询和表达式转换为默认值。如果停用高级选项,您的查询和条件将重置为默认设置。" }, + "last15m": "", + "last1h": "", "preview": "预览", - "previewCondition": "预览提醒规则条件" + "previewCondition": "预览提醒规则条件", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "按联络点筛选", diff --git a/public/locales/zh-Hant/grafana.json b/public/locales/zh-Hant/grafana.json index 0302a7ffb6f..5e3786ac559 100644 --- a/public/locales/zh-Hant/grafana.json +++ b/public/locales/zh-Hant/grafana.json @@ -720,6 +720,11 @@ "placeholder-value-input": "", "placeholder-value-input-default": "輸入自訂註解內容…" }, + "backtest": { + "error-title": "", + "loading": "", + "panel-title": "" + }, "bulk-actions": { "delete": { "success": "已成功從資料夾中刪除規則" @@ -2195,11 +2200,15 @@ "min-interval": "最小間隔 = {{minInterval}}" }, "queryAndExpressionsStep": { + "custom": "", "disableAdvancedOptions": { "text": "所選查詢和表達式無法轉換為預設值。如果停用進階選項,您的查詢和條件將重設為預設設定。" }, + "last15m": "", + "last1h": "", "preview": "預覽", - "previewCondition": "預覽警報規則條件" + "previewCondition": "預覽警報規則條件", + "testRule": "" }, "receiver-filter": { "aria-label-contact-points": "按聯絡點篩選", From 4ba2fe6cce816da9c98d26ba473fda48261f897a Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 29 Dec 2025 09:31:58 +0100 Subject: [PATCH 36/80] Auditing: Add Event struct to map audit logs into (#115509) --- pkg/apiserver/auditing/event.go | 88 ++++++++++++++++++++++++++++ pkg/apiserver/auditing/event_test.go | 64 ++++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 pkg/apiserver/auditing/event.go create mode 100644 pkg/apiserver/auditing/event_test.go diff --git a/pkg/apiserver/auditing/event.go b/pkg/apiserver/auditing/event.go new file mode 100644 index 00000000000..dc5829096e6 --- /dev/null +++ b/pkg/apiserver/auditing/event.go @@ -0,0 +1,88 @@ +package auditing + +import ( + "encoding/json" + "time" +) + +type Event struct { + // The namespace the action was performed in. + Namespace string `json:"namespace"` + + // When it happened. + ObservedAt time.Time `json:"-"` // see MarshalJSON for why this is omitted + + // Who/what performed the action. + SubjectName string `json:"subjectName"` + SubjectUID string `json:"subjectUID"` + + // What was performed. + Verb string `json:"verb"` + + // The object the action was performed on. For verbs like "list" this will be empty. + Object string `json:"object,omitempty"` + + // API information. + APIGroup string `json:"apiGroup,omitempty"` + APIVersion string `json:"apiVersion,omitempty"` + Kind string `json:"kind,omitempty"` + + // Outcome of the action. + Outcome EventOutcome `json:"outcome"` + + // Extra fields to add more context to the event. + Extra map[string]string `json:"extra,omitempty"` +} + +func (e Event) Time() time.Time { + return e.ObservedAt +} + +func (e Event) MarshalJSON() ([]byte, error) { + type Alias Event + return json.Marshal(&struct { + FormattedTimestamp string `json:"observedAt"` + Alias + }{ + FormattedTimestamp: e.ObservedAt.UTC().Format(time.RFC3339Nano), + Alias: (Alias)(e), + }) +} + +func (e Event) KVPairs() []any { + args := []any{ + "audit", true, + "namespace", e.Namespace, + "observedAt", e.ObservedAt.UTC().Format(time.RFC3339Nano), + "subjectName", e.SubjectName, + "subjectUID", e.SubjectUID, + "verb", e.Verb, + "object", e.Object, + "apiGroup", e.APIGroup, + "apiVersion", e.APIVersion, + "kind", e.Kind, + "outcome", e.Outcome, + } + + if len(e.Extra) > 0 { + extraArgs := make([]any, 0, len(e.Extra)*2) + + for k, v := range e.Extra { + extraArgs = append(extraArgs, "extra_"+k, v) + } + + args = append(args, extraArgs...) + } + + return args +} + +type EventOutcome string + +const ( + EventOutcomeUnknown EventOutcome = "unknown" + EventOutcomeSuccess EventOutcome = "success" + EventOutcomeFailureUnauthorized EventOutcome = "failure_unauthorized" + EventOutcomeFailureNotFound EventOutcome = "failure_not_found" + EventOutcomeFailureGeneric EventOutcome = "failure_generic" +) diff --git a/pkg/apiserver/auditing/event_test.go b/pkg/apiserver/auditing/event_test.go new file mode 100644 index 00000000000..3267936b02a --- /dev/null +++ b/pkg/apiserver/auditing/event_test.go @@ -0,0 +1,64 @@ +package auditing_test + +import ( + "encoding/json" + "strconv" + "strings" + "testing" + "time" + + "github.com/grafana/grafana/pkg/apiserver/auditing" + "github.com/stretchr/testify/require" +) + +func TestEvent_MarshalJSON(t *testing.T) { + t.Parallel() + + t.Run("marshals the event", func(t *testing.T) { + t.Parallel() + + now := time.Now() + + event := auditing.Event{ + ObservedAt: now, + Extra: map[string]string{"k1": "v1", "k2": "v2"}, + } + + data, err := json.Marshal(event) + require.NoError(t, err) + + var result map[string]any + require.NoError(t, json.Unmarshal(data, &result)) + + require.Equal(t, event.Time().UTC().Format(time.RFC3339Nano), result["observedAt"]) + require.NotNil(t, result["extra"]) + require.Len(t, result["extra"], 2) + }) +} + +func TestEvent_KVPairs(t *testing.T) { + t.Parallel() + + t.Run("records extra fields", func(t *testing.T) { + t.Parallel() + + extraFields := 2 + extra := make(map[string]string, 0) + for i := 0; i < extraFields; i++ { + extra[strconv.Itoa(i)] = "value" + } + + event := auditing.Event{Extra: extra} + + kvPairs := event.KVPairs() + + extraCount := 0 + for i := 0; i < len(kvPairs); i += 2 { + if strings.HasPrefix(kvPairs[i].(string), "extra_") { + extraCount++ + } + } + + require.Equal(t, extraCount, extraFields) + }) +} From 0b58cd3900f721224f616b572aadb424654c6eca Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Mon, 29 Dec 2025 09:53:45 +0100 Subject: [PATCH 37/80] Dashboard: Remove BOMs from links during conversion (#115689) * Dashboard: Add test case for BOM characters in link URLs This test demonstrates the issue where BOM (Byte Order Mark) characters in dashboard link URLs cause CUE validation errors during v1 to v2 conversion ('illegal byte order mark'). The test input contains BOMs in various URL locations: - Dashboard links - Panel data links - Field config override links - Options dataLinks - Field config default links * Dashboard: Strip BOM characters from URLs during v1 to v2 conversion BOM (Byte Order Mark) characters in dashboard link URLs cause CUE validation errors ('illegal byte order mark') when opening v2 dashboards. This fix strips BOMs from all URL fields during conversion: - Dashboard links - Panel data links - Field config override links - Options dataLinks - Field config default links The stripBOM helper recursively processes nested structures to ensure all string values have BOMs removed. * Dashboard: Strip BOM characters in frontend v2 conversion Add stripBOMs parameter to sortedDeepCloneWithoutNulls utility to remove Byte Order Mark (U+FEFF) characters from all strings when serializing dashboards to v2 format. This prevents CUE validation errors ('illegal byte order mark') that occur when BOMs are present in any string field. BOMs can be introduced through copy/paste from certain editors or text sources. Applied at the final serialization step so it catches BOMs from: - Existing v1 dashboards being converted - New data entered during dashboard editing --- .../testdata/input/v1beta1.bom-in-links.json | 142 ++++++++++ ...estdata-nested-variables.v42.v2alpha1.json | 2 +- ...testdata-nested-variables.v42.v2beta1.json | 2 +- .../v0alpha1.gauge_tests_new.v42.v1beta1.json | 2 +- ...v0alpha1.gauge_tests_new.v42.v2alpha1.json | 2 +- .../v0alpha1.gauge_tests_new.v42.v2beta1.json | 2 +- ...a1.gauge_tests_old_to_new.v42.v1beta1.json | 2 +- ...1.gauge_tests_old_to_new.v42.v2alpha1.json | 2 +- ...a1.gauge_tests_old_to_new.v42.v2beta1.json | 2 +- .../output/v1beta1.bom-in-links.v0alpha1.json | 161 ++++++++++++ .../output/v1beta1.bom-in-links.v2alpha1.json | 242 +++++++++++++++++ .../output/v1beta1.bom-in-links.v2beta1.json | 246 ++++++++++++++++++ .../conversion/v1beta1_to_v2alpha1.go | 54 +++- public/app/core/utils/object.ts | 16 +- .../transformSceneToSaveModelSchemaV2.ts | 3 +- 15 files changed, 861 insertions(+), 19 deletions(-) create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json create mode 100644 apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json diff --git a/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json new file mode 100644 index 00000000000..86992c3380c --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/input/v1beta1.bom-in-links.json @@ -0,0 +1,142 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v1beta1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "title": "BOM Stripping Test Dashboard", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "schemaVersion": 42, + "tags": ["test", "bom"], + "editable": true, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "url": "http://example.com?var=${datasource}&other=value", + "targetBlank": true, + "icon": "external link" + } + ], + "panels": [ + { + "id": 1, + "type": "table", + "title": "Panel with BOM in field config override links", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + {"color": "green"}, + {"color": "red", "value": 80} + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}&var-server=${__value.raw}" + } + ] + } + ] + } + ] + }, + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}&var=value", + "targetBlank": true + } + ], + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "test-ds" + } + } + ] + }, + { + "id": 2, + "type": "timeseries", + "title": "Panel with BOM in options dataLinks", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "options": { + "legend": { + "showLegend": true, + "displayMode": "list", + "placement": "bottom" + }, + "dataLinks": [ + { + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}&time=${__value.time}", + "targetBlank": true + } + ] + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}&value=${__value.raw}", + "targetBlank": false + } + ] + }, + "overrides": [] + }, + "targets": [ + { + "refId": "A", + "datasource": { + "type": "prometheus", + "uid": "test-ds" + } + } + ] + } + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": ["5s", "10s", "30s", "1m", "5m"] + } + } +} + diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json index 89857905689..b1dbd3de041 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2alpha1.json @@ -120,7 +120,7 @@ "value": [ { "title": "filter", - "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json index 13320b47904..9089dd1d1fb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/feature-templating/v0alpha1.testdata-nested-variables.v42.v2beta1.json @@ -124,7 +124,7 @@ "value": [ { "title": "filter", - "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + "url": "http://localhost:3000/d/-Y-tnEDWk/templating-nested-template-variables?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" } ] } diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json index e04d448a5b8..66ce1cd0f3a 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v1beta1.json @@ -2051,4 +2051,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json index 0e6e3e13da5..95850646c59 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2alpha1.json @@ -2691,4 +2691,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json index ad2b8ca0385..fda0d31e71b 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_new.v42.v2beta1.json @@ -2764,4 +2764,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json index 1d9f7e56513..2dddd657c5f 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v1beta1.json @@ -1173,4 +1173,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json index 7b3f601b5cf..db19ac588c1 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2alpha1.json @@ -1618,4 +1618,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json index 534e7a1600c..8ddc6feb297 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/migrated_dev_dashboards/panel-gauge/v0alpha1.gauge_tests_old_to_new.v42.v2beta1.json @@ -1670,4 +1670,4 @@ "storedVersion": "v0alpha1" } } -} +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json new file mode 100644 index 00000000000..449e76f1173 --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v0alpha1.json @@ -0,0 +1,161 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v0alpha1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "links": [ + { + "icon": "external link", + "targetBlank": true, + "title": "Dashboard link with BOM", + "type": "link", + "url": "http://example.com?var=${datasource}\u0026other=value" + } + ], + "panels": [ + { + "fieldConfig": { + "defaults": { + "custom": {}, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green" + }, + { + "color": "red", + "value": 80 + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "links": [ + { + "targetBlank": true, + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value" + } + ], + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A" + } + ], + "title": "Panel with BOM in field config override links", + "type": "table" + }, + { + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + }, + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A" + } + ], + "title": "Panel with BOM in options dataLinks", + "type": "timeseries" + } + ], + "schemaVersion": 42, + "tags": [ + "test", + "bom" + ], + "time": { + "from": "now-6h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ] + }, + "title": "BOM Stripping Test Dashboard" + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json new file mode 100644 index 00000000000..38547ea5b8e --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2alpha1.json @@ -0,0 +1,242 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2alpha1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "annotations": [], + "cursorSync": "Off", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with BOM in field config override links", + "description": "", + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value", + "targetBlank": true + } + ], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": {} + }, + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "table", + "spec": { + "pluginVersion": "", + "options": {}, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel with BOM in options dataLinks", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "prometheus", + "spec": {} + }, + "datasource": { + "type": "prometheus", + "uid": "test-ds" + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "timeseries", + "spec": { + "pluginVersion": "", + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "icon": "external link", + "tooltip": "", + "url": "http://example.com?var=${datasource}\u0026other=value", + "tags": [], + "asDropdown": false, + "targetBlank": true, + "includeVars": false, + "keepTime": false + } + ], + "liveNow": false, + "preload": false, + "tags": [ + "test", + "bom" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "BOM Stripping Test Dashboard", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json new file mode 100644 index 00000000000..d85da89fe7a --- /dev/null +++ b/apps/dashboard/pkg/migration/conversion/testdata/output/v1beta1.bom-in-links.v2beta1.json @@ -0,0 +1,246 @@ +{ + "kind": "Dashboard", + "apiVersion": "dashboard.grafana.app/v2beta1", + "metadata": { + "name": "bom-in-links-test", + "namespace": "org-1", + "labels": { + "test": "bom-stripping" + } + }, + "spec": { + "annotations": [], + "cursorSync": "Off", + "description": "Testing that BOM characters are stripped from URLs during conversion", + "editable": true, + "elements": { + "panel-1": { + "kind": "Panel", + "spec": { + "id": 1, + "title": "Panel with BOM in field config override links", + "description": "", + "links": [ + { + "title": "Panel data link with BOM", + "url": "http://example.com/${__data.fields.cluster}\u0026var=value", + "targetBlank": true + } + ], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-ds" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "table", + "version": "", + "spec": { + "options": {}, + "fieldConfig": { + "defaults": { + "thresholds": { + "mode": "absolute", + "steps": [ + { + "value": null, + "color": "green" + }, + { + "value": 80, + "color": "red" + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "server" + }, + "properties": [ + { + "id": "links", + "value": [ + { + "title": "Override link with BOM", + "url": "http://localhost:3000/d/test?var-datacenter=${__data.fields[datacenter]}\u0026var-server=${__value.raw}" + } + ] + } + ] + } + ] + } + } + } + } + }, + "panel-2": { + "kind": "Panel", + "spec": { + "id": 2, + "title": "Panel with BOM in options dataLinks", + "description": "", + "links": [], + "data": { + "kind": "QueryGroup", + "spec": { + "queries": [ + { + "kind": "PanelQuery", + "spec": { + "query": { + "kind": "DataQuery", + "group": "prometheus", + "version": "v0", + "datasource": { + "name": "test-ds" + }, + "spec": {} + }, + "refId": "A", + "hidden": false + } + } + ], + "transformations": [], + "queryOptions": {} + } + }, + "vizConfig": { + "kind": "VizConfig", + "group": "timeseries", + "version": "", + "spec": { + "options": { + "dataLinks": [ + { + "targetBlank": true, + "title": "Options data link with BOM", + "url": "http://example.com?series=${__series.name}\u0026time=${__value.time}" + } + ], + "legend": { + "displayMode": "list", + "placement": "bottom", + "showLegend": true + } + }, + "fieldConfig": { + "defaults": { + "links": [ + { + "targetBlank": false, + "title": "Field config default link with BOM", + "url": "http://example.com?field=${__field.name}\u0026value=${__value.raw}" + } + ] + }, + "overrides": [] + } + } + } + } + } + }, + "layout": { + "kind": "GridLayout", + "spec": { + "items": [ + { + "kind": "GridLayoutItem", + "spec": { + "x": 0, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-1" + } + } + }, + { + "kind": "GridLayoutItem", + "spec": { + "x": 12, + "y": 0, + "width": 12, + "height": 8, + "element": { + "kind": "ElementReference", + "name": "panel-2" + } + } + } + ] + } + }, + "links": [ + { + "title": "Dashboard link with BOM", + "type": "link", + "icon": "external link", + "tooltip": "", + "url": "http://example.com?var=${datasource}\u0026other=value", + "tags": [], + "asDropdown": false, + "targetBlank": true, + "includeVars": false, + "keepTime": false + } + ], + "liveNow": false, + "preload": false, + "tags": [ + "test", + "bom" + ], + "timeSettings": { + "timezone": "browser", + "from": "now-6h", + "to": "now", + "autoRefresh": "", + "autoRefreshIntervals": [ + "5s", + "10s", + "30s", + "1m", + "5m" + ], + "hideTimepicker": false, + "fiscalYearStartMonth": 0 + }, + "title": "BOM Stripping Test Dashboard", + "variables": [] + }, + "status": { + "conversion": { + "failed": false, + "storedVersion": "v1beta1" + } + } +} \ No newline at end of file diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index 224f222ae33..b63e0146cc2 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -229,6 +229,36 @@ func getBoolField(m map[string]interface{}, key string, defaultValue bool) bool return defaultValue } +// stripBOM removes Byte Order Mark (BOM) characters from a string. +// BOMs (U+FEFF) can be introduced through copy/paste from certain editors +// and cause CUE validation errors ("illegal byte order mark"). +func stripBOM(s string) string { + return strings.ReplaceAll(s, "\ufeff", "") +} + +// stripBOMFromInterface recursively strips BOM characters from all strings +// in an interface{} value (map, slice, or string). +func stripBOMFromInterface(v interface{}) interface{} { + switch val := v.(type) { + case string: + return stripBOM(val) + case map[string]interface{}: + result := make(map[string]interface{}, len(val)) + for k, v := range val { + result[k] = stripBOMFromInterface(v) + } + return result + case []interface{}: + result := make([]interface{}, len(val)) + for i, item := range val { + result[i] = stripBOMFromInterface(item) + } + return result + default: + return v + } +} + func getUnionField[T ~string](m map[string]interface{}, key string) *T { if val, ok := m[key]; ok { if str, ok := val.(string); ok && str != "" { @@ -393,7 +423,8 @@ func transformLinks(dashboard map[string]interface{}) []dashv2alpha1.DashboardDa // Optional field - only set if present if url, exists := linkMap["url"]; exists { if urlStr, ok := url.(string); ok { - dashLink.Url = &urlStr + cleanUrl := stripBOM(urlStr) + dashLink.Url = &cleanUrl } } @@ -2239,7 +2270,7 @@ func transformDataLinks(panelMap map[string]interface{}) []dashv2alpha1.Dashboar if linkMap, ok := link.(map[string]interface{}); ok { dataLink := dashv2alpha1.DashboardDataLink{ Title: schemaversion.GetStringValue(linkMap, "title"), - Url: schemaversion.GetStringValue(linkMap, "url"), + Url: stripBOM(schemaversion.GetStringValue(linkMap, "url")), } if _, exists := linkMap["targetBlank"]; exists { targetBlank := getBoolField(linkMap, "targetBlank", false) @@ -2331,6 +2362,12 @@ func buildVizConfig(panelMap map[string]interface{}) dashv2alpha1.DashboardVizCo } } + // Strip BOMs from options (may contain dataLinks with URLs that have BOMs) + cleanedOptions := stripBOMFromInterface(options) + if cleanedMap, ok := cleanedOptions.(map[string]interface{}); ok { + options = cleanedMap + } + // Build field config by mapping each field individually fieldConfigSource := extractFieldConfigSource(fieldConfig) @@ -2474,9 +2511,14 @@ func extractFieldConfigDefaults(defaults map[string]interface{}) dashv2alpha1.Da hasDefaults = true } - // Extract array field + // Extract array field - strip BOMs from link URLs if linksArray, ok := extractArrayField(defaults, "links"); ok { - fieldConfigDefaults.Links = linksArray + cleanedLinks := stripBOMFromInterface(linksArray) + if cleanedArray, ok := cleanedLinks.([]interface{}); ok { + fieldConfigDefaults.Links = cleanedArray + } else { + fieldConfigDefaults.Links = linksArray + } hasDefaults = true } @@ -2762,9 +2804,11 @@ func extractFieldConfigOverrides(fieldConfig map[string]interface{}) []dashv2alp fieldOverride.Properties = make([]dashv2alpha1.DashboardDynamicConfigValue, 0, len(propertiesArray)) for _, property := range propertiesArray { if propertyMap, ok := property.(map[string]interface{}); ok { + // Strip BOMs from property values (may contain links with URLs) + cleanedValue := stripBOMFromInterface(propertyMap["value"]) fieldOverride.Properties = append(fieldOverride.Properties, dashv2alpha1.DashboardDynamicConfigValue{ Id: schemaversion.GetStringValue(propertyMap, "id"), - Value: propertyMap["value"], + Value: cleanedValue, }) } } diff --git a/public/app/core/utils/object.ts b/public/app/core/utils/object.ts index 7ace78598c4..ba1426b163c 100644 --- a/public/app/core/utils/object.ts +++ b/public/app/core/utils/object.ts @@ -1,23 +1,29 @@ -import { isArray, isPlainObject } from 'lodash'; +import { isArray, isPlainObject, isString } from 'lodash'; /** * @returns A deep clone of the object, but with any null value removed. * @param value - The object to be cloned and cleaned. * @param convertInfinity - If true, -Infinity or Infinity is converted to 0. * This is because Infinity is not a valid JSON value, and sometimes we want to convert it to 0 instead of default null. + * @param stripBOMs - If true, strips Byte Order Mark (BOM) characters from all strings. + * BOMs (U+FEFF) can cause CUE validation errors ("illegal byte order mark"). */ -export function sortedDeepCloneWithoutNulls(value: T, convertInfinity?: boolean): T { +export function sortedDeepCloneWithoutNulls(value: T, convertInfinity?: boolean, stripBOMs?: boolean): T { if (isArray(value)) { - return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity)) as unknown as T; + return value.map((item) => sortedDeepCloneWithoutNulls(item, convertInfinity, stripBOMs)) as unknown as T; } if (isPlainObject(value)) { return Object.keys(value as { [key: string]: any }) .sort() .reduce((acc: any, key) => { - const v = (value as any)[key]; + let v = (value as any)[key]; // Remove null values if (v != null) { - acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity); + // Strip BOMs from strings + if (stripBOMs && isString(v)) { + v = v.replace(/\ufeff/g, ''); + } + acc[key] = sortedDeepCloneWithoutNulls(v, convertInfinity, stripBOMs); } if (convertInfinity && (v === Infinity || v === -Infinity)) { diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts index 0ef2a5e5f05..90c7f5e2e61 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModelSchemaV2.ts @@ -144,7 +144,8 @@ export function transformSceneToSaveModelSchemaV2(scene: DashboardScene, isSnaps try { // validateDashboardSchemaV2 will throw an error if the dashboard is not valid if (validateDashboardSchemaV2(dashboardSchemaV2)) { - return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true); + // Strip BOMs from all strings to prevent CUE validation errors ("illegal byte order mark") + return sortedDeepCloneWithoutNulls(dashboardSchemaV2, true, true); } // should never reach this point, validation should throw an error throw new Error('Error we could transform the dashboard to schema v2: ' + dashboardSchemaV2); From 30ad61e0e9fe02c4846e56d0c43ca5bf66907762 Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Mon, 29 Dec 2025 10:29:50 +0100 Subject: [PATCH 38/80] Dashboards: Fix adhoc filter click when panel has no panel-level datasource (#115576) * V2: Panel datasource is defined only for mixed ds * if getDatasourceFromQueryRunner only returns ds.type, resolve to full ds ref throgh ds service --------- Co-authored-by: Haris Rozajac --- .../scene/setDashboardPanelContext.test.ts | 35 +++++++++++++++- .../scene/setDashboardPanelContext.ts | 41 ++++++++++++++++--- .../dashboard-scene/utils/drilldownUtils.ts | 4 +- .../dashboard-scene/utils/urlBuilders.ts | 5 ++- .../features/dashboard-scene/utils/utils.ts | 22 +++++++++- 5 files changed, 95 insertions(+), 12 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts index d5669497180..cf1968f45f7 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.test.ts @@ -1,10 +1,10 @@ import { AdHocVariableModel, EventBusSrv, GroupByVariableModel, VariableModel } from '@grafana/data'; import { BackendSrv, config, setBackendSrv } from '@grafana/runtime'; -import { GroupByVariable, sceneGraph } from '@grafana/scenes'; +import { GroupByVariable, sceneGraph, SceneQueryRunner } from '@grafana/scenes'; import { AdHocFilterItem, PanelContext } from '@grafana/ui'; import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene'; -import { findVizPanelByKey } from '../utils/utils'; +import { findVizPanelByKey, getQueryRunnerFor } from '../utils/utils'; import { getAdHocFilterVariableFor, setDashboardPanelContext } from './setDashboardPanelContext'; @@ -159,6 +159,23 @@ describe('setDashboardPanelContext', () => { // Verify existing filter value updated expect(variable.state.filters[1].operator).toBe('!='); }); + + it('Should use existing adhoc filter when panel has no panel-level datasource because queries have all the same datasources (v2 behavior)', () => { + const { scene, context } = buildTestScene({ existingFilterVariable: true, panelDatasourceUndefined: true }); + + const variable = getAdHocFilterVariableFor(scene, { uid: 'my-ds-uid' }); + variable.setState({ filters: [] }); + + context.onAddAdHocFilter!({ key: 'hello', value: 'world', operator: '=' }); + + // Should use the existing adhoc filter variable, not create a new one + expect(variable.state.filters).toEqual([{ key: 'hello', value: 'world', operator: '=' }]); + + // Verify no new adhoc variables were created + const variables = sceneGraph.getVariables(scene); + const adhocVars = variables.state.variables.filter((v) => v.state.type === 'adhoc'); + expect(adhocVars.length).toBe(1); + }); }); describe('getFiltersBasedOnGrouping', () => { @@ -312,6 +329,7 @@ interface SceneOptions { existingFilterVariable?: boolean; existingGroupByVariable?: boolean; groupByDatasourceUid?: string; + panelDatasourceUndefined?: boolean; } function buildTestScene(options: SceneOptions) { @@ -385,6 +403,19 @@ function buildTestScene(options: SceneOptions) { }); const vizPanel = findVizPanelByKey(scene, 'panel-4')!; + + // Simulate v2 dashboard behavior where non-mixed panels don't have panel-level datasource + // but the queries have their own datasources + if (options.panelDatasourceUndefined) { + const queryRunner = getQueryRunnerFor(vizPanel); + if (queryRunner instanceof SceneQueryRunner) { + queryRunner.setState({ + datasource: undefined, + queries: [{ refId: 'A', datasource: { uid: 'my-ds-uid', type: 'prometheus' } }], + }); + } + } + const context: PanelContext = { eventBus: new EventBusSrv(), eventsScope: 'global', diff --git a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts index c9b3fdf44bd..a256a3305b1 100644 --- a/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts +++ b/public/app/features/dashboard-scene/scene/setDashboardPanelContext.ts @@ -6,7 +6,12 @@ import { AdHocFilterItem, PanelContext } from '@grafana/ui'; import { annotationServer } from 'app/features/annotations/api'; import { dashboardSceneGraph } from '../utils/dashboardSceneGraph'; -import { getDashboardSceneFor, getPanelIdForVizPanel, getQueryRunnerFor } from '../utils/utils'; +import { + getDashboardSceneFor, + getDatasourceFromQueryRunner, + getPanelIdForVizPanel, + getQueryRunnerFor, +} from '../utils/utils'; import { DashboardScene } from './DashboardScene'; @@ -121,7 +126,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte context.eventBus.publish(new AnnotationChangeEvent({ id })); }; - context.onAddAdHocFilter = (newFilter: AdHocFilterItem) => { + context.onAddAdHocFilter = async (newFilter: AdHocFilterItem) => { const dashboard = getDashboardSceneFor(vizPanel); const queryRunner = getQueryRunnerFor(vizPanel); @@ -129,7 +134,19 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return; } - const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource); + let datasource = getDatasourceFromQueryRunner(queryRunner); + + // If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries) + // we need to resolve it to a full datasource + if (datasource && !datasource.uid) { + const datasourceToLoad = await getDataSourceSrv().get(datasource); + datasource = { + uid: datasourceToLoad.uid, + type: datasourceToLoad.type, + }; + } + + const filterVar = getAdHocFilterVariableFor(dashboard, datasource); updateAdHocFilterVariable(filterVar, newFilter); }; @@ -141,7 +158,8 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return []; } - const groupByVar = getGroupByVariableFor(dashboard, queryRunner.state.datasource); + const datasource = getDatasourceFromQueryRunner(queryRunner); + const groupByVar = getGroupByVariableFor(dashboard, datasource); if (!groupByVar) { return []; @@ -158,7 +176,7 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte .filter((item) => item !== undefined); }; - context.onAddAdHocFilters = (items: AdHocFilterItem[]) => { + context.onAddAdHocFilters = async (items: AdHocFilterItem[]) => { const dashboard = getDashboardSceneFor(vizPanel); const queryRunner = getQueryRunnerFor(vizPanel); @@ -166,7 +184,18 @@ export function setDashboardPanelContext(vizPanel: VizPanel, context: PanelConte return; } - const filterVar = getAdHocFilterVariableFor(dashboard, queryRunner.state.datasource); + let datasource = getDatasourceFromQueryRunner(queryRunner); + + // If the datasource is type-only (e.g. it's possible that only group is set in V2 schema queries) + // we need to resolve it to a full datasource + if (datasource && !datasource.uid) { + const datasourceToLoad = await getDataSourceSrv().get(datasource); + datasource = { + uid: datasourceToLoad.uid, + type: datasourceToLoad.type, + }; + } + const filterVar = getAdHocFilterVariableFor(dashboard, datasource); bulkUpdateAdHocFiltersVariable(filterVar, items); }; diff --git a/public/app/features/dashboard-scene/utils/drilldownUtils.ts b/public/app/features/dashboard-scene/utils/drilldownUtils.ts index 2ff0ecf7c6e..cf6f1271162 100644 --- a/public/app/features/dashboard-scene/utils/drilldownUtils.ts +++ b/public/app/features/dashboard-scene/utils/drilldownUtils.ts @@ -3,6 +3,8 @@ import { getDataSourceSrv } from '@grafana/runtime'; import { AdHocFiltersVariable, GroupByVariable, sceneGraph, SceneObject, SceneQueryRunner } from '@grafana/scenes'; import { DataSourceRef } from '@grafana/schema'; +import { getDatasourceFromQueryRunner } from './utils'; + export function verifyDrilldownApplicability( sourceObject: SceneObject, queriesDataSource: DataSourceRef | undefined, @@ -26,7 +28,7 @@ export async function getDrilldownApplicability( return; } - const datasource = queryRunner.state.datasource; + const datasource = getDatasourceFromQueryRunner(queryRunner); const queries = queryRunner.state.data?.request?.targets; const ds = await getDataSourceSrv().get(datasource?.uid); diff --git a/public/app/features/dashboard-scene/utils/urlBuilders.ts b/public/app/features/dashboard-scene/utils/urlBuilders.ts index 942d378a38f..11f604d6990 100644 --- a/public/app/features/dashboard-scene/utils/urlBuilders.ts +++ b/public/app/features/dashboard-scene/utils/urlBuilders.ts @@ -4,7 +4,7 @@ import { sceneGraph, VizPanel } from '@grafana/scenes'; import { contextSrv } from 'app/core/services/context_srv'; import { getExploreUrl } from 'app/core/utils/explore'; -import { getQueryRunnerFor } from './utils'; +import { getDatasourceFromQueryRunner, getQueryRunnerFor } from './utils'; export function getViewPanelUrl(vizPanel: VizPanel) { return locationUtil.getUrlForPartial(locationService.getLocation(), { @@ -27,10 +27,11 @@ export function tryGetExploreUrlForPanel(vizPanel: VizPanel): Promise Date: Mon, 29 Dec 2025 10:10:04 -0500 Subject: [PATCH 39/80] E2E: Use updated setVisualization from grafana/e2e (#115640) --- .../panels-suite/canvas-scene.spec.ts | 6 +-- .../panels-suite/vizpicker-utils.ts | 24 --------- .../as-admin-user/panelDataAssertion.spec.ts | 9 ++-- .../as-admin-user/panelEditPage.spec.ts | 51 +++++++++---------- 4 files changed, 31 insertions(+), 59 deletions(-) delete mode 100644 e2e-playwright/panels-suite/vizpicker-utils.ts diff --git a/e2e-playwright/panels-suite/canvas-scene.spec.ts b/e2e-playwright/panels-suite/canvas-scene.spec.ts index b1fc028f3ae..c0b392d544b 100644 --- a/e2e-playwright/panels-suite/canvas-scene.spec.ts +++ b/e2e-playwright/panels-suite/canvas-scene.spec.ts @@ -2,18 +2,16 @@ import { Locator } from '@playwright/test'; import { test, expect } from '@grafana/plugin-e2e'; -import { setVisualization } from './vizpicker-utils'; - test.use({ featureToggles: { canvasPanelPanZoom: true, }, }); test.describe('Canvas Panel - Scene Tests', () => { - test.beforeEach(async ({ page, gotoDashboardPage, selectors }) => { + test.beforeEach(async ({ page, gotoDashboardPage }) => { const dashboardPage = await gotoDashboardPage({}); const panelEditPage = await dashboardPage.addPanel(); - await setVisualization(panelEditPage, 'Canvas', selectors); + await panelEditPage.setVisualization('Canvas'); // Wait for canvas panel to load await page.waitForSelector('[data-testid="canvas-scene-pan-zoom"]', { timeout: 10000 }); diff --git a/e2e-playwright/panels-suite/vizpicker-utils.ts b/e2e-playwright/panels-suite/vizpicker-utils.ts deleted file mode 100644 index 1785dd7e04a..00000000000 --- a/e2e-playwright/panels-suite/vizpicker-utils.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { expect, E2ESelectorGroups, PanelEditPage } from '@grafana/plugin-e2e'; - -// this replaces the panelEditPage.setVisualization method used previously in tests, since it -// does not know how to use the updated 12.4 viz picker UI to set the visualization -export const setVisualization = async (panelEditPage: PanelEditPage, vizName: string, selectors: E2ESelectorGroups) => { - const vizPicker = panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.toggleVizPicker); - await expect(vizPicker, '"Change" button should be visible').toBeVisible(); - await vizPicker.click(); - - const allVizTabBtn = panelEditPage.getByGrafanaSelector(selectors.components.Tab.title('All visualizations')); - await expect(allVizTabBtn, '"All visualiations" button should be visible').toBeVisible(); - await allVizTabBtn.click(); - - const vizItem = panelEditPage.getByGrafanaSelector(selectors.components.PluginVisualization.item(vizName)); - await expect(vizItem, `"${vizName}" item should be visible`).toBeVisible(); - await vizItem.scrollIntoViewIfNeeded(); - await vizItem.click(); - - await expect(vizPicker, '"Change" button should be visible again').toBeVisible(); - await expect( - panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), - 'Panel header should have the new viz type name' - ).toHaveText(vizName); -}; diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts index 336dbef0a29..0133a3e3712 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelDataAssertion.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; @@ -25,10 +24,10 @@ test.describe( ).toContainText(['Field', 'Max', 'Mean', 'Last']); }); - test('table panel data assertions', async ({ panelEditPage, selectors }) => { + test('table panel data assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Table', selectors); + await panelEditPage.setVisualization('Table'); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.locator, @@ -44,10 +43,10 @@ test.describe( ).toContainText(['val1', 'val2', 'val3', 'val4']); }); - test('timeseries panel - table view assertions', async ({ panelEditPage, selectors }) => { + test('timeseries panel - table view assertions', async ({ panelEditPage }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, 'Time series', selectors); + await panelEditPage.setVisualization('Time series'); await panelEditPage.refreshPanel(); await panelEditPage.toggleTableView(); await expect( diff --git a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts index 93e0525ab0e..46c36277848 100644 --- a/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts +++ b/e2e-playwright/plugin-e2e/plugin-e2e-api-tests/as-admin-user/panelEditPage.spec.ts @@ -1,6 +1,5 @@ import { expect, test } from '@grafana/plugin-e2e'; -import { setVisualization } from '../../../panels-suite/vizpicker-utils'; import { formatExpectError } from '../errors'; import { successfulDataQuery } from '../mocks/queries'; import { scenarios } from '../mocks/resources'; @@ -54,10 +53,10 @@ test.describe( ).toHaveText(scenarios.map((s) => s.name)); }); - test('mocked query data response', async ({ panelEditPage, page, selectors }) => { + test('mocked query data response', async ({ panelEditPage, page }) => { await panelEditPage.mockQueryDataResponse(successfulDataQuery, 200); await panelEditPage.datasource.set('gdev-testdata'); - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await panelEditPage.refreshPanel(); await expect( panelEditPage.panel.getErrorIcon(), @@ -76,7 +75,7 @@ test.describe( selectors, page, }) => { - await setVisualization(panelEditPage, TABLE_VIZ_NAME, selectors); + await panelEditPage.setVisualization(TABLE_VIZ_NAME); await expect( panelEditPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.header), formatExpectError('Expected panel visualization to be set to table') @@ -93,8 +92,8 @@ test.describe( ).toBeVisible(); }); - test('Select time zone in timezone picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('Select time zone in timezone picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = await panelEditPage.getCustomOptions('Axis'); const timeZonePicker = axisOptions.getSelect('Time zone'); @@ -102,8 +101,8 @@ test.describe( await expect(timeZonePicker).toHaveSelected('Europe/Stockholm'); }); - test('select unit in unit picker', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select unit in unit picker', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const unitPicker = standardOptions.getUnitPicker('Unit'); @@ -112,8 +111,8 @@ test.describe( await expect(unitPicker).toHaveSelected('Pixels'); }); - test('enter value in number input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in number input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const lineWith = axisOptions.getNumberInput('Soft min'); @@ -122,8 +121,8 @@ test.describe( await expect(lineWith).toHaveValue('10'); }); - test('enter value in slider', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in slider', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const graphOptions = panelEditPage.getCustomOptions('Graph styles'); const lineWidth = graphOptions.getSliderInput('Line width'); @@ -132,8 +131,8 @@ test.describe( await expect(lineWidth).toHaveValue('10'); }); - test('select value in single value select', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('select value in single value select', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const standardOptions = panelEditPage.getStandardOptions(); const colorSchemeSelect = standardOptions.getSelect('Color scheme'); @@ -141,8 +140,8 @@ test.describe( await expect(colorSchemeSelect).toHaveSelected('Classic palette'); }); - test('clear input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('clear input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const title = panelOptions.getTextInput('Title'); @@ -151,8 +150,8 @@ test.describe( await expect(title).toHaveValue(''); }); - test('enter value in input', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('enter value in input', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const panelOptions = panelEditPage.getPanelOptions(); const description = panelOptions.getTextInput('Description'); @@ -161,8 +160,8 @@ test.describe( await expect(description).toHaveValue('This is a panel'); }); - test('unchecking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('unchecking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -174,8 +173,8 @@ test.describe( await expect(showBorder).toBeChecked({ checked: false }); }); - test('checking switch', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('checking switch', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const showBorder = axisOptions.getSwitch('Show border'); @@ -184,8 +183,8 @@ test.describe( await expect(showBorder).toBeChecked(); }); - test('re-selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('re-selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); @@ -196,8 +195,8 @@ test.describe( await expect(placement).toHaveChecked('Auto'); }); - test('selecting value in radio button group', async ({ panelEditPage, selectors }) => { - await setVisualization(panelEditPage, TIME_SERIES_VIZ_NAME, selectors); + test('selecting value in radio button group', async ({ panelEditPage }) => { + await panelEditPage.setVisualization(TIME_SERIES_VIZ_NAME); const axisOptions = panelEditPage.getCustomOptions('Axis'); const placement = axisOptions.getRadioGroup('Placement'); From 7182511bcf6ae8dd50f68557d2532486b45c522d Mon Sep 17 00:00:00 2001 From: Rodrigo Vasconcelos de Barros Date: Mon, 29 Dec 2025 10:18:42 -0500 Subject: [PATCH 40/80] Alerting: Auto-format numeric values in Alert Rule History (#115708) * Add helper function to format numeric values in alert rule history * Use formatting function in LogRecordViewer * Refactor numerical formatting logic * Handle edge cases when counting decimal places * Cleanup tests and numberFormatter code --- .../state-history/LogRecordViewer.test.tsx | 72 ++++++++ .../rules/state-history/LogRecordViewer.tsx | 3 +- .../state-history/numberFormatter.test.ts | 173 ++++++++++++++++++ .../rules/state-history/numberFormatter.ts | 75 ++++++++ 4 files changed, 322 insertions(+), 1 deletion(-) create mode 100644 public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts create mode 100644 public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx index a90e9dc52a8..cbc5563538f 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.test.tsx @@ -60,4 +60,76 @@ describe('LogRecordViewerByTimestamp', () => { expect(within(errorRows[1]).getByText(/Error message:/)).toBeInTheDocument(); expect(within(errorRows[1]).getByText(/explicit message/)).toBeInTheDocument(); }); + + describe('Numeric Value Formatting', () => { + it('should format numeric values correctly in AlertInstanceValues', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + cpu_usage: 42.987654321, + memory_mb: 1234567.89, + disk_io: 0.001234, + request_count: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/cpu_usage/)).toBeInTheDocument(); + expect(screen.getByText(/4\.299e\+1/i)).toBeInTheDocument(); + + expect(screen.getByText(/memory_mb/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+6/i)).toBeInTheDocument(); + + expect(screen.getByText(/disk_io/)).toBeInTheDocument(); + expect(screen.getByText(/1\.234e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/request_count/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + + it('should format various numeric ranges correctly', () => { + const records: LogRecord[] = [ + { + timestamp: 1681739580000, + line: { + current: 'Alerting', + previous: 'Pending', + labels: {}, + values: { + small: 0.001, + normal: 42.5, + large: 123456, + boundary_low: 0.01, + boundary_high: 10000, + }, + }, + }, + ]; + + render(); + + expect(screen.getByText(/small/)).toBeInTheDocument(); + expect(screen.getByText(/1\.000e-3/i)).toBeInTheDocument(); + + expect(screen.getByText(/normal/)).toBeInTheDocument(); + expect(screen.getByText(/42\.5/)).toBeInTheDocument(); + + expect(screen.getByText(/large/)).toBeInTheDocument(); + expect(screen.getByText(/1\.235e\+5/i)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_low/)).toBeInTheDocument(); + expect(screen.getByText(/0\.01/)).toBeInTheDocument(); + + expect(screen.getByText(/boundary_high/)).toBeInTheDocument(); + expect(screen.getByText(/10000/)).toBeInTheDocument(); + }); + }); }); diff --git a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx index c1d90347c74..06fcde4a1ae 100644 --- a/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx +++ b/public/app/features/alerting/unified/components/rules/state-history/LogRecordViewer.tsx @@ -13,6 +13,7 @@ import { AlertStateTag } from '../AlertStateTag'; import { ErrorMessageRow } from './ErrorMessageRow'; import { LogRecord, omitLabels } from './common'; +import { formatNumericValue } from './numberFormatter'; type LogRecordViewerProps = { records: LogRecord[]; @@ -182,7 +183,7 @@ const AlertInstanceValues = memo(({ record }: { record: Record } return ( <> {values.map(([key, value]) => ( - + ))} ); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts new file mode 100644 index 00000000000..77dfe40df5a --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.test.ts @@ -0,0 +1,173 @@ +import { formatNumericValue } from './numberFormatter'; + +describe('formatNumericValue', () => { + describe('Zero and special values', () => { + it('should format zero correctly', () => { + expect(formatNumericValue(0)).toBe('0'); + expect(formatNumericValue(-0)).toBe('0'); + }); + + it('should handle NaN', () => { + expect(formatNumericValue(NaN)).toBe('NaN'); + }); + + it('should handle Infinity', () => { + expect(formatNumericValue(Infinity)).toBe('Infinity'); + expect(formatNumericValue(-Infinity)).toBe('-Infinity'); + }); + }); + + describe('Very small numbers (scientific notation)', () => { + it('should use scientific notation for values less than 1e-2', () => { + const result1 = formatNumericValue(1e-3); + expect(result1).toMatch(/^1\.000e-3$/i); + + const result2 = formatNumericValue(0.001); + expect(result2).toMatch(/^1\.000e-3$/i); + + const result3 = formatNumericValue(0.009); + expect(result3).toMatch(/^9\.000e-3$/i); + }); + + it('should use scientific notation for values just below 1e-2', () => { + const result = formatNumericValue(0.00999); + expect(result).toMatch(/^9\.990e-3$/i); + }); + + it('should format the example from requirements correctly', () => { + // 1.4153928131348452 has > 4 decimal places, so should use scientific notation + const result = formatNumericValue(1.4153928131348452); + expect(result).toMatch(/^1\.415e\+0$/i); + }); + + it('should handle negative very small numbers', () => { + const result = formatNumericValue(-1e-3); + expect(result).toMatch(/^-1\.000e-3$/i); + + const result2 = formatNumericValue(-0.001); + expect(result2).toMatch(/^-1\.000e-3$/i); + }); + }); + + describe('Human-readable range (standard notation)', () => { + it('should use standard notation for boundary value 1e-2', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + }); + + it('should use standard notation for values in readable range', () => { + expect(formatNumericValue(0.1)).toBe('0.1'); + expect(formatNumericValue(1)).toBe('1'); + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(42.5)).toBe('42.5'); + }); + + it('should limit to 4 decimal places without rounding integer parts', () => { + expect(formatNumericValue(123.456)).toBe('123.456'); + expect(formatNumericValue(1234.567)).toBe('1234.567'); + expect(formatNumericValue(9999.9)).toBe('9999.9'); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + }); + + it('should use scientific notation for numbers with more than 4 decimal places', () => { + // Numbers with > 4 decimals should use scientific notation even in readable range + const result1 = formatNumericValue(123.456789); + expect(result1).toMatch(/^1\.235e\+2$/i); + + const result2 = formatNumericValue(1.23456789); + expect(result2).toMatch(/^1\.235e\+0$/i); + + const result3 = formatNumericValue(42.987654321); + expect(result3).toMatch(/^4\.299e\+1$/i); + }); + + it('should use standard notation for boundary value 1e4', () => { + expect(formatNumericValue(10000)).toBe('10000'); + }); + + it('should handle negative numbers in readable range', () => { + expect(formatNumericValue(-0.1)).toBe('-0.1'); + expect(formatNumericValue(-123.456)).toBe('-123.456'); + expect(formatNumericValue(-9999.9)).toBe('-9999.9'); + }); + + it('should use scientific notation for negative numbers with excessive precision', () => { + const result = formatNumericValue(-42.987654321); + expect(result).toMatch(/^-4\.299e\+1$/i); + }); + }); + + describe('Very large numbers (scientific notation)', () => { + it('should use scientific notation for values greater than 1e4', () => { + const result1 = formatNumericValue(10001); + expect(result1).toMatch(/^1\.000e\+4$/i); + + const result2 = formatNumericValue(123456); + expect(result2).toMatch(/^1\.235e\+5$/i); + }); + + it('should handle negative very large numbers', () => { + const result = formatNumericValue(-1e5); + expect(result).toMatch(/^-1\.000e\+5$/i); + + const result2 = formatNumericValue(-123456); + expect(result2).toMatch(/^-1\.235e\+5$/i); + }); + }); + + describe('Edge cases', () => { + it('should handle numbers exactly at boundaries', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + + const justBelow = formatNumericValue(0.009999); + expect(justBelow).toMatch(/^9\.999e-3$/i); + + expect(formatNumericValue(10000)).toBe('10000'); + + const justAbove = formatNumericValue(10001); + expect(justAbove).toMatch(/^1\.000e\+4$/i); + }); + + it('should use scientific notation for very precise decimals with > 4 decimal places', () => { + expect(formatNumericValue(1.23456789)).toMatch(/^1\.235e\+0$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + expect(formatNumericValue(0.123456789)).toMatch(/^1\.235e-1$/i); + }); + + it('should use standard notation for numbers with exactly 4 or fewer decimal places', () => { + expect(formatNumericValue(1.2345)).toBe('1.2345'); + expect(formatNumericValue(0.1234)).toBe('0.1234'); + expect(formatNumericValue(123.4567)).toBe('123.4567'); + }); + }); + + describe('countDecimalPlaces edge cases', () => { + it('should handle numbers that toString() would convert to scientific notation', () => { + const result = formatNumericValue(1e-10); + expect(result).toMatch(/^1\.000e-10$/i); + + const result2 = formatNumericValue(1e10); + expect(result2).toMatch(/^1\.000e\+10$/i); + }); + + it('should correctly count decimals for numbers with trailing zeros', () => { + expect(formatNumericValue(1.234)).toBe('1.234'); + expect(formatNumericValue(1.2)).toBe('1.2'); + expect(formatNumericValue(1.0)).toBe('1'); + }); + + it('should handle boundary values correctly', () => { + expect(formatNumericValue(0.01)).toBe('0.01'); + expect(formatNumericValue(10000)).toBe('10000'); + + expect(formatNumericValue(0.01001)).toMatch(/^1\.001e-2$/i); + expect(formatNumericValue(9999.1234)).toBe('9999.1234'); + expect(formatNumericValue(9999.12345)).toMatch(/^9\.999e\+3$/i); + }); + + it('should handle numbers in readable range that have many decimals', () => { + expect(formatNumericValue(1.4153928131348452)).toMatch(/^1\.415e\+0$/i); + expect(formatNumericValue(42.987654321)).toMatch(/^4\.299e\+1$/i); + expect(formatNumericValue(123.456789)).toMatch(/^1\.235e\+2$/i); + }); + }); +}); diff --git a/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts new file mode 100644 index 00000000000..8e518c2c932 --- /dev/null +++ b/public/app/features/alerting/unified/components/rules/state-history/numberFormatter.ts @@ -0,0 +1,75 @@ +const SCIENTIFIC_NOTATION_THRESHOLD_SMALL = 1e-2; +const SCIENTIFIC_NOTATION_THRESHOLD_LARGE = 1e4; +const MAX_DECIMAL_PLACES = 4; +const EXPONENTIAL_DECIMALS = 3; // 4 significant digits = 1 digit + 3 decimals + +const readableRangeFormatter = new Intl.NumberFormat(undefined, { + maximumFractionDigits: MAX_DECIMAL_PLACES, + useGrouping: false, +}); + +/** + * Counts the number of decimal places in a number. + * Only processes numbers in readable range (1e-2 to 1e4) to avoid + * toString() scientific notation issues for very large/small numbers. + * + * Uses toFixed(10) to ensure standard notation representation. + * 10 decimal places is sufficient to detect if a number has > 4 decimal places. + */ +function countDecimalPlaces(value: number): number { + if (Number.isInteger(value)) { + return 0; + } + + const absValue = Math.abs(value); + + // Only count decimals for numbers in readable range + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return 0; + } + + const str = value.toFixed(10); + const decimalIndex = str.indexOf('.'); + + if (decimalIndex === -1) { + return 0; + } + + // Count decimal places, removing trailing zeros + const decimalPart = str.substring(decimalIndex + 1).replace(/0+$/, ''); + return decimalPart.length; +} + +/** + * Formats a numeric value for display in alert rule history. + * - For values in human-readable range (1e-2 to 1e4) with ≤ 4 decimal places: shows up to 4 decimal places + * - For very small values (< 1e-2): uses scientific notation with 4 significant digits + * - For very large values (> 1e4): uses scientific notation with 4 significant digits + * - For numbers with > 4 decimal places: uses scientific notation with 4 significant digits + * + * @param value - The number to format + * @returns A formatted string representation of the number + */ +export function formatNumericValue(value: number): string { + if (!Number.isFinite(value)) { + return String(value); + } + + if (value === 0) { + return '0'; + } + + const absValue = Math.abs(value); + + if (absValue < SCIENTIFIC_NOTATION_THRESHOLD_SMALL || absValue > SCIENTIFIC_NOTATION_THRESHOLD_LARGE) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + const decimalPlaces = countDecimalPlaces(value); + + if (decimalPlaces > MAX_DECIMAL_PLACES) { + return value.toExponential(EXPONENTIAL_DECIMALS); + } + + return readableRangeFormatter.format(value); +} From e088c9aac9884f0820ad261fdb4c670f8829c7ed Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Mon, 29 Dec 2025 16:28:29 +0100 Subject: [PATCH 41/80] Auditing: Add feature flag (#115726) --- .../grafana-data/src/types/featureToggles.gen.ts | 4 ++++ 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 ++++++++++++++ 5 files changed, 31 insertions(+) diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 981b10dfb1c..04b0b28847c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -421,6 +421,10 @@ export interface FeatureToggles { */ jitterAlertRulesWithinGroups?: boolean; /** + * Enable audit logging with Kubernetes under app platform + */ + auditLoggingAppPlatform?: boolean; + /** * Enable the secrets management API and services under app platform */ secretsManagementAppPlatform?: boolean; diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index d6f2bcbec2e..22e832034bc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -688,6 +688,14 @@ var ( HideFromDocs: true, RequiresRestart: true, }, + { + Name: "auditLoggingAppPlatform", + Description: "Enable audit logging with Kubernetes under app platform", + Stage: FeatureStageExperimental, + Owner: grafanaOperatorExperienceSquad, + HideFromDocs: true, + RequiresRestart: true, + }, { Name: "secretsManagementAppPlatform", Description: "Enable the secrets management API and services under app platform", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 179568aa0c4..87001f263f8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -95,6 +95,7 @@ kubernetesFeatureToggles,experimental,@grafana/grafana-operator-experience-squad cloudRBACRoles,preview,@grafana/identity-access-team,false,true,false alertingQueryOptimization,GA,@grafana/alerting-squad,false,false,false jitterAlertRulesWithinGroups,preview,@grafana/alerting-squad,false,true,false +auditLoggingAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,true,false secretsManagementAppPlatform,experimental,@grafana/grafana-operator-experience-squad,false,false,false secretsManagementAppPlatformUI,experimental,@grafana/grafana-operator-experience-squad,false,false,false alertingSaveStatePeriodic,privatePreview,@grafana/alerting-squad,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 2797b046d57..6543d31dba5 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -279,6 +279,10 @@ const ( // Distributes alert rule evaluations more evenly over time, including spreading out rules within the same group. Disables sequential evaluation if enabled. FlagJitterAlertRulesWithinGroups = "jitterAlertRulesWithinGroups" + // FlagAuditLoggingAppPlatform + // Enable audit logging with Kubernetes under app platform + FlagAuditLoggingAppPlatform = "auditLoggingAppPlatform" + // FlagSecretsManagementAppPlatform // Enable the secrets management API and services under app platform FlagSecretsManagementAppPlatform = "secretsManagementAppPlatform" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 42922ecf82d..5bea1b2e40f 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -658,6 +658,20 @@ "frontend": true } }, + { + "metadata": { + "name": "auditLoggingAppPlatform", + "resourceVersion": "1767013056996", + "creationTimestamp": "2025-12-29T12:57:36Z" + }, + "spec": { + "description": "Enable audit logging with Kubernetes under app platform", + "stage": "experimental", + "codeowner": "@grafana/grafana-operator-experience-squad", + "requiresRestart": true, + "hideFromDocs": true + } + }, { "metadata": { "name": "authZGRPCServer", From 4c79775b574ffe848cead70186ef1cf8dd1f5079 Mon Sep 17 00:00:00 2001 From: linoman <2051016+linoman@users.noreply.github.com> Date: Mon, 29 Dec 2025 17:19:49 +0100 Subject: [PATCH 42/80] auth: Protect from empty session token panic (#115728) * Protect from empty session token panic * Rename returned error --- pkg/services/auth/auth.go | 7 ++++--- pkg/services/oauthtoken/oauth_token.go | 4 ++++ 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/pkg/services/auth/auth.go b/pkg/services/auth/auth.go index cc678914b31..76b60b68517 100644 --- a/pkg/services/auth/auth.go +++ b/pkg/services/auth/auth.go @@ -20,9 +20,10 @@ const ( // Typed errors var ( - ErrUserTokenNotFound = errors.New("user token not found") - ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken - ErrExternalSessionNotFound = errors.New("external session not found") + ErrUserTokenNotFound = errors.New("user token not found") + ErrInvalidSessionToken = usertoken.ErrInvalidSessionToken + ErrExternalSessionNotFound = errors.New("external session not found") + ErrExternalSessionTokenNotFound = errors.New("session token was nil") ) type ( diff --git a/pkg/services/oauthtoken/oauth_token.go b/pkg/services/oauthtoken/oauth_token.go index 0efe5e553f3..6d320251ccc 100644 --- a/pkg/services/oauthtoken/oauth_token.go +++ b/pkg/services/oauthtoken/oauth_token.go @@ -660,6 +660,10 @@ func (o *Service) getExternalSession(ctx context.Context, usr identity.Requester return externalSessions[0], nil } + if sessionToken == nil { + return nil, auth.ErrExternalSessionTokenNotFound + } + // For regular users, we use the session token ID to fetch the external session return o.sessionService.GetExternalSession(ctx, sessionToken.ExternalSessionId) } From 0c6b97bee2b91dadbe44d4900ab9862545626039 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Mon, 29 Dec 2025 19:11:44 +0100 Subject: [PATCH 43/80] Prometheus: Fallback to fetch metric names when metadata returns nothing (#115369) fallback to fetch metric names when metadata returns nothing --- .../metrics-modal/MetricsModal.test.tsx | 8 +++++-- .../components/metrics-modal/MetricsModal.tsx | 2 +- .../MetricsModalContext.test.tsx | 24 +++++++++++++++---- .../metrics-modal/MetricsModalContext.tsx | 16 ++++++++++--- 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx index f57fbb59bd6..a91179fad3e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.test.tsx @@ -48,7 +48,7 @@ describe('MetricsModal', () => { operations: [], }; - setup(query, ['with-labels'], true); + setup(query, ['with-labels']); await waitFor(() => { expect(screen.getByText('with-labels')).toBeInTheDocument(); }); @@ -220,6 +220,10 @@ function createDatasource(withLabels?: boolean) { // display different results if their labels are selected in the PromVisualQuery if (withLabels) { languageProvider.queryMetricsMetadata = jest.fn().mockResolvedValue({ + ALERTS: { + type: 'gauge', + help: 'alerts help text', + }, 'with-labels': { type: 'with-labels-type', help: 'with-labels-help', @@ -297,7 +301,7 @@ function createProps(query: PromVisualQuery, datasource: PrometheusDatasource, m }; } -function setup(query: PromVisualQuery, metrics: string[], withlabels?: boolean) { +function setup(query: PromVisualQuery, metrics: string[]) { const withLabels: boolean = query.labels.length > 0; const datasource = createDatasource(withLabels); const props = createProps(query, datasource, metrics); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx index 59c4c703ccf..bf92a3ddc77 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModal.tsx @@ -138,7 +138,7 @@ const MetricsModalContent = (props: MetricsModalProps) => { export const MetricsModal = (props: MetricsModalProps) => { return ( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx index 955b2c1b585..46082f476b5 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.test.tsx @@ -4,6 +4,7 @@ import { ReactNode } from 'react'; import { TimeRange } from '@grafana/data'; import { PrometheusLanguageProviderInterface } from '../../../language_provider'; +import { getMockTimeRange } from '../../../test/mocks/datasource'; import { DEFAULT_RESULTS_PER_PAGE, MetricsModalContextProvider, useMetricsModal } from './MetricsModalContext'; import { generateMetricData } from './helpers'; @@ -25,7 +26,9 @@ const mockLanguageProvider: PrometheusLanguageProviderInterface = { // Helper to create wrapper component const createWrapper = (languageProvider = mockLanguageProvider) => { return ({ children }: { children: ReactNode }) => ( - {children} + + {children} + ); }; @@ -167,6 +170,7 @@ describe('MetricsModalContext', () => { it('should handle empty metadata response', async () => { (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({}); + (mockLanguageProvider.queryLabelValues as jest.Mock).mockResolvedValue(['metric1', 'metric2']); const { result } = renderHook(() => useMetricsModal(), { wrapper: createWrapper(), @@ -176,7 +180,18 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toEqual([]); + expect(result.current.filteredMetricsData).toEqual([ + { + value: 'metric1', + type: 'counter', + description: 'Test metric', + }, + { + value: 'metric2', + type: 'counter', + description: 'Test metric', + }, + ]); }); it('should handle metadata fetch error', async () => { @@ -239,6 +254,7 @@ describe('MetricsModalContext', () => { })); (mockLanguageProvider.queryMetricsMetadata as jest.Mock).mockResolvedValue({ + ALERTS: { type: 'gauge', help: 'Test alerts help' }, test_metric: { type: 'counter', help: 'Test metric' }, }); @@ -250,7 +266,7 @@ describe('MetricsModalContext', () => { expect(result.current.isLoading).toBe(false); }); - expect(result.current.filteredMetricsData).toHaveLength(1); + expect(result.current.filteredMetricsData).toHaveLength(2); expect(result.current.selectedTypes).toEqual([]); }); @@ -318,7 +334,7 @@ describe('MetricsModalContext', () => { }; const { getByTestId } = render( - + ); diff --git a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx index 3361b448547..117e3aad56e 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/metrics-modal/MetricsModalContext.tsx @@ -52,11 +52,13 @@ const MetricsModalContext = createContext( type MetricsModalContextProviderProps = { languageProvider: PrometheusLanguageProviderInterface; + timeRange: TimeRange; }; export const MetricsModalContextProvider: FC> = ({ children, languageProvider, + timeRange, }) => { const [isLoading, setIsLoading] = useState(true); const [metricsData, setMetricsData] = useState([]); @@ -111,8 +113,16 @@ export const MetricsModalContextProvider: FC generateMetricData(m, languageProvider)); + setMetricsData(processedData); } else { const processedData = Object.keys(metadata).map((m) => generateMetricData(m, languageProvider)); setMetricsData(processedData); @@ -122,7 +132,7 @@ export const MetricsModalContextProvider: FC From 5c0ee2d7461c02d5d345521a6344a84a1958a2a1 Mon Sep 17 00:00:00 2001 From: Lewis John McGibbney Date: Mon, 29 Dec 2025 23:46:57 -0800 Subject: [PATCH 44/80] Documentation: Fix JSON file export relative link (#115650) --- .../visualizations/dashboards/share-dashboards-panels/_index.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md index 5fcd2344fe2..e7749ba5b88 100644 --- a/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md +++ b/docs/sources/visualizations/dashboards/share-dashboards-panels/_index.md @@ -98,7 +98,7 @@ You can share dashboards in the following ways: - [As a report](#schedule-a-report) - [As a snapshot](#share-a-snapshot) - [As a PDF export](#export-a-dashboard-as-pdf) -- [As a JSON file export](#export-a-dashboard-as-json) +- [As a JSON file export](#export-a-dashboard-as-code) - [As an image export](#export-a-dashboard-as-an-image) When you share a dashboard externally as a link or by email, those dashboards are included in a list of your shared dashboards. To view the list and manage these dashboards, navigate to **Dashboards > Shared dashboards**. From 6e155523a3c41133aadccd787981e7e3898d9669 Mon Sep 17 00:00:00 2001 From: Todd Treece <360020+toddtreece@users.noreply.github.com> Date: Tue, 30 Dec 2025 03:14:06 -0500 Subject: [PATCH 45/80] Plugins App: Add basic README (#115507) * Plugins App: Add basic README * prettier:write --------- Co-authored-by: Ryan McKinley --- apps/plugins/README.md | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 apps/plugins/README.md diff --git a/apps/plugins/README.md b/apps/plugins/README.md new file mode 100644 index 00000000000..7f91dd6ea12 --- /dev/null +++ b/apps/plugins/README.md @@ -0,0 +1,20 @@ +# Plugins App + +API documentation is available at http://localhost:3000/swagger?api=plugins.grafana.app-v0alpha1 + +## Codegen + +- Go: `make generate` +- Frontend: Follow instructions in this [README](../..//packages/grafana-api-clients/README.md) + +## Plugin sync + +The plugin sync pushes the plugins loaded from disk to the plugins API. + +To enable, add these feature toggles in your `custom.ini`: + +```ini +[feature_toggles] +pluginInstallAPISync = true +pluginStoreServiceLoading = true +``` From 759035a465acada67d1ffed9620371b0a32f1f4a Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Tue, 30 Dec 2025 09:45:33 +0100 Subject: [PATCH 46/80] Remove kubernetesDashboardsV2 feature toggle (#114912) Co-authored-by: Haris Rozajac --- .../dashboards-suite/dashboard-browse-nested.spec.ts | 2 +- e2e-playwright/dashboards-suite/dashboard-browse.spec.ts | 2 +- .../dashboards-suite/dashboard-export-image.spec.ts | 2 +- .../dashboards-suite/dashboard-export-json.spec.ts | 2 +- .../dashboards-suite/dashboard-keybindings.spec.ts | 2 +- .../dashboards-suite/dashboard-links-without-slug.spec.ts | 2 +- .../dashboards-suite/dashboard-live-streaming.spec.ts | 2 +- .../dashboards-suite/dashboard-public-create.spec.ts | 2 +- .../dashboards-suite/dashboard-public-templating.spec.ts | 2 +- .../dashboard-share-externally-create.spec.ts | 2 +- .../dashboards-suite/dashboard-share-internally.spec.ts | 2 +- .../dashboard-share-snapshot-create.spec.ts | 2 +- .../dashboards-suite/dashboard-templating.spec.ts | 2 +- .../dashboards-suite/dashboard-time-zone.spec.ts | 2 +- .../dashboards-suite/dashboard-timepicker.spec.ts | 2 +- e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts | 2 +- e2e-playwright/dashboards-suite/general-dashboards.spec.ts | 2 +- e2e-playwright/dashboards-suite/import-dashboard.spec.ts | 2 +- .../dashboards-suite/load-options-from-url.spec.ts | 2 +- .../dashboards-suite/new-constant-variable.spec.ts | 2 +- .../dashboards-suite/new-custom-variable.spec.ts | 2 +- .../dashboards-suite/new-datasource-variable.spec.ts | 2 +- .../dashboards-suite/new-interval-variable.spec.ts | 2 +- e2e-playwright/dashboards-suite/new-query-variable.spec.ts | 2 +- .../dashboards-suite/new-text-box-variable.spec.ts | 2 +- .../repeating-a-panel-horizontally.spec.ts | 2 +- .../dashboards-suite/repeating-a-panel-vertically.spec.ts | 2 +- .../dashboards-suite/repeating-an-empty-row.spec.ts | 2 +- .../dashboards-suite/set-options-from-ui.spec.ts | 2 +- e2e-playwright/dashboards-suite/snapshot-create.spec.ts | 2 +- .../templating-dashboard-links-and-variables.spec.ts | 2 +- e2e-playwright/dashboards-suite/textbox-variables.spec.ts | 2 +- go.mod | 2 +- packages/grafana-data/src/types/featureToggles.gen.ts | 4 ---- pkg/extensions/enterprise_imports.go | 6 +++--- pkg/registry/apis/dashboard/register.go | 2 +- 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 | 5 +++-- .../pages/DashboardScenePageStateManager.ts | 2 +- public/app/features/dashboard/api/utils.ts | 3 +-- 42 files changed, 42 insertions(+), 58 deletions(-) diff --git a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts index 6765d299e53..caa83bfdb90 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse-nested.spec.ts @@ -10,7 +10,7 @@ const NUM_NESTED_DASHBOARDS = 60; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts index 8ae318bcefa..6949eca4555 100644 --- a/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-browse.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts index a97a04a14b8..e15991514a2 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-image.spec.ts @@ -7,7 +7,7 @@ test.use({ scenes: true, sharingDashboardImage: true, // Enable the export image feature kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts index 428193ab5fa..26a8fb61dc8 100644 --- a/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-export-json.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts index b0ecf44f9f1..f874cefa27c 100644 --- a/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-keybindings.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts index 0a982e148b5..ca05fd24160 100644 --- a/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-links-without-slug.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DataLinkWithoutSlugTest.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts index 20f455ea3a8..b7e18e56b45 100644 --- a/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-live-streaming.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/DashboardLiveTest.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts index fd5dc979d81..9653218f5ff 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-create.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts index c59e323076d..9f15a740fcf 100644 --- a/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-public-templating.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts index 3398e9aaa35..3827872c199 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-externally-create.spec.ts @@ -4,7 +4,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts index 26f8b85d13e..6ff1825f9cd 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-internally.spec.ts @@ -4,7 +4,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts index 1a7e03d6243..b5c77458121 100644 --- a/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-share-snapshot-create.spec.ts @@ -6,7 +6,7 @@ test.use({ featureToggles: { scenes: true, kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts index 78c35dc5de7..0a4eb5d3a9a 100644 --- a/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-templating.spec.ts @@ -6,7 +6,7 @@ test.use({ timezoneId: 'Pacific/Easter', featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts index 937224290b0..ee3de574512 100644 --- a/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-time-zone.spec.ts @@ -8,7 +8,7 @@ const TIMEZONE_DASHBOARD_UID = 'd41dbaa2-a39e-4536-ab2b-caca52f1a9c8'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts index 4ffd65b83a3..ae2f08b230f 100644 --- a/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts +++ b/e2e-playwright/dashboards-suite/dashboard-timepicker.spec.ts @@ -17,7 +17,7 @@ test.use({ }, featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts index f457eddf19e..aad8ed3367a 100644 --- a/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/embedded-dashboard.spec.ts @@ -3,7 +3,7 @@ import { test, expect } from '@grafana/plugin-e2e'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts index 99f84cb6d9f..d3ba9d9046e 100644 --- a/e2e-playwright/dashboards-suite/general-dashboards.spec.ts +++ b/e2e-playwright/dashboards-suite/general-dashboards.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'edediimbjhdz4b/a-tall-dashboard'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts index 5fdca6954aa..f489f576887 100644 --- a/e2e-playwright/dashboards-suite/import-dashboard.spec.ts +++ b/e2e-playwright/dashboards-suite/import-dashboard.spec.ts @@ -5,7 +5,7 @@ import testDashboard from '../dashboards/TestDashboard.json'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts index ca06e31528a..08847be8d8e 100644 --- a/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts +++ b/e2e-playwright/dashboards-suite/load-options-from-url.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts index fa0b5fc1bfd..0abd9d248f1 100644 --- a/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-constant-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts index c14a952e1d9..79e545c9a41 100644 --- a/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-custom-variable.spec.ts @@ -53,7 +53,7 @@ async function assertPreviewValues( test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts index cc19e67cda4..03859ccc5ea 100644 --- a/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-datasource-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts index d76b5291c42..9c5cc8cc60f 100644 --- a/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-interval-variable.spec.ts @@ -19,7 +19,7 @@ async function assertPreviewValues( test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts index 852261dbaf5..96375f3fb97 100644 --- a/e2e-playwright/dashboards-suite/new-query-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-query-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Templating - Nested Template Variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts index c669dc563c4..ba3b9466e7d 100644 --- a/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts +++ b/e2e-playwright/dashboards-suite/new-text-box-variable.spec.ts @@ -6,7 +6,7 @@ const DASHBOARD_NAME = 'Test variable output'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts index a55f14b8643..413466972e1 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-horizontally.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'WVpf2jp7z/repeating-a-panel-horizontally'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts index bb188c87e8d..53966eadf05 100644 --- a/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-a-panel-vertically.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'OY8Ghjt7k/repeating-a-panel-vertically'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts index e31c5792062..06c0e77989b 100644 --- a/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts +++ b/e2e-playwright/dashboards-suite/repeating-an-empty-row.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = 'dtpl2Ctnk/repeating-an-empty-row'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts index 53290345e73..629abda2ce3 100644 --- a/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts +++ b/e2e-playwright/dashboards-suite/set-options-from-ui.spec.ts @@ -5,7 +5,7 @@ const PAGE_UNDER_TEST = '-Y-tnEDWk/templating-nested-template-variables'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts index 123aa7f3279..50febc211b8 100644 --- a/e2e-playwright/dashboards-suite/snapshot-create.spec.ts +++ b/e2e-playwright/dashboards-suite/snapshot-create.spec.ts @@ -5,7 +5,7 @@ const DASHBOARD_UID = 'ZqZnVvFZz'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', dashboardScene: false, // this test is for the old sharing modal only used when scenes is turned off }, }); diff --git a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 1d8fd32ff06..1806aca24bf 100644 --- a/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -5,7 +5,7 @@ const DASHBOARD_UID = 'yBCC3aKGk'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts index 4fb56ef8b8e..b78e781dad1 100644 --- a/e2e-playwright/dashboards-suite/textbox-variables.spec.ts +++ b/e2e-playwright/dashboards-suite/textbox-variables.spec.ts @@ -7,7 +7,7 @@ const PAGE_UNDER_TEST = 'AejrN1AMz'; test.use({ featureToggles: { kubernetesDashboards: process.env.FORCE_V2_DASHBOARDS_API === 'true', - kubernetesDashboardsV2: process.env.FORCE_V2_DASHBOARDS_API === 'true', + dashboardNewLayouts: process.env.FORCE_V2_DASHBOARDS_API === 'true', }, }); diff --git a/go.mod b/go.mod index f22d410c51f..b848514cea4 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group - 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/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 04b0b28847c..aebbab8c6f9 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -356,10 +356,6 @@ export interface FeatureToggles { */ dashboardNewLayouts?: boolean; /** - * Use the v2 kubernetes API in the frontend for dashboards - */ - kubernetesDashboardsV2?: boolean; - /** * Enables undo/redo in dynamic dashboards */ dashboardUndoRedo?: boolean; 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/register.go b/pkg/registry/apis/dashboard/register.go index e651a5716ac..eeeb76f924e 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -237,7 +237,7 @@ func NewAPIService(ac authlib.AccessClient, features featuremgmt.FeatureToggles, } func (b *DashboardsAPIBuilder) GetGroupVersions() []schema.GroupVersion { - if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts, featuremgmt.FlagKubernetesDashboardsV2) { + if featuremgmt.AnyEnabled(b.features, featuremgmt.FlagDashboardNewLayouts) { // If dashboards v2 is enabled, we want to use v2beta1 as the default API version. return []schema.GroupVersion{ dashv2beta1.DashboardResourceInfo.GroupVersion(), diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 22e832034bc..3748db8e6b4 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -572,13 +572,6 @@ var ( FrontendOnly: false, // The restore backend feature changes behavior based on this flag Owner: grafanaDashboardsSquad, }, - { - Name: "kubernetesDashboardsV2", - Description: "Use the v2 kubernetes API in the frontend for dashboards", - Stage: FeatureStageExperimental, - FrontendOnly: false, - Owner: grafanaDashboardsSquad, - }, { Name: "dashboardUndoRedo", Description: "Enables undo/redo in dynamic dashboards", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index 87001f263f8..0c85021cff8 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -79,7 +79,6 @@ dashboardSceneForViewers,GA,@grafana/dashboards-squad,false,false,true dashboardSceneSolo,GA,@grafana/dashboards-squad,false,false,true dashboardScene,GA,@grafana/dashboards-squad,false,false,true dashboardNewLayouts,experimental,@grafana/dashboards-squad,false,false,false -kubernetesDashboardsV2,experimental,@grafana/dashboards-squad,false,false,false dashboardUndoRedo,experimental,@grafana/dashboards-squad,false,false,true unlimitedLayoutsNesting,experimental,@grafana/dashboards-squad,false,false,true drilldownRecommendations,experimental,@grafana/dashboards-squad,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 6543d31dba5..5de71954e2f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -259,10 +259,6 @@ const ( // Enables experimental new dashboard layouts FlagDashboardNewLayouts = "dashboardNewLayouts" - // FlagKubernetesDashboardsV2 - // Use the v2 kubernetes API in the frontend for dashboards - FlagKubernetesDashboardsV2 = "kubernetesDashboardsV2" - // FlagPdfTables // Enables generating table data as PDF in reporting FlagPdfTables = "pdfTables" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 5bea1b2e40f..6d55a6ca617 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2017,8 +2017,9 @@ { "metadata": { "name": "kubernetesDashboardsV2", - "resourceVersion": "1764664939750", - "creationTimestamp": "2025-12-02T08:42:19Z" + "resourceVersion": "1764236054307", + "creationTimestamp": "2025-11-27T09:34:14Z", + "deletionTimestamp": "2025-12-05T13:43:57Z" }, "spec": { "description": "Use the v2 kubernetes API in the frontend for dashboards", diff --git a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts index 3cc0df33e8a..097c0d8d26c 100644 --- a/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts +++ b/public/app/features/dashboard-scene/pages/DashboardScenePageStateManager.ts @@ -959,7 +959,7 @@ export class DashboardScenePageStateManagerV2 extends DashboardScenePageStateMan } export function shouldForceV2API(): boolean { - return Boolean(config.featureToggles.kubernetesDashboardsV2 || config.featureToggles.dashboardNewLayouts); + return Boolean(config.featureToggles.dashboardNewLayouts); } export class UnifiedDashboardScenePageStateManager extends DashboardScenePageStateManagerBase< diff --git a/public/app/features/dashboard/api/utils.ts b/public/app/features/dashboard/api/utils.ts index ab3e995fc34..af4cb6ecd04 100644 --- a/public/app/features/dashboard/api/utils.ts +++ b/public/app/features/dashboard/api/utils.ts @@ -20,7 +20,6 @@ export function isV0V1StoredVersion(version: string | undefined): boolean { export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { const isDashboardSceneEnabled = config.featureToggles.dashboardScene; const isKubernetesDashboardsEnabled = config.featureToggles.kubernetesDashboards; - const isV2DashboardAPIVersionEnabled = config.featureToggles.kubernetesDashboardsV2; const isDashboardNewLayoutsEnabled = config.featureToggles.dashboardNewLayouts; const forcingOldDashboardArch = locationService.getSearch().get('scenes') === 'false'; @@ -39,7 +38,7 @@ export function getDashboardsApiVersion(responseFormat?: 'v1' | 'v2') { if (responseFormat === 'v1') { return 'v1'; } - if (responseFormat === 'v2' || isV2DashboardAPIVersionEnabled || isDashboardNewLayoutsEnabled) { + if (responseFormat === 'v2' || isDashboardNewLayoutsEnabled) { return 'v2'; } return 'unified'; From 9a831ab4e18ef4b2da3ee11ec499ea787a6707bf Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 30 Dec 2025 09:47:00 +0100 Subject: [PATCH 47/80] Auditing: Set default policy rule level for create to req+resp (#115727) Auditing: Set default policy rule level to req+resp --- pkg/apiserver/auditing/policy.go | 15 ++++++++++++--- pkg/apiserver/auditing/policy_test.go | 18 +++++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/pkg/apiserver/auditing/policy.go b/pkg/apiserver/auditing/policy.go index e88acf7c4cc..ed053ca205d 100644 --- a/pkg/apiserver/auditing/policy.go +++ b/pkg/apiserver/auditing/policy.go @@ -46,14 +46,23 @@ func (defaultGrafanaPolicyRuleEvaluator) EvaluatePolicyRule(attrs authorizer.Att } } + // Logging the response object allows us to get the resource name for create requests. + level := auditinternal.LevelMetadata + if attrs.GetVerb() == utils.VerbCreate { + level = auditinternal.LevelRequestResponse + } + return audit.RequestAuditConfig{ - Level: auditinternal.LevelMetadata, + Level: level, + + // Only log on StageResponseComplete, to avoid noisy logs. OmitStages: []auditinternal.Stage{ - // Only log on StageResponseComplete auditinternal.StageRequestReceived, auditinternal.StageResponseStarted, auditinternal.StagePanic, }, - OmitManagedFields: false, // Setting it to true causes extra copying/unmarshalling. + + // Setting it to true causes extra copying/unmarshalling. + OmitManagedFields: false, } } diff --git a/pkg/apiserver/auditing/policy_test.go b/pkg/apiserver/auditing/policy_test.go index af18f9110fd..ccabaa5e6e2 100644 --- a/pkg/apiserver/auditing/policy_test.go +++ b/pkg/apiserver/auditing/policy_test.go @@ -55,7 +55,7 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { require.Equal(t, auditinternal.LevelNone, config.Level) }) - t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Run("return audit level request+response for create requests", func(t *testing.T) { t.Parallel() attrs := authorizer.AttributesRecord{ @@ -67,6 +67,22 @@ func TestDefaultGrafanaPolicyRuleEvaluator(t *testing.T) { }, } + config := evaluator.EvaluatePolicyRule(attrs) + require.Equal(t, auditinternal.LevelRequestResponse, config.Level) + }) + + t.Run("return audit level metadata for other resource requests", func(t *testing.T) { + t.Parallel() + + attrs := authorizer.AttributesRecord{ + ResourceRequest: true, + Verb: utils.VerbGet, + User: &user.DefaultInfo{ + Name: "test-user", + Groups: []string{"test-group"}, + }, + } + config := evaluator.EvaluatePolicyRule(attrs) require.Equal(t, auditinternal.LevelMetadata, config.Level) }) From 2dad8b7b5b69d1c63e568a0516765aae99969e39 Mon Sep 17 00:00:00 2001 From: "Marc M." <146180665+grafakus@users.noreply.github.com> Date: Tue, 30 Dec 2025 10:54:00 +0100 Subject: [PATCH 48/80] DynamicDashboards: Add button to feedback form (#114980) --- .../edit-pane/DashboardEditPaneRenderer.tsx | 18 ++++++++++++++++++ public/locales/en-US/grafana.json | 3 +++ 2 files changed, 21 insertions(+) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx index 7ce42744241..950785c2ffd 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -83,6 +83,24 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro onClick={() => dashboard.openV2SchemaEditor()} /> */} + + window.open( + 'https://docs.google.com/forms/d/e/1FAIpQLSfDZJM_VlZgRHDx8UPtLWbd9bIBPRxoA28qynTHEYniyPXO6Q/viewform', + '_blank' + ) + } + title={t( + 'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience', + 'Give feedback on the new dashboard editing experience' + )} + tooltip={t( + 'dashboard-scene.dashboard-edit-pane-renderer.title-feedback-dashboard-editing-experience', + 'Give feedback on the new dashboard editing experience' + )} + /> )} {hasUid && } diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index a51e48d0e7f..7430957c560 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5967,6 +5967,9 @@ "name-values-separated-comma": "Values separated by comma", "selection-options": "Selection options" }, + "dashboard-edit-pane-renderer": { + "title-feedback-dashboard-editing-experience": "Give feedback on the new dashboard editing experience" + }, "dashboard-link-form": { "back-to-list": "Back to list", "label-icon": "Icon", From 9c3cdd4814929a29df18b7325eedbdbda0feddc8 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 30 Dec 2025 08:46:43 -0300 Subject: [PATCH 49/80] Playlists: Support get with None role (#115713) --- .../apiserver/auth/authorizer/role.go | 2 + pkg/tests/apis/playlist/playlist_test.go | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/pkg/services/apiserver/auth/authorizer/role.go b/pkg/services/apiserver/auth/authorizer/role.go index e8e70dd01c8..63313352571 100644 --- a/pkg/services/apiserver/auth/authorizer/role.go +++ b/pkg/services/apiserver/auth/authorizer/role.go @@ -15,6 +15,8 @@ var _ authorizer.Authorizer = &roleAuthorizer{} var orgRoleNoneAsViewerAPIGroups = []string{ "productactivation.ext.grafana.com", + // playlist can be removed after this issue is resolved: https://github.com/grafana/grafana/issues/115712 + "playlist.grafana.app", } type roleAuthorizer struct{} diff --git a/pkg/tests/apis/playlist/playlist_test.go b/pkg/tests/apis/playlist/playlist_test.go index 2611624debb..da9a6530e5b 100644 --- a/pkg/tests/apis/playlist/playlist_test.go +++ b/pkg/tests/apis/playlist/playlist_test.go @@ -426,6 +426,45 @@ func doPlaylistTests(t *testing.T, helper *apis.K8sTestHelper) *apis.K8sTestHelp require.Equal(t, metav1.StatusReasonForbidden, rsp.Status.Reason) }) + t.Run("Check CRUD operations with None role", func(t *testing.T) { + // Create a playlist with admin user + clientAdmin := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.Admin, + GVR: gvr, + }) + created, err := clientAdmin.Resource.Create(context.Background(), + helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"), + metav1.CreateOptions{}, + ) + require.NoError(t, err) + + clientNone := helper.GetResourceClient(apis.ResourceClientArgs{ + User: helper.Org1.None, + GVR: gvr, + }) + + // Now check if None user can perform a Get to start a playlist + _, err = clientNone.Resource.Get(context.Background(), created.GetName(), metav1.GetOptions{}) + require.NoError(t, err) + + // None role can get but can not create edit or delete a playlist + _, err = clientNone.Resource.Create(context.Background(), + helper.LoadYAMLOrJSONFile("testdata/playlist-generate.yaml"), + metav1.CreateOptions{}, + ) + require.Error(t, err) + + _, err = clientNone.Resource.Update(context.Background(), created, metav1.UpdateOptions{}) + require.Error(t, err) + + err = clientNone.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{}) + require.Error(t, err) + + // delete created resource + err = clientAdmin.Resource.Delete(context.Background(), created.GetName(), metav1.DeleteOptions{}) + require.NoError(t, err) + }) + t.Run("Check k8s client-go List from different org users", func(t *testing.T) { // Check Org1 Viewer client := helper.GetResourceClient(apis.ResourceClientArgs{ From 45fc95cfc9672177d12a54da8ab94291ffc79cd5 Mon Sep 17 00:00:00 2001 From: Ezequiel Victorero Date: Tue, 30 Dec 2025 09:54:20 -0300 Subject: [PATCH 50/80] Snapshots: Use settings MT service (#115541) --- .../rtkq/dashboard/v0alpha1/endpoints.gen.ts | 8 ++ pkg/registry/apis/dashboard/register.go | 11 ++- .../apis/dashboard/snapshot/routes.go | 81 +++++++++++++++++++ .../snapshot/snapshot_legacy_store.go | 18 ----- pkg/server/wire_gen.go | 4 +- .../dashboard.grafana.app-v0alpha1.json | 37 +++++++++ .../dashboard/services/SnapshotSrv.ts | 5 +- 7 files changed, 137 insertions(+), 27 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts index b50a074e4a2..326b53ccedd 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/dashboard/v0alpha1/endpoints.gen.ts @@ -285,6 +285,10 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/snapshots/delete/${queryArg.deleteKey}`, method: 'DELETE' }), invalidatesTags: ['Snapshot'], }), + getSnapshotSettings: build.query({ + query: () => ({ url: `/snapshots/settings` }), + providesTags: ['Snapshot'], + }), getSnapshot: build.query({ query: (queryArg) => ({ url: `/snapshots/${queryArg.name}`, @@ -742,6 +746,8 @@ export type DeleteWithKeyApiArg = { /** unique key returned in create */ deleteKey: string; }; +export type GetSnapshotSettingsApiResponse = /** status 200 undefined */ any; +export type GetSnapshotSettingsApiArg = void; export type GetSnapshotApiResponse = /** status 200 OK */ Snapshot; export type GetSnapshotApiArg = { /** name of the Snapshot */ @@ -1273,6 +1279,8 @@ export const { useLazyListSnapshotQuery, useCreateSnapshotMutation, useDeleteWithKeyMutation, + useGetSnapshotSettingsQuery, + useLazyGetSnapshotSettingsQuery, useGetSnapshotQuery, useLazyGetSnapshotQuery, useDeleteSnapshotMutation, diff --git a/pkg/registry/apis/dashboard/register.go b/pkg/registry/apis/dashboard/register.go index eeeb76f924e..eed79dd6f0d 100644 --- a/pkg/registry/apis/dashboard/register.go +++ b/pkg/registry/apis/dashboard/register.go @@ -8,6 +8,7 @@ import ( "strconv" "strings" + "github.com/grafana/grafana/pkg/configprovider" "github.com/prometheus/client_golang/prometheus" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -62,7 +63,6 @@ import ( "github.com/grafana/grafana/pkg/services/quota" "github.com/grafana/grafana/pkg/services/search/sort" "github.com/grafana/grafana/pkg/services/user" - "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/legacysql" "github.com/grafana/grafana/pkg/storage/legacysql/dualwrite" "github.com/grafana/grafana/pkg/storage/unified/apistore" @@ -128,7 +128,6 @@ type DashboardsAPIBuilder struct { } func RegisterAPIService( - cfg *setting.Cfg, features featuremgmt.FeatureToggles, apiregistration builder.APIRegistrar, dashboardService dashboards.DashboardService, @@ -154,7 +153,14 @@ func RegisterAPIService( publicDashboardService publicdashboards.Service, snapshotService dashboardsnapshots.Service, dashboardActivityChannel live.DashboardActivityChannel, + configProvider configprovider.ConfigProvider, ) *DashboardsAPIBuilder { + cfg, err := configProvider.Get(context.Background()) + if err != nil { + logging.DefaultLogger.Error("failed to load settings configuration instance", "stackId", cfg.StackID, "err", err) + return nil + } + dbp := legacysql.NewDatabaseProvider(sql) namespacer := request.GetNamespaceMapper(cfg) legacyDashboardSearcher := legacysearcher.NewDashboardSearchClient(dashStore, sorter) @@ -747,7 +753,6 @@ func (b *DashboardsAPIBuilder) storageForVersion( ResourceInfo: *snapshots, Service: b.snapshotService, Namespacer: b.namespacer, - Options: b.snapshotOptions, } storage[snapshots.StoragePath()] = snapshotLegacyStore storage[snapshots.StoragePath("dashboard")], err = snapshot.NewDashboardREST(dashboards, b.snapshotService) diff --git a/pkg/registry/apis/dashboard/snapshot/routes.go b/pkg/registry/apis/dashboard/snapshot/routes.go index c8175d6d9dd..832589f5c68 100644 --- a/pkg/registry/apis/dashboard/snapshot/routes.go +++ b/pkg/registry/apis/dashboard/snapshot/routes.go @@ -29,6 +29,8 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin createCmd := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateCommand"].Schema createExample := `{"dashboard":{"annotations":{"list":[{"name":"Annotations & Alerts","enable":true,"iconColor":"rgba(0, 211, 255, 1)","snapshotData":[],"type":"dashboard","builtIn":1,"hide":true}]},"editable":true,"fiscalYearStartMonth":0,"graphTooltip":0,"id":203,"links":[],"liveNow":false,"panels":[{"datasource":null,"fieldConfig":{"defaults":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"overrides":[]},"gridPos":{"h":8,"w":12,"x":0,"y":0},"id":1,"options":{"legend":{"calcs":[],"displayMode":"list","placement":"bottom","showLegend":true},"tooltip":{"mode":"single","sort":"none"}},"pluginVersion":"10.4.0-pre","snapshotData":[{"fields":[{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"showPoints":"auto","thresholdsStyle":{"mode":"off"}},"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"time","type":"time","values":[1706030536378,1706034856378,1706039176378,1706043496378,1706047816378,1706052136378]},{"config":{"color":{"mode":"palette-classic"},"custom":{"axisBorderShow":false,"axisCenteredZero":false,"axisColorMode":"text","axisLabel":"","axisPlacement":"auto","barAlignment":0,"drawStyle":"line","fillOpacity":43,"gradientMode":"opacity","hideFrom":{"legend":false,"tooltip":false,"viz":false},"insertNulls":false,"lineInterpolation":"smooth","lineWidth":1,"pointSize":5,"scaleDistribution":{"type":"linear"},"showPoints":"auto","spanNulls":false,"stacking":{"group":"A","mode":"none"},"thresholdsStyle":{"mode":"off"}},"mappings":[],"thresholds":{"mode":"absolute","steps":[{"color":"green","value":null},{"color":"red","value":80}]},"unitScale":true},"name":"A-series","type":"number","values":[1,20,90,30,50,0]}],"refId":"A"}],"targets":[],"title":"Simple example","type":"timeseries","links":[]}],"refresh":"","schemaVersion":39,"snapshot":{"timestamp":"2024-01-23T23:22:16.377Z"},"tags":[],"templating":{"list":[]},"time":{"from":"2024-01-23T17:22:20.380Z","to":"2024-01-23T23:22:20.380Z","raw":{"from":"now-6h","to":"now"}},"timepicker":{},"timezone":"","title":"simple and small","uid":"b22ec8db-399b-403b-b6c7-b0fb30ccb2a5","version":1,"weekStart":""},"name":"simple and small","expires":86400}` createRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.DashboardCreateResponse"].Schema + getSettingsRsp := defs["github.com/grafana/grafana/apps/dashboard/pkg/apissnapshot/v0alpha1.SnapshotSharingOptions"].Schema + getSettingsRspExample := `{"snapshotsEnabled":true,"externalSnapshotURL":"https://externalurl.com","externalSnapshotName":"external","externalEnabled":true}` return &builder.APIRoutes{ Namespace: []builder.APIRouteHandler{ @@ -167,5 +169,84 @@ func GetRoutes(service dashboardsnapshots.Service, options dashv0.SnapshotSharin }) }, }, + { + Path: prefix + "/settings", + Spec: &spec3.PathProps{ + Get: &spec3.Operation{ + VendorExtensible: spec.VendorExtensible{ + Extensions: map[string]any{ + "x-grafana-action": "get", + "x-kubernetes-group-version-kind": metav1.GroupVersionKind{ + Group: dashv0.GROUP, + Version: dashv0.VERSION, + Kind: "SnapshotSharingOptions", + }, + }, + }, + OperationProps: spec3.OperationProps{ + Tags: tags, + OperationId: "getSnapshotSettings", + Description: "Get Snapshot sharing settings", + Parameters: []*spec3.Parameter{ + { + ParameterProps: spec3.ParameterProps{ + Name: "namespace", + In: "path", + Required: true, + Example: "default", + Description: "workspace", + Schema: spec.StringProperty(), + }, + }, + }, + Responses: &spec3.Responses{ + ResponsesProps: spec3.ResponsesProps{ + StatusCodeResponses: map[int]*spec3.Response{ + 200: { + ResponseProps: spec3.ResponseProps{ + Content: map[string]*spec3.MediaType{ + "application/json": { + MediaTypeProps: spec3.MediaTypeProps{ + Schema: &getSettingsRsp, + Example: getSettingsRspExample, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + Handler: func(w http.ResponseWriter, r *http.Request) { + user, err := identity.GetRequester(r.Context()) + if err != nil { + errhttp.Write(r.Context(), err, w) + return + } + wrap := &contextmodel.ReqContext{ + Context: &web.Context{ + Req: r, + Resp: web.NewResponseWriter(r.Method, w), + }, + } + + vars := mux.Vars(r) + info, err := authlib.ParseNamespace(vars["namespace"]) + if err != nil { + wrap.JsonApiErr(http.StatusBadRequest, "expected namespace", nil) + return + } + if info.OrgID != user.GetOrgID() { + wrap.JsonApiErr(http.StatusBadRequest, + fmt.Sprintf("user orgId does not match namespace (%d != %d)", info.OrgID, user.GetOrgID()), nil) + return + } + + wrap.JSON(http.StatusOK, options) + }, + }, }} } diff --git a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go index aafbc2b283d..7ba2d4228c5 100644 --- a/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go +++ b/pkg/registry/apis/dashboard/snapshot/snapshot_legacy_store.go @@ -2,7 +2,6 @@ package snapshot import ( "context" - "fmt" "k8s.io/apimachinery/pkg/apis/meta/internalversion" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -29,7 +28,6 @@ type SnapshotLegacyStore struct { ResourceInfo utils.ResourceInfo Service dashboardsnapshots.Service Namespacer request.NamespaceMapper - Options dashV0.SnapshotSharingOptions } func (s *SnapshotLegacyStore) New() runtime.Object { @@ -117,15 +115,6 @@ func (s *SnapshotLegacyStore) List(ctx context.Context, options *internalversion } func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *metav1.GetOptions) (runtime.Object, error) { - info, err := request.NamespaceInfoFrom(ctx, true) - if err != nil { - return nil, err - } - - err = s.checkEnabled(info.Value) - if err != nil { - return nil, err - } query := dashboardsnapshots.GetDashboardSnapshotQuery{ Key: name, } @@ -140,10 +129,3 @@ func (s *SnapshotLegacyStore) Get(ctx context.Context, name string, options *met } return nil, s.ResourceInfo.NewNotFound(name) } - -func (s *SnapshotLegacyStore) checkEnabled(ns string) error { - if !s.Options.SnapshotsEnabled { - return fmt.Errorf("snapshots not enabled") - } - return nil -} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 676b0605e83..b958e5f7ad9 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -875,7 +875,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) + dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) if err != nil { return nil, err @@ -1537,7 +1537,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac ldapImpl := service12.ProvideService(cfg, featureToggles, ssosettingsimplService) apiService := api4.ProvideService(cfg, routeRegisterImpl, accessControl, userService, authinfoimplService, ossGroups, identitySynchronizer, orgService, ldapImpl, userAuthTokenService, bundleregistryService) dashboardActivityChannel := live.ProvideDashboardActivityChannel(grafanaLive) - dashboardsAPIBuilder := dashboard.RegisterAPIService(cfg, featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel) + dashboardsAPIBuilder := dashboard.RegisterAPIService(featureToggles, apiserverService, dashboardService, dashboardProvisioningService, service15, dashboardServiceImpl, dashboardPermissionsService, accessControl, accessClient, provisioningServiceImpl, dashboardsStore, registerer, sqlStore, tracingService, resourceClient, dualwriteService, sortService, quotaService, libraryPanelService, eventualRestConfigProvider, userService, libraryElementService, publicDashboardServiceImpl, serviceImpl, dashboardActivityChannel, configProvider) dataSourceAPIBuilder, err := datasource.RegisterAPIService(featureToggles, apiserverService, middlewareHandler, scopedPluginDatasourceProvider, plugincontextProvider, accessControl, registerer, sourcesService) if err != nil { return nil, err diff --git a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json index 61834093866..4634143bd45 100644 --- a/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json +++ b/pkg/tests/apis/openapi_snapshots/dashboard.grafana.app-v0alpha1.json @@ -2169,6 +2169,43 @@ ] } }, + "/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/settings": { + "get": { + "tags": [ + "Snapshot" + ], + "description": "Get Snapshot sharing settings", + "operationId": "getSnapshotSettings", + "parameters": [ + { + "name": "namespace", + "in": "path", + "description": "workspace", + "required": true, + "schema": { + "type": "string" + }, + "example": "default" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": {}, + "example": "{\"snapshotsEnabled\":true,\"externalSnapshotURL\":\"https://externalurl.com\",\"externalSnapshotName\":\"external\",\"externalEnabled\":true}" + } + } + } + }, + "x-grafana-action": "get", + "x-kubernetes-group-version-kind": { + "group": "dashboard.grafana.app", + "version": "v0alpha1", + "kind": "SnapshotSharingOptions" + } + } + }, "/apis/dashboard.grafana.app/v0alpha1/namespaces/{namespace}/snapshots/{name}": { "get": { "tags": [ diff --git a/public/app/features/dashboard/services/SnapshotSrv.ts b/public/app/features/dashboard/services/SnapshotSrv.ts index 276f9d717df..ba74866499a 100644 --- a/public/app/features/dashboard/services/SnapshotSrv.ts +++ b/public/app/features/dashboard/services/SnapshotSrv.ts @@ -118,10 +118,7 @@ class K8sAPI implements DashboardSnapshotSrv { } async getSharingOptions() { - // TODO? should this be in a config service, or in the same service? - // we have http://localhost:3000/apis/dashboardsnapshot.grafana.app/v0alpha1/namespaces/default/options - // BUT that has an unclear user mapping story still, so lets stick with the existing shared-options endpoint - return getBackendSrv().get('/api/snapshot/shared-options'); + return getBackendSrv().get(this.url + '/settings'); } async getSnapshot(uid: string): Promise { From 75b2c905cd2f117b6d98c4d4a7fed0fef0f1df62 Mon Sep 17 00:00:00 2001 From: Matheus Macabu Date: Tue, 30 Dec 2025 14:05:23 +0100 Subject: [PATCH 51/80] Auditing: Move sinkable/logger interfaces and add global default logger implementation (#115743) * Auditing: Move sinkable and logger interfaces * Auditing: Add global default logger implementation * Chore: Fix enterprise imports --- go.mod | 2 +- pkg/apiserver/auditing/logger.go | 55 ++++++++++++++++++++++++++++ pkg/apiserver/auditing/noop.go | 15 +++++++- pkg/extensions/enterprise_imports.go | 6 +-- 4 files changed, 72 insertions(+), 6 deletions(-) create mode 100644 pkg/apiserver/auditing/logger.go diff --git a/go.mod b/go.mod index b848514cea4..f22d410c51f 100644 --- a/go.mod +++ b/go.mod @@ -52,7 +52,7 @@ require ( github.com/crewjam/saml v0.4.14 // @grafana/identity-access-team github.com/dgraph-io/badger/v4 v4.7.0 // @grafana/grafana-search-and-storage github.com/dlmiddlecote/sqlstats v1.0.2 // @grafana/grafana-backend-group - github.com/docker/go-connections v0.6.0 // indirect; @grafana/grafana-app-platform-squad + github.com/docker/go-connections v0.6.0 // @grafana/grafana-app-platform-squad github.com/dolthub/go-mysql-server v0.19.1-0.20250410182021-5632d67cd46e // @grafana/grafana-datasources-core-services github.com/dolthub/vitess v0.0.0-20250930230441-70c2c6a98e33 // @grafana/grafana-datasources-core-services github.com/dustin/go-humanize v1.0.1 // @grafana/observability-traces-and-profiling diff --git a/pkg/apiserver/auditing/logger.go b/pkg/apiserver/auditing/logger.go new file mode 100644 index 00000000000..8e60d463255 --- /dev/null +++ b/pkg/apiserver/auditing/logger.go @@ -0,0 +1,55 @@ +package auditing + +import ( + "context" + "encoding/json" + "time" +) + +// Sinkable is a log entry abstraction that can be sent to an audit log sink through the different implementing methods. +type Sinkable interface { + json.Marshaler + KVPairs() []any + Time() time.Time +} + +// Logger specifies the contract for a specific audit logger. +type Logger interface { + Log(entry Sinkable) error + Close() error + Type() string +} + +// Implementation inspired by https://github.com/grafana/grafana-app-sdk/blob/main/logging/logger.go +type loggerContextKey struct{} + +var ( + // DefaultLogger is the default Logger if one hasn't been provided in the context. + // You may use this to add arbitrary audit logging outside of an API request lifecycle. + DefaultLogger Logger = &NoopLogger{} + + contextKey = loggerContextKey{} +) + +// FromContext returns the Logger set in the context with Context(), or the DefaultLogger if no Logger is set in the context. +// If DefaultLogger is nil, it returns a *NoopLogger so that the return is always valid to call methods on without nil-checking. +// You may use this to add arbitrary audit logging outside of an API request lifecycle. +func FromContext(ctx context.Context) Logger { + if l := ctx.Value(contextKey); l != nil { + if logger, ok := l.(Logger); ok { + return logger + } + } + + if DefaultLogger != nil { + return DefaultLogger + } + + return &NoopLogger{} +} + +// Context returns a new context built from the provided context with the provided logger in it. +// The Logger added with Context() can be retrieved with FromContext() +func Context(ctx context.Context, logger Logger) context.Context { + return context.WithValue(ctx, contextKey, logger) +} diff --git a/pkg/apiserver/auditing/noop.go b/pkg/apiserver/auditing/noop.go index 5a6b39a3b71..c36c3577a09 100644 --- a/pkg/apiserver/auditing/noop.go +++ b/pkg/apiserver/auditing/noop.go @@ -11,9 +11,9 @@ type NoopBackend struct{} func ProvideNoopBackend() audit.Backend { return &NoopBackend{} } -func (b *NoopBackend) ProcessEvents(k8sEvents ...*auditinternal.Event) bool { return false } +func (NoopBackend) ProcessEvents(...*auditinternal.Event) bool { return false } -func (NoopBackend) Run(stopCh <-chan struct{}) error { return nil } +func (NoopBackend) Run(<-chan struct{}) error { return nil } func (NoopBackend) Shutdown() {} @@ -34,3 +34,14 @@ type NoopPolicyRuleEvaluator struct{} func (NoopPolicyRuleEvaluator) EvaluatePolicyRule(authorizer.Attributes) audit.RequestAuditConfig { return audit.RequestAuditConfig{Level: auditinternal.LevelNone} } + +// NoopLogger is a no-op implementation of Logger +type NoopLogger struct{} + +func ProvideNoopLogger() Logger { return &NoopLogger{} } + +func (NoopLogger) Type() string { return "noop" } + +func (NoopLogger) Log(Sinkable) error { return nil } + +func (NoopLogger) Close() error { return nil } diff --git a/pkg/extensions/enterprise_imports.go b/pkg/extensions/enterprise_imports.go index 113c2f8e4bb..472652cc103 100644 --- a/pkg/extensions/enterprise_imports.go +++ b/pkg/extensions/enterprise_imports.go @@ -15,6 +15,7 @@ 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" @@ -30,6 +31,7 @@ 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" @@ -54,9 +56,7 @@ 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/testcontainers/testcontainers-go" + _ "github.com/grafana/tempo/pkg/traceql" ) From e7625186af89454eb60f03e4702fb9aa85df4a67 Mon Sep 17 00:00:00 2001 From: Ayush Kaithwas Date: Tue, 30 Dec 2025 20:05:43 +0530 Subject: [PATCH 52/80] Dashboards: Clear edit pane selection when entering panel edit (#115658) * Clear selection on entering edit mode. Added test to verify selection is cleared when editing a panel. * Update comment --------- Co-authored-by: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> --- .../panel-edit/PanelEditor.test.ts | 31 +++++++++++++++++++ .../panel-edit/PanelEditor.tsx | 5 +++ 2 files changed, 36 insertions(+) diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts index ee2bda935fd..89634322347 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.test.ts @@ -112,6 +112,37 @@ describe('PanelEditor', () => { }); }); + describe('Entering panel edit', () => { + it('should clear edit pane selection', () => { + pluginPromise = Promise.resolve(getPanelPlugin({ id: 'text', skipDataQuery: true })); + + const panel = new VizPanel({ + key: 'panel-1', + pluginId: 'text', + title: 'original title', + }); + const gridItem = new DashboardGridItem({ body: panel }); + const panelEditor = buildPanelEditScene(panel); + const dashboard = new DashboardScene({ + editPanel: panelEditor, + isEditing: true, + $timeRange: new SceneTimeRange({ from: 'now-6h', to: 'now' }), + body: new DefaultGridLayoutManager({ + grid: new SceneGridLayout({ + children: [gridItem], + }), + }), + }); + + dashboard.state.editPane.selectObject(panel, panel.state.key!, { force: true }); + expect(dashboard.state.editPane.getSelection()).toBe(panel); + + deactivate = activateFullSceneTree(dashboard); + + expect(dashboard.state.editPane.getSelection()).toBeUndefined(); + }); + }); + describe('When discarding', () => { it('should discard changes revert all changes', async () => { const { panelEditor, panel, dashboard } = await setup(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx index e656a39e6a1..497d58e505a 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelEditor.tsx @@ -84,6 +84,11 @@ export class PanelEditor extends SceneObjectBase { private _activationHandler() { const panel = this.state.panelRef.resolve(); + const dashboard = getDashboardSceneFor(this); + + // Clear any panel selection when entering panel edit mode. + // Need to clear selection here since selection is activated when panel edit mode is entered through the panel actions menu. This causes sidebar panel editor to be open when exiting panel edit mode + dashboard.state.editPane.clearSelection(); if (panel.state.pluginId === UNCONFIGURED_PANEL_PLUGIN_ID) { if (config.featureToggles.newVizSuggestions) { From 9c6feb8de5fb5adf0304b79b88fc03917ff5b177 Mon Sep 17 00:00:00 2001 From: Andrew Hackmann <5140848+bossinc@users.noreply.github.com> Date: Tue, 30 Dec 2025 09:37:19 -0600 Subject: [PATCH 53/80] Elasticsearch: Builder queries no longer execute in code mode (#115456) * The builder query no longer runs if code mode query is empty. Remove checks for query being empty to run raw query. * missed save * prettier? * Update public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts Co-authored-by: Andreas Christou --------- Co-authored-by: Andreas Christou --- .../elasticsearch/data_query_processor.go | 2 +- .../elasticsearch/data_query_validator.go | 2 +- .../state/reducer.test.ts | 25 ++++++++++- .../BucketAggregationsEditor/state/reducer.ts | 7 ++- .../state/reducer.test.ts | 24 ++++++++++- .../MetricAggregationsEditor/state/reducer.ts | 7 ++- .../components/QueryEditor/state.test.ts | 43 ++++++++++++++++++- .../components/QueryEditor/state.ts | 4 ++ 8 files changed, 107 insertions(+), 7 deletions(-) diff --git a/pkg/tsdb/elasticsearch/data_query_processor.go b/pkg/tsdb/elasticsearch/data_query_processor.go index 1c4ec7b3cdd..288d6ce30de 100644 --- a/pkg/tsdb/elasticsearch/data_query_processor.go +++ b/pkg/tsdb/elasticsearch/data_query_processor.go @@ -24,7 +24,7 @@ func (e *elasticsearchDataQuery) processQuery(q *Query, ms *es.MultiSearchReques filters.AddDateRangeFilter(defaultTimeField, to, from, es.DateFormatEpochMS) filters.AddQueryStringFilter(q.RawQuery, true) - if q.EditorType != nil && *q.EditorType == "code" && q.RawDSLQuery != "" { + if q.EditorType != nil && *q.EditorType == "code" { cfg := backend.GrafanaConfigFromContext(e.ctx) if !cfg.FeatureToggles().IsEnabled("elasticsearchRawDSLQuery") { return backend.DownstreamError(fmt.Errorf("raw DSL query feature is disabled. Enable the elasticsearchRawDSLQuery feature toggle to use this query type")) diff --git a/pkg/tsdb/elasticsearch/data_query_validator.go b/pkg/tsdb/elasticsearch/data_query_validator.go index 648dbb53109..72bcde016b6 100644 --- a/pkg/tsdb/elasticsearch/data_query_validator.go +++ b/pkg/tsdb/elasticsearch/data_query_validator.go @@ -7,7 +7,7 @@ import ( // isQueryWithError validates the query and returns an error if invalid func isQueryWithError(query *Query) error { // Skip validation for raw DSL queries because no easy way to see it is valid without just running it - if query.EditorType != nil && *query.EditorType == "code" && query.RawDSLQuery != "" { + if query.EditorType != nil && *query.EditorType == "code" { return nil } if len(query.BucketAggs) == 0 { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts index 6a34d5d7d91..f4a5cc02dde 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.test.ts @@ -7,7 +7,7 @@ import { import { defaultBucketAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; import { @@ -180,4 +180,27 @@ describe('Bucket Aggregations Reducer', () => { .thenStateShouldEqual([bucketAgg]); }); }); + + describe('When switching editor type', () => { + it('Should reset bucket aggregations to default when switching editor types', () => { + const defaultTimeField = '@timestamp'; + const initialState: BucketAggregation[] = [ + { + id: '1', + type: 'date_histogram', + field: '@timestamp', + }, + { + id: '2', + type: 'terms', + field: 'status', + }, + ]; + + reducerTester() + .givenReducer(createReducer(defaultTimeField), initialState) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual([{ ...defaultBucketAgg('2'), field: defaultTimeField }]); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts index b3638e1f1d1..5ba29e656d8 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/BucketAggregationsEditor/state/reducer.ts @@ -6,7 +6,7 @@ import { defaultBucketAgg } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; import { changeMetricType } from '../../MetricAggregationsEditor/state/actions'; import { metricAggregationConfig } from '../../MetricAggregationsEditor/utils'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { bucketAggregationConfig } from '../utils'; import { @@ -87,6 +87,11 @@ export const createReducer = return state; } + if (changeEditorTypeAndResetQuery.match(action)) { + // Returns the default bucket agg. We will always want to set the default when switching types + return [{ ...defaultBucketAgg('2'), field: defaultTimeField }]; + } + if (changeBucketAggregationSetting.match(action)) { return state!.map((bucketAgg) => { if (bucketAgg.id !== action.payload.bucketAgg.id) { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts index 5662ad399ea..9dcbaa9f974 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.test.ts @@ -7,7 +7,7 @@ import { import { defaultMetricAgg } from '../../../../queryDef'; import { reducerTester } from '../../../reducerTester'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { metricAggregationConfig } from '../utils'; import { @@ -248,4 +248,26 @@ describe('Metric Aggregations Reducer', () => { .whenActionIsDispatched(initQuery()) .thenStateShouldEqual([defaultMetricAgg('1')]); }); + + describe('When switching editor type', () => { + it('Should reset to single default metric when switching to code editor', () => { + const initialState: MetricAggregation[] = [ + { + id: '1', + type: 'avg', + field: 'value', + }, + { + id: '2', + type: 'max', + field: 'value', + }, + ]; + + reducerTester() + .givenReducer(reducer, initialState) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual([defaultMetricAgg('1')]); + }); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts index 966bd71d6c8..c0dab7bd4b1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/reducer.ts @@ -4,7 +4,7 @@ import { ElasticsearchDataQuery, MetricAggregation } from 'app/plugins/datasourc import { defaultMetricAgg, queryTypeToMetricType } from '../../../../queryDef'; import { removeEmpty } from '../../../../utils'; -import { initQuery } from '../../state'; +import { changeEditorTypeAndResetQuery, initQuery } from '../../state'; import { isMetricAggregationWithMeta, isMetricAggregationWithSettings, isPipelineAggregation } from '../aggregations'; import { getChildren, metricAggregationConfig } from '../utils'; @@ -65,6 +65,11 @@ export const reducer = ( }); } + if (changeEditorTypeAndResetQuery.match(action)) { + // Reset to default metric when switching to editor types + return [defaultMetricAgg('1')]; + } + if (changeMetricField.match(action)) { return state!.map((metric) => { if (metric.id !== action.payload.id) { diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts index cad89cd32a7..111b284eb79 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.test.ts @@ -1,7 +1,15 @@ import { ElasticsearchDataQuery } from '../../dataquery.gen'; import { reducerTester } from '../reducerTester'; -import { aliasPatternReducer, changeAliasPattern, changeQuery, initQuery, queryReducer } from './state'; +import { + aliasPatternReducer, + changeAliasPattern, + changeEditorTypeAndResetQuery, + changeQuery, + initQuery, + queryReducer, + rawDSLQueryReducer, +} from './state'; describe('Query Reducer', () => { describe('On Init', () => { @@ -42,6 +50,17 @@ describe('Query Reducer', () => { .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) .thenStateShouldEqual(initialState); }); + + describe('When switching editor type', () => { + it('Should clear query when switching editor types', () => { + const initialQuery: ElasticsearchDataQuery['query'] = 'Some lucene query'; + + reducerTester() + .givenReducer(queryReducer, initialQuery) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual(''); + }); + }); }); describe('Alias Pattern Reducer', () => { @@ -62,4 +81,26 @@ describe('Alias Pattern Reducer', () => { .whenActionIsDispatched({ type: 'THIS ACTION SHOULD NOT HAVE ANY EFFECT IN THIS REDUCER' }) .thenStateShouldEqual(initialState); }); + + describe('When switching editor type', () => { + it('Should clear alias when switching editor types', () => { + const initialAlias: ElasticsearchDataQuery['alias'] = 'Some alias pattern'; + + reducerTester() + .givenReducer(aliasPatternReducer, initialAlias) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('code')) + .thenStateShouldEqual(''); + }); + }); +}); + +describe('Raw DSL Query Reducer', () => { + it('Should clear raw DSL query when switching editor types', () => { + const initialRawQuery: ElasticsearchDataQuery['rawDSLQuery'] = '{"query": {"match_all": {}}}'; + + reducerTester() + .givenReducer(rawDSLQueryReducer, initialRawQuery) + .whenActionIsDispatched(changeEditorTypeAndResetQuery('builder')) + .thenStateShouldEqual(''); + }); }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts index a9ed51b39ff..5a1be7be31c 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -58,6 +58,10 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al return action.payload; } + if (changeEditorTypeAndResetQuery.match(action)) { + return ''; + } + if (initQuery.match(action)) { return prevAliasPattern || ''; } From d291dfb35b324f12f55ebf34dc97be36d8e27f1c Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Tue, 30 Dec 2025 08:51:46 -0700 Subject: [PATCH 54/80] Dashboard Conversion: Fix type assertion mismatch in data loss detection (#115749) --- .../conversion_data_loss_detection.go | 33 ++++++++++++------- 1 file changed, 21 insertions(+), 12 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go index db3353b66a1..269fb51bd70 100644 --- a/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go +++ b/apps/dashboard/pkg/migration/conversion/conversion_data_loss_detection.go @@ -180,12 +180,15 @@ func countAnnotationsV0V1(spec map[string]interface{}) int { return 0 } - annotationList, ok := annotations["list"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if annotationList, ok := annotations["list"].([]interface{}); ok { + return len(annotationList) + } + if annotationList, ok := annotations["list"].([]map[string]interface{}); ok { + return len(annotationList) } - return len(annotationList) + return 0 } // countLinksV0V1 counts dashboard links in v0alpha1 or v1beta1 dashboard spec @@ -194,12 +197,15 @@ func countLinksV0V1(spec map[string]interface{}) int { return 0 } - links, ok := spec["links"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if links, ok := spec["links"].([]interface{}); ok { + return len(links) + } + if links, ok := spec["links"].([]map[string]interface{}); ok { + return len(links) } - return len(links) + return 0 } // countVariablesV0V1 counts template variables in v0alpha1 or v1beta1 dashboard spec @@ -213,12 +219,15 @@ func countVariablesV0V1(spec map[string]interface{}) int { return 0 } - variableList, ok := templating["list"].([]interface{}) - if !ok { - return 0 + // Handle both []interface{} (from JSON unmarshaling) and []map[string]interface{} (from programmatic creation) + if variableList, ok := templating["list"].([]interface{}); ok { + return len(variableList) + } + if variableList, ok := templating["list"].([]map[string]interface{}); ok { + return len(variableList) } - return len(variableList) + return 0 } // collectStatsV0V1 collects statistics from v0alpha1 or v1beta1 dashboard From 52698cf0da5d07eeef04398d9c5cbd2c57a4c3ad Mon Sep 17 00:00:00 2001 From: Paul Marbach Date: Tue, 30 Dec 2025 10:55:40 -0500 Subject: [PATCH 55/80] Sparkline: Restore to a function component (#115447) * Sparkline: Restore to a function component * fix whitespace lint issue --- .../src/components/Sparkline/Sparkline.tsx | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx index d1fb4f3b0e0..a9d3f039c42 100644 --- a/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx +++ b/packages/grafana-ui/src/components/Sparkline/Sparkline.tsx @@ -17,8 +17,9 @@ export interface SparklineProps extends Themeable2 { showHighlights?: boolean; } -export const SparklineFn: React.FC = memo((props) => { +export const Sparkline: React.FC = memo((props) => { const { sparkline, config: fieldConfig, theme, width, height, showHighlights } = props; + const { frame: alignedDataFrame, warning } = prepareSeries(sparkline, theme, fieldConfig, showHighlights); if (warning) { return null; @@ -30,14 +31,4 @@ export const SparklineFn: React.FC = memo((props) => { return ; }); -SparklineFn.displayName = 'Sparkline'; - -// we converted to function component above, but some apps extend Sparkline, so we need -// to keep exporting a class component until those apps are all rolled out. -// see https://github.com/grafana/app-observability-plugin/pull/2079 -// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -export class Sparkline extends React.PureComponent { - render() { - return ; - } -} +Sparkline.displayName = 'Sparkline'; From 82b4ce0ece684c46ba1d749a939fbbaee8627bf7 Mon Sep 17 00:00:00 2001 From: Sean Griffin Date: Tue, 30 Dec 2025 11:46:29 -0500 Subject: [PATCH 56/80] Redesign Empty Transformation Panel (#115648) Co-authored-by: Alex Spencer <52186778+alexjonspencer1@users.noreply.github.com> --- .../EmptyTransformationsMessage.tsx | 47 ++++---- .../SqlExpressionCard.tsx | 62 ++-------- .../TransformationCard.tsx | 106 ++++-------------- .../TransformationPickerNg.tsx | 9 +- .../TransformationsEditor/getCardStyles.ts | 34 ++++++ 5 files changed, 96 insertions(+), 162 deletions(-) create mode 100644 public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts 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 eab5f3e9c58..1e8ff639785 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -4,7 +4,7 @@ import { DataFrame, DataTransformerID, standardTransformersRegistry, Transformer import { selectors } from '@grafana/e2e-selectors'; import { t, Trans } from '@grafana/i18n'; import { reportInteraction } from '@grafana/runtime'; -import { Box, Button, Grid, Stack, Text } from '@grafana/ui'; +import { Box, Button, Stack, Text } from '@grafana/ui'; import config from 'app/core/config'; import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard'; @@ -26,9 +26,6 @@ const TRANSFORMATION_IDS = [ DataTransformerID.filterByValue, ]; -const GRID_COLUMNS_WITH_SQL = 5; -const GRID_COLUMNS_WITHOUT_SQL = 4; - export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) { return ( @@ -94,13 +91,25 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) }; const showSqlCard = hasGoToQueries && config.featureToggles.sqlExpressions; - const gridColumns = showSqlCard ? GRID_COLUMNS_WITH_SQL : GRID_COLUMNS_WITHOUT_SQL; return ( - - + + + + + Add a Transformation + + + + Transformations allow data to be changed in various ways before your visualization is shown. +
+ This includes joining data together, renaming fields, making calculations, formatting data for display, + and more. +
+
+
{(hasAddTransformation || hasGoToQueries) && ( - + {showSqlCard && ( ))} - +
)} - - - +
); diff --git a/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx b/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx index 0cb9302df2e..5f9712897b8 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx @@ -1,7 +1,6 @@ -import { css } from '@emotion/css'; +import { Card, Text, useStyles2 } from '@grafana/ui'; -import { GrafanaTheme2 } from '@grafana/data'; -import { Card, useStyles2 } from '@grafana/ui'; +import { getCardStyles } from './getCardStyles'; export interface SqlExpressionCardProps { name: string; @@ -12,60 +11,15 @@ export interface SqlExpressionCardProps { } export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) { - const styles = useStyles2(getSqlExpressionCardStyles); + const styles = useStyles2(getCardStyles); return ( - - -
- {name} -
-
- - {description} - {imageUrl && ( - - {name} - - )} + + {name} + + {description} + {imageUrl && {name}} ); } - -function getSqlExpressionCardStyles(theme: GrafanaTheme2) { - return { - card: css({ - gridTemplateRows: 'min-content 0 1fr 0', - marginBottom: 0, - }), - heading: css({ - fontWeight: 400, - '> button': { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(1), - }, - }), - titleRow: css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'nowrap', - width: '100%', - }), - description: css({ - fontSize: theme.typography.bodySmall.fontSize, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - }), - image: css({ - display: 'block', - maxWidth: '100%', - marginTop: theme.spacing(2), - }), - }; -} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx index ad113f1b227..8e909480f74 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx @@ -1,35 +1,38 @@ -import { cx, css } from '@emotion/css'; +import { cx } from '@emotion/css'; import { DataFrame, - GrafanaTheme2, TransformerRegistryItem, TransformationApplicabilityLevels, standardTransformersRegistry, } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { Badge, Card, IconButton, Stack, Text, useStyles2, useTheme2 } from '@grafana/ui'; import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; +import { getCardStyles } from './getCardStyles'; + export interface TransformationCardProps { - transform: TransformerRegistryItem; + data?: DataFrame[]; + fullWidth?: boolean; onClick: (id: string) => void; showIllustrations?: boolean; - data?: DataFrame[]; showPluginState?: boolean; showTags?: boolean; + transform: TransformerRegistryItem; } export function TransformationCard({ - transform, - showIllustrations, - onClick, data = [], + fullWidth = false, + onClick, + showIllustrations, showPluginState = true, showTags = true, + transform, }: TransformationCardProps) { const theme = useTheme2(); - const styles = useStyles2(getTransformationCardStyles); + const styles = useStyles2(getCardStyles, fullWidth); // Check to see if the transform is applicable to the given data let applicabilityScore = TransformationApplicabilityLevels.Applicable; @@ -47,7 +50,7 @@ export function TransformationCard({ } } - const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard; + const cardClasses = cx(styles.baseCard, { [styles.cardDisabled]: !isApplicable }); const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight; const description = standardTransformersRegistry.getIfExists(transform.id)?.description; @@ -58,15 +61,11 @@ export function TransformationCard({ onClick={() => onClick(transform.id)} noMargin > - -
- {transform.name} - {showPluginState && ( - - - - )} -
+ + + {transform.name} + {showPluginState && } + {showTags && transform.tags && transform.tags.size > 0 && (
{Array.from(transform.tags).map((tag) => ( @@ -75,74 +74,13 @@ export function TransformationCard({
)}
- - {description} - {showIllustrations && imageUrl && ( - - {transform.name} - - )} + + {description || ''} + {showIllustrations && imageUrl && {transform.name}} {!isApplicable && applicabilityDescription !== null && ( - + )}
); } - -function getTransformationCardStyles(theme: GrafanaTheme2) { - return { - heading: css({ - fontWeight: 400, - '> button': { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(1), - }, - }), - titleRow: css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'nowrap', - width: '100%', - }), - description: css({ - fontSize: theme.typography.bodySmall.fontSize, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - }), - image: css({ - display: 'block', - maxWidth: '100%', - marginTop: theme.spacing(2), - }), - cardDisabled: css({ - backgroundColor: theme.colors.action.disabledBackground, - img: { - filter: 'grayscale(100%)', - opacity: 0.33, - }, - }), - cardApplicableInfo: css({ - position: 'absolute', - bottom: theme.spacing(1), - right: theme.spacing(1), - }), - newCard: css({ - gridTemplateRows: 'min-content 0 1fr 0', - marginBottom: 0, - }), - pluginStateInfoWrapper: css({ - marginLeft: theme.spacing(0.5), - }), - tagsWrapper: css({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(0.5), - }), - }; -} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx index fb0be6864f5..e27e554fada 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx @@ -165,11 +165,12 @@ function TransformationsGrid({ showIllustrations, transformations, onClick, data {transformations.map((transform) => ( ))} diff --git a/public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts b/public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts new file mode 100644 index 00000000000..b3989282ee2 --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/getCardStyles.ts @@ -0,0 +1,34 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; + +export const getCardStyles = (theme: GrafanaTheme2, fullWidth?: boolean) => ({ + baseCard: css({ + maxWidth: fullWidth ? 'none' : '200px', + width: fullWidth ? '100%' : 'auto', + marginBottom: 0, + }), + image: css({ + display: 'block', + maxWidth: '100%', + marginTop: theme.spacing(2), + }), + cardDisabled: css({ + backgroundColor: theme.colors.action.disabledBackground, + img: { + filter: 'grayscale(100%)', + opacity: 0.33, + }, + }), + applicableInfoButton: css({ + position: 'absolute', + bottom: theme.spacing(1), + right: theme.spacing(1), + }), + tagsWrapper: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + marginTop: theme.spacing(0.5), + }), +}); From 014d4758c68a091de9ce4e553c46933e4d057163 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Tue, 30 Dec 2025 14:27:38 -0500 Subject: [PATCH 57/80] Dashboards: Prevent row selection when clicking canvas add actions (#115580) * event propogation issues * Action items width * prevent pointer up event --- .../grafana-ui/src/components/PanelChrome/PanelChrome.tsx | 8 +++++--- .../scene/layouts-shared/CanvasGridAddActions.tsx | 7 +++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx index 8eace0b38b8..f969bbcf3f0 100644 --- a/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx +++ b/packages/grafana-ui/src/components/PanelChrome/PanelChrome.tsx @@ -248,15 +248,17 @@ export function PanelChrome({ const onContentPointerDown = React.useCallback( (evt: React.PointerEvent) => { - // Ignore clicks inside buttons, links, canvas and svg elments + // When selected, ignore clicks inside buttons, links, canvas and svg elments // This does prevent a clicks inside a graphs from selecting panel as there is normal div above the canvas element that intercepts the click - if (evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) { + if (isSelected && evt.target instanceof Element && evt.target.closest('button,a,canvas,svg')) { + // Stop propagation otherwise row config editor will get selected + evt.stopPropagation(); return; } onSelect?.(evt); }, - [onSelect] + [isSelected, onSelect] ); const headerContent = ( diff --git a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx index dd5c4ac20b6..9f75b5b7be4 100644 --- a/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx +++ b/public/app/features/dashboard-scene/scene/layouts-shared/CanvasGridAddActions.tsx @@ -59,7 +59,11 @@ export function CanvasGridAddActions({ layoutManager }: Props) { }, [layoutManager]); return ( -
+
evt.stopPropagation()} + onPointerDown={(evt) => evt.stopPropagation()} + > - )} - - - + + + + {showBackButton && ( + + )} + + + + {listMode === VisualizationSelectPaneTab.Suggestions && ( + + )} + {listMode === VisualizationSelectPaneTab.Visualizations && ( - - )} + )} +
@@ -155,7 +162,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ gap: theme.spacing(2), }), searchField: css({ - marginTop: theme.spacing(0.5), // input glow with the boundary without this + margin: theme.spacing(0.5, 0, 1, 0), // input glow with the boundary without this }), tabs: css({ width: '100%', diff --git a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx index d1763ad835f..924b5f3b6bf 100644 --- a/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx +++ b/public/app/features/panel/components/VizTypePicker/VisualizationSuggestions.tsx @@ -9,8 +9,10 @@ import { PanelPluginMeta, PanelPluginVisualizationSuggestion, } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; +import { VizPanel } from '@grafana/scenes'; import { Alert, Button, Icon, Spinner, Text, useStyles2 } from '@grafana/ui'; import { UNCONFIGURED_PANEL_PLUGIN_ID } from 'app/features/dashboard-scene/scene/UnconfiguredPanel'; @@ -23,25 +25,47 @@ import { VisualizationSuggestionCard } from './VisualizationSuggestionCard'; import { VizTypeChangeDetails } from './types'; export interface Props { - onChange: (options: VizTypeChangeDetails) => void; + onChange: (options: VizTypeChangeDetails, panel?: VizPanel) => void; + editPreview?: VizPanel; data?: PanelData; panel?: PanelModel; + searchQuery?: string; } -const useSuggestions = (data: PanelData | undefined) => { +const useSuggestions = (data: PanelData | undefined, searchQuery: string | undefined) => { const [hasFetched, setHasFetched] = useState(false); const { value, loading, error, retry } = useAsyncRetry(async () => { await new Promise((resolve) => setTimeout(resolve, hasFetched ? 75 : 0)); setHasFetched(true); return await getAllSuggestions(data); }, [hasFetched, data]); - return { value, loading, error, retry }; + + const filteredValue = useMemo(() => { + if (!value || !searchQuery) { + return value; + } + + const lowerCaseQuery = searchQuery.toLowerCase(); + const filteredSuggestions = value.suggestions.filter( + (suggestion) => + suggestion.name.toLowerCase().includes(lowerCaseQuery) || + suggestion.pluginId.toLowerCase().includes(lowerCaseQuery) || + suggestion.description?.toLowerCase().includes(lowerCaseQuery) + ); + + return { + ...value, + suggestions: filteredSuggestions, + }; + }, [value, searchQuery]); + + return { value: filteredValue, loading, error, retry }; }; -export function VisualizationSuggestions({ onChange, data, panel }: Props) { +export function VisualizationSuggestions({ onChange, editPreview, data, panel, searchQuery }: Props) { const styles = useStyles2(getStyles); - const { value: result, loading, error, retry } = useSuggestions(data); + const { value: result, loading, error, retry } = useSuggestions(data, searchQuery); const suggestions = result?.suggestions; const hasLoadingErrors = result?.hasErrors ?? false; @@ -73,18 +97,21 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { const applySuggestion = useCallback( (suggestion: PanelPluginVisualizationSuggestion, isPreview?: boolean) => { - onChange({ - pluginId: suggestion.pluginId, - options: suggestion.options, - fieldConfig: suggestion.fieldConfig, - withModKey: isPreview, - }); + onChange( + { + pluginId: suggestion.pluginId, + options: suggestion.options, + fieldConfig: suggestion.fieldConfig, + withModKey: isPreview, + }, + isPreview ? editPreview : undefined + ); if (isPreview) { setSuggestionHash(suggestion.hash); } }, - [onChange] + [onChange, editPreview] ); useEffect(() => { @@ -185,17 +212,13 @@ export function VisualizationSuggestions({ onChange, data, panel }: Props) { variant="primary" size={'md'} className={styles.applySuggestionButton} + data-testid={selectors.components.VisualizationPreview.confirm(suggestion.name)} aria-label={t( 'panel.visualization-suggestions.apply-suggestion-aria-label', 'Apply {{suggestionName}} visualization', { suggestionName: suggestion.name } )} - onClick={() => - onChange({ - pluginId: suggestion.pluginId, - withModKey: false, - }) - } + onClick={() => applySuggestion(suggestion, false)} > {t('panel.visualization-suggestions.use-this-suggestion', 'Use this suggestion')} From 79ca4e5aec154f9db15912ae50b637ae94fb7c42 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Wed, 31 Dec 2025 16:04:41 +0000 Subject: [PATCH 67/80] Alerting: Update alerting module to b7821017d69f2e31500fc0e49cd0ba3b85372a1b (#115767) * [create-pull-request] automated change * Fix tests --------- Co-authored-by: alexander-akhmetov <1875873+alexander-akhmetov@users.noreply.github.com> Co-authored-by: Alexander Akhmetov --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 ++-- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 4 ++-- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- go.mod | 2 +- go.sum | 4 ++-- .../alerting/api_notification_channel_test.go | 4 ++-- .../test-data/alert-notifiers-v1-snapshot.json | 18 ++++++++++++++++++ .../test-data/alert-notifiers-v2-snapshot.json | 18 ++++++++++++++++++ 13 files changed, 53 insertions(+), 17 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 646ceed9a86..84a6ca5f010 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -157,7 +157,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-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // 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 750d9f97fc5..873cbf6de62 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -619,8 +619,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/alerting/historian/go.mod b/apps/alerting/historian/go.mod index fb624d65db3..a79829d45c2 100644 --- a/apps/alerting/historian/go.mod +++ b/apps/alerting/historian/go.mod @@ -4,7 +4,7 @@ go 1.25.5 require ( github.com/go-kit/log v0.2.1 - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.7 github.com/grafana/grafana-app-sdk/logging v0.48.7 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 0835100976a..d45d418dfb8 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -243,8 +243,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/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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.7 h1:9mF7nqkqP0QUYYDlznoOt+GIyjzj45wGfUHB32u2ZMo= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 54689bc54f3..d3f31d6f7a4 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -223,7 +223,7 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect github.com/googleapis/gax-go/v2 v2.15.0 // indirect github.com/gorilla/mux v1.8.1 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 28bf1486774..7e6806e89d0 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -827,8 +827,8 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index a2657edda7a..678d460910b 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -90,7 +90,7 @@ require ( github.com/google/gnostic-models v0.7.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/grafana/alerting v0.0.0-20251223160021-926c74910196 // indirect + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // indirect github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // indirect github.com/grafana/authlib/types v0.0.0-20251119142549-be091cf2f4d4 // indirect github.com/grafana/dataplane/sdata v0.0.9 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index f0c923083af..1c9800a8bab 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -213,8 +213,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/alerting v0.0.0-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/go.mod b/go.mod index f22d410c51f..becd164c9dd 100644 --- a/go.mod +++ b/go.mod @@ -87,7 +87,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-20251223160021-926c74910196 // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f // @grafana/alerting-backend github.com/grafana/authlib v0.0.0-20250930082137-a40e2c2b094f // @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 diff --git a/go.sum b/go.sum index 069d53dd5e9..ea251101dc8 100644 --- a/go.sum +++ b/go.sum @@ -1622,8 +1622,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-20251223160021-926c74910196 h1:A9UJtyBBUE7PkRsAITKU05iz+HpHO9SaVjfdo2Df3UQ= -github.com/grafana/alerting v0.0.0-20251223160021-926c74910196/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f h1:Br4SaUL3dnVopKKNhDavCLgehw60jdtl/sIxdfzmVts= +github.com/grafana/alerting v0.0.0-20251231150637-b7821017d69f/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= 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-20251119142549-be091cf2f4d4 h1:Muoy+FMGrHj3GdFbvsMzUT7eusgii9PKf9L1ZaXDDbY= diff --git a/pkg/tests/api/alerting/api_notification_channel_test.go b/pkg/tests/api/alerting/api_notification_channel_test.go index b35bf6959c5..ea6fa972f97 100644 --- a/pkg/tests/api/alerting/api_notification_channel_test.go +++ b/pkg/tests/api/alerting/api_notification_channel_test.go @@ -2470,7 +2470,7 @@ var expNonEmailNotifications = map[string][]string{ "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert1/view?orgId=1", "text": "Integration Test ", "fallback": "Integration Test [FIRING:1] SlackAlert1 (default)", - "footer": "Grafana v", + "footer": "Grafana", "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, @@ -2490,7 +2490,7 @@ var expNonEmailNotifications = map[string][]string{ "title_link": "http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1", "text": "**Firing**\n\nValue: A=1\nLabels:\n - alertname = SlackAlert2\n - grafana_folder = default\nAnnotations:\nSource: http://localhost:3000/alerting/grafana/UID_SlackAlert2/view?orgId=1\nSilence: http://localhost:3000/alerting/silence/new?alertmanager=grafana&matcher=__alert_rule_uid__%%3DUID_SlackAlert2&orgId=1\n", "fallback": "[FIRING:1] SlackAlert2 (default)", - "footer": "Grafana v", + "footer": "Grafana", "footer_icon": "https://grafana.com/static/assets/img/fav32.png", "color": "#D63232", "ts": %s, 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 b3dabb7cde2..fe4f2f2f924 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 @@ -2699,6 +2699,24 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Footer", + "description": "Templated footer of the slack message", + "placeholder": "{{ template \"slack.default.footer\" . }}", + "propertyName": "footer", + "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 50c92e4d069..d3797d9cafa 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 @@ -7017,6 +7017,24 @@ "secure": false, "dependsOn": "", "subformOptions": null + }, + { + "element": "input", + "inputType": "text", + "label": "Footer", + "description": "Templated footer of the slack message", + "placeholder": "{{ template \"slack.default.footer\" . }}", + "propertyName": "footer", + "selectOptions": null, + "showWhen": { + "field": "", + "is": "" + }, + "required": false, + "validationRule": "", + "secure": false, + "dependsOn": "", + "subformOptions": null } ] }, From 521670981add82ce8368b416fdc590b4f7ef9095 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Wed, 31 Dec 2025 11:42:09 -0700 Subject: [PATCH 68/80] Zanzana: Add metric for last reconciliation (#115768) --- pkg/server/wire_gen.go | 4 +- .../accesscontrol/dualwrite/reconciler.go | 19 +++- pkg/tests/apis/folder/folder_tree_test.go | 4 + pkg/tests/apis/zanzana_reconcile.go | 87 +++++++++++++++++++ 4 files changed, 110 insertions(+), 4 deletions(-) create mode 100644 pkg/tests/apis/zanzana_reconcile.go diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index b958e5f7ad9..4ae1194ef28 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -847,7 +847,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api if err != nil { return nil, err } - zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService) + zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) investigationsAppProvider := investigations.RegisterApp(cfg) appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) if err != nil { @@ -1509,7 +1509,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac if err != nil { return nil, err } - zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService) + zanzanaReconciler := dualwrite2.ProvideZanzanaReconciler(cfg, featureToggles, zanzanaClient, sqlStore, serverLockService, folderimplService, registerer) investigationsAppProvider := investigations.RegisterApp(cfg) appregistryService, err := appregistry.ProvideBuilderRunners(apiserverService, eventualRestConfigProvider, featureToggles, investigationsAppProvider, cfg) if err != nil { diff --git a/pkg/services/accesscontrol/dualwrite/reconciler.go b/pkg/services/accesscontrol/dualwrite/reconciler.go index d66039d44f2..ab27972e86e 100644 --- a/pkg/services/accesscontrol/dualwrite/reconciler.go +++ b/pkg/services/accesscontrol/dualwrite/reconciler.go @@ -6,6 +6,8 @@ import ( "strconv" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promauto" "go.opentelemetry.io/otel" claims "github.com/grafana/authlib/types" @@ -34,12 +36,15 @@ type ZanzanaReconciler struct { store db.DB client zanzana.Client lock *serverlock.ServerLockService + metrics struct { + lastSuccess prometheus.Gauge + } // reconcilers are migrations that tries to reconcile the state of grafana db to zanzana store. // These are run periodically to try to maintain a consistent state. reconcilers []resourceReconciler } -func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service) *ZanzanaReconciler { +func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureToggles, client zanzana.Client, store db.DB, lock *serverlock.ServerLockService, folderService folder.Service, reg prometheus.Registerer) *ZanzanaReconciler { zanzanaReconciler := &ZanzanaReconciler{ cfg: cfg, log: reconcilerLogger, @@ -93,6 +98,13 @@ func ProvideZanzanaReconciler(cfg *setting.Cfg, features featuremgmt.FeatureTogg }, } + if reg != nil { + zanzanaReconciler.metrics.lastSuccess = promauto.With(reg).NewGauge(prometheus.GaugeOpts{ + Name: "grafana_zanzana_reconcile_last_success_timestamp_seconds", + Help: "Unix timestamp (seconds) when the Zanzana reconciler last completed a reconciliation cycle.", + }) + } + if cfg.Anonymous.Enabled { zanzanaReconciler.reconcilers = append(zanzanaReconciler.reconcilers, newResourceReconciler( @@ -165,7 +177,7 @@ func (r *ZanzanaReconciler) hasBasicRolePermissions(ctx context.Context) bool { func (r *ZanzanaReconciler) waitForBasicRolesSeeded(ctx context.Context) { // Best-effort: don't block forever. If we can't observe basic roles, proceed anyway. const ( - maxWait = 30 * time.Second + maxWait = 15 * time.Second interval = 1 * time.Second ) @@ -199,6 +211,9 @@ func (r *ZanzanaReconciler) reconcile(ctx context.Context) { r.log.Warn("Failed to perform reconciliation for resource", "err", err) } } + if r.metrics.lastSuccess != nil { + r.metrics.lastSuccess.SetToCurrentTime() + } r.log.Debug("Finished reconciliation", "elapsed", time.Since(now)) } diff --git a/pkg/tests/apis/folder/folder_tree_test.go b/pkg/tests/apis/folder/folder_tree_test.go index 26e7b5f6884..613d021b236 100644 --- a/pkg/tests/apis/folder/folder_tree_test.go +++ b/pkg/tests/apis/folder/folder_tree_test.go @@ -102,6 +102,8 @@ func runIntegrationFolderTree(t *testing.T, opts testinfra.GrafanaOpts) { helper := apis.NewK8sTestHelper(t, opts) defer helper.Shutdown() + apis.AwaitZanzanaReconcileNext(t, helper) + tests := []struct { Name string Definition FolderDefinition @@ -247,6 +249,8 @@ func (f *FolderDefinition) CreateWithLegacyAPI(t *testing.T, h *apis.K8sTestHelp }) require.NoError(t, err) + apis.AwaitZanzanaReconcileNext(t, h) + var statusCode int result := client.Post().AbsPath("api", "folders"). Body(body). diff --git a/pkg/tests/apis/zanzana_reconcile.go b/pkg/tests/apis/zanzana_reconcile.go new file mode 100644 index 00000000000..f8a5673fed7 --- /dev/null +++ b/pkg/tests/apis/zanzana_reconcile.go @@ -0,0 +1,87 @@ +package apis + +import ( + "bytes" + "context" + "net/http" + "testing" + "time" + + dto "github.com/prometheus/client_model/go" + "github.com/prometheus/common/expfmt" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/grafana/grafana/pkg/services/featuremgmt" +) + +const zanzanaReconcileLastSuccessMetric = "grafana_zanzana_reconcile_last_success_timestamp_seconds" + +// AwaitZanzanaReconcileNext waits for the next Zanzana reconciliation cycle to complete. +// It is a no-op unless the `zanzana` feature toggle is enabled for the running test env. +func AwaitZanzanaReconcileNext(t *testing.T, helper *K8sTestHelper) { + t.Helper() + + enabled := false + if helper != nil { + enabled = helper.GetEnv().FeatureToggles.GetEnabled(context.Background())[featuremgmt.FlagZanzana] + } + if helper == nil || !enabled { + return + } + + prev, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) + if !ok { + prev = 0 + } + + require.EventuallyWithT(t, func(c *assert.CollectT) { + ts, ok := getZanzanaReconcileLastSuccessTimestampSeconds(t, helper) + assert.True(c, ok, "expected to find %s in /metrics", zanzanaReconcileLastSuccessMetric) + if !ok { + return + } + assert.Greater(c, ts, prev, "expected %s (%v) > %v", zanzanaReconcileLastSuccessMetric, ts, prev) + }, 30*time.Second, 50*time.Millisecond) +} + +func getZanzanaReconcileLastSuccessTimestampSeconds(t *testing.T, helper *K8sTestHelper) (float64, bool) { + t.Helper() + + rsp := DoRequest(helper, RequestParams{ + User: helper.Org1.Admin, + Path: "/metrics", + Accept: "text/plain", + }, &struct{}{}) + if rsp.Response == nil || rsp.Response.StatusCode != http.StatusOK { + return 0, false + } + + parser := expfmt.NewTextParser(model.UTF8Validation) + metrics, err := parser.TextToMetricFamilies(bytes.NewReader(rsp.Body)) + if err != nil { + return 0, false + } + + metric := metrics[zanzanaReconcileLastSuccessMetric] + if metric == nil || len(metric.Metric) == 0 { + return 0, false + } + + m := metric.Metric[0] + switch metric.GetType() { + case dto.MetricType_GAUGE: + if m.Gauge == nil { + return 0, false + } + return m.Gauge.GetValue(), true + case dto.MetricType_UNTYPED: + if m.Untyped == nil { + return 0, false + } + return m.Untyped.GetValue(), true + default: + return 0, false + } +} From 33a1c60433652108c6aac18611eebac2d01195af Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 2 Jan 2026 02:15:40 -0500 Subject: [PATCH 69/80] Dashboard: Add lazy loading for repeated panels (#115047) Co-authored-by: Haris Rozajac Co-authored-by: Ivan Ortega --- .../dashboard-scene/scene/DashboardScene.tsx | 3 +- .../scene/SoloPanelContext.tsx | 18 +++++-- .../layout-auto-grid/AutoGridItemRenderer.tsx | 7 ++- .../DashboardGridItemRenderer.tsx | 50 +++++++++++++------ .../DefaultGridLayoutManager.tsx | 11 ++-- .../scene/layout-rows/RowsLayoutManager.tsx | 3 +- 6 files changed, 62 insertions(+), 30 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 7ddd7c4e779..91adc3660a8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -90,7 +90,6 @@ import { DashboardGridItem } from './layout-default/DashboardGridItem'; import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager'; import { addNewRowTo } from './layouts-shared/addNew'; import { clearClipboard } from './layouts-shared/paste'; -import { getIsLazy } from './layouts-shared/utils'; import { DashboardLayoutManager } from './types/DashboardLayoutManager'; import { LayoutParent } from './types/LayoutParent'; @@ -199,7 +198,7 @@ export class DashboardScene extends SceneObjectBase impleme meta: {}, editable: true, $timeRange: state.$timeRange ?? new SceneTimeRange({}), - body: state.body ?? DefaultGridLayoutManager.fromVizPanels([], getIsLazy(state.preload)), + body: state.body ?? DefaultGridLayoutManager.fromVizPanels([]), links: state.links ?? [], ...state, editPane: new DashboardEditPane(), diff --git a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx index 2186d9b4863..b1eca307731 100644 --- a/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx +++ b/public/app/features/dashboard-scene/scene/SoloPanelContext.tsx @@ -1,7 +1,7 @@ import React, { useContext, useEffect, useState } from 'react'; import { Trans } from '@grafana/i18n'; -import { VizPanel } from '@grafana/scenes'; +import { LazyLoader, VizPanel } from '@grafana/scenes'; import { Box, Spinner } from '@grafana/ui'; import { DashboardScene } from './DashboardScene'; @@ -51,11 +51,23 @@ export function useSoloPanelContext() { return useContext(SoloPanelContext); } -export function renderMatchingSoloPanels(soloPanelContext: SoloPanelContextValue, panels: VizPanel[]) { +export function renderMatchingSoloPanels( + soloPanelContext: SoloPanelContextValue, + panels: VizPanel[], + isLazy?: boolean +) { const matches: React.ReactNode[] = []; for (const panel of panels) { if (soloPanelContext.matches(panel)) { - matches.push(); + if (isLazy) { + matches.push( + + + + ); + } else { + matches.push(); + } } } diff --git a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx index 15b7e82ae36..6ead7a35d22 100644 --- a/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx +++ b/public/app/features/dashboard-scene/scene/layout-auto-grid/AutoGridItemRenderer.tsx @@ -8,6 +8,7 @@ import { useStyles2 } from '@grafana/ui'; import { ConditionalRenderingGroup } from '../../conditional-rendering/group/ConditionalRenderingGroup'; import { useIsConditionallyHidden } from '../../conditional-rendering/hooks/useIsConditionallyHidden'; import { useDashboardState } from '../../utils/utils'; +import { SoloPanelContextValueWithSearchStringFilter } from '../PanelSearchLayout'; import { renderMatchingSoloPanels, useSoloPanelContext } from '../SoloPanelContext'; import { getIsLazy } from '../layouts-shared/utils'; @@ -89,7 +90,11 @@ export function AutoGridItemRenderer({ model }: SceneComponentProps; +} + +function PanelWrapper({ panel, isLazy, containerRef }: PanelWrapperProps) { + if (isLazy) { + return ( + + + + ); + } + return ( +
+ +
+ ); +} + export function DashboardGridItemRenderer({ model }: SceneComponentProps) { const { repeatedPanels = [], itemHeight, variableName, body } = model.useState(); const soloPanelContext = useSoloPanelContext(); + const { preload } = useDashboardState(model); + const isLazy = useMemo(() => getIsLazy(preload), [preload]); const layoutStyle = useLayoutStyle( model.getRepeatDirection(), model.getChildCount(), @@ -20,26 +46,22 @@ export function DashboardGridItemRenderer({ model }: SceneComponentProps - -
- ); + return ; } return (
-
- -
+ {repeatedPanels.map((panel) => ( -
- -
+ ))}
); diff --git a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx index e299272de78..68288297e42 100644 --- a/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-default/DefaultGridLayoutManager.tsx @@ -47,7 +47,6 @@ import { AutoGridItem } from '../layout-auto-grid/AutoGridItem'; import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions'; import { clearClipboard, getDashboardGridItemFromClipboard } from '../layouts-shared/paste'; import { dashboardCanvasAddButtonHoverStyles } from '../layouts-shared/styles'; -import { getIsLazy } from '../layouts-shared/utils'; import { DashboardLayoutGrid } from '../types/DashboardLayoutGrid'; import { DashboardLayoutManager } from '../types/DashboardLayoutManager'; import { LayoutRegistryItem } from '../types/LayoutRegistryItem'; @@ -565,11 +564,10 @@ export class DefaultGridLayoutManager public static createFromLayout(currentLayout: DashboardLayoutManager): DefaultGridLayoutManager { const panels = currentLayout.getVizPanels(); - const isLazy = getIsLazy(getDashboardSceneFor(currentLayout).state.preload)!; - return DefaultGridLayoutManager.fromVizPanels(panels, isLazy); + return DefaultGridLayoutManager.fromVizPanels(panels); } - public static fromVizPanels(panels: VizPanel[] = [], isLazy?: boolean | undefined): DefaultGridLayoutManager { + public static fromVizPanels(panels: VizPanel[] = []): DefaultGridLayoutManager { const children: DashboardGridItem[] = []; const panelHeight = 10; const panelWidth = GRID_COLUMN_COUNT / 3; @@ -607,7 +605,6 @@ export class DefaultGridLayoutManager children: children, isDraggable: true, isResizable: true, - isLazy, }), }); } @@ -615,8 +612,7 @@ export class DefaultGridLayoutManager public static fromGridItems( gridItems: SceneGridItemLike[], isDraggable?: boolean, - isResizable?: boolean, - isLazy?: boolean | undefined + isResizable?: boolean ): DefaultGridLayoutManager { const children = gridItems.reduce((acc, gridItem) => { gridItem.clearParent(); @@ -630,7 +626,6 @@ export class DefaultGridLayoutManager children, isDraggable, isResizable, - isLazy, }), }); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx index 48f11357e24..b7459463958 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowsLayoutManager.tsx @@ -358,8 +358,7 @@ export class RowsLayoutManager extends SceneObjectBase i layout: DefaultGridLayoutManager.fromGridItems( rowConfig.children, rowConfig.isDraggable ?? layout.state.grid.state.isDraggable, - rowConfig.isResizable ?? layout.state.grid.state.isResizable, - layout.state.grid.state.isLazy + rowConfig.isResizable ?? layout.state.grid.state.isResizable ), }) ); From dc4c106e91b68caa876d08944efbad730ee3734b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mustafa=20Sencer=20=C3=96zcan?= <32759850+mustafasencer@users.noreply.github.com> Date: Fri, 2 Jan 2026 13:51:51 +0100 Subject: [PATCH 70/80] fix: use memory index if index file already open (#115720) * feat: add lock structure into bleve index files * fix: another approach * fix: new check * fix: build in memory if index file already open * fix: update workspace * fix: add test * refactor: update func signature * fix: address comments * fix: make const --- go.mod | 2 +- pkg/storage/unified/search/bleve.go | 73 +++++++++++++++++------- pkg/storage/unified/search/bleve_test.go | 73 ++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 22 deletions(-) diff --git a/go.mod b/go.mod index becd164c9dd..8768e51f86a 100644 --- a/go.mod +++ b/go.mod @@ -181,6 +181,7 @@ require ( github.com/xlab/treeprint v1.2.0 // @grafana/observability-traces-and-profiling github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // @grafana/grafana-operator-experience-squad github.com/yudai/gojsondiff v1.0.0 // @grafana/grafana-backend-group + go.etcd.io/bbolt v1.4.2 // @grafana/grafana-search-and-storage go.opentelemetry.io/collector/pdata v1.44.0 // @grafana/grafana-backend-group go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 // @grafana/plugins-platform-backend go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.63.0 // @grafana/grafana-operator-experience-squad @@ -603,7 +604,6 @@ require ( github.com/yuin/gopher-lua v1.1.1 // indirect github.com/zclconf/go-cty v1.16.3 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect - go.etcd.io/bbolt v1.4.2 // indirect go.etcd.io/etcd/api/v3 v3.6.6 // indirect go.etcd.io/etcd/client/pkg/v3 v3.6.6 // indirect go.etcd.io/etcd/client/v3 v3.6.6 // indirect diff --git a/pkg/storage/unified/search/bleve.go b/pkg/storage/unified/search/bleve.go index eb9fa4df3bd..d6ff00a81c0 100644 --- a/pkg/storage/unified/search/bleve.go +++ b/pkg/storage/unified/search/bleve.go @@ -25,6 +25,7 @@ import ( bleveSearch "github.com/blevesearch/bleve/v2/search/searcher" index "github.com/blevesearch/bleve_index_api" "github.com/prometheus/client_golang/prometheus" + bolterrors "go.etcd.io/bbolt/errors" "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/attribute" "go.uber.org/atomic" @@ -44,6 +45,7 @@ import ( const ( indexStorageMemory = "memory" indexStorageFile = "file" + boltTimeout = "500ms" ) // Keys used to store internal data in index. @@ -415,14 +417,25 @@ func (b *bleveBackend) BuildIndex( // This happens on startup, or when memory-based index has expired. (We don't expire file-based indexes) // If we do have an unexpired cached index already, we always build a new index from scratch. if cachedIndex == nil && !rebuild { - index, fileIndexName, indexRV = b.findPreviousFileBasedIndex(resourceDir) + result := b.findPreviousFileBasedIndex(resourceDir) + if result != nil && result.IsOpen { + // Index file exists but is opened by another process, fallback to memory. + // Keep the name so we can skip cleanup of that directory. + newIndexType = indexStorageMemory + fileIndexName = result.Name + } else if result != nil && result.Index != nil { + // Found and opened existing index successfully + index = result.Index + fileIndexName = result.Name + indexRV = result.RV + } } - if index != nil { + if newIndexType == indexStorageFile && index != nil { build = false logWithDetails.Debug("Existing index found on filesystem", "indexRV", indexRV, "directory", filepath.Join(resourceDir, fileIndexName)) defer closeIndexOnExit(index, "") // Close index, but don't delete directory. - } else { + } else if newIndexType == indexStorageFile { // Building index from scratch. Index name has a time component in it to be unique, but if // we happen to create non-unique name, we bump the time and try again. @@ -449,7 +462,9 @@ func (b *bleveBackend) BuildIndex( logWithDetails.Info("Building index using filesystem", "directory", indexDir) defer closeIndexOnExit(index, indexDir) // Close index, and delete new index directory. } - } else { + } + + if newIndexType == indexStorageMemory { index, err = newBleveIndex("", mapper, time.Now(), b.opts.BuildVersion) if err != nil { return nil, fmt.Errorf("error creating new in-memory bleve index: %w", err) @@ -552,30 +567,30 @@ func cleanFileSegment(input string) string { return input } -// cleanOldIndexes deletes all subdirectories inside dir, skipping directory with "skipName". +// cleanOldIndexes deletes all subdirectories inside resourceDir, skipping directory with "skipName". // "skipName" can be empty. -func (b *bleveBackend) cleanOldIndexes(dir string, skipName string) { - files, err := os.ReadDir(dir) +func (b *bleveBackend) cleanOldIndexes(resourceDir string, skipName string) { + entries, err := os.ReadDir(resourceDir) if err != nil { if os.IsNotExist(err) { return } - b.log.Warn("error cleaning folders from", "directory", dir, "error", err) + b.log.Warn("error cleaning folders from", "directory", resourceDir, "error", err) return } - for _, file := range files { - if file.IsDir() && file.Name() != skipName { - fpath := filepath.Join(dir, file.Name()) - if !isPathWithinRoot(fpath, b.opts.Root) { - b.log.Warn("Skipping cleanup of directory", "directory", fpath) + for _, ent := range entries { + if ent.IsDir() && ent.Name() != skipName { + indexDir := filepath.Join(resourceDir, ent.Name()) + if !isPathWithinRoot(indexDir, b.opts.Root) { + b.log.Warn("Skipping cleanup of directory", "directory", indexDir) continue } - err = os.RemoveAll(fpath) + err = os.RemoveAll(indexDir) if err != nil { - b.log.Error("Unable to remove old index folder", "directory", fpath, "error", err) + b.log.Error("Unable to remove old index folder", "directory", indexDir, "error", err) } else { - b.log.Info("Removed old index folder", "directory", fpath) + b.log.Info("Removed old index folder", "directory", indexDir) } } } @@ -622,10 +637,17 @@ func formatIndexName(now time.Time) string { return now.Format("20060102-150405") } -func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Index, string, int64) { +type fileIndex struct { + Index bleve.Index + Name string + RV int64 + IsOpen bool +} + +func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) *fileIndex { entries, err := os.ReadDir(resourceDir) if err != nil { - return nil, "", 0 + return nil } for _, ent := range entries { @@ -635,8 +657,13 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind indexName := ent.Name() indexDir := filepath.Join(resourceDir, indexName) - idx, err := bleve.Open(indexDir) + + idx, err := bleve.OpenUsing(indexDir, map[string]interface{}{"bolt_timeout": boltTimeout}) if err != nil { + if errors.Is(err, bolterrors.ErrTimeout) { + b.log.Debug("Index is opened by another process (timeout), skipping", "indexDir", indexDir) + return &fileIndex{Name: indexName, IsOpen: true} + } b.log.Debug("error opening index", "indexDir", indexDir, "err", err) continue } @@ -648,10 +675,14 @@ func (b *bleveBackend) findPreviousFileBasedIndex(resourceDir string) (bleve.Ind continue } - return idx, indexName, indexRV + return &fileIndex{ + Index: idx, + Name: indexName, + RV: indexRV, + } } - return nil, "", 0 + return nil } // Stop closes all indexes and stops background tasks. diff --git a/pkg/storage/unified/search/bleve_test.go b/pkg/storage/unified/search/bleve_test.go index a23f261cfc5..c879440e7b6 100644 --- a/pkg/storage/unified/search/bleve_test.go +++ b/pkg/storage/unified/search/bleve_test.go @@ -1583,3 +1583,76 @@ func docCount(t *testing.T, idx resource.ResourceIndex) int { require.NoError(t, err) return int(cnt) } + +func TestBleveBackendFallsBackToMemory(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + tmpDir := t.TempDir() + + // First, create a file-based index with one backend and keep it open + backend1, reg1 := setupBleveBackend(t, withRootDir(tmpDir)) + index1, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + require.NotNil(t, index1) + + // Verify first index is file-based + bleveIdx1, ok := index1.(*bleveIndex) + require.True(t, ok) + require.Equal(t, indexStorageFile, bleveIdx1.indexStorage) + checkOpenIndexes(t, reg1, 0, 1) + + // Now create a second backend using the same directory + // This simulates another instance trying to open the same index + backend2, reg2 := setupBleveBackend(t, withRootDir(tmpDir)) + + // BuildIndex should detect the file is locked and fallback to memory + index2, err := backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + require.NotNil(t, index2) + + // Verify second index fell back to in-memory despite size being above file threshold + bleveIdx2, ok := index2.(*bleveIndex) + require.True(t, ok) + require.Equal(t, indexStorageMemory, bleveIdx2.indexStorage) + + // Verify metrics show 1 memory index and 0 file indexes for backend2 + checkOpenIndexes(t, reg2, 1, 0) + + // Verify the in-memory index works correctly + require.Equal(t, 10, docCount(t, index2)) + + // Clean up: close first backend to release the file lock + backend1.Stop() +} + +func TestBleveSkipCleanOldIndexesOnMemoryFallback(t *testing.T) { + ns := resource.NamespacedResource{ + Namespace: "test", + Group: "group", + Resource: "resource", + } + + tmpDir := t.TempDir() + + backend1, _ := setupBleveBackend(t, withRootDir(tmpDir)) + _, err := backend1.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + + // Now create a second backend using the same directory + // This simulates another instance trying to open the same index + backend2, _ := setupBleveBackend(t, withRootDir(tmpDir)) + + // BuildIndex should detect the file is locked and fallback to memory + _, err = backend2.BuildIndex(context.Background(), ns, 100 /* file based */, nil, "test", indexTestDocs(ns, 10, 100), nil, false) + require.NoError(t, err) + + // Verify that the index directory still exists (i.e., cleanOldIndexes was skipped) + verifyDirEntriesCount(t, backend2.getResourceDir(ns), 1) + + // Clean up: close first backend to release the file lock + backend1.Stop() +} From 105b4076297047890fead0b6c4fc9bfdae860383 Mon Sep 17 00:00:00 2001 From: Will Browne Date: Fri, 2 Jan 2026 15:52:10 +0000 Subject: [PATCH 71/80] Plugins: Sync validator plugin.json schema copy edits back to source of truth (#115790) sync validator copy edits back to source of truth --- docs/sources/developers/plugins/plugin.schema.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/sources/developers/plugins/plugin.schema.json b/docs/sources/developers/plugins/plugin.schema.json index 1898cd46b94..cae948ce4f3 100644 --- a/docs/sources/developers/plugins/plugin.schema.json +++ b/docs/sources/developers/plugins/plugin.schema.json @@ -369,7 +369,7 @@ "description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).", "items": { "type": "object", - "description": "", + "description": "For data source plugins. Proxy routes used for plugin authentication and adding headers to HTTP requests made by the plugin. For more information, refer to [Authentication for data source plugins](https://grafana.com/developers/plugin-tools/how-to-guides/data-source-plugins/add-authentication-for-data-source-plugins).", "additionalProperties": false, "properties": { "path": { From 967ba3acaf2ee71c211fee66b44840cbe4583119 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Fri, 2 Jan 2026 13:12:04 -0500 Subject: [PATCH 72/80] Dashboard: Fix dashboardUID in conversion logs to use actual dashboard UID (#115797) udpate loggers --- apps/dashboard/pkg/migration/conversion/metrics.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/metrics.go b/apps/dashboard/pkg/migration/conversion/metrics.go index 5a60aa848de..9cbdec193e8 100644 --- a/apps/dashboard/pkg/migration/conversion/metrics.go +++ b/apps/dashboard/pkg/migration/conversion/metrics.go @@ -85,20 +85,20 @@ func withConversionMetrics(sourceVersionAPI, targetVersionAPI string, conversion // Only track schema versions for v0/v1 dashboards (v2+ info is redundant with API version) switch source := a.(type) { case *dashv0.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name if source.Spec.Object != nil { sourceSchemaVersion = schemaversion.GetSchemaVersion(source.Spec.Object) } case *dashv1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name if source.Spec.Object != nil { sourceSchemaVersion = schemaversion.GetSchemaVersion(source.Spec.Object) } case *dashv2alpha1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name // Don't track schema version for v2+ (redundant with API version) case *dashv2beta1.Dashboard: - dashboardUID = string(source.UID) + dashboardUID = source.Name // Don't track schema version for v2+ (redundant with API version) } From eb2a390425611773b892b5b04f9103268bd7aab5 Mon Sep 17 00:00:00 2001 From: Stephanie Hingtgen Date: Mon, 5 Jan 2026 00:51:23 -0700 Subject: [PATCH 73/80] Unistore: Prevent deadlock on startup errors (#115799) --- pkg/storage/unified/sql/service.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/storage/unified/sql/service.go b/pkg/storage/unified/sql/service.go index 75b3e80fcb0..06275c8754c 100644 --- a/pkg/storage/unified/sql/service.go +++ b/pkg/storage/unified/sql/service.go @@ -115,6 +115,7 @@ func ProvideUnifiedStorageGrpcService( cfg: cfg, features: features, stopCh: make(chan struct{}), + stoppedCh: make(chan error, 1), authenticator: authn, tracing: tracer, db: db, From 3b3e87ff898157d8572614e3339dfcbdc1fb4e5f Mon Sep 17 00:00:00 2001 From: Gareth Date: Mon, 5 Jan 2026 16:35:19 +0700 Subject: [PATCH 74/80] OpenTSDB: Migrate frontend requests to data source backend (#115221) * OpenTSDB: Migrate metadata queries to data source backend * OpenTSDB: Migrate annotations to the data source backend * return errors for failed unmarshal * remove trailing / from metadata requests * remove console logs --- pkg/tsdb/opentsdb/callresource.go | 386 ++++++++++++++++++ pkg/tsdb/opentsdb/opentsdb.go | 3 + pkg/tsdb/opentsdb/types.go | 13 +- pkg/tsdb/opentsdb/utils.go | 12 +- .../plugins/datasource/opentsdb/datasource.ts | 109 +++-- 5 files changed, 493 insertions(+), 30 deletions(-) diff --git a/pkg/tsdb/opentsdb/callresource.go b/pkg/tsdb/opentsdb/callresource.go index be0f81b9c80..74ed9b53188 100644 --- a/pkg/tsdb/opentsdb/callresource.go +++ b/pkg/tsdb/opentsdb/callresource.go @@ -1,10 +1,13 @@ package opentsdb import ( + "encoding/json" "fmt" "net/http" "net/url" "path" + "sort" + "strings" "github.com/grafana/grafana-plugin-sdk-go/backend" ) @@ -65,3 +68,386 @@ func (s *Service) HandleSuggestQuery(rw http.ResponseWriter, req *http.Request) return } } + +func (s *Service) HandleAggregatorsQuery(rw http.ResponseWriter, req *http.Request) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/aggregators") + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var aggregators []string + if err := json.Unmarshal(responseBody, &aggregators); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal aggregators response: %v", err), http.StatusInternalServerError) + return + } + + sort.Strings(aggregators) + sortedResponse, err := json.Marshal(aggregators) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleFiltersQuery(rw http.ResponseWriter, req *http.Request) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "/api/config/filters") + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var filters map[string]json.RawMessage + if err := json.Unmarshal(responseBody, &filters); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal filters response: %v", err), http.StatusInternalServerError) + return + } + + keys := make([]string, 0, len(filters)) + for key := range filters { + keys = append(keys, key) + } + + sort.Strings(keys) + sortedResponse, err := json.Marshal(keys) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleLookupQuery(rw http.ResponseWriter, req *http.Request) { + queryParams := req.URL.Query() + typeParam := queryParams.Get("type") + if typeParam == "" { + http.Error(rw, "missing 'type' parameter", http.StatusBadRequest) + return + } + + switch typeParam { + case "key": + s.HandleKeyLookup(rw, req, queryParams) + case "keyvalue": + s.HandleKeyValueLookup(rw, req, queryParams) + default: + http.Error(rw, fmt.Sprintf("unsupported type: %s", typeParam), http.StatusBadRequest) + return + } +} + +func (s *Service) HandleKeyLookup(rw http.ResponseWriter, req *http.Request, queryParams url.Values) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + metric := queryParams.Get("metric") + if metric == "" { + http.Error(rw, "missing 'metric' parameter", http.StatusBadRequest) + return + } + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/search/lookup") + lookupQueryParams := u.Query() + lookupQueryParams.Set("m", metric) + lookupQueryParams.Set("limit", "1000") + u.RawQuery = lookupQueryParams.Encode() + + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var lookupResponse struct { + Results []struct { + Tags map[string]string `json:"tags"` + } `json:"results"` + } + + if err := json.Unmarshal(responseBody, &lookupResponse); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal lookup response: %v", err), http.StatusInternalServerError) + return + } + + tagKeysMap := make(map[string]bool) + for _, result := range lookupResponse.Results { + for tagKey := range result.Tags { + tagKeysMap[tagKey] = true + } + } + + tagKeys := make([]string, 0, len(tagKeysMap)) + for tagKey := range tagKeysMap { + tagKeys = append(tagKeys, tagKey) + } + + sort.Strings(tagKeys) + sortedResponse, err := json.Marshal(tagKeys) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} + +func (s *Service) HandleKeyValueLookup(rw http.ResponseWriter, req *http.Request, queryParams url.Values) { + logger := logger.FromContext(req.Context()) + + dsInfo, err := s.getDSInfo(req.Context(), backend.PluginConfigFromContext(req.Context())) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to get datasource info: %v", err), http.StatusInternalServerError) + return + } + + metric := queryParams.Get("metric") + if metric == "" { + http.Error(rw, "missing 'metric' parameter", http.StatusBadRequest) + return + } + + keys := queryParams.Get("keys") + if keys == "" { + http.Error(rw, "missing 'keys' parameter", http.StatusBadRequest) + return + } + + keysArray := strings.Split(keys, ",") + for i := range keysArray { + keysArray[i] = strings.TrimSpace(keysArray[i]) + } + + if len(keysArray) == 0 { + http.Error(rw, "keys parameter cannot be empty", http.StatusBadRequest) + return + } + + key := keysArray[0] + keysQuery := key + "=*" + + if len(keysArray) > 1 { + keysQuery += "," + strings.Join(keysArray[1:], ",") + } + + m := metric + "{" + keysQuery + "}" + + u, err := url.Parse(dsInfo.URL) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to parse datasource URL: %v", err), http.StatusInternalServerError) + return + } + + u.Path = path.Join(u.Path, "api/search/lookup") + lookupQueryParams := u.Query() + lookupQueryParams.Set("m", m) + lookupQueryParams.Set("limit", fmt.Sprintf("%d", dsInfo.LookupLimit)) + u.RawQuery = lookupQueryParams.Encode() + + httpReq, err := http.NewRequestWithContext(req.Context(), http.MethodGet, u.String(), nil) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to create request: %v", err), http.StatusInternalServerError) + return + } + + res, err := dsInfo.HTTPClient.Do(httpReq) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to execute request: %v", err), http.StatusInternalServerError) + return + } + + defer func() { + if err := res.Body.Close(); err != nil { + logger.Error("Failed to close response body", "error", err) + } + }() + + responseBody, err := DecodeResponseBody(res, logger) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to decode response: %v", err), http.StatusInternalServerError) + return + } + + var lookupResponse struct { + Results []struct { + Tags map[string]string `json:"tags"` + } `json:"results"` + } + + if err := json.Unmarshal(responseBody, &lookupResponse); err != nil { + http.Error(rw, fmt.Sprintf("failed to unmarshal lookup response: %v", err), http.StatusInternalServerError) + return + } + + tagValuesMap := make(map[string]bool) + for _, result := range lookupResponse.Results { + if tagValue, exists := result.Tags[key]; exists { + tagValuesMap[tagValue] = true + } + } + + tagValues := make([]string, 0, len(tagValuesMap)) + for tagValue := range tagValuesMap { + tagValues = append(tagValues, tagValue) + } + + sort.Strings(tagValues) + sortedResponse, err := json.Marshal(tagValues) + if err != nil { + http.Error(rw, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError) + return + } + + for name, values := range res.Header { + if name == "Content-Encoding" || name == "Content-Length" { + continue + } + for _, value := range values { + rw.Header().Add(name, value) + } + } + + rw.Header().Set("Content-Type", "application/json") + rw.WriteHeader(res.StatusCode) + if _, err := rw.Write(sortedResponse); err != nil { + logger.Error("Failed to write response", "error", err) + return + } +} diff --git a/pkg/tsdb/opentsdb/opentsdb.go b/pkg/tsdb/opentsdb/opentsdb.go index a694445e1cd..533fadccb75 100644 --- a/pkg/tsdb/opentsdb/opentsdb.go +++ b/pkg/tsdb/opentsdb/opentsdb.go @@ -152,6 +152,9 @@ func (s *Service) CheckHealth(ctx context.Context, req *backend.CheckHealthReque func (s *Service) CallResource(ctx context.Context, req *backend.CallResourceRequest, sender backend.CallResourceResponseSender) error { mux := http.NewServeMux() mux.HandleFunc("/api/suggest", s.HandleSuggestQuery) + mux.HandleFunc("/api/aggregators", s.HandleAggregatorsQuery) + mux.HandleFunc("/api/config/filters", s.HandleFiltersQuery) + mux.HandleFunc("/api/search/lookup", s.HandleLookupQuery) handler := httpadapter.New(mux) return handler.CallResource(ctx, req, sender) diff --git a/pkg/tsdb/opentsdb/types.go b/pkg/tsdb/opentsdb/types.go index 89aed49baa8..0a01239ce65 100644 --- a/pkg/tsdb/opentsdb/types.go +++ b/pkg/tsdb/opentsdb/types.go @@ -7,9 +7,16 @@ type OpenTsdbQuery struct { } type OpenTsdbCommon struct { - Metric string `json:"metric"` - Tags map[string]string `json:"tags"` - AggregateTags []string `json:"aggregateTags"` + Metric string `json:"metric"` + Tags map[string]string `json:"tags"` + AggregateTags []string `json:"aggregateTags"` + Annotations []OpenTsdbAnnotation `json:"annotations,omitempty"` + GlobalAnnotations []OpenTsdbAnnotation `json:"globalAnnotations,omitempty"` +} + +type OpenTsdbAnnotation struct { + Description string `json:"description"` + StartTime float64 `json:"startTime"` } type OpenTsdbResponse struct { diff --git a/pkg/tsdb/opentsdb/utils.go b/pkg/tsdb/opentsdb/utils.go index ddfa8122fce..df3ea67ae25 100644 --- a/pkg/tsdb/opentsdb/utils.go +++ b/pkg/tsdb/opentsdb/utils.go @@ -198,11 +198,21 @@ func CreateDataFrame(val OpenTsdbCommon, length int, refID string) *data.Frame { sort.Strings(tagKeys) tagKeys = append(tagKeys, val.AggregateTags...) + custom := map[string]any{ + "tagKeys": tagKeys, + } + if len(val.Annotations) > 0 { + custom["annotations"] = val.Annotations + } + if len(val.GlobalAnnotations) > 0 { + custom["globalAnnotations"] = val.GlobalAnnotations + } + frame := data.NewFrameOfFieldTypes(val.Metric, length, data.FieldTypeTime, data.FieldTypeFloat64) frame.Meta = &data.FrameMeta{ Type: data.FrameTypeTimeSeriesMulti, TypeVersion: data.FrameTypeVersion{0, 1}, - Custom: map[string]any{"tagKeys": tagKeys}, + Custom: custom, } frame.RefID = refID timeField := frame.Fields[0] diff --git a/public/app/plugins/datasource/opentsdb/datasource.ts b/public/app/plugins/datasource/opentsdb/datasource.ts index 24356eefbac..da3473be8ad 100644 --- a/public/app/plugins/datasource/opentsdb/datasource.ts +++ b/public/app/plugins/datasource/opentsdb/datasource.ts @@ -77,8 +77,28 @@ export default class OpenTsDatasource extends DataSourceWithBackend): Observable { + if (options.targets.some((target: OpenTsdbQuery) => target.fromAnnotations)) { + const streams: Array> = []; + + for (const annotation of options.targets) { + if (annotation.target) { + streams.push( + new Observable((subscriber) => { + this.annotationEvent(options, annotation) + .then((events) => subscriber.next({ data: [toDataFrame(events)] })) + .catch((ex) => { + return subscriber.next({ data: [toDataFrame([])] }); + }) + .finally(() => subscriber.complete()); + }) + ); + } + } + + return merge(...streams); + } + if (config.featureToggles.opentsdbBackendMigration) { const hasValidTargets = options.targets.some((target) => target.metric && !target.hide); if (!hasValidTargets) { @@ -93,31 +113,6 @@ export default class OpenTsDatasource extends DataSourceWithBackend target.fromAnnotations)) { - const streams: Array> = []; - - for (const annotation of options.targets) { - if (annotation.target) { - streams.push( - new Observable((subscriber) => { - this.annotationEvent(options, annotation) - .then((events) => subscriber.next({ data: [toDataFrame(events)] })) - .catch((ex) => { - // grafana fetch throws the error so for annotation consistency among datasources - // we return an empty array which displays as 'no events found' - // in the annnotation editor - return subscriber.next({ data: [toDataFrame([])] }); - }) - .finally(() => subscriber.complete()); - }) - ); - } - } - - return merge(...streams); - } - const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); const qs: any[] = []; @@ -181,6 +176,50 @@ export default class OpenTsDatasource extends DataSourceWithBackend { + if (config.featureToggles.opentsdbBackendMigration) { + const query: OpenTsdbQuery = { + refId: annotation.refId ?? 'Anno', + metric: annotation.target, + aggregator: 'sum', + fromAnnotations: true, + isGlobal: annotation.isGlobal, + disableDownsampling: true, + }; + + const queryRequest: DataQueryRequest = { + ...options, + targets: [query], + }; + + return lastValueFrom( + super.query(queryRequest).pipe( + map((response) => { + const eventList: AnnotationEvent[] = []; + + for (const frame of response.data) { + const annotationObject = annotation.isGlobal + ? frame.meta?.custom?.globalAnnotations + : frame.meta?.custom?.annotations; + + if (annotationObject && isArray(annotationObject)) { + annotationObject.forEach((ann) => { + const event: AnnotationEvent = { + text: ann.description, + time: Math.floor(ann.startTime) * 1000, + annotation: annotation, + }; + + eventList.push(event); + }); + } + } + + return eventList; + }) + ) + ); + } + const start = this.convertToTSDBTime(options.range.raw.from, false, options.timezone); const end = this.convertToTSDBTime(options.range.raw.to, true, options.timezone); const qs = []; @@ -306,6 +345,10 @@ export default class OpenTsDatasource extends DataSourceWithBackend { return key.trim(); }); @@ -337,6 +380,10 @@ export default class OpenTsDatasource extends DataSourceWithBackend { result = result.data.results; @@ -450,6 +497,11 @@ export default class OpenTsDatasource extends DataSourceWithBackend { @@ -468,6 +520,11 @@ export default class OpenTsDatasource extends DataSourceWithBackend { From 1a0bc39ec3907a6b86e82d12b3cd30940d67a2dd Mon Sep 17 00:00:00 2001 From: Will Browne Date: Mon, 5 Jan 2026 09:42:47 +0000 Subject: [PATCH 75/80] Plugins: Remove some pkg/infra/* dependencies from pkg/plugins (#115795) * tackle some /pkg/infra/* packages * run make update-workspace * add owner for slugify dep --- apps/advisor/go.mod | 1 + apps/advisor/go.sum | 2 ++ apps/iam/go.mod | 1 + apps/iam/go.sum | 2 ++ apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 +-- go.mod | 2 ++ go.sum | 2 ++ .../backendplugin/coreplugin/registry.go | 6 ++-- .../backendplugin/coreplugin/registry_test.go | 4 +-- .../backendplugin/grpcplugin/grpc_plugin.go | 9 ----- .../manager/pipeline/bootstrap/bootstrap.go | 2 +- .../manager/pipeline/bootstrap/steps.go | 3 +- .../manager/pipeline/discovery/discovery.go | 2 +- .../pipeline/initialization/initialization.go | 2 +- .../pipeline/termination/termination.go | 2 +- .../manager/pipeline/validation/validation.go | 2 +- .../manager/sources/source_local_disk.go | 12 +++---- pkg/plugins/tracing/tracing.go | 35 +++++++++++++++++++ pkg/server/wire_gen.go | 8 ++--- 20 files changed, 69 insertions(+), 34 deletions(-) create mode 100644 pkg/plugins/tracing/tracing.go diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 84a6ca5f010..314726c5ecb 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -54,6 +54,7 @@ require ( github.com/Azure/go-ntlmssp v0.0.0-20220621081337-cb9428e4ac1e // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect github.com/BurntSushi/toml v1.5.0 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/sprig/v3 v3.3.0 // indirect diff --git a/apps/advisor/go.sum b/apps/advisor/go.sum index 873cbf6de62..112228d6ed8 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -115,6 +115,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapp github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0/go.mod h1:cSgYe11MCNYunTnRXrKiR/tHc0eoKjICUuWpNZoVCOo= github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index d3f31d6f7a4..aed406c5434 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -89,6 +89,7 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.53.0 // indirect github.com/HdrHistogram/hdrhistogram-go v1.1.2 // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/Masterminds/semver v1.5.0 // indirect github.com/Masterminds/semver/v3 v3.4.0 // indirect diff --git a/apps/iam/go.sum b/apps/iam/go.sum index 7e6806e89d0..35997e0d1ec 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -167,6 +167,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2 h1:5IcZpTvzydCQeHzK4Ef/D5rrSqwxob github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXYg9BVp4O+o5f6V/ehm6Oo= github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/apps/plugins/go.mod b/apps/plugins/go.mod index 678d460910b..9a3e3776efb 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -23,6 +23,7 @@ require ( require ( cel.dev/expr v0.25.1 // indirect + github.com/Machiel/slugify v1.0.1 // indirect github.com/NYTimes/gziphandler v1.1.1 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -191,7 +192,6 @@ require ( go.opentelemetry.io/contrib/propagators/jaeger v1.38.0 // indirect go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect - go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect diff --git a/apps/plugins/go.sum b/apps/plugins/go.sum index 1c9800a8bab..3a7e9849fad 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -7,6 +7,8 @@ filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4 github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/DataDog/datadog-go v3.2.0+incompatible/go.mod h1:LButxg5PwREeZtORoXG3tL4fMGNddJ+vMq1mwgfaqoQ= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= @@ -541,8 +543,6 @@ go.opentelemetry.io/contrib/samplers/jaegerremote v0.32.0/go.mod h1:B9Oka5QVD0bn go.opentelemetry.io/otel v1.21.0/go.mod h1:QZzNPQPm1zLX4gZK4cMi+71eaorMSGT3A4znnUvNNEo= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4= -go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= diff --git a/go.mod b/go.mod index 8768e51f86a..83d82e3af5d 100644 --- a/go.mod +++ b/go.mod @@ -660,6 +660,8 @@ require ( require github.com/grafana/tempo v1.5.1-0.20250529124718-87c2dc380cec // @grafana/observability-traces-and-profiling +require github.com/Machiel/slugify v1.0.1 // @grafana/plugins-platform-backend + require ( github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect github.com/IBM/pgxpoolprometheus v1.1.2 // indirect diff --git a/go.sum b/go.sum index ea251101dc8..2b3b2cb4e3f 100644 --- a/go.sum +++ b/go.sum @@ -738,6 +738,8 @@ github.com/HdrHistogram/hdrhistogram-go v1.1.2/go.mod h1:yDgFjdqOqDEKOvasDdhWNXY github.com/IBM/pgxpoolprometheus v1.1.2 h1:sHJwxoL5Lw4R79Zt+H4Uj1zZ4iqXJLdk7XDE7TPs97U= github.com/IBM/pgxpoolprometheus v1.1.2/go.mod h1:+vWzISN6S9ssgurhUNmm6AlXL9XLah3TdWJktquKTR8= github.com/JohnCGriffin/overflow v0.0.0-20211019200055-46fa312c352c/go.mod h1:X0CRv0ky0k6m906ixxpzmDRLvX58TFUKS2eePweuyxk= +github.com/Machiel/slugify v1.0.1 h1:EfWSlRWstMadsgzmiV7d0yVd2IFlagWH68Q+DcYCm4E= +github.com/Machiel/slugify v1.0.1/go.mod h1:fTFGn5uWEynW4CUMG7sWkYXOf1UgDxyTM3DbR6Qfg3k= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3QEww= diff --git a/pkg/plugins/backendplugin/coreplugin/registry.go b/pkg/plugins/backendplugin/coreplugin/registry.go index 1e610b1ef1c..fb17fd279b8 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry.go +++ b/pkg/plugins/backendplugin/coreplugin/registry.go @@ -10,8 +10,8 @@ import ( sdktracing "github.com/grafana/grafana-plugin-sdk-go/backend/tracing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" + "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" @@ -94,7 +94,7 @@ func NewRegistry(store map[string]backendplugin.PluginFactoryFunc) *Registry { } } -func ProvideCoreRegistry(tracer tracing.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, +func ProvideCoreRegistry(tracer trace.Tracer, am *azuremonitor.Service, cw *cloudwatch.Service, cm *cloudmonitoring.Service, es *elasticsearch.Service, grap *graphite.Service, idb *influxdb.Service, lk *loki.Service, otsdb *opentsdb.Service, pr *prometheus.Service, t *tempo.Service, td *testdatasource.Service, pg *postgres.Service, my *mysql.Service, ms *mssql.Service, graf *grafanads.Service, pyroscope *pyroscope.Service, parca *parca.Service, zipkin *zipkin.Service, jaeger *jaeger.Service) *Registry { @@ -204,7 +204,7 @@ var ErrCorePluginNotFound = errors.New("core plugin not found") // NewPlugin factory for creating and initializing a single core plugin. // Note: cfg only needed for mssql connection pooling defaults. -func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer tracing.Tracer, features featuremgmt.FeatureToggles) (*plugins.Plugin, error) { +func NewPlugin(pluginID string, cfg *setting.Cfg, httpClientProvider *httpclient.Provider, tracer trace.Tracer, features featuremgmt.FeatureToggles) (*plugins.Plugin, error) { jsonData := plugins.JSONData{ ID: pluginID, AliasIDs: []string{}, diff --git a/pkg/plugins/backendplugin/coreplugin/registry_test.go b/pkg/plugins/backendplugin/coreplugin/registry_test.go index 41a1ca7f7ec..76f531a25b7 100644 --- a/pkg/plugins/backendplugin/coreplugin/registry_test.go +++ b/pkg/plugins/backendplugin/coreplugin/registry_test.go @@ -4,8 +4,8 @@ import ( "testing" "github.com/grafana/grafana-plugin-sdk-go/backend/httpclient" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/setting" "github.com/stretchr/testify/require" @@ -46,7 +46,7 @@ func TestNewPlugin(t *testing.T) { tc.ExpectedID = tc.ID } - p, err := NewPlugin(tc.ID, setting.NewCfg(), httpclient.NewProvider(), tracing.InitializeTracerForTest(), featuremgmt.WithFeatures()) + p, err := NewPlugin(tc.ID, setting.NewCfg(), httpclient.NewProvider(), tracing.NoopTracer(), featuremgmt.WithFeatures()) if tc.ExpectedNotFoundErr { require.ErrorIs(t, err, ErrCorePluginNotFound) require.Nil(t, p) diff --git a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go index d1bcb5640a2..f8ffd6d6d71 100644 --- a/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go +++ b/pkg/plugins/backendplugin/grpcplugin/grpc_plugin.go @@ -9,7 +9,6 @@ import ( "github.com/hashicorp/go-plugin" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/process" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/backendplugin" "github.com/grafana/grafana/pkg/plugins/log" @@ -90,14 +89,6 @@ func (p *grpcPlugin) Start(_ context.Context) error { return errors.New("no compatible plugin implementation found") } - elevated, err := process.IsRunningWithElevatedPrivileges() - if err != nil { - p.logger.Debug("Error checking plugin process execution privilege", "error", err) - } - if elevated { - p.logger.Warn("Plugin process is running with elevated privileges. This is not recommended") - } - p.state = pluginStateStartSuccess return nil } diff --git a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go index e6845322516..f20c1ff1ead 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go +++ b/pkg/plugins/manager/pipeline/bootstrap/bootstrap.go @@ -6,12 +6,12 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" "github.com/grafana/grafana/pkg/plugins/manager/signature" "github.com/grafana/grafana/pkg/plugins/pluginassets" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/bootstrap/steps.go b/pkg/plugins/manager/pipeline/bootstrap/steps.go index 7608ba2c4fa..5c365ebb47c 100644 --- a/pkg/plugins/manager/pipeline/bootstrap/steps.go +++ b/pkg/plugins/manager/pipeline/bootstrap/steps.go @@ -5,7 +5,8 @@ import ( "path" "slices" - "github.com/grafana/grafana/pkg/infra/slugify" + "github.com/Machiel/slugify" + "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" diff --git a/pkg/plugins/manager/pipeline/discovery/discovery.go b/pkg/plugins/manager/pipeline/discovery/discovery.go index e5bdc50dd62..08a74b1cce0 100644 --- a/pkg/plugins/manager/pipeline/discovery/discovery.go +++ b/pkg/plugins/manager/pipeline/discovery/discovery.go @@ -7,10 +7,10 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" ) // Discoverer is responsible for the Discovery stage of the plugin loader pipeline. diff --git a/pkg/plugins/manager/pipeline/initialization/initialization.go b/pkg/plugins/manager/pipeline/initialization/initialization.go index 4319f4811a7..6a697fc7009 100644 --- a/pkg/plugins/manager/pipeline/initialization/initialization.go +++ b/pkg/plugins/manager/pipeline/initialization/initialization.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/termination/termination.go b/pkg/plugins/manager/pipeline/termination/termination.go index fdb28396bbf..f27ec531bc7 100644 --- a/pkg/plugins/manager/pipeline/termination/termination.go +++ b/pkg/plugins/manager/pipeline/termination/termination.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/pipeline/validation/validation.go b/pkg/plugins/manager/pipeline/validation/validation.go index 36db1f25163..465ed0ce089 100644 --- a/pkg/plugins/manager/pipeline/validation/validation.go +++ b/pkg/plugins/manager/pipeline/validation/validation.go @@ -6,10 +6,10 @@ import ( "go.opentelemetry.io/otel" "go.opentelemetry.io/otel/trace" - "github.com/grafana/grafana/pkg/infra/tracing" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" + "github.com/grafana/grafana/pkg/plugins/tracing" "github.com/grafana/grafana/pkg/semconv" ) diff --git a/pkg/plugins/manager/sources/source_local_disk.go b/pkg/plugins/manager/sources/source_local_disk.go index 0ec55afbe0b..22830b69734 100644 --- a/pkg/plugins/manager/sources/source_local_disk.go +++ b/pkg/plugins/manager/sources/source_local_disk.go @@ -10,7 +10,6 @@ import ( "slices" "strings" - "github.com/grafana/grafana/pkg/infra/fs" "github.com/grafana/grafana/pkg/plugins" "github.com/grafana/grafana/pkg/plugins/config" "github.com/grafana/grafana/pkg/plugins/log" @@ -79,15 +78,14 @@ func (s *LocalSource) Discover(_ context.Context) ([]*plugins.FoundBundle, error pluginJSONPaths := make([]string, 0, len(s.paths)) for _, path := range s.paths { - exists, err := fs.Exists(path) - if err != nil { + if _, err := os.Stat(path); err != nil { + if os.IsNotExist(err) { + s.log.Warn("Skipping finding plugins as directory does not exist", "path", path) + continue + } s.log.Warn("Skipping finding plugins as an error occurred", "path", path, "error", err) continue } - if !exists { - s.log.Warn("Skipping finding plugins as directory does not exist", "path", path) - continue - } paths, err := s.getAbsPluginJSONPaths(path) if err != nil { diff --git a/pkg/plugins/tracing/tracing.go b/pkg/plugins/tracing/tracing.go new file mode 100644 index 00000000000..f039b10914b --- /dev/null +++ b/pkg/plugins/tracing/tracing.go @@ -0,0 +1,35 @@ +package tracing + +import ( + "context" + "net/http" + + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" + "go.opentelemetry.io/otel/trace/noop" +) + +// Tracer defines the service used to create new spans. +type Tracer interface { + trace.Tracer + + // Inject adds identifying information for the span to the + // headers defined in [http.Header] map (this mutates http.Header). + Inject(context.Context, http.Header, trace.Span) +} + +// Error sets the status to error and record the error as an exception in the provided span. +// This is a simplified version that works directly with OpenTelemetry spans. +func Error(span trace.Span, err error) error { + if err == nil { + return nil + } + span.SetStatus(codes.Error, err.Error()) + span.RecordError(err) + return err +} + +// NoopTracer returns a no-op tracer that can be used when tracing is not available. +func NoopTracer() trace.Tracer { + return noop.NewTracerProvider().Tracer("") +} diff --git a/pkg/server/wire_gen.go b/pkg/server/wire_gen.go index 4ae1194ef28..6569066fcdf 100644 --- a/pkg/server/wire_gen.go +++ b/pkg/server/wire_gen.go @@ -390,13 +390,13 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api return nil, err } validate := pipeline.ProvideValidationStage(pluginManagementCfg, validation, angularinspectorService) + tracer := otelTracer() ossDataSourceRequestURLValidator := validations.ProvideURLValidator() httpclientProvider := httpclientprovider.New(cfg, ossDataSourceRequestURLValidator, tracingService) azuremonitorService := azuremonitor.ProvideService(httpclientProvider) cloudwatchService := cloudwatch.ProvideService() cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider) elasticsearchService := elasticsearch.ProvideService(httpclientProvider) - tracer := otelTracer() graphiteService := graphite.ProvideService(httpclientProvider, tracer) influxdbService := influxdb.ProvideService(httpclientProvider) lokiService := loki.ProvideService(httpclientProvider, tracer) @@ -556,7 +556,7 @@ func Initialize(ctx context.Context, cfg *setting.Cfg, opts Options, apiOpts api parcaService := parca.ProvideService(httpclientProvider) zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) - corepluginRegistry := coreplugin.ProvideCoreRegistry(tracingService, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) + corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) providerService := provider2.ProvideService(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) @@ -1050,13 +1050,13 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac return nil, err } validate := pipeline.ProvideValidationStage(pluginManagementCfg, validation, angularinspectorService) + tracer := otelTracer() ossDataSourceRequestURLValidator := validations.ProvideURLValidator() httpclientProvider := httpclientprovider.New(cfg, ossDataSourceRequestURLValidator, tracingService) azuremonitorService := azuremonitor.ProvideService(httpclientProvider) cloudwatchService := cloudwatch.ProvideService() cloudmonitoringService := cloudmonitoring.ProvideService(httpclientProvider) elasticsearchService := elasticsearch.ProvideService(httpclientProvider) - tracer := otelTracer() graphiteService := graphite.ProvideService(httpclientProvider, tracer) influxdbService := influxdb.ProvideService(httpclientProvider) lokiService := loki.ProvideService(httpclientProvider, tracer) @@ -1216,7 +1216,7 @@ func InitializeForTest(ctx context.Context, t sqlutil.ITestDB, testingT interfac parcaService := parca.ProvideService(httpclientProvider) zipkinService := zipkin.ProvideService(httpclientProvider) jaegerService := jaeger.ProvideService(httpclientProvider) - corepluginRegistry := coreplugin.ProvideCoreRegistry(tracingService, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) + corepluginRegistry := coreplugin.ProvideCoreRegistry(tracer, azuremonitorService, cloudwatchService, cloudmonitoringService, elasticsearchService, graphiteService, influxdbService, lokiService, opentsdbService, prometheusService, tempoService, testdatasourceService, postgresService, mysqlService, mssqlService, grafanadsService, pyroscopeService, parcaService, zipkinService, jaegerService) providerService := provider2.ProvideService(corepluginRegistry) processService := process.ProvideService() retrieverService := retriever.ProvideService(sqlStore, apikeyService, kvStore, userService, orgService) From 76a6db818e6b036da6127fa88a8c43d333698b19 Mon Sep 17 00:00:00 2001 From: Mariell Hoversholm Date: Mon, 5 Jan 2026 11:07:23 +0100 Subject: [PATCH 76/80] Frontend: Remove bootstrap (#115813) --- public/vendor/bootstrap/bootstrap.js | 1512 -------------------------- 1 file changed, 1512 deletions(-) delete mode 100644 public/vendor/bootstrap/bootstrap.js diff --git a/public/vendor/bootstrap/bootstrap.js b/public/vendor/bootstrap/bootstrap.js deleted file mode 100644 index 8730550092a..00000000000 --- a/public/vendor/bootstrap/bootstrap.js +++ /dev/null @@ -1,1512 +0,0 @@ -/* =================================================== - * bootstrap-transition.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#transitions - * =================================================== - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function($) { - - "use strict"; // jshint ;_; - - - /* CSS TRANSITION SUPPORT (http://www.modernizr.com/) - * ======================================================= */ - - $(function() { - - $.support.transition = (function() { - - var transitionEnd = (function() { - - var el = document.createElement('bootstrap') - , transEndEventNames = { - 'WebkitTransition': 'webkitTransitionEnd' - , 'MozTransition': 'transitionend' - , 'OTransition': 'oTransitionEnd otransitionend' - , 'transition': 'transitionend' - } - , name - - for (name in transEndEventNames) { - if (el.style[name] !== undefined) { - return transEndEventNames[name] - } - } - - }()) - - return transitionEnd && { - end: transitionEnd - } - - })() - - }) - -}(window.jQuery);/* ========================================================== - * bootstrap-alert.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#alerts - * ========================================================== - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ========================================================== */ - - -!function($) { - - "use strict"; // jshint ;_; - - /* ============================================================ - * bootstrap-dropdown.js v2.3.2 - * http://getbootstrap.com/2.3.2/javascript.html#dropdowns - * ============================================================ - * Copyright 2013 Twitter, Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * ============================================================ */ - - - /* DROPDOWN CLASS DEFINITION - * ========================= */ - - var toggle = '[data-toggle=dropdown]' - , Dropdown = function(element) { - var $el = $(element).on('click.dropdown.data-api', this.toggle) - $('html').on('click.dropdown.data-api', function() { - $el.parent().removeClass('open') - }) - } - - Dropdown.prototype = { - - constructor: Dropdown - - , toggle: function(e) { - var $this = $(this) - , $parent - , isActive - - if ($this.is('.disabled, :disabled')) return - - $parent = getParent($this) - - isActive = $parent.hasClass('open') - - clearMenus() - - if (!isActive) { - if ('ontouchstart' in document.documentElement) { - // if mobile we we use a backdrop because click events don't delegate - $('