From 49c5c0ce41855458e90037a409477df03ac0e362 Mon Sep 17 00:00:00 2001 From: Isabel Matwawana <76437239+imatwawana@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:23:32 -0500 Subject: [PATCH 01/27] Docs: Clarify section title for repeating rows and tabs (#115170) Co-authored-by: grafakus --- .../build-dashboards/create-dynamic-dashboard/index.md | 3 ++- .../dashboard-scene/scene/layout-tabs/TabItemEditor.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md b/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md index a77e4ca988f..0167ff147e5 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/create-dynamic-dashboard/index.md @@ -245,11 +245,12 @@ To configure repeats, follow these steps: 1. Click **Save**. 1. Toggle off the edit mode switch. -### Repeating rows and the Dashboard special data source +### Repeating rows and tabs and the Dashboard special data source If a row includes panels using the special [Dashboard data source](ref:built-in-special-data-sources)—the data source that uses a result set from another panel in the same dashboard—then corresponding panels in repeated rows will reference the panel in the original row, not the ones in the repeated rows. +The same behavior applies to tabs. For example, in a dashboard: diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx index 745f612a8cc..ceb04d2fbe4 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItemEditor.tsx @@ -135,7 +135,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) { Learn more From b805d5cae0a896949817eea8d51a60212c02732d Mon Sep 17 00:00:00 2001 From: Kevin Minehart <5140827+kminehart@users.noreply.github.com> Date: Mon, 15 Dec 2025 14:30:38 +0100 Subject: [PATCH 02/27] Update PR Patch check to work on forks (#115308) * Update PR Patch check to work on forks --- .github/workflows/pr-patch-check-event.yml | 1 + .github/workflows/pr-patch-check.yml | 21 +++++++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/.github/workflows/pr-patch-check-event.yml b/.github/workflows/pr-patch-check-event.yml index 2b30e0fa375..86a4ad64917 100644 --- a/.github/workflows/pr-patch-check-event.yml +++ b/.github/workflows/pr-patch-check-event.yml @@ -12,6 +12,7 @@ on: permissions: id-token: write contents: read + statuses: write # Since this is run on a pull request, we want to apply the patches intended for the # target branch onto the source branch, to verify compatibility before merging. diff --git a/.github/workflows/pr-patch-check.yml b/.github/workflows/pr-patch-check.yml index 52f75a05ff2..8a1f70174d7 100644 --- a/.github/workflows/pr-patch-check.yml +++ b/.github/workflows/pr-patch-check.yml @@ -29,6 +29,10 @@ permissions: # target branch onto the source branch, to verify compatibility before merging. jobs: dispatch-job: + # If the source is not from a fork then dispatch the job to the workflow. + # This will fail on forks when trying to broker a token, so instead, forks will create the required status and mark + # it as a success + if: ${{ ! github.event.pull_request.head.repo.fork }} env: HEAD_REF: ${{ inputs.head_ref }} BASE_REF: ${{ github.base_ref }} @@ -76,3 +80,20 @@ jobs: triggering_github_handle: SENDER } }) + dispatch-job-fork: + # If the source is from a fork then use the built-in workflow token to create the same status and unconditionally + # mark it as a success. + if: ${{ github.event.pull_request.head.repo.fork }} + permissions: + statuses: write + runs-on: ubuntu-latest + steps: + - name: Create status + uses: myrotvorets/set-commit-status-action@6d6905c99cd24a4a2cbccc720b62dc6ca5587141 + with: + token: ${{ github.token }} + sha: ${{ inputs.pr_commit_sha }} + repo: ${{ inputs.repo }} + status: success + context: "Test Patches (event)" + description: "Test Patches (event) on a fork" From 7c6475262d10e8a5f2da35e5a1f76578a221cca5 Mon Sep 17 00:00:00 2001 From: Levente Balogh Date: Mon, 15 Dec 2025 14:41:58 +0100 Subject: [PATCH 03/27] Docs: Update docs for annotation controls placement (#115207) * docs: update docs for annotation controls placement * chore: prettier fix * chore: revert changes to annotations-schema.md * fix: review note --- .../build-dashboards/annotate-visualizations/index.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md b/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md index fcccfa6bd1b..f356d51c4a5 100644 --- a/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md +++ b/docs/sources/visualizations/dashboards/build-dashboards/annotate-visualizations/index.md @@ -149,7 +149,10 @@ To add a new annotation query to a dashboard, follow these steps: You can also click **Open advanced data source picker** to see more options, including adding a data source (Admins only). 1. If you don't want to use the annotation query right away, clear the **Enabled** checkbox. -1. If you don't want the annotation query toggle to be displayed in the dashboard, select the **Hidden** checkbox. +1. Select one of the following options in the **Show annotation controls in** drop-down list to control where annotations are displayed: + - **Above dashboard** - The annotation toggle is displayed above the dashboard. This is the default. + - **Controls menu** - The annotation toggle is displayed in the dashboard controls menu instead of above the dashboard. The dashboard controls menu appears as a button in the dashboard toolbar. + - **Hidden** - The annotation toggle is not displayed on the dashboard. 1. Select a color for the event markers. 1. In the **Show in** drop-down, choose one of the following options: - **All panels** - The annotations are displayed on all panels that support annotations. From 12dd3dffe079bac4e0dae1b84adbc186f486a48e Mon Sep 17 00:00:00 2001 From: Will Assis <35489495+gassiss@users.noreply.github.com> Date: Mon, 15 Dec 2025 08:56:15 -0500 Subject: [PATCH 04/27] unified-storage: sqlkv skeleton (#115176) * implement sqlkv skeleton and include sqlkv in badgerkv tests --- pkg/setting/setting.go | 1 + pkg/setting/setting_unified_storage.go | 3 + .../unified/resource/datastore_test.go | 184 +++++++++++++----- .../unified/resource/eventstore_test.go | 112 ++++++++--- pkg/storage/unified/resource/notifier_test.go | 86 ++++++-- pkg/storage/unified/resource/sqlkv.go | 70 +++++++ .../unified/resource/storage_backend.go | 21 +- pkg/storage/unified/sql/server.go | 47 +++-- pkg/storage/unified/testing/kv.go | 8 +- pkg/storage/unified/testing/kv_test.go | 34 ++++ .../unified/testing/storage_backend_test.go | 53 ++++- 11 files changed, 511 insertions(+), 108 deletions(-) create mode 100644 pkg/storage/unified/resource/sqlkv.go diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go index a01549d9da5..8e309433032 100644 --- a/pkg/setting/setting.go +++ b/pkg/setting/setting.go @@ -618,6 +618,7 @@ type Cfg struct { EnableSearch bool OverridesFilePath string OverridesReloadInterval time.Duration + EnableSQLKVBackend bool // Secrets Management SecretsManagement SecretsManagerSettings diff --git a/pkg/setting/setting_unified_storage.go b/pkg/setting/setting_unified_storage.go index 4f69daa64fd..72e01ce6ce9 100644 --- a/pkg/setting/setting_unified_storage.go +++ b/pkg/setting/setting_unified_storage.go @@ -100,6 +100,9 @@ func (cfg *Cfg) setUnifiedStorageConfig() { cfg.OverridesFilePath = section.Key("overrides_path").String() cfg.OverridesReloadInterval = section.Key("overrides_reload_period").MustDuration(30 * time.Second) + // use sqlkv (resource/sqlkv) instead of the sql backend (sql/backend) as the StorageServer + cfg.EnableSQLKVBackend = section.Key("enable_sqlkv_backend").MustBool(false) + cfg.MaxFileIndexAge = section.Key("max_file_index_age").MustDuration(0) cfg.MinFileIndexBuildVersion = section.Key("min_file_index_build_version").MustString("") } diff --git a/pkg/storage/unified/resource/datastore_test.go b/pkg/storage/unified/resource/datastore_test.go index 8f167c2e16e..02c318fe6d9 100644 --- a/pkg/storage/unified/resource/datastore_test.go +++ b/pkg/storage/unified/resource/datastore_test.go @@ -9,6 +9,9 @@ import ( "testing" "github.com/bwmarrin/snowflake" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/stretchr/testify/require" ) @@ -24,6 +27,16 @@ func TestNewDataStore(t *testing.T) { require.NotNil(t, ds) } +// nolint:unused +func setupTestDataStoreSqlKv(t *testing.T) *dataStore { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := NewSQLKV(eDB) + require.NoError(t, err) + return newDataStore(kv) +} + func TestDataKey_String(t *testing.T) { rv := int64(1934555792099250176) tests := []struct { @@ -679,10 +692,21 @@ func TestParseKey(t *testing.T) { } } -func TestDataStore_Save_And_Get(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() +func runDataStoreTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) *dataStore, testFn func(*testing.T, context.Context, *dataStore)) { + t.Run(storeName, func(t *testing.T) { + ctx := context.Background() + store := newStoreFn(t) + testFn(t, ctx, store) + }) +} +func TestDataStore_Save_And_Get(t *testing.T) { + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreSaveAndGet) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreSaveAndGet) +} + +func testDataStoreSaveAndGet(t *testing.T, ctx context.Context, ds *dataStore) { rv := node.Generate() testKey := DataKey{ @@ -744,9 +768,12 @@ func TestDataStore_Save_And_Get(t *testing.T) { } func TestDataStore_Delete(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreDelete) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreDelete) +} +func testDataStoreDelete(t *testing.T, ctx context.Context, ds *dataStore) { rv := node.Generate() testKey := DataKey{ @@ -795,9 +822,12 @@ func TestDataStore_Delete(t *testing.T) { } func TestDataStore_List(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreList) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreList) +} +func testDataStoreList(t *testing.T, ctx context.Context, ds *dataStore) { resourceKey := ListRequestKey{ Namespace: "test-namespace", Group: "test-group", @@ -919,9 +949,12 @@ func TestDataStore_List(t *testing.T) { } func TestDataStore_Integration(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreIntegration) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreIntegration) +} +func testDataStoreIntegration(t *testing.T, ctx context.Context, ds *dataStore) { t.Run("full lifecycle test", func(t *testing.T) { resourceKey := ListRequestKey{ Namespace: "integration-ns", @@ -1007,9 +1040,12 @@ func TestDataStore_Integration(t *testing.T) { } func TestDataStore_Keys(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreKeys) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreKeys) +} +func testDataStoreKeys(t *testing.T, ctx context.Context, ds *dataStore) { resourceKey := ListRequestKey{ Namespace: "test-namespace", Group: "test-group", @@ -1154,9 +1190,12 @@ func TestDataStore_Keys(t *testing.T) { } func TestDataStore_ValidationEnforced(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreValidationEnforced) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreValidationEnforced) +} +func testDataStoreValidationEnforced(t *testing.T, ctx context.Context, ds *dataStore) { // Create an invalid key invalidKey := DataKey{ Namespace: "Invalid-Namespace-$$$", @@ -1483,9 +1522,12 @@ func TestListRequestKey_Prefix(t *testing.T) { } func TestDataStore_LastResourceVersion(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreLastResourceVersion) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreLastResourceVersion) +} +func testDataStoreLastResourceVersion(t *testing.T, ctx context.Context, ds *dataStore) { t.Run("returns last resource version for existing data", func(t *testing.T) { resourceKey := ListRequestKey{ Namespace: "test-namespace", @@ -1585,9 +1627,12 @@ func TestDataStore_LastResourceVersion(t *testing.T) { } func TestDataStore_GetLatestResourceKey(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKey) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKey) +} +func testDataStoreGetLatestResourceKey(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1648,9 +1693,12 @@ func TestDataStore_GetLatestResourceKey(t *testing.T) { } func TestDataStore_GetLatestResourceKey_Deleted(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKeyDeleted) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKeyDeleted) +} +func testDataStoreGetLatestResourceKeyDeleted(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1676,9 +1724,12 @@ func TestDataStore_GetLatestResourceKey_Deleted(t *testing.T) { } func TestDataStore_GetLatestResourceKey_NotFound(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestResourceKeyNotFound) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestResourceKeyNotFound) +} +func testDataStoreGetLatestResourceKeyNotFound(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1691,9 +1742,12 @@ func TestDataStore_GetLatestResourceKey_NotFound(t *testing.T) { } func TestDataStore_GetResourceKeyAtRevision(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetResourceKeyAtRevision) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetResourceKeyAtRevision) +} +func testDataStoreGetResourceKeyAtRevision(t *testing.T, ctx context.Context, ds *dataStore) { key := GetRequestKey{ Group: "apps", Resource: "resources", @@ -1766,9 +1820,12 @@ func TestDataStore_GetResourceKeyAtRevision(t *testing.T) { } func TestDataStore_ListLatestResourceKeys(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeys) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeys) +} +func testDataStoreListLatestResourceKeys(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -1819,9 +1876,12 @@ func TestDataStore_ListLatestResourceKeys(t *testing.T) { } func TestDataStore_ListLatestResourceKeys_Deleted(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeysDeleted) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeysDeleted) +} +func testDataStoreListLatestResourceKeysDeleted(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -1869,9 +1929,12 @@ func TestDataStore_ListLatestResourceKeys_Deleted(t *testing.T) { } func TestDataStore_ListLatestResourceKeys_Multiple(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListLatestResourceKeysMultiple) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListLatestResourceKeysMultiple) +} +func testDataStoreListLatestResourceKeysMultiple(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -1940,9 +2003,12 @@ func TestDataStore_ListLatestResourceKeys_Multiple(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevision) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevision) +} +func testDataStoreListResourceKeysAtRevision(t *testing.T, ctx context.Context, ds *dataStore) { // Create multiple resources with different versions rv1 := node.Generate().Int64() rv2 := node.Generate().Int64() @@ -2152,9 +2218,12 @@ func TestDataStore_ListResourceKeysAtRevision(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision_ValidationErrors(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionValidationErrors) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionValidationErrors) +} +func testDataStoreListResourceKeysAtRevisionValidationErrors(t *testing.T, ctx context.Context, ds *dataStore) { tests := []struct { name string key ListRequestKey @@ -2194,9 +2263,12 @@ func TestDataStore_ListResourceKeysAtRevision_ValidationErrors(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision_EmptyResults(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionEmptyResults) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionEmptyResults) +} +func testDataStoreListResourceKeysAtRevisionEmptyResults(t *testing.T, ctx context.Context, ds *dataStore) { listKey := ListRequestKey{ Group: "apps", Resource: "resources", @@ -2213,9 +2285,12 @@ func TestDataStore_ListResourceKeysAtRevision_EmptyResults(t *testing.T) { } func TestDataStore_ListResourceKeysAtRevision_ResourcesNewerThanRevision(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision) +} +func testDataStoreListResourceKeysAtRevisionResourcesNewerThanRevision(t *testing.T, ctx context.Context, ds *dataStore) { // Create a resource with a high resource version rv := node.Generate().Int64() key := DataKey{ @@ -2681,9 +2756,12 @@ func TestGetRequestKey_Prefix(t *testing.T) { } func TestDataStore_GetResourceStats_Comprehensive(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetResourceStatsComprehensive) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetResourceStatsComprehensive) +} +func testDataStoreGetResourceStatsComprehensive(t *testing.T, ctx context.Context, ds *dataStore) { // Test setup: 3 namespaces × 3 groups × 3 resources × 3 names × 3 versions = 243 total entries // But each name will have only 1 latest version that counts, so 3 × 3 × 3 × 3 = 81 non-deleted resources namespaces := []string{"ns1", "ns2", "ns3"} @@ -2888,9 +2966,12 @@ func TestDataStore_GetResourceStats_Comprehensive(t *testing.T) { } func TestDataStore_getGroupResources(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetGroupResources) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetGroupResources) +} +func testDataStoreGetGroupResources(t *testing.T, ctx context.Context, ds *dataStore) { // Create test data with multiple group/resource combinations testData := []struct { group string @@ -2951,9 +3032,12 @@ func TestDataStore_getGroupResources(t *testing.T) { } func TestDataStore_BatchDelete(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreBatchDelete) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreBatchDelete) +} +func testDataStoreBatchDelete(t *testing.T, ctx context.Context, ds *dataStore) { keys := make([]DataKey, 95) for i := 0; i < 95; i++ { rv := node.Generate().Int64() @@ -2987,9 +3071,12 @@ func TestDataStore_BatchDelete(t *testing.T) { } func TestDataStore_BatchGet(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreBatchGet) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreBatchGet) +} +func testDataStoreBatchGet(t *testing.T, ctx context.Context, ds *dataStore) { t.Run("batch get multiple existing keys", func(t *testing.T) { // Create test data keys := make([]DataKey, 5) @@ -3132,9 +3219,12 @@ func TestDataStore_BatchGet(t *testing.T) { } func TestDataStore_GetLatestAndPredecessor(t *testing.T) { - ds := setupTestDataStore(t) - ctx := context.Background() + runDataStoreTestWith(t, "badger", setupTestDataStore, testDataStoreGetLatestAndPredecessor) + // enable this when sqlkv is ready + // runDataStoreTestWith(t, "sqlkv", setupTestDataStoreSqlKv, testDataStoreGetLatestAndPredecessor) +} +func testDataStoreGetLatestAndPredecessor(t *testing.T, ctx context.Context, ds *dataStore) { resourceKey := ListRequestKey{ Namespace: "test-namespace", Group: "test-group", diff --git a/pkg/storage/unified/resource/eventstore_test.go b/pkg/storage/unified/resource/eventstore_test.go index a9d2ee93eb4..270db1ddd3f 100644 --- a/pkg/storage/unified/resource/eventstore_test.go +++ b/pkg/storage/unified/resource/eventstore_test.go @@ -7,6 +7,10 @@ import ( "time" "github.com/bwmarrin/snowflake" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" + "github.com/grafana/grafana/pkg/tests/testsuite" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -21,6 +25,20 @@ func setupTestEventStore(t *testing.T) *eventStore { return newEventStore(kv) } +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +// nolint:unused +func setupTestEventStoreSqlKv(t *testing.T) *eventStore { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := NewSQLKV(eDB) + require.NoError(t, err) + return newEventStore(kv) +} + func TestNewEventStore(t *testing.T) { store := setupTestEventStore(t) assert.NotNil(t, store.kv) @@ -180,10 +198,21 @@ func TestEventStore_ParseEventKey(t *testing.T) { assert.Equal(t, originalKey, parsedKey) } -func TestEventStore_Save_Get(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) +func runEventStoreTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) *eventStore, testFn func(*testing.T, context.Context, *eventStore)) { + t.Run(storeName, func(t *testing.T) { + ctx := context.Background() + store := newStoreFn(t) + testFn(t, ctx, store) + }) +} +func TestEventStore_Save_Get(t *testing.T) { + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreSaveGet) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreSaveGet) +} + +func testEventStoreSaveGet(t *testing.T, ctx context.Context, store *eventStore) { event := Event{ Namespace: "default", Group: "apps", @@ -216,9 +245,12 @@ func TestEventStore_Save_Get(t *testing.T) { } func TestEventStore_Get_NotFound(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreGetNotFound) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreGetNotFound) +} +func testEventStoreGetNotFound(t *testing.T, ctx context.Context, store *eventStore) { nonExistentKey := EventKey{ Namespace: "default", Group: "apps", @@ -233,9 +265,12 @@ func TestEventStore_Get_NotFound(t *testing.T) { } func TestEventStore_LastEventKey(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreLastEventKey) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreLastEventKey) +} +func testEventStoreLastEventKey(t *testing.T, ctx context.Context, store *eventStore) { // Test when no events exist _, err := store.LastEventKey(ctx) assert.Error(t, err) @@ -292,9 +327,12 @@ func TestEventStore_LastEventKey(t *testing.T) { } func TestEventStore_ListKeysSince(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListKeysSince) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListKeysSince) +} +func testEventStoreListKeysSince(t *testing.T, ctx context.Context, store *eventStore) { // Add events with different resource versions events := []Event{ { @@ -349,9 +387,12 @@ func TestEventStore_ListKeysSince(t *testing.T) { } func TestEventStore_ListSince(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListSince) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListSince) +} +func testEventStoreListSince(t *testing.T, ctx context.Context, store *eventStore) { // Add events with different resource versions events := []Event{ { @@ -404,9 +445,12 @@ func TestEventStore_ListSince(t *testing.T) { } func TestEventStore_ListSince_Empty(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreListSinceEmpty) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreListSinceEmpty) +} +func testEventStoreListSinceEmpty(t *testing.T, ctx context.Context, store *eventStore) { // List events when store is empty retrievedEvents := make([]Event, 0) for event, err := range store.ListSince(ctx, 0) { @@ -459,9 +503,12 @@ func TestEventKey_Struct(t *testing.T) { } func TestEventStore_Save_InvalidJSON(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreSaveInvalidJSON) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreSaveInvalidJSON) +} +func testEventStoreSaveInvalidJSON(t *testing.T, ctx context.Context, store *eventStore) { // This should work fine as the Event struct should be serializable event := Event{ Namespace: "default", @@ -477,9 +524,12 @@ func TestEventStore_Save_InvalidJSON(t *testing.T) { } func TestEventStore_CleanupOldEvents(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEvents) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEvents) +} +func testEventStoreCleanupOldEvents(t *testing.T, ctx context.Context, store *eventStore) { now := time.Now() oldRV := snowflakeFromTime(now.Add(-48 * time.Hour)) // 48 hours ago recentRV := snowflakeFromTime(now.Add(-1 * time.Hour)) // 1 hour ago @@ -565,9 +615,12 @@ func TestEventStore_CleanupOldEvents(t *testing.T) { } func TestEventStore_CleanupOldEvents_NoOldEvents(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEventsNoOldEvents) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEventsNoOldEvents) +} +func testEventStoreCleanupOldEventsNoOldEvents(t *testing.T, ctx context.Context, store *eventStore) { // Create an event 1 hour old rv := snowflakeFromTime(time.Now().Add(-1 * time.Hour)) event := Event{ @@ -603,9 +656,12 @@ func TestEventStore_CleanupOldEvents_NoOldEvents(t *testing.T) { } func TestEventStore_CleanupOldEvents_EmptyStore(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreCleanupOldEventsEmptyStore) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreCleanupOldEventsEmptyStore) +} +func testEventStoreCleanupOldEventsEmptyStore(t *testing.T, ctx context.Context, store *eventStore) { // Clean up events from empty store deletedCount, err := store.CleanupOldEvents(ctx, time.Now().Add(-24*time.Hour)) require.NoError(t, err) @@ -613,9 +669,12 @@ func TestEventStore_CleanupOldEvents_EmptyStore(t *testing.T) { } func TestEventStore_BatchDelete(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testEventStoreBatchDelete) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testEventStoreBatchDelete) +} +func testEventStoreBatchDelete(t *testing.T, ctx context.Context, store *eventStore) { // Create multiple events (more than batch size to test batching) eventKeys := make([]string, 75) for i := 0; i < 75; i++ { @@ -722,9 +781,12 @@ func TestSnowflakeFromTime(t *testing.T) { } func TestListKeysSince_WithSnowflakeTime(t *testing.T) { - ctx := context.Background() - store := setupTestEventStore(t) + runEventStoreTestWith(t, "badger", setupTestEventStore, testListKeysSinceWithSnowflakeTime) + // enable this when sqlkv is ready + // runEventStoreTestWith(t, "sqlkv", setupTestEventStoreSqlKv, testListKeysSinceWithSnowflakeTime) +} +func testListKeysSinceWithSnowflakeTime(t *testing.T, ctx context.Context, store *eventStore) { // Create events with snowflake-based resource versions at different times now := time.Now() events := []Event{ diff --git a/pkg/storage/unified/resource/notifier_test.go b/pkg/storage/unified/resource/notifier_test.go index 7b201f47420..060f8eecfbe 100644 --- a/pkg/storage/unified/resource/notifier_test.go +++ b/pkg/storage/unified/resource/notifier_test.go @@ -6,6 +6,9 @@ import ( "time" "github.com/grafana/grafana-app-sdk/logging" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -22,6 +25,18 @@ func setupTestNotifier(t *testing.T) (*notifier, *eventStore) { return notifier, eventStore } +// nolint:unused +func setupTestNotifierSqlKv(t *testing.T) (*notifier, *eventStore) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := NewSQLKV(eDB) + require.NoError(t, err) + eventStore := newEventStore(kv) + notifier := newNotifier(eventStore, notifierOptions{log: &logging.NoOpLogger{}}) + return notifier, eventStore +} + func TestNewNotifier(t *testing.T) { notifier, _ := setupTestNotifier(t) @@ -35,10 +50,21 @@ func TestDefaultWatchOptions(t *testing.T) { assert.Equal(t, defaultBufferSize, opts.BufferSize) } -func TestNotifier_lastEventResourceVersion(t *testing.T) { - ctx := context.Background() - notifier, eventStore := setupTestNotifier(t) +func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testing.T) (*notifier, *eventStore), testFn func(*testing.T, context.Context, *notifier, *eventStore)) { + t.Run(storeName, func(t *testing.T) { + ctx := context.Background() + notifier, eventStore := newStoreFn(t) + testFn(t, ctx, notifier, eventStore) + }) +} +func TestNotifier_lastEventResourceVersion(t *testing.T) { + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion) +} + +func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { // Test with no events rv, err := notifier.lastEventResourceVersion(ctx) assert.Error(t, err) @@ -85,8 +111,12 @@ func TestNotifier_lastEventResourceVersion(t *testing.T) { } func TestNotifier_cachekey(t *testing.T) { - notifier, _ := setupTestNotifier(t) + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey) +} +func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { tests := []struct { name string event Event @@ -136,10 +166,14 @@ func TestNotifier_cachekey(t *testing.T) { } func TestNotifier_Watch_NoEvents(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond) + defer cancel() // Add at least one event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ @@ -174,10 +208,14 @@ func TestNotifier_Watch_NoEvents(t *testing.T) { } func TestNotifier_Watch_WithExistingEvents(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() // Save some initial events initialEvents := []Event{ @@ -245,10 +283,14 @@ func TestNotifier_Watch_WithExistingEvents(t *testing.T) { } func TestNotifier_Watch_EventDeduplication(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ @@ -308,9 +350,13 @@ func TestNotifier_Watch_EventDeduplication(t *testing.T) { } func TestNotifier_Watch_ContextCancellation(t *testing.T) { - ctx, cancel := context.WithCancel(context.Background()) + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithCancel(ctx) // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ @@ -351,10 +397,14 @@ func TestNotifier_Watch_ContextCancellation(t *testing.T) { } func TestNotifier_Watch_MultipleEvents(t *testing.T) { - ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) - defer cancel() + runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents) + // enable this when sqlkv is ready + // runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents) +} - notifier, eventStore := setupTestNotifier(t) +func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() rv := time.Now().UnixNano() // Add an initial event so that lastEventResourceVersion doesn't return ErrNotFound initialEvent := Event{ diff --git a/pkg/storage/unified/resource/sqlkv.go b/pkg/storage/unified/resource/sqlkv.go new file mode 100644 index 00000000000..f6e8d0698f3 --- /dev/null +++ b/pkg/storage/unified/resource/sqlkv.go @@ -0,0 +1,70 @@ +package resource + +import ( + "context" + "fmt" + "io" + "iter" + + "github.com/grafana/grafana/pkg/storage/unified/sql/db" +) + +var _ KV = &sqlKV{} + +type sqlKV struct { + dbProvider db.DBProvider + db db.DB +} + +func NewSQLKV(dbProvider db.DBProvider) (KV, error) { + if dbProvider == nil { + return nil, fmt.Errorf("dbProvider is required") + } + + ctx := context.Background() + dbConn, err := dbProvider.Init(ctx) + if err != nil { + return nil, fmt.Errorf("error initializing DB: %w", err) + } + + return &sqlKV{ + dbProvider: dbProvider, + db: dbConn, + }, nil +} + +func (k *sqlKV) Ping(ctx context.Context) error { + return k.db.PingContext(ctx) +} + +func (k *sqlKV) Keys(ctx context.Context, section string, opt ListOptions) iter.Seq2[string, error] { + return func(yield func(string, error) bool) { + panic("not implemented!") + } +} + +func (k *sqlKV) Get(ctx context.Context, section string, key string) (io.ReadCloser, error) { + panic("not implemented!") +} + +func (k *sqlKV) BatchGet(ctx context.Context, section string, keys []string) iter.Seq2[KeyValue, error] { + return func(yield func(KeyValue, error) bool) { + panic("not implemented!") + } +} + +func (k *sqlKV) Save(ctx context.Context, section string, key string) (io.WriteCloser, error) { + panic("not implemented!") +} + +func (k *sqlKV) Delete(ctx context.Context, section string, key string) error { + panic("not implemented!") +} + +func (k *sqlKV) BatchDelete(ctx context.Context, section string, keys []string) error { + panic("not implemented!") +} + +func (k *sqlKV) UnixTimestamp(ctx context.Context) (int64, error) { + panic("not implemented!") +} diff --git a/pkg/storage/unified/resource/storage_backend.go b/pkg/storage/unified/resource/storage_backend.go index 0de97b0355e..b0f51702775 100644 --- a/pkg/storage/unified/resource/storage_backend.go +++ b/pkg/storage/unified/resource/storage_backend.go @@ -70,7 +70,12 @@ type kvStorageBackend struct { //reg prometheus.Registerer } -var _ StorageBackend = &kvStorageBackend{} +var _ KVBackend = &kvStorageBackend{} + +type KVBackend interface { + StorageBackend + resourcepb.DiagnosticsServer +} type KVBackendOptions struct { KvStore KV @@ -82,7 +87,7 @@ type KVBackendOptions struct { Reg prometheus.Registerer // TODO add metrics } -func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) { +func NewKVStorageBackend(opts KVBackendOptions) (KVBackend, error) { ctx := context.Background() kv := opts.KvStore @@ -126,6 +131,18 @@ func NewKVStorageBackend(opts KVBackendOptions) (StorageBackend, error) { return backend, nil } +func (k *kvStorageBackend) IsHealthy(ctx context.Context, _ *resourcepb.HealthCheckRequest) (*resourcepb.HealthCheckResponse, error) { + type pinger interface { + Ping(context.Context) error + } + if p, ok := k.kv.(pinger); ok { + if err := p.Ping(ctx); err != nil { + return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_NOT_SERVING}, fmt.Errorf("KV store health check failed: %w", err) + } + } + return &resourcepb.HealthCheckResponse{Status: resourcepb.HealthCheckResponse_SERVING}, nil +} + // runCleanupOldEvents starts a background goroutine that periodically cleans up old events func (k *kvStorageBackend) runCleanupOldEvents(ctx context.Context) { // Run cleanup every hour diff --git a/pkg/storage/unified/sql/server.go b/pkg/storage/unified/sql/server.go index 6723a58dd29..84eda71ca20 100644 --- a/pkg/storage/unified/sql/server.go +++ b/pkg/storage/unified/sql/server.go @@ -97,22 +97,41 @@ func NewResourceServer(opts ServerOptions) (resource.ResourceServer, error) { return nil, err } - isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), - opts.Cfg.SectionWithEnvOverrides("resource_api")) + if opts.Cfg.EnableSQLKVBackend { + sqlkv, err := resource.NewSQLKV(eDB) + if err != nil { + return nil, fmt.Errorf("error creating sqlkv: %s", err) + } - backend, err := NewBackend(BackendOptions{ - DBProvider: eDB, - Reg: opts.Reg, - IsHA: isHA, - storageMetrics: opts.StorageMetrics, - LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age. - }) - if err != nil { - return nil, err + kvBackend, err := resource.NewKVStorageBackend(resource.KVBackendOptions{ + KvStore: sqlkv, + Tracer: opts.Tracer, + Reg: opts.Reg, + }) + if err != nil { + return nil, fmt.Errorf("error creating kv backend: %s", err) + } + + serverOptions.Backend = kvBackend + serverOptions.Diagnostics = kvBackend + } else { + isHA := isHighAvailabilityEnabled(opts.Cfg.SectionWithEnvOverrides("database"), + opts.Cfg.SectionWithEnvOverrides("resource_api")) + + backend, err := NewBackend(BackendOptions{ + DBProvider: eDB, + Reg: opts.Reg, + IsHA: isHA, + storageMetrics: opts.StorageMetrics, + LastImportTimeMaxAge: opts.SearchOptions.MaxIndexAge, // No need to keep last_import_times older than max index age. + }) + if err != nil { + return nil, err + } + serverOptions.Backend = backend + serverOptions.Diagnostics = backend + serverOptions.Lifecycle = backend } - serverOptions.Backend = backend - serverOptions.Diagnostics = backend - serverOptions.Lifecycle = backend } serverOptions.Search = opts.SearchOptions diff --git a/pkg/storage/unified/testing/kv.go b/pkg/storage/unified/testing/kv.go index eab9aa9c845..f30f1d761d7 100644 --- a/pkg/storage/unified/testing/kv.go +++ b/pkg/storage/unified/testing/kv.go @@ -35,7 +35,8 @@ type NewKVFunc func(ctx context.Context) resource.KV // KVTestOptions configures which tests to run type KVTestOptions struct { - NSPrefix string // namespace prefix for isolation + SkipTests map[string]bool + NSPrefix string // namespace prefix for isolation } // GenerateRandomKVPrefix creates a random namespace prefix for test isolation @@ -72,6 +73,11 @@ func RunKVTest(t *testing.T, newKV NewKVFunc, opts *KVTestOptions) { } for _, tc := range cases { + if shouldSkip := opts.SkipTests[tc.name]; shouldSkip { + t.Logf("Skipping test: %s", tc.name) + continue + } + t.Run(tc.name, func(t *testing.T) { tc.fn(t, newKV(context.Background()), opts.NSPrefix) }) diff --git a/pkg/storage/unified/testing/kv_test.go b/pkg/storage/unified/testing/kv_test.go index 1e9b1a16c45..4dbd27d5ec9 100644 --- a/pkg/storage/unified/testing/kv_test.go +++ b/pkg/storage/unified/testing/kv_test.go @@ -7,7 +7,11 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" + "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/tests/testsuite" ) func TestBadgerKV(t *testing.T) { @@ -26,3 +30,33 @@ func TestBadgerKV(t *testing.T) { NSPrefix: "badger-kv-test", }) } + +func TestMain(m *testing.M) { + testsuite.Run(m) +} + +func TestSQLKV(t *testing.T) { + RunKVTest(t, func(ctx context.Context) resource.KV { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + return kv + }, &KVTestOptions{ + NSPrefix: "sql-kv-test", + SkipTests: map[string]bool{ + TestKVGet: true, + TestKVSave: true, + TestKVDelete: true, + TestKVKeys: true, + TestKVKeysWithLimits: true, + TestKVKeysWithSort: true, + TestKVConcurrent: true, + TestKVUnixTimestamp: true, + TestKVBatchGet: true, + TestKVBatchDelete: true, + }, + }) +} diff --git a/pkg/storage/unified/testing/storage_backend_test.go b/pkg/storage/unified/testing/storage_backend_test.go index 04f34e9102f..70e3b15aa7b 100644 --- a/pkg/storage/unified/testing/storage_backend_test.go +++ b/pkg/storage/unified/testing/storage_backend_test.go @@ -7,7 +7,11 @@ import ( badger "github.com/dgraph-io/badger/v4" "github.com/stretchr/testify/require" + "github.com/grafana/grafana/pkg/infra/db" + "github.com/grafana/grafana/pkg/setting" "github.com/grafana/grafana/pkg/storage/unified/resource" + sqldb "github.com/grafana/grafana/pkg/storage/unified/sql/db" + "github.com/grafana/grafana/pkg/storage/unified/sql/db/dbimpl" ) func TestBadgerKVStorageBackend(t *testing.T) { @@ -25,7 +29,7 @@ func TestBadgerKVStorageBackend(t *testing.T) { require.NoError(t, err) return backend }, &TestOptions{ - NSPrefix: "kvstorage-test", + NSPrefix: "badgerkvstorage-test", SkipTests: map[string]bool{ // TODO: fix these tests and remove this skip TestBlobSupport: true, @@ -35,3 +39,50 @@ func TestBadgerKVStorageBackend(t *testing.T) { }, }) } + +func TestSQLKVStorageBackend(t *testing.T) { + newBackendFunc := func(ctx context.Context) (resource.StorageBackend, sqldb.DB) { + dbstore := db.InitTestDB(t) + eDB, err := dbimpl.ProvideResourceDB(dbstore, setting.NewCfg(), nil) + require.NoError(t, err) + kv, err := resource.NewSQLKV(eDB) + require.NoError(t, err) + kvOpts := resource.KVBackendOptions{ + KvStore: kv, + } + backend, err := resource.NewKVStorageBackend(kvOpts) + require.NoError(t, err) + db, err := eDB.Init(ctx) + require.NoError(t, err) + return backend, db + } + + RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend { + backend, _ := newBackendFunc(ctx) + return backend + }, &TestOptions{ + NSPrefix: "sqlkvstorage-test", + SkipTests: map[string]bool{ + TestHappyPath: true, + TestWatchWriteEvents: true, + TestList: true, + TestBlobSupport: true, + TestGetResourceStats: true, + TestListHistory: true, + TestListHistoryErrorReporting: true, + TestListModifiedSince: true, + TestListTrash: true, + TestCreateNewResource: true, + TestGetResourceLastImportTime: true, + TestOptimisticLocking: true, + TestKeyPathGeneration: true, + }, + }) + + RunSQLStorageBackendCompatibilityTest(t, newBackendFunc, &TestOptions{ + NSPrefix: "sqlkvstorage-compatibility-test", + SkipTests: map[string]bool{ + TestKeyPathGeneration: true, + }, + }) +} From 9ceff992aa4704cffb0e621b25704fbf4830b9b4 Mon Sep 17 00:00:00 2001 From: Marcus Andersson Date: Mon, 15 Dec 2025 15:15:08 +0100 Subject: [PATCH 05/27] Sandbox: Exclude transferable objects from near membrane proxy unboxing (#115016) * Fixing so geomap works with sandbox * Will not try to unbox transferable instances. --- public/app/features/plugins/sandbox/utils.ts | 25 ++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/public/app/features/plugins/sandbox/utils.ts b/public/app/features/plugins/sandbox/utils.ts index 9a411a32a3e..9a9edbc994d 100644 --- a/public/app/features/plugins/sandbox/utils.ts +++ b/public/app/features/plugins/sandbox/utils.ts @@ -78,11 +78,36 @@ export function unboxNearMembraneProxies(structure: unknown): unknown { if (Array.isArray(structure)) { return structure.map(unboxNearMembraneProxies); } + + if (isTransferable(structure)) { + return structure; + } + if (typeof structure === 'object') { return Object.keys(structure).reduce((acc, key) => { Reflect.set(acc, key, unboxNearMembraneProxies(Reflect.get(structure, key))); return acc; }, {}); } + return structure; } + +function isTransferable(structure: unknown): structure is Transferable { + // We should probably add all of the transferable types here. + // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects + return ( + structure instanceof ArrayBuffer || + structure instanceof OffscreenCanvas || + structure instanceof ImageBitmap || + structure instanceof MessagePort || + structure instanceof MediaSourceHandle || + structure instanceof ReadableStream || + structure instanceof WritableStream || + structure instanceof TransformStream || + structure instanceof AudioData || + structure instanceof VideoFrame || + structure instanceof RTCDataChannel || + structure instanceof ArrayBuffer + ); +} From 35affc57c2776f66c8bdf3cd86526212d87d057e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Mon, 15 Dec 2025 15:19:55 +0100 Subject: [PATCH 06/27] Provisioning: Deprecate folder move and delete on configured branch (#115329) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Provisioning: Deprecate single file/folder move and delete on configured branch Reject individual file and folder move/delete operations on the configured branch via the single files endpoints (HTTP 405 MethodNotAllowed). Users must use the bulk operations API (jobs API) instead. Motivation: - Reconciliation for these operations is not reliable as it must be recursive and cannot run synchronously since it could take a long time - Simplifies authorization logic - fewer operations to secure and validate - Reduces complexity and surface area for potential bugs - Bulk operations via jobs API provide better control and observability Operations on non-configured branches (e.g., creating PRs) continue to work as before since they don't update the Grafana database. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 * fix: remove trailing whitespace in test file * Fix behaviour to match current behavior * Revert changes for individual files --------- Co-authored-by: Claude Sonnet 4.5 --- .../apis/provisioning/resources/dualwriter.go | 152 +++------- pkg/tests/apis/provisioning/files_test.go | 262 +++++++----------- .../apis/provisioning/repository_test.go | 2 +- 3 files changed, 137 insertions(+), 279 deletions(-) diff --git a/pkg/registry/apis/provisioning/resources/dualwriter.go b/pkg/registry/apis/provisioning/resources/dualwriter.go index 62f4ffd3b98..8ffcee696e8 100644 --- a/pkg/registry/apis/provisioning/resources/dualwriter.go +++ b/pkg/registry/apis/provisioning/resources/dualwriter.go @@ -3,6 +3,7 @@ package resources import ( "context" "fmt" + "net/http" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" @@ -315,7 +316,19 @@ func (r *DualReadWriter) MoveResource(ctx context.Context, opts DualWriteOptions } func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) { - // For directory moves, we just perform the repository move without parsing + // Reject directory move operations for configured branch - use bulk operations instead + if r.isConfiguredBranch(opts) { + return nil, &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusMethodNotAllowed, + Reason: metav1.StatusReasonMethodNotAllowed, + Message: "directory move operations are not available for configured branch. Use bulk move operations via the jobs API instead", + }, + } + } + + // For branch operations, we just perform the repository move without updating Grafana DB // Always use the provisioning identity when writing ctx, _, err := identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace) if err != nil { @@ -349,35 +362,6 @@ func (r *DualReadWriter) moveDirectory(ctx context.Context, opts DualWriteOption }, } - // Handle folder management for main branch - if r.shouldUpdateGrafanaDB(opts, nil) { - // Ensure destination folder path exists - if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil { - return nil, fmt.Errorf("ensure destination folder path exists: %w", err) - } - - // Try to delete the old folder structure from grafana (if it exists) - // This handles cleanup when folders are moved to new locations - oldFolderName, err := r.folders.EnsureFolderPathExist(ctx, opts.OriginalPath) - if err != nil { - return nil, fmt.Errorf("ensure original folder path exists: %w", err) - } - - if oldFolderName != "" { - oldFolder, err := r.folders.GetFolder(ctx, oldFolderName) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("get old folder for cleanup: %w", err) - } - - if err == nil { - err = r.folders.Client().Delete(ctx, oldFolder.GetName(), metav1.DeleteOptions{}) - if err != nil && !apierrors.IsNotFound(err) { - return nil, fmt.Errorf("delete old folder from storage: %w", err) - } - } - } - } - return parsed, nil } @@ -551,41 +535,22 @@ func (r *DualReadWriter) authorizeCreateFolder(ctx context.Context, _ string) er } func (r *DualReadWriter) deleteFolder(ctx context.Context, opts DualWriteOptions) (*ParsedResource, error) { - // if the ref is set, it is not the active branch, so just delete the files from the branch - // and do not delete the items from grafana itself - if !r.shouldUpdateGrafanaDB(opts, nil) { - err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message) - if err != nil { - return nil, fmt.Errorf("error deleting folder from repository: %w", err) + // Reject directory delete operations for configured branch - use bulk operations instead + if r.isConfiguredBranch(opts) { + return nil, &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusMethodNotAllowed, + Reason: metav1.StatusReasonMethodNotAllowed, + Message: "directory delete operations are not available for configured branch. Use bulk delete operations via the jobs API instead", + }, } - - return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo) } - // before deleting from the repo, first get all children resources to delete from grafana afterwards - treeEntries, err := r.repo.ReadTree(ctx, "") + // For branch operations, just delete from the repository without updating Grafana DB + err := r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message) if err != nil { - return nil, fmt.Errorf("read repository tree: %w", err) - } - // note: parsedFolders will include the folder itself - parsedResources, parsedFolders, err := r.getChildren(ctx, opts.Path, treeEntries) - if err != nil { - return nil, fmt.Errorf("parse resources in folder: %w", err) - } - - // delete from the repo - err = r.repo.Delete(ctx, opts.Path, opts.Ref, opts.Message) - if err != nil { - return nil, fmt.Errorf("delete folder from repository: %w", err) - } - - // delete from grafana - ctx, _, err = identity.WithProvisioningIdentity(ctx, r.repo.Config().Namespace) - if err != nil { - return nil, err - } - if err := r.deleteChildren(ctx, parsedResources, parsedFolders); err != nil { - return nil, fmt.Errorf("delete folder from grafana: %w", err) + return nil, fmt.Errorf("error deleting folder from repository: %w", err) } return folderDeleteResponse(ctx, opts.Path, opts.Ref, r.repo) @@ -640,60 +605,11 @@ func folderDeleteResponse(ctx context.Context, path, ref string, repo repository return parsed, nil } -func (r *DualReadWriter) getChildren(ctx context.Context, folderPath string, treeEntries []repository.FileTreeEntry) ([]*ParsedResource, []Folder, error) { - var resourcesInFolder []repository.FileTreeEntry - var foldersInFolder []Folder - for _, entry := range treeEntries { - // make sure the path is supported (i.e. not ignored by git sync) and that the path is the folder itself or a child of the folder - if IsPathSupported(entry.Path) != nil || !safepath.InDir(entry.Path, folderPath) { - continue - } - // folders cannot be parsed as resources, so handle them separately - if entry.Blob { - resourcesInFolder = append(resourcesInFolder, entry) - } else { - folder := ParseFolder(entry.Path, r.repo.Config().Name) - foldersInFolder = append(foldersInFolder, folder) - } - } - - parsedResources := make([]*ParsedResource, len(resourcesInFolder)) - for i, entry := range resourcesInFolder { - fileInfo, err := r.repo.Read(ctx, entry.Path, "") - if err != nil && !apierrors.IsNotFound(err) { - return nil, nil, fmt.Errorf("could not find resource in repository: %w", err) - } - - parsed, err := r.parser.Parse(ctx, fileInfo) - if err != nil { - return nil, nil, fmt.Errorf("could not parse resource: %w", err) - } - - parsedResources[i] = parsed - } - - return parsedResources, foldersInFolder, nil -} - -func (r *DualReadWriter) deleteChildren(ctx context.Context, childrenResources []*ParsedResource, folders []Folder) error { - for _, parsed := range childrenResources { - err := parsed.Client.Delete(ctx, parsed.Obj.GetName(), metav1.DeleteOptions{}) - if err != nil && !apierrors.IsNotFound(err) { - return fmt.Errorf("failed to delete nested resource from grafana: %w", err) - } - } - - // we need to delete the folders furthest down in the tree first, as folder deletion will fail if there is anything inside of it - safepath.SortByDepth(folders, func(f Folder) string { return f.Path }, false) - - for _, f := range folders { - err := r.folders.Client().Delete(ctx, f.ID, metav1.DeleteOptions{}) - if err != nil { - return fmt.Errorf("failed to delete folder from grafana: %w", err) - } - } - - return nil +// isConfiguredBranch returns true if the ref targets the configured branch +// (empty ref means configured branch, or ref explicitly matches configured branch) +func (r *DualReadWriter) isConfiguredBranch(opts DualWriteOptions) bool { + configuredBranch := r.repo.Config().Branch() + return opts.Ref == "" || opts.Ref == configuredBranch } // shouldUpdateGrafanaDB returns true if we have an empty ref (targeting the configured branch) @@ -703,9 +619,5 @@ func (r *DualReadWriter) shouldUpdateGrafanaDB(opts DualWriteOptions, parsed *Pa return false } - if opts.Ref != "" && opts.Ref != opts.Branch { - return false - } - - return true + return r.isConfiguredBranch(opts) } diff --git a/pkg/tests/apis/provisioning/files_test.go b/pkg/tests/apis/provisioning/files_test.go index 3eed9171578..823241aa6b0 100644 --- a/pkg/tests/apis/provisioning/files_test.go +++ b/pkg/tests/apis/provisioning/files_test.go @@ -68,22 +68,45 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) { helper.validateManagedDashboardsFolderMetadata(t, ctx, repo, dashboards.Items) - t.Run("delete individual dashboard file, should delete from repo and grafana", func(t *testing.T) { + t.Run("delete individual dashboard file on configured branch should succeed", func(t *testing.T) { result := helper.AdminREST.Delete(). Namespace("default"). Resource("repositories"). Name(repo). SubResource("files", "dashboard1.json"). Do(ctx) - require.NoError(t, result.Error()) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "dashboard1.json") - require.Error(t, err) - dashboards, err = helper.DashboardsV1.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err) - require.Equal(t, 2, len(dashboards.Items)) + require.NoError(t, result.Error(), "delete file on configured branch should succeed") + + // Verify the dashboard is removed from Grafana + const allPanelsUID = "n1jR8vnnz" // UID from all-panels.json + _, err := helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) + require.Error(t, err, "dashboard should be deleted from Grafana") + require.True(t, apierrors.IsNotFound(err), "should return NotFound for deleted dashboard") }) - t.Run("delete folder, should delete from repo and grafana all nested resources too", func(t *testing.T) { + t.Run("delete individual dashboard file on branch should succeed", func(t *testing.T) { + // Create a branch first by creating a file on a branch + branchRef := "test-branch-delete" + helper.CopyToProvisioningPath(t, "testdata/text-options.json", "branch-test-delete.json") + + // Delete on branch should work + result := helper.AdminREST.Delete(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("files", "branch-test-delete.json"). + Param("ref", branchRef). + Do(ctx) + // Note: This might fail if branch doesn't exist, but the important thing is it doesn't return MethodNotAllowed + if result.Error() != nil { + var statusErr *apierrors.StatusError + if errors.As(result.Error(), &statusErr) { + require.NotEqual(t, int32(http.StatusMethodNotAllowed), statusErr.ErrStatus.Code, "should not return MethodNotAllowed for branch delete") + } + } + }) + + t.Run("delete folder on configured branch should return MethodNotAllowed", func(t *testing.T) { // need to delete directly through the url, because the k8s client doesn't support `/` in a subresource // but that is needed by gitsync to know that it is a folder addr := helper.GetEnv().Server.HTTPServer.Listener.Addr().String() @@ -94,27 +117,11 @@ func TestIntegrationProvisioning_DeleteResources(t *testing.T) { require.NoError(t, err) // nolint:errcheck defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode) + require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "should return MethodNotAllowed for configured branch folder delete") - // should be deleted from the repo - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder") - require.Error(t, err) + // Verify a file inside the folder still exists (operation was rejected) _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "dashboard2.json") - require.Error(t, err) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested") - require.Error(t, err) - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "folder", "nested", "dashboard3.json") - require.Error(t, err) - - // all should be deleted from grafana - for _, d := range dashboards.Items { - _, err = helper.DashboardsV1.Resource.Get(ctx, d.GetName(), metav1.GetOptions{}) - require.Error(t, err) - } - for _, f := range folders.Items { - _, err = helper.Folders.Resource.Get(ctx, f.GetName(), metav1.GetOptions{}) - require.Error(t, err) - } + require.NoError(t, err, "file inside folder should still exist after rejected delete") }) t.Run("deleting a non-existent file should fail", func(t *testing.T) { @@ -158,10 +165,10 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { require.NoError(t, err, "original dashboard should exist in Grafana") require.Equal(t, repo, obj.GetAnnotations()[utils.AnnoKeyManagerIdentity]) - t.Run("move file without content change", func(t *testing.T) { + t.Run("move file without content change on configured branch should succeed", func(t *testing.T) { const targetPath = "moved/simple-move.json" - // Perform the move operation using helper function + // Perform the move operation using helper function (no ref = configured branch) resp := helper.postFilesRequest(t, repo, filesPostOptions{ targetPath: targetPath, originalPath: "all-panels.json", @@ -169,32 +176,52 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { }) // nolint:errcheck defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed") + require.Equal(t, http.StatusOK, resp.StatusCode, "move operation on configured branch should succeed") - // Verify the file moved in the repository - movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") - require.NoError(t, err, "moved file should exist in repository") + // Verify file was moved - read from new location + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") + require.NoError(t, err, "file should exist at new location") - // Check the content is preserved (verify it's still the all-panels dashboard) - resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") - require.NoError(t, err) - dryRun, _, err := unstructured.NestedMap(resource, "dryRun") - require.NoError(t, err) - title, _, err := unstructured.NestedString(dryRun, "spec", "title") - require.NoError(t, err) - require.Equal(t, "Panel tests - All panels", title, "content should be preserved") - - // Verify original file no longer exists + // Verify file no longer exists at old location _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "all-panels.json") - require.Error(t, err, "original file should no longer exist") - - // Verify dashboard still exists in Grafana with same content but may have updated path references - helper.SyncAndWait(t, repo, nil) - _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) - require.NoError(t, err, "dashboard should still exist in Grafana after move") + require.Error(t, err, "file should not exist at old location") }) - t.Run("move file to nested path without ref", func(t *testing.T) { + t.Run("move file without content change on branch should succeed", func(t *testing.T) { + const targetPath = "moved/simple-move-branch.json" + branchRef := "test-branch-move" + + // Perform the move operation using helper function with ref parameter + resp := helper.postFilesRequest(t, repo, filesPostOptions{ + targetPath: targetPath, + originalPath: "all-panels.json", + message: "move file without content change", + ref: branchRef, + }) + // nolint:errcheck + defer resp.Body.Close() + // Note: This might fail if branch doesn't exist, but the important thing is it doesn't return MethodNotAllowed + if resp.StatusCode == http.StatusMethodNotAllowed { + t.Fatal("should not return MethodNotAllowed for branch move") + } + + // If move succeeded (not MethodNotAllowed), verify the file moved in the repository + if resp.StatusCode == http.StatusOK { + movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move-branch.json") + require.NoError(t, err, "moved file should exist in repository") + + // Check the content is preserved (verify it's still the all-panels dashboard) + resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") + require.NoError(t, err) + dryRun, _, err := unstructured.NestedMap(resource, "dryRun") + require.NoError(t, err) + title, _, err := unstructured.NestedString(dryRun, "spec", "title") + require.NoError(t, err) + require.Equal(t, "Panel tests - All panels", title, "content should be preserved") + } + }) + + t.Run("move file to nested path on configured branch should succeed", func(t *testing.T) { // Test a different scenario: Move a file that was never synced to Grafana // This might reveal the issue if dashboard creation fails during move const sourceFile = "never-synced.json" @@ -203,7 +230,7 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { // DO NOT sync - move the file immediately without it ever being in Grafana const targetPath = "deep/nested/timeline.json" - // Perform the move operation without the file ever being synced to Grafana + // Perform the move operation without the file ever being synced to Grafana (no ref = configured branch) resp := helper.postFilesRequest(t, repo, filesPostOptions{ targetPath: targetPath, originalPath: sourceFile, @@ -211,70 +238,25 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { }) // nolint:errcheck defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "move operation should succeed") + require.Equal(t, http.StatusOK, resp.StatusCode, "move operation on configured branch should succeed") - // Check folders were created and validate hierarchy - folderList, err := helper.Folders.Resource.List(ctx, metav1.ListOptions{}) - require.NoError(t, err, "should be able to list folders") + // File should exist at new location + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json") + require.NoError(t, err, "file should exist at new nested location") - // Build a map of folder names to their objects for easier lookup - folders := make(map[string]*unstructured.Unstructured) - for _, folder := range folderList.Items { - title, _, _ := unstructured.NestedString(folder.Object, "spec", "title") - folders[title] = &folder - parent, _, _ := unstructured.NestedString(folder.Object, "metadata", "annotations", "grafana.app/folder") - t.Logf(" - %s: %s (parent: %s)", folder.GetName(), title, parent) - } - - // Validate expected folders exist with proper hierarchy - // Expected structure: deep -> deep/nested - deepFolderTitle := "deep" - nestedFolderTitle := "nested" - - // Validate "deep" folder exists and has no parent (is top-level) - require.Contains(t, folders, deepFolderTitle, "deep folder should exist") - f := folders[deepFolderTitle] - deepFolderName := f.GetName() - title, _, _ := unstructured.NestedString(f.Object, "spec", "title") - require.Equal(t, deepFolderTitle, title, "deep folder should have correct title") - parent, found, _ := unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder") - require.True(t, !found || parent == "", "deep folder should be top-level (no parent)") - - // Validate "deep/nested" folder exists and has "deep" as parent - require.Contains(t, folders, nestedFolderTitle, "nested folder should exist") - f = folders[nestedFolderTitle] - nestedFolderName := f.GetName() - title, _, _ = unstructured.NestedString(f.Object, "spec", "title") - require.Equal(t, nestedFolderTitle, title, "nested folder should have correct title") - parent, _, _ = unstructured.NestedString(f.Object, "metadata", "annotations", "grafana.app/folder") - require.Equal(t, deepFolderName, parent, "nested folder should have deep folder as parent") - - // The key test: Check if dashboard was created in Grafana during move - const timelineUID = "mIJjFy8Kz" - dashboard, err := helper.DashboardsV1.Resource.Get(ctx, timelineUID, metav1.GetOptions{}) - require.NoError(t, err, "dashboard should exist in Grafana after moving never-synced file") - dashboardFolder, _, _ := unstructured.NestedString(dashboard.Object, "metadata", "annotations", "grafana.app/folder") - - // Validate dashboard is in the correct nested folder - require.Equal(t, nestedFolderName, dashboardFolder, "dashboard should be in the nested folder") - - // Verify the file moved in the repository - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "deep", "nested", "timeline.json") - require.NoError(t, err, "moved file should exist in nested repository path") - - // Verify the original file no longer exists in the repository + // File should not exist at original location _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourceFile) - require.Error(t, err, "original file should no longer exist in repository") + require.Error(t, err, "file should not exist at original location after move") }) - t.Run("move file with content update", func(t *testing.T) { - const sourcePath = "moved/simple-move.json" // Use the file from previous test + t.Run("move file with content update on configured branch should succeed", func(t *testing.T) { + const sourcePath = "moved/simple-move.json" // Use the file we moved earlier const targetPath = "updated/content-updated.json" // Use text-options.json content for the update updatedContent := helper.LoadFile("testdata/text-options.json") - // Perform move with content update using helper function + // Perform move with content update using helper function (no ref = configured branch) resp := helper.postFilesRequest(t, repo, filesPostOptions{ targetPath: targetPath, originalPath: sourcePath, @@ -283,51 +265,27 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { }) // nolint:errcheck defer resp.Body.Close() - require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update should succeed") + require.Equal(t, http.StatusOK, resp.StatusCode, "move with content update on configured branch should succeed") - // Verify the moved file has updated content (should now be text-options dashboard) + // File should exist at new location with updated content movedObj, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "updated", "content-updated.json") - require.NoError(t, err, "moved file should exist in repository") + require.NoError(t, err, "file should exist at new location") + // Verify content was updated (should be text-options dashboard now) resource, _, err := unstructured.NestedMap(movedObj.Object, "resource") require.NoError(t, err) dryRun, _, err := unstructured.NestedMap(resource, "dryRun") require.NoError(t, err) title, _, err := unstructured.NestedString(dryRun, "spec", "title") require.NoError(t, err) - require.Equal(t, "Text options", title, "content should be updated to text-options dashboard") + require.Equal(t, "Text options", title, "content should be updated") - // Check it has the expected UID from text-options.json - name, _, err := unstructured.NestedString(dryRun, "metadata", "name") - require.NoError(t, err) - require.Equal(t, "WZ7AhQiVz", name, "should have the UID from text-options.json") - - // Verify source file no longer exists - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved", "simple-move.json") - require.Error(t, err, "source file should no longer exist") - - // Sync and verify the updated dashboard exists in Grafana - helper.SyncAndWait(t, repo, nil) - const textOptionsUID = "WZ7AhQiVz" // UID from text-options.json - updatedDashboard, err := helper.DashboardsV1.Resource.Get(ctx, textOptionsUID, metav1.GetOptions{}) - require.NoError(t, err, "updated dashboard should exist in Grafana") - - // Verify the original dashboard was deleted from Grafana - _, err = helper.DashboardsV1.Resource.Get(ctx, allPanelsUID, metav1.GetOptions{}) - require.Error(t, err, "original dashboard should be deleted from Grafana") - require.True(t, apierrors.IsNotFound(err)) - - // Verify the new dashboard has the updated content - updatedTitle, _, err := unstructured.NestedString(updatedDashboard.Object, "spec", "title") - require.NoError(t, err) - require.Equal(t, "Text options", updatedTitle) + // Source file should not exist anymore + _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", sourcePath) + require.Error(t, err, "source file should not exist after move") }) - t.Run("move directory", func(t *testing.T) { - t.Skip("Skip as implementation is broken and leaves dashboards behind in the move") - // FIXME: https://github.com/grafana/git-ui-sync-project/issues/379 - // The current implementation of moving directories is flawed. - // It will be deprecated in favor of queuing a move job + t.Run("move directory on configured branch should return MethodNotAllowed", func(t *testing.T) { // Create some files in a directory first using existing testdata files helper.CopyToProvisioningPath(t, "testdata/timeline-demo.json", "source-dir/timeline-demo.json") helper.CopyToProvisioningPath(t, "testdata/text-options.json", "source-dir/text-options.json") @@ -338,7 +296,7 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { const sourceDir = "source-dir/" const targetDir = "moved-dir/" - // Move directory using helper function + // Move directory using helper function (no ref = configured branch) resp := helper.postFilesRequest(t, repo, filesPostOptions{ targetPath: targetDir, originalPath: sourceDir, @@ -346,20 +304,11 @@ func TestIntegrationProvisioning_MoveResources(t *testing.T) { }) // nolint:errcheck defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - require.NoError(t, err, "should read response body") - t.Logf("Response Body: %s", string(body)) - require.Equal(t, http.StatusOK, resp.StatusCode, "directory move should succeed") + require.Equal(t, http.StatusMethodNotAllowed, resp.StatusCode, "directory move on configured branch should return MethodNotAllowed") - // Verify source directory no longer exists - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir") - require.Error(t, err, "source directory should no longer exist") - - // Verify target directory and files exist - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "timeline-demo.json") - require.NoError(t, err, "moved timeline-demo.json should exist") - _, err = helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "moved-dir", "text-options.json") - require.NoError(t, err, "moved text-options.json should exist") + // Verify files in source directory still exist (operation was rejected) + _, err := helper.Repositories.Resource.Get(ctx, repo, metav1.GetOptions{}, "files", "source-dir", "timeline-demo.json") + require.NoError(t, err, "file in source directory should still exist after rejected move") }) t.Run("error cases", func(t *testing.T) { @@ -566,7 +515,7 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) { }) t.Run("DELETE resource owned by different repository - should fail", func(t *testing.T) { - // Create a file manually in the second repo which is already in first one + // Create a file manually in the second repo which has UID from first repo helper.CopyToProvisioningPath(t, "testdata/all-panels.json", "repo2/conflicting-delete.json") printFileTree(t, helper.ProvisioningPath) @@ -590,10 +539,7 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) { } // Verify it returns BadRequest (400) for ownership conflicts - if !apierrors.IsBadRequest(err) { - t.Errorf("Expected BadRequest error but got: %T - %v", err, err) - return - } + require.True(t, apierrors.IsBadRequest(err), "Expected BadRequest error but got: %T - %v", err, err) // Check error message contains ownership conflict information errorMsg := err.Error() @@ -607,7 +553,7 @@ func TestIntegrationProvisioning_FilesOwnershipProtection(t *testing.T) { targetPath: "moved-dashboard.json", originalPath: path.Join("dashboard2.json"), message: "attempt to move file from different repository", - body: string(helper.LoadFile("testdata/all-panels.json")), // Content to move with the conflicting UID + body: string(helper.LoadFile("testdata/all-panels.json")), // Content with the conflicting UID }) // nolint:errcheck defer resp.Body.Close() diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index d7850c52d0a..37796da7b3d 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -786,7 +786,7 @@ func TestIntegrationProvisioning_ImportAllPanelsFromLocalRepository(t *testing.T v, _, _ := unstructured.NestedString(obj.Object, "metadata", "annotations", utils.AnnoKeyUpdatedBy) require.Equal(t, "access-policy:provisioning", v) - // Should not be able to directly delete the managed resource + // Should be able to directly delete the managed resource err = helper.DashboardsV1.Resource.Delete(ctx, allPanels, metav1.DeleteOptions{}) require.NoError(t, err, "user can delete") From dc0ccd238b4a724713ced5ccb8da5c1efec29848 Mon Sep 17 00:00:00 2001 From: Dominik Prokop Date: Mon, 15 Dec 2025 15:45:44 +0100 Subject: [PATCH 07/27] Comment out schema editor button in dashboard edit pane (#115342) --- .../dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx index 80ec361476a..7ce42744241 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx @@ -76,12 +76,12 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro data-testid={selectors.pages.Dashboard.Sidebar.optionsButton} active={selectedObject === dashboard ? true : false} /> - dashboard.openV2SchemaEditor()} - /> + /> */} )} From 657bf769225c1deed7869dedc883c928bc1af7d1 Mon Sep 17 00:00:00 2001 From: Gabriel MABILLE Date: Mon, 15 Dec 2025 15:47:12 +0100 Subject: [PATCH 08/27] `grafana-iam`: Instantiate parent provider (#115224) --- pkg/registry/apis/iam/authorizer/resource_permissions.go | 5 ++--- pkg/registry/apis/iam/register.go | 6 ++++-- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/pkg/registry/apis/iam/authorizer/resource_permissions.go b/pkg/registry/apis/iam/authorizer/resource_permissions.go index 3e039e36222..0fbf413adac 100644 --- a/pkg/registry/apis/iam/authorizer/resource_permissions.go +++ b/pkg/registry/apis/iam/authorizer/resource_permissions.go @@ -40,7 +40,7 @@ func NewResourcePermissionsAuthorizer( return &ResourcePermissionsAuthorizer{ accessClient: accessClient, parentProvider: parentProvider, - logger: log.New("iam.resource-permissions-authorizer"), + logger: log.New("iam.authorizer.resource-permissions"), } } @@ -216,8 +216,7 @@ func (r *ResourcePermissionsAuthorizer) FilterList(ctx context.Context, list run // Skip item on error fetching parent r.logger.Warn("filter list: error fetching parent, skipping item", "error", err.Error(), - "namespace", - item.Namespace, + "namespace", item.Namespace, "group", target.ApiGroup, "resource", target.Resource, "name", target.Name, diff --git a/pkg/registry/apis/iam/register.go b/pkg/registry/apis/iam/register.go index 5145cf3afa4..a9a68e90d2c 100644 --- a/pkg/registry/apis/iam/register.go +++ b/pkg/registry/apis/iam/register.go @@ -21,6 +21,7 @@ import ( "k8s.io/kube-openapi/pkg/spec3" "k8s.io/kube-openapi/pkg/validation/spec" + "github.com/grafana/authlib/authn" "github.com/grafana/authlib/types" iamv0 "github.com/grafana/grafana/apps/iam/pkg/apis/iam/v0alpha1" @@ -142,6 +143,8 @@ func NewAPIService( features featuremgmt.FeatureToggles, zClient zanzana.Client, reg prometheus.Registerer, + tokenExchanger authn.TokenExchanger, + authorizerDialConfigs map[schema.GroupResource]iamauthorizer.DialConfig, ) *IdentityAccessManagementAPIBuilder { store := legacy.NewLegacySQLStores(dbProvider) resourcePermissionsStorage := resourcepermission.ProvideStorageBackend(dbProvider) @@ -150,9 +153,8 @@ func NewAPIService( resourceAuthorizer := gfauthorizer.NewResourceAuthorizer(accessClient) coreRoleAuthorizer := iamauthorizer.NewCoreRoleAuthorizer(accessClient) - // TODO: in a follow up PR, make this configurable resourceParentProvider := iamauthorizer.NewApiParentProvider( - iamauthorizer.NewRemoteConfigProvider(map[schema.GroupResource]iamauthorizer.DialConfig{}, nil), + iamauthorizer.NewRemoteConfigProvider(authorizerDialConfigs, tokenExchanger), iamauthorizer.Versions, ) From 95baa89e0f10614517d077ee1e1d7fd6b4b05d3a Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Mon, 15 Dec 2025 17:47:33 +0300 Subject: [PATCH 09/27] DashboardsAPI: Deprecate /api/dashboards/home (#115333) --- .../src/clients/rtkq/legacy/endpoints.gen.ts | 92 ++++++++----------- pkg/api/dashboard.go | 4 +- public/api-merged.json | 3 +- public/openapi3.json | 3 +- 4 files changed, 46 insertions(+), 56 deletions(-) diff --git a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts index 5ba0b811289..f614aed35a8 100644 --- a/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts +++ b/packages/grafana-api-clients/src/clients/rtkq/legacy/endpoints.gen.ts @@ -658,10 +658,6 @@ const injectedRtkApi = api query: (queryArg) => ({ url: `/dashboards/db`, method: 'POST', body: queryArg.saveDashboardCommand }), invalidatesTags: ['dashboards'], }), - getHomeDashboard: build.query({ - query: () => ({ url: `/dashboards/home` }), - providesTags: ['dashboards'], - }), importDashboard: build.mutation({ query: (queryArg) => ({ url: `/dashboards/import`, method: 'POST', body: queryArg.importDashboardRequest }), invalidatesTags: ['dashboards'], @@ -2574,8 +2570,6 @@ export type PostDashboardApiResponse = /** status 200 (empty) */ { export type PostDashboardApiArg = { saveDashboardCommand: SaveDashboardCommand; }; -export type GetHomeDashboardApiResponse = /** status 200 (empty) */ GetHomeDashboardResponse; -export type GetHomeDashboardApiArg = void; export type ImportDashboardApiResponse = /** status 200 (empty) */ ImportDashboardResponseResponseObjectReturnedWhenImportingADashboard; export type ImportDashboardApiArg = { @@ -4399,51 +4393,6 @@ export type SaveDashboardCommand = { overwrite?: boolean; userId?: number; }; -export type AnnotationActions = { - canAdd?: boolean; - canDelete?: boolean; - canEdit?: boolean; -}; -export type AnnotationPermission = { - dashboard?: AnnotationActions; - organization?: AnnotationActions; -}; -export type DashboardMeta = { - annotationsPermissions?: AnnotationPermission; - apiVersion?: string; - canAdmin?: boolean; - canDelete?: boolean; - canEdit?: boolean; - canSave?: boolean; - canStar?: boolean; - created?: string; - createdBy?: string; - expires?: string; - /** Deprecated: use FolderUID instead */ - folderId?: number; - folderTitle?: string; - folderUid?: string; - folderUrl?: string; - hasAcl?: boolean; - isFolder?: boolean; - isSnapshot?: boolean; - isStarred?: boolean; - provisioned?: boolean; - provisionedExternalId?: string; - publicDashboardEnabled?: boolean; - slug?: string; - type?: string; - updated?: string; - updatedBy?: string; - url?: string; - version?: number; -}; -export type GetHomeDashboardResponse = { - dashboard?: Json; - meta?: DashboardMeta; -} & { - redirectUri?: string; -}; export type ImportDashboardResponseResponseObjectReturnedWhenImportingADashboard = { dashboardId?: number; description?: string; @@ -4535,6 +4484,45 @@ export type PublicDashboardDto = { timeSelectionEnabled?: boolean; uid?: string; }; +export type AnnotationActions = { + canAdd?: boolean; + canDelete?: boolean; + canEdit?: boolean; +}; +export type AnnotationPermission = { + dashboard?: AnnotationActions; + organization?: AnnotationActions; +}; +export type DashboardMeta = { + annotationsPermissions?: AnnotationPermission; + apiVersion?: string; + canAdmin?: boolean; + canDelete?: boolean; + canEdit?: boolean; + canSave?: boolean; + canStar?: boolean; + created?: string; + createdBy?: string; + expires?: string; + /** Deprecated: use FolderUID instead */ + folderId?: number; + folderTitle?: string; + folderUid?: string; + folderUrl?: string; + hasAcl?: boolean; + isFolder?: boolean; + isSnapshot?: boolean; + isStarred?: boolean; + provisioned?: boolean; + provisionedExternalId?: string; + publicDashboardEnabled?: boolean; + slug?: string; + type?: string; + updated?: string; + updatedBy?: string; + url?: string; + version?: number; +}; export type DashboardFullWithMeta = { dashboard?: Json; meta?: DashboardMeta; @@ -6619,8 +6607,6 @@ export const { useSearchDashboardSnapshotsQuery, useLazySearchDashboardSnapshotsQuery, usePostDashboardMutation, - useGetHomeDashboardQuery, - useLazyGetHomeDashboardQuery, useImportDashboardMutation, useInterpolateDashboardMutation, useListPublicDashboardsQuery, diff --git a/pkg/api/dashboard.go b/pkg/api/dashboard.go index 3b764568e75..a560ff47c5d 100644 --- a/pkg/api/dashboard.go +++ b/pkg/api/dashboard.go @@ -493,7 +493,9 @@ func (hs *HTTPServer) postDashboard(c *contextmodel.ReqContext, cmd dashboards.S // swagger:route GET /dashboards/home dashboards getHomeDashboard // -// Get home dashboard. +// NOTE: the home dashboard is configured in preferences. This API will be removed in G13 +// +// Deprecated: true // // Responses: // 200: getHomeDashboardResponse diff --git a/public/api-merged.json b/public/api-merged.json index 6effd7054fa..570c4c2687d 100644 --- a/public/api-merged.json +++ b/public/api-merged.json @@ -3402,11 +3402,12 @@ }, "/dashboards/home": { "get": { + "description": "NOTE: the home dashboard is configured in preferences. This API will be removed in G13", "tags": [ "dashboards" ], - "summary": "Get home dashboard.", "operationId": "getHomeDashboard", + "deprecated": true, "responses": { "200": { "$ref": "#/responses/getHomeDashboardResponse" diff --git a/public/openapi3.json b/public/openapi3.json index 546f15a7a86..3257a516bb0 100644 --- a/public/openapi3.json +++ b/public/openapi3.json @@ -17651,6 +17651,8 @@ }, "/dashboards/home": { "get": { + "deprecated": true, + "description": "NOTE: the home dashboard is configured in preferences. This API will be removed in G13", "operationId": "getHomeDashboard", "responses": { "200": { @@ -17663,7 +17665,6 @@ "$ref": "#/components/responses/internalServerError" } }, - "summary": "Get home dashboard.", "tags": [ "dashboards" ] From fdc84474ce32c5ff886b28b454e0a904506f8942 Mon Sep 17 00:00:00 2001 From: Anna Urbiztondo Date: Mon, 15 Dec 2025 16:05:34 +0100 Subject: [PATCH 10/27] Docs: Plugin install deprecation note (#115160) * Placeholder * Updated note * Update docs/sources/administration/plugin-management/plugin-install.md Co-authored-by: David Harris * Feedback * Update docs/sources/administration/plugin-management/plugin-install.md Co-authored-by: David Harris --------- Co-authored-by: David Harris --- .../plugin-management/plugin-install.md | 21 +++++++++++++++++-- .../terraform/terraform-plugins.md | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/docs/sources/administration/plugin-management/plugin-install.md b/docs/sources/administration/plugin-management/plugin-install.md index 18be4ea58fa..dfec5002944 100644 --- a/docs/sources/administration/plugin-management/plugin-install.md +++ b/docs/sources/administration/plugin-management/plugin-install.md @@ -21,11 +21,28 @@ weight: 120 # Install a plugin -Besides the UI, you can use alternative methods to install a plugin depending on your environment or set-up. +{{< admonition type="note" >}} + +Installing plugins from the Grafana website into a Grafana Cloud instance will be removed in February 2026. + +If you're a Grafana Cloud user, follow [Install a plugin through the Grafana UI](#install-a-plugin-through-the-grafana-uiinstall-a-plugin-through-the-grafana-ui) instead. + +{{< /admonition >}} + +## Install a plugin through the Grafana UI + +The most common way to install a plugin is through the Grafana UI. + +1. In Grafana, click **Administration > Plugins and data > Plugins** in the side navigation menu to view all plugins. +1. Browse and find a plugin. +1. Click the plugin's logo. +1. Click **Install**. + +You can use use the following alternative methods to install a plugin depending on your environment or setup. ## Install a plugin using Grafana CLI -The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](/docs/grafana//cli/#plugins-commands). +The Grafana CLI allows you to install, upgrade, and manage your Grafana plugins using a command line tool. For more information about Grafana CLI plugin commands, refer to [Plugin commands](https://grafana.com/docs/grafana//administration/cli/#plugins-commands). ## Install a plugin from a ZIP file diff --git a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md index f0626540ca3..eea698aace1 100644 --- a/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md +++ b/docs/sources/as-code/infrastructure-as-code/terraform/terraform-plugins.md @@ -24,7 +24,7 @@ Before you begin, you should have the following available: - Administrator permissions in your Grafana instance; for more information on assigning Grafana RBAC roles, refer to [Assign RBAC roles](/docs/grafana-cloud/security-and-account-management/authentication-and-permissions/access-control/assign-rbac-roles/). {{< admonition type="note" >}} -All of the following Terraform configuration files should be saved in the same directory. +Save all of the following Terraform configuration files in the same directory. {{< /admonition >}} ## Configure the Grafana provider From 7779c907130180810d0b1339a374f064c6d8e143 Mon Sep 17 00:00:00 2001 From: "alerting-team[bot]" <158350966+alerting-team[bot]@users.noreply.github.com> Date: Mon, 15 Dec 2025 10:21:24 -0500 Subject: [PATCH 11/27] Alerting: Add limits for the size of expanded notification templates (#115242) * [create-pull-request] automated change * propagate template limits from config * fmt --------- Co-authored-by: yuri-tceretian <25988953+yuri-tceretian@users.noreply.github.com> Co-authored-by: Yuri Tseretyan --- apps/advisor/go.mod | 2 +- apps/advisor/go.sum | 4 ++-- apps/alerting/historian/go.mod | 2 +- apps/alerting/historian/go.sum | 6 ++---- apps/iam/go.mod | 2 +- apps/iam/go.sum | 4 ++-- apps/plugins/go.mod | 2 +- apps/plugins/go.sum | 4 ++-- conf/defaults.ini | 4 ++++ .../setup-grafana/configure-grafana/_index.md | 7 +++++++ go.mod | 2 +- go.sum | 4 ++-- pkg/services/ngalert/notifier/alertmanager.go | 15 ++++++++++++++- pkg/setting/setting_unified_alerting.go | 8 ++++++++ 14 files changed, 48 insertions(+), 18 deletions(-) diff --git a/apps/advisor/go.mod b/apps/advisor/go.mod index 941d8b9bc0f..79e5242ba5e 100644 --- a/apps/advisor/go.mod +++ b/apps/advisor/go.mod @@ -149,7 +149,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-20251204145817-de8c2bbf9eba // indirect + github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // 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 4695785bbd1..30238124dd4 100644 --- a/apps/advisor/go.sum +++ b/apps/advisor/go.sum @@ -606,8 +606,8 @@ github.com/gorilla/mux v1.6.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2z github.com/gorilla/mux v1.7.1/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= -github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba h1:psKWNETD5nGxmFAlqnWsXoRyUwSa2GHNEMSEDKGKfQ4= -github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +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/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 21ad42c90af..dc8b8ef80a9 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-20251204145817-de8c2bbf9eba + github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 github.com/grafana/dskit v0.0.0-20250908063411-6b6da59b5cc4 github.com/grafana/grafana-app-sdk v0.48.5 github.com/grafana/grafana-app-sdk/logging v0.48.3 diff --git a/apps/alerting/historian/go.sum b/apps/alerting/historian/go.sum index 9c00f19a029..e4440ed687f 100644 --- a/apps/alerting/historian/go.sum +++ b/apps/alerting/historian/go.sum @@ -216,12 +216,10 @@ github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/grafana/grafana-app-sdk v0.48.5 h1:MS8l9fTZz+VbTfgApn09jw27GxhQ6fNOWGhC4ydvZmM= -github.com/grafana/grafana-app-sdk v0.48.5/go.mod h1:HJsMOSBmt/D/Ihs1SvagOwmXKi0coBMVHlfvdd+qe9Y= 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-20251204145817-de8c2bbf9eba h1:psKWNETD5nGxmFAlqnWsXoRyUwSa2GHNEMSEDKGKfQ4= -github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +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/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.5 h1:MS8l9fTZz+VbTfgApn09jw27GxhQ6fNOWGhC4ydvZmM= diff --git a/apps/iam/go.mod b/apps/iam/go.mod index 474779e0efe..c741eb97423 100644 --- a/apps/iam/go.mod +++ b/apps/iam/go.mod @@ -221,7 +221,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-20251204145817-de8c2bbf9eba // indirect + github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // 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 4e1eb9d56c7..cf4535fbe71 100644 --- a/apps/iam/go.sum +++ b/apps/iam/go.sum @@ -817,8 +817,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-20251204145817-de8c2bbf9eba h1:psKWNETD5nGxmFAlqnWsXoRyUwSa2GHNEMSEDKGKfQ4= -github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +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/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 287c2ff0bbe..5341081d027 100644 --- a/apps/plugins/go.mod +++ b/apps/plugins/go.mod @@ -74,7 +74,7 @@ require ( github.com/google/gnostic-models v0.7.0 // 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-20251204145817-de8c2bbf9eba // indirect + github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // 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 1d7387b28b3..7d58d87be04 100644 --- a/apps/plugins/go.sum +++ b/apps/plugins/go.sum @@ -174,8 +174,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-20251204145817-de8c2bbf9eba h1:psKWNETD5nGxmFAlqnWsXoRyUwSa2GHNEMSEDKGKfQ4= -github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +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/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/conf/defaults.ini b/conf/defaults.ini index c2d7e4da3b6..de83393e43d 100644 --- a/conf/defaults.ini +++ b/conf/defaults.ini @@ -1327,6 +1327,10 @@ alertmanager_max_silences_count = # Maximum silence size in bytes. Default: 0 (no limit). alertmanager_max_silence_size_bytes = +# Maximum size of the expanded template output in bytes. Default: 10485760 (0 - no limit). +# The result of template expansion will be truncated to the limit. +alertmanager_max_template_output_bytes = + # Redis server address or addresses. It can be a single Redis address if using Redis standalone, # or a list of comma-separated addresses if using Redis Cluster/Sentinel. ha_redis_address = diff --git a/docs/sources/setup-grafana/configure-grafana/_index.md b/docs/sources/setup-grafana/configure-grafana/_index.md index 1a3e4aea652..a82ca8f91dd 100644 --- a/docs/sources/setup-grafana/configure-grafana/_index.md +++ b/docs/sources/setup-grafana/configure-grafana/_index.md @@ -1776,6 +1776,13 @@ Specify the frequency of polling for Alertmanager configuration changes. The def The interval string is a possibly signed sequence of decimal numbers, followed by a unit suffix (ms, s, m, h, d), for example, 30s or 1m. +#### `alertmanager_max_template_output_bytes` + +Maximum size in bytes that the expanded result of any single template expression (e.g. {{ .CommonAnnotations.description }}, {{ .ExternalURL }}, etc.) may reach during notification rendering. +The limit is checked after template execution for each templated field, but before the value is inserted into the final notification payload sent to the receiver. +If exceeded, the notification will contain output truncated up to the limit and a warning will be logged. +The default value is 10,485,760 bytes (10Mb). + #### `ha_redis_address` Redis server address or addresses. It can be a single Redis address if using Redis standalone, diff --git a/go.mod b/go.mod index 1ca49313cab..91d8a0a42fc 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-20251204145817-de8c2bbf9eba // @grafana/alerting-backend + github.com/grafana/alerting v0.0.0-20251212143239-491433b332b7 // @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 58adec21785..d056a11c1cb 100644 --- a/go.sum +++ b/go.sum @@ -1613,8 +1613,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-20251204145817-de8c2bbf9eba h1:psKWNETD5nGxmFAlqnWsXoRyUwSa2GHNEMSEDKGKfQ4= -github.com/grafana/alerting v0.0.0-20251204145817-de8c2bbf9eba/go.mod h1:l7v67cgP7x72ajB9UPZlumdrHqNztpKoqQ52cU8T3LU= +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/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/services/ngalert/notifier/alertmanager.go b/pkg/services/ngalert/notifier/alertmanager.go index 05e42a66b09..f192ed88058 100644 --- a/pkg/services/ngalert/notifier/alertmanager.go +++ b/pkg/services/ngalert/notifier/alertmanager.go @@ -12,6 +12,7 @@ import ( "github.com/grafana/alerting/models" alertingNotify "github.com/grafana/alerting/notify" "github.com/grafana/alerting/notify/nfstatus" + alertingTemplates "github.com/grafana/alerting/templates" "github.com/prometheus/alertmanager/config" amv2 "github.com/prometheus/alertmanager/api/v2/models" @@ -58,6 +59,7 @@ type alertmanager struct { decryptFn alertingNotify.GetDecryptedValueFn crypto Crypto features featuremgmt.FeatureToggles + dynamicLimits alertingNotify.DynamicLimits } // maintenanceOptions represent the options for components that need maintenance on a frequency within the Alertmanager. @@ -148,6 +150,16 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A return nil, err } + limits := alertingNotify.DynamicLimits{ + Dispatcher: nilLimits{}, + Templates: alertingTemplates.Limits{ + MaxTemplateOutputSize: cfg.UnifiedAlerting.AlertmanagerMaxTemplateOutputSize, + }, + } + if err := limits.Templates.Validate(); err != nil { + return nil, fmt.Errorf("invalid template limits: %w", err) + } + am := &alertmanager{ Base: gam, ConfigMetrics: m.AlertmanagerConfigMetrics, @@ -158,6 +170,7 @@ func NewAlertmanager(ctx context.Context, orgID int64, cfg *setting.Cfg, store A decryptFn: decryptFn, crypto: crypto, features: featureToggles, + dynamicLimits: limits, } return am, nil @@ -382,7 +395,7 @@ func (am *alertmanager) applyConfig(ctx context.Context, cfg *apimodels.Postable TimeIntervals: amConfig.TimeIntervals, Templates: templates, Receivers: receivers, - DispatcherLimits: &nilLimits{}, + Limits: am.dynamicLimits, Raw: rawConfig, Hash: configHash, }) diff --git a/pkg/setting/setting_unified_alerting.go b/pkg/setting/setting_unified_alerting.go index 7a365aec624..0733e8241e6 100644 --- a/pkg/setting/setting_unified_alerting.go +++ b/pkg/setting/setting_unified_alerting.go @@ -153,6 +153,9 @@ type UnifiedAlertingSettings struct { // DeletedRuleRetention defines the maximum duration to retain deleted alerting rules before permanent removal. DeletedRuleRetention time.Duration + + // AlertmanagerMaxTemplateOutputSize specifies the maximum allowed size for rendered template output in bytes. + AlertmanagerMaxTemplateOutputSize int64 } type RecordingRuleSettings struct { @@ -583,6 +586,11 @@ func (cfg *Cfg) ReadUnifiedAlertingSettings(iniFile *ini.File) error { return fmt.Errorf("setting 'deleted_rule_retention' is invalid, only 0 or a positive duration are allowed") } + uaCfg.AlertmanagerMaxTemplateOutputSize = ua.Key("alertmanager_max_template_output_bytes").MustInt64(10485760) + if uaCfg.AlertmanagerMaxTemplateOutputSize < 0 { + return fmt.Errorf("setting 'alertmanager_max_template_output_bytes' is invalid, only 0 or a positive integer are allowed") + } + cfg.UnifiedAlerting = uaCfg return nil } From 6bc534d5925dc12494ac0780a0ede3aea6eda0f1 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Mon, 15 Dec 2025 16:31:31 +0100 Subject: [PATCH 12/27] Chore: Move OpenTSDB to big tent (#114837) --- .github/CODEOWNERS | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 47688554cce..7ef6be6644c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -208,7 +208,7 @@ /pkg/tests/apis/shorturl @grafana/sharing-squad /pkg/tests/api/correlations/ @grafana/datapro /pkg/tsdb/grafanads/ @grafana/grafana-backend-group -/pkg/tsdb/opentsdb/ @grafana/partner-datasources +/pkg/tsdb/opentsdb/ @grafana/oss-big-tent /pkg/util/ @grafana/grafana-backend-group /pkg/web/ @grafana/grafana-backend-group @@ -260,7 +260,7 @@ /devenv/dev-dashboards/dashboards.go @grafana/dataviz-squad /devenv/dev-dashboards/home.json @grafana/dataviz-squad /devenv/dev-dashboards/datasource-elasticsearch/ @grafana/partner-datasources -/devenv/dev-dashboards/datasource-opentsdb/ @grafana/partner-datasources +/devenv/dev-dashboards/datasource-opentsdb/ @grafana/oss-big-tent /devenv/dev-dashboards/datasource-influxdb/ @grafana/partner-datasources /devenv/dev-dashboards/datasource-mssql/ @grafana/partner-datasources /devenv/dev-dashboards/datasource-loki/ @grafana/plugins-platform-frontend @@ -307,7 +307,7 @@ /devenv/docker/blocks/mysql_exporter/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_opendata/ @grafana/oss-big-tent /devenv/docker/blocks/mysql_tests/ @grafana/oss-big-tent -/devenv/docker/blocks/opentsdb/ @grafana/partner-datasources +/devenv/docker/blocks/opentsdb/ @grafana/oss-big-tent /devenv/docker/blocks/postgres/ @grafana/oss-big-tent /devenv/docker/blocks/postgres_tests/ @grafana/oss-big-tent /devenv/docker/blocks/prometheus/ @grafana/oss-big-tent @@ -1101,7 +1101,7 @@ eslint-suppressions.json @grafanabot /public/app/plugins/datasource/mixed/ @grafana/dashboards-squad /public/app/plugins/datasource/mssql/ @grafana/partner-datasources /public/app/plugins/datasource/mysql/ @grafana/oss-big-tent -/public/app/plugins/datasource/opentsdb/ @grafana/partner-datasources +/public/app/plugins/datasource/opentsdb/ @grafana/oss-big-tent /public/app/plugins/datasource/grafana-postgresql-datasource/ @grafana/oss-big-tent /public/app/plugins/datasource/prometheus/ @grafana/oss-big-tent /public/app/plugins/datasource/cloud-monitoring/ @grafana/partner-datasources From 08a6f31733d0f8b42b79c77645e7231fba318eb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roberto=20Jim=C3=A9nez=20S=C3=A1nchez?= Date: Mon, 15 Dec 2025 16:39:07 +0100 Subject: [PATCH 13/27] Provisioning: allow editors to POST jobs in provisioning API (#115351) fix: allow editors to POST jobs in provisioning API Editors should be able to post jobs in the 'jobs' endpoint for syncing repositories. This aligns with the requirement that syncing a repository requires editor privileges. - Separated 'jobs' subresource authorization from repository/test - Allow both admins and editors to POST jobs - Added integration tests to verify permissions Fixes authorization bug where editors were incorrectly denied access. --- pkg/registry/apis/provisioning/register.go | 197 ++++++++++-------- .../apis/provisioning/repository_test.go | 83 ++++++++ 2 files changed, 198 insertions(+), 82 deletions(-) diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index d18fc1156a8..cbf50a027f4 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -328,91 +328,124 @@ func (b *APIBuilder) GetAuthorizer() authorizer.Authorizer { return authorizer.DecisionDeny, "failed to find requester", err } - // Different routes may need different permissions. - // * Reading and modifying a repository's configuration requires administrator privileges. - // * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges. - // * Reading a repository's files requires viewer privileges. - // * Reading a repository's refs requires viewer privileges. - // * Editing a repository's files requires editor privileges. - // * Syncing a repository requires editor privileges. - // * Exporting a repository requires administrator privileges. - // * Migrating a repository requires administrator privileges. - // * Testing a repository configuration requires administrator privileges. - // * Viewing a repository's history requires editor privileges. - - switch a.GetResource() { - case provisioning.RepositoryResourceInfo.GetName(): - // TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise. - switch a.GetSubresource() { - case "", "test", "jobs": - // Doing something with the repository itself. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil - - case "refs": - // This is strictly a read operation. It is handy on the frontend for viewers. - if id.GetOrgRole().Includes(identity.RoleViewer) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "viewer role is required", nil - case "files": - // Access to files is controlled by the AccessClient - return authorizer.DecisionAllow, "", nil - - case "resources", "sync", "history": - // These are strictly read operations. - // Sync can also be somewhat destructive, but it's expected to be fine to import changes. - if id.GetOrgRole().Includes(identity.RoleEditor) { - return authorizer.DecisionAllow, "", nil - } else { - return authorizer.DecisionDeny, "editor role is required", nil - } - case "status": - if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "users cannot update the status of a repository", nil - default: - if id.GetIsGrafanaAdmin() { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil - } - - case "stats": - // This can leak information one shouldn't necessarily have access to. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil - - case "settings": - // This is strictly a read operation. It is handy on the frontend for viewers. - if id.GetOrgRole().Includes(identity.RoleViewer) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "viewer role is required", nil - - case provisioning.JobResourceInfo.GetName(), - provisioning.HistoricJobResourceInfo.GetName(): - // Jobs are shown on the configuration page. - if id.GetOrgRole().Includes(identity.RoleAdmin) { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "admin role is required", nil - - default: - // We haven't bothered with this kind yet. - if id.GetIsGrafanaAdmin() { - return authorizer.DecisionAllow, "", nil - } - return authorizer.DecisionDeny, "unmapped kind defaults to no access", nil - } + return b.authorizeResource(ctx, a, id) }) } +// authorizeResource handles authorization for different resources. +// Different routes may need different permissions. +// * Reading and modifying a repository's configuration requires administrator privileges. +// * Reading a repository's limited configuration (/stats & /settings) requires viewer privileges. +// * Reading a repository's files requires viewer privileges. +// * Reading a repository's refs requires viewer privileges. +// * Editing a repository's files requires editor privileges. +// * Syncing a repository requires editor privileges. +// * Exporting a repository requires administrator privileges. +// * Migrating a repository requires administrator privileges. +// * Testing a repository configuration requires administrator privileges. +// * Viewing a repository's history requires editor privileges. +func (b *APIBuilder) authorizeResource(ctx context.Context, a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { + switch a.GetResource() { + case provisioning.RepositoryResourceInfo.GetName(): + return b.authorizeRepositorySubresource(a, id) + case "stats": + return b.authorizeStats(id) + case "settings": + return b.authorizeSettings(id) + case provisioning.JobResourceInfo.GetName(), provisioning.HistoricJobResourceInfo.GetName(): + return b.authorizeJobs(id) + default: + return b.authorizeDefault(id) + } +} + +// authorizeRepositorySubresource handles authorization for repository subresources. +func (b *APIBuilder) authorizeRepositorySubresource(a authorizer.Attributes, id identity.Requester) (authorizer.Decision, string, error) { + // TODO: Support more fine-grained permissions than the basic roles. Especially on Enterprise. + switch a.GetSubresource() { + case "", "test": + // Doing something with the repository itself. + if id.GetOrgRole().Includes(identity.RoleAdmin) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "admin role is required", nil + + case "jobs": + // Posting jobs requires editor privileges (for syncing). + if id.GetOrgRole().Includes(identity.RoleAdmin) || id.GetOrgRole().Includes(identity.RoleEditor) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "editor role is required", nil + + case "refs": + // This is strictly a read operation. It is handy on the frontend for viewers. + if id.GetOrgRole().Includes(identity.RoleViewer) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "viewer role is required", nil + + case "files": + // Access to files is controlled by the AccessClient + return authorizer.DecisionAllow, "", nil + + case "resources", "sync", "history": + // These are strictly read operations. + // Sync can also be somewhat destructive, but it's expected to be fine to import changes. + if id.GetOrgRole().Includes(identity.RoleEditor) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "editor role is required", nil + + case "status": + if id.GetOrgRole().Includes(identity.RoleViewer) && a.GetVerb() == apiutils.VerbGet { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "users cannot update the status of a repository", nil + + default: + if id.GetIsGrafanaAdmin() { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "unmapped subresource defaults to no access", nil + } +} + +// authorizeStats handles authorization for stats resource. +func (b *APIBuilder) authorizeStats(id identity.Requester) (authorizer.Decision, string, error) { + // This can leak information one shouldn't necessarily have access to. + if id.GetOrgRole().Includes(identity.RoleAdmin) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "admin role is required", nil +} + +// authorizeSettings handles authorization for settings resource. +func (b *APIBuilder) authorizeSettings(id identity.Requester) (authorizer.Decision, string, error) { + // This is strictly a read operation. It is handy on the frontend for viewers. + if id.GetOrgRole().Includes(identity.RoleViewer) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "viewer role is required", nil +} + +// authorizeJobs handles authorization for job resources. +func (b *APIBuilder) authorizeJobs(id identity.Requester) (authorizer.Decision, string, error) { + // Jobs are shown on the configuration page. + if id.GetOrgRole().Includes(identity.RoleAdmin) { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "admin role is required", nil +} + +// authorizeDefault handles authorization for unmapped resources. +func (b *APIBuilder) authorizeDefault(id identity.Requester) (authorizer.Decision, string, error) { + // We haven't bothered with this kind yet. + if id.GetIsGrafanaAdmin() { + return authorizer.DecisionAllow, "", nil + } + return authorizer.DecisionDeny, "unmapped kind defaults to no access", nil +} + func (b *APIBuilder) GetGroupVersion() schema.GroupVersion { return provisioning.SchemeGroupVersion } diff --git a/pkg/tests/apis/provisioning/repository_test.go b/pkg/tests/apis/provisioning/repository_test.go index 37796da7b3d..877ed0d5f98 100644 --- a/pkg/tests/apis/provisioning/repository_test.go +++ b/pkg/tests/apis/provisioning/repository_test.go @@ -867,3 +867,86 @@ func TestIntegrationProvisioning_DeleteRepositoryAndReleaseResources(t *testing. } }, time.Second*20, time.Millisecond*10, "Expected folders to be released") } + +func TestIntegrationProvisioning_JobPermissions(t *testing.T) { + testutil.SkipIntegrationTestInShortMode(t) + + helper := runGrafana(t) + ctx := context.Background() + + const repo = "job-permissions-test" + testRepo := TestRepo{ + Name: repo, + Target: "folder", + Copies: map[string]string{}, // No files needed for this test + ExpectedDashboards: 0, + ExpectedFolders: 1, // Repository creates a folder + } + helper.CreateRepo(t, testRepo) + + jobSpec := provisioning.JobSpec{ + Action: provisioning.JobActionPull, + Pull: &provisioning.SyncJobOptions{}, + } + body := asJSON(jobSpec) + + t.Run("editor can POST jobs", func(t *testing.T) { + var statusCode int + result := helper.EditorREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.NoError(t, result.Error(), "editor should be able to POST jobs") + require.Equal(t, http.StatusAccepted, statusCode, "should return 202 Accepted") + + // Verify the job was created + obj, err := result.Get() + require.NoError(t, err, "should get job object") + unstruct, ok := obj.(*unstructured.Unstructured) + require.True(t, ok, "expecting unstructured object") + require.NotEmpty(t, unstruct.GetName(), "job should have a name") + }) + + t.Run("viewer cannot POST jobs", func(t *testing.T) { + var statusCode int + result := helper.ViewerREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + require.Error(t, result.Error(), "viewer should not be able to POST jobs") + require.Equal(t, http.StatusForbidden, statusCode, "should return 403 Forbidden") + require.True(t, apierrors.IsForbidden(result.Error()), "error should be forbidden") + }) + + t.Run("admin can POST jobs", func(t *testing.T) { + var statusCode int + result := helper.AdminREST.Post(). + Namespace("default"). + Resource("repositories"). + Name(repo). + SubResource("jobs"). + Body(body). + SetHeader("Content-Type", "application/json"). + Do(ctx).StatusCode(&statusCode) + + // Job might already exist from previous test, which is acceptable + if apierrors.IsAlreadyExists(result.Error()) { + // Wait for the existing job to complete + helper.AwaitJobs(t, repo) + return + } + + require.NoError(t, result.Error(), "admin should be able to POST jobs") + require.Equal(t, http.StatusAccepted, statusCode, "should return 202 Accepted") + }) +} From ad793a52888b6c2cf78f2e3bbb9e6cee431a8371 Mon Sep 17 00:00:00 2001 From: Andreas Christou Date: Mon, 15 Dec 2025 16:43:22 +0100 Subject: [PATCH 14/27] Logs: Improved flexibility of `hasSupplementaryQuerySupport` (#115348) Pass the request for improved control --- packages/grafana-data/src/types/logs.ts | 7 ++++--- public/app/features/explore/utils/supplementaryQueries.ts | 2 +- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/grafana-data/src/types/logs.ts b/packages/grafana-data/src/types/logs.ts index 94f1d97518c..b5e66d705f4 100644 --- a/packages/grafana-data/src/types/logs.ts +++ b/packages/grafana-data/src/types/logs.ts @@ -273,7 +273,7 @@ export interface DataSourceWithSupplementaryQueriesSupport): SupplementaryQueryType[]; /** * Returns a supplementary query to be used to fetch supplementary data based on the provided type and original query. * If the provided query is not suitable for the provided supplementary query type, undefined should be returned. @@ -283,7 +283,8 @@ export interface DataSourceWithSupplementaryQueriesSupport( datasource: DataSourceApi | (DataSourceApi & DataSourceWithSupplementaryQueriesSupport), - type: SupplementaryQueryType + type: SupplementaryQueryType, + dsRequest?: DataQueryRequest ): datasource is DataSourceApi & DataSourceWithSupplementaryQueriesSupport => { if (!datasource) { return false; @@ -293,7 +294,7 @@ export const hasSupplementaryQuerySupport = ( ('getDataProvider' in datasource || 'getSupplementaryRequest' in datasource) && 'getSupplementaryQuery' in datasource && 'getSupportedSupplementaryQueryTypes' in datasource && - datasource.getSupportedSupplementaryQueryTypes().includes(type) + datasource.getSupportedSupplementaryQueryTypes(dsRequest).includes(type) ); }; diff --git a/public/app/features/explore/utils/supplementaryQueries.ts b/public/app/features/explore/utils/supplementaryQueries.ts index a705c8193fb..45ffe4a5f08 100644 --- a/public/app/features/explore/utils/supplementaryQueries.ts +++ b/public/app/features/explore/utils/supplementaryQueries.ts @@ -129,7 +129,7 @@ export const getSupplementaryQueryProvider = ( dsRequest.requestId = `${dsRequest.requestId || ''}_${i}`; dsRequest.targets = targets; - if (hasSupplementaryQuerySupport(datasource, type)) { + if (hasSupplementaryQuerySupport(datasource, type, dsRequest)) { if (datasource.getDataProvider) { return datasource.getDataProvider(type, dsRequest); } else if (datasource.getSupplementaryRequest) { From 1ffd19f1e9e7c423b576c5e84f91f20facf80731 Mon Sep 17 00:00:00 2001 From: Sonia Aguilar <33540275+soniaAguilarPeiron@users.noreply.github.com> Date: Mon, 15 Dec 2025 16:50:31 +0100 Subject: [PATCH 15/27] Alerting: Update prompt for Analyze rule AI button (#115341) * update prompt for analayze rule AI button * bring back the follow up in prompt * use navigation suggestion instead of follow up --- .../alerting/unified/components/assistant/AnalizeRuleButton.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/public/app/features/alerting/unified/components/assistant/AnalizeRuleButton.tsx b/public/app/features/alerting/unified/components/assistant/AnalizeRuleButton.tsx index ba37a05cda1..2736a2e4f0f 100644 --- a/public/app/features/alerting/unified/components/assistant/AnalizeRuleButton.tsx +++ b/public/app/features/alerting/unified/components/assistant/AnalizeRuleButton.tsx @@ -99,7 +99,7 @@ function buildAnalyzeAlertingRulePrompt(rule: GrafanaAlertingRule): string { const state = rule.state || 'firing'; const timeInfo = rule.activeAt ? ` starting at ${new Date(rule.activeAt).toISOString()}` : ''; const alertsNavigationPrompt = config.featureToggles.alertingTriage - ? '\n- Include navigation to follow up on the alerts page' + ? '\n- Include navigation to the alerts page ONLY if the alert is firing or pending' : ''; let prompt = ` From 7b8d7d94ac0da34983b5cb0cf282405b0cac8201 Mon Sep 17 00:00:00 2001 From: Oscar Kilhed Date: Mon, 15 Dec 2025 17:04:50 +0100 Subject: [PATCH 16/27] Dashboards: Fix dashboard controls margin (#115360) fix dashboard controls margin --- public/app/features/dashboard-scene/scene/DashboardControls.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index d2b80340dfc..16be88595d9 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -246,6 +246,7 @@ function getStyles(theme: GrafanaTheme2) { position: 'relative', width: '100%', marginLeft: 'auto', + display: 'inline-block', [theme.breakpoints.down('sm')]: { flexDirection: 'column-reverse', alignItems: 'stretch', From 4d6fc09cb14a46819c35bd642a87a99731b562d0 Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:30:38 -0600 Subject: [PATCH 17/27] alerting docs: RBAC updates (#114776) * alerting docs: RBAC updates added permissions that weren't listed, broke up into smaller sections * clarifications, edits, and suggestions changed the formatting to address some comments, suggestions, and typos * Update index.md * basic roles table added to alerting * permissions overview chart * ai caught some other things... * prettier * "provenance:writer" addition apparently it's not actually "status.writer"? * prettier * re: yuri comments --- .../index.md | 176 ++++++++-------- .../alerting/set-up/configure-rbac/_index.md | 197 ++++++++++++++---- .../configure-rbac/access-folders/index.md | 2 +- .../configure-rbac/access-roles/index.md | 6 +- .../alerting/set-up/configure-roles/index.md | 40 ++-- 5 files changed, 271 insertions(+), 150 deletions(-) diff --git a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md index 43b6e9c74a1..82ebfa0c49a 100644 --- a/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md +++ b/docs/sources/administration/roles-and-permissions/access-control/rbac-fixed-basic-role-definitions/index.md @@ -44,7 +44,7 @@ refs: destination: /docs/grafana-cloud/alerting-and-irm/oncall/user-and-team-management/#available-grafana-oncall-rbac-roles--granted-actions --- -# RBAC role definitions +# Grafana RBAC role definitions {{< admonition type="note" >}} Available in [Grafana Enterprise](/docs/grafana//introduction/grafana-enterprise/) and [Grafana Cloud](/docs/grafana-cloud). @@ -59,7 +59,7 @@ The following tables list permissions associated with basic and fixed roles. Thi | Grafana Admin | `basic_grafana_admin` | | `fixed:authentication.config:writer`
`fixed:general.auth.config:writer`
`fixed:ldap:writer`
`fixed:licensing:writer`
`fixed:migrationassistant:migrator`
`fixed:org.users:writer`
`fixed:organization:maintainer`
`fixed:plugins:maintainer`
`fixed:provisioning:writer`
`fixed:roles:writer`
`fixed:settings:reader`
`fixed:settings:writer`
`fixed:stats:reader`
`fixed:support.bundles:writer`
`fixed:usagestats:reader`
`fixed:users:writer` | Default [Grafana server administrator](/docs/grafana//administration/roles-and-permissions/#grafana-server-administrators) assignments. | | Admin | `basic_admin` | All roles assigned to Editor and `fixed:reports:writer`
`fixed:datasources:writer`
`fixed:organization:writer`
`fixed:datasources.permissions:writer`
`fixed:teams:writer`
`fixed:dashboards:writer`
`fixed:dashboards.permissions:writer`
`fixed:dashboards.public:writer`
`fixed:folders:writer`
`fixed:folders.permissions:writer`
`fixed:alerting:writer`
`fixed:alerting.provisioning.secrets:reader`
`fixed:alerting.provisioning:writer`
`fixed:datasources.caching:writer`
`fixed:plugins:writer`
`fixed:library.panels:writer` | Default [Grafana organization administrator](ref:rbac-basic-roles) assignments. | -| Editor | `basic_editor` | All roles assigned to Viewer and `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:alerting:writer`
`fixed:library.panels:creator`
`fixed:library.panels:general.writer`
`fixed:alerting.provisioning.status:writer` | Default [Editor](ref:rbac-basic-roles) assignments. | +| Editor | `basic_editor` | All roles assigned to Viewer and `fixed:datasources:explorer`
`fixed:dashboards:creator`
`fixed:folders:creator`
`fixed:annotations:writer`
`fixed:alerting:writer`
`fixed:library.panels:creator`
`fixed:library.panels:general.writer`
`fixed:alerting.provisioning.provenance:writer` | Default [Editor](ref:rbac-basic-roles) assignments. | | Viewer | `basic_viewer` | `fixed:datasources.id:reader`
`fixed:organization:reader`
`fixed:annotations:reader`
`fixed:annotations.dashboard:writer`
`fixed:alerting:reader`
`fixed:plugins.app:reader`
`fixed:dashboards.insights:reader`
`fixed:datasources.insights:reader`
`fixed:library.panels:general.reader`
`fixed:folders.general:reader`
`fixed:datasources.builtin:reader` | Default [Viewer](ref:rbac-basic-roles) assignments. | | No Basic Role | n/a | | Default [No Basic Role](ref:rbac-basic-roles) | @@ -74,86 +74,86 @@ These UUIDs won't be available if your instance was created before Grafana v10.2 To learn how to use the roles API to determine the role UUIDs, refer to [Manage RBAC roles](ref:rbac-manage-rbac-roles). {{< /admonition >}} -| Fixed role | UUID | Permissions | Description | -| -------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `fixed:alerting:reader` | `fixed_O2oP1_uBFozI2i93klAkcvEWR30` | All permissions from `fixed:alerting.rules:reader`
`fixed:alerting.instances:reader`
`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting:writer` | `fixed_-PAZgSJsDlRD8NUg-PFSeH_BkJY` | All permissions from `fixed:alerting.rules:writer`
`fixed:alerting.instances:writer`
`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting.instances:reader` | `fixed_ut5fVS-Ulh_ejFoskFhJT_rYg0Y` | `alert.instances:read` for organization scope
`alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) | -| `fixed:alerting.instances:writer` | `fixed_pKOBJE346uyqMLdgWbk1NsQfEl0` | All permissions from `fixed:alerting.instances:reader` and
`alert.instances:create`
`alert.instances:write` for organization scope
`alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) | -| `fixed:alerting.notifications:reader` | `fixed_hmBn0lX5h1RZXB9Vaot420EEdA0` | `alert.notifications:read` for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) | -| `fixed:alerting.notifications:writer` | `fixed_XplK6HPNxf9AP5IGTdB5Iun4tJc` | All permissions from `fixed:alerting.notifications:reader` and
`alert.notifications:write`for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) | -| `fixed:alerting.provisioning:writer` | `fixed_y7pFjdEkxpx5ETdcxPvp0AgRuUo` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) | -| `fixed:alerting.provisioning.secrets:reader` | `fixed_9fmzXXZZG-Od0Amy2ofEG8Uk--c` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) | -| `fixed:alerting.provisioning.status:writer` | `fixed_eAxlzfkTuobvKEgXHveFMBZrOj8` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) | -| `fixed:alerting.rules:reader` | `fixed_fRGKL_vAqUsmUWq5EYKnOha9DcA` | `alert.rule:read`, `alert.silences:read` for scope `folders:*`
`alert.rules.external:read` for scope `datasources:*`
`alert.notifications.time-intervals:read`
`alert.notifications.receivers:list` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and read rule-specific silences | -| `fixed:alerting.rules:writer` | `fixed_YJJGwAalUwDZPrXSyFH8GfYBXAc` | All permissions from `fixed:alerting.rules:reader` and
`alert.rule:create`
`alert.rule:write`
`alert.rule:delete`
`alert.silences:create`
`alert.silences:write` for scope `folders:*`
`alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and manage rule-specific silences | -| `fixed:annotations:reader` | `fixed_hpZnoizrfAJsrceNcNQqWYV-xNU` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. | -| `fixed:annotations:writer` | `fixed_ZVW-Aa9Tzle6J4s2aUFcq1StKWE` | All permissions from `fixed:annotations:reader`
`annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. | -| `fixed:annotations.dashboard:writer` | `fixed_8A775xenXeKaJk4Cr7bchP9yXOA` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. | -| `fixed:authentication.config:writer` | `fixed_0rYhZ2Qnzs8AdB1nX7gexk3fHDw` | `settings:read` for scope `settings:auth.saml:*`
`settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. | -| `fixed:general.auth.config:writer` | `fixed_QFxIT_FGtBqbIVJIwx1bLgI5z6c` | `settings:read` for scope `settings:auth:oauth_allow_insecure_email_lookup`
`settings:write` for scope `settings:auth:oauth_allow_insecure_email_lookup` | Read and update the Grafana instance's general authentication configuration settings. | -| `fixed:dashboards:creator` | `fixed_ZorKUcEPCM01A1fPakEzGBUyU64` | `dashboards:create`
`folders:read` | Create dashboards. | -| `fixed:dashboards:reader` | `fixed_Sgr67JTOhjQGFlzYRahOe45TdWM` | `dashboards:read` | Read all dashboards. | -| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and
`dashboards:write`
`dashboards:delete`
`dashboards:create`
`dashboards.permissions:read`
`dashboards.permissions:write` | Read, create, update, and delete all dashboards. | -| `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. | -| `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. | -| `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and
`dashboards.permissions:write` | Read and update all dashboard permissions. | -| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a shared dashboard. | -| `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. | -| `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | -| `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`
`datasources:query` | Read and query data sources. | -| `fixed:datasources:writer` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | All permissions from `fixed:datasources:reader` and
`datasources:create`
`datasources:write`
`datasources:delete` | Read, query, create, delete, or update a data source. | -| `fixed:datasources.builtin:reader` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | `datasources:read` and `datasources:query` scoped to `datasources:uid:grafana` | An internal role used to grant Viewers access to the builtin example data source in Grafana. | -| `fixed:datasources.caching:reader` | `fixed_D2ddpGxJYlw0mbsTS1ek9fj0kj4` | `datasources.caching:read` | Read data source query caching settings. | -| `fixed:datasources.caching:writer` | `fixed_JtFjHr7jd7hSqUYcktKvRvIOGRE` | `datasources.caching:read`
`datasources.caching:write` | Enable, disable, or update query caching settings. | -| `fixed:datasources.id:reader` | `fixed_entg--fHmDqWY2-69N0ocawK0Os` | `datasources.id:read` | Read the ID of a data source based on its name. | -| `fixed:datasources.insights:reader` | `fixed_EBZ3NwlfecNPp2p0XcZRC1nfEYk` | `datasources.insights:read` | Read data source insights data. | -| `fixed:datasources.permissions:reader` | `fixed_ErYA-cTN3yn4h4GxaVPcawRhiOY` | `datasources.permissions:read` | Read data source permissions. | -| `fixed:datasources.permissions:writer` | `fixed_aiQh9YDfLOKjQhYasF9_SFUjQiw` | All permissions from `fixed:datasources.permissions:reader` and
`datasources.permissions:write` | Create, read, or delete permissions of a data source. | -| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. | -| `fixed:folders:reader` | `fixed_yeW-5QPeo-i5PZUIUXMlAA97GnQ` | `folders:read`
`dashboards:read` | Read all folders and dashboards. | -| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and
`folders:read`
`folders:write`
`folders:create`
`folders:delete`
`folders.permissions:read`
`folders.permissions:write` | Read, update, and delete all folders and dashboards. Create folders and subfolders. | -| `fixed:folders.general:reader` | `fixed_rSASbkg8DvpG_gTX5s41d7uxRvI` | `folders:read` scoped to `folders:uid:general` | An internal role used to correctly display access to the folder tree for Viewer role. | -| `fixed:folders.permissions:reader` | `fixed_E06l4cx0JFm47EeLBE4nmv3pnSo` | `folders.permissions:read` | Read all folder permissions. | -| `fixed:folders.permissions:writer` | `fixed_3GAgpQ_hWG8o7-lwNb86_VB37eI` | All permissions from `fixed:folders.permissions:reader` and
`folders.permissions:write` | Read and update all folder permissions. | -| `fixed:ldap:reader` | `fixed_lMcOPwSkxKY-qCK8NMJc5k6izLE` | `ldap.user:read`
`ldap.status:read` | Read the LDAP configuration and LDAP status information. | -| `fixed:ldap:writer` | `fixed_p6AvnU4GCQyIh7-hbwI-bk3GYnU` | All permissions from `fixed:ldap:reader` and
`ldap.user:sync`
`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. | -| `fixed:library.panels:creator` | `fixed_6eX6ItfegCIY5zLmPqTDW8ZV7KY` | `library.panels:create`
`folders:read` | Create library panel at the root level. | -| `fixed:library.panels:general.reader` | `fixed_ct0DghiBWR_2BiQm3EvNPDVmpio` | `library.panels:read` | Read all library panels at the root level. | -| `fixed:library.panels:general.writer` | `fixed_DgprkmqfN_1EhZ2v1_d1fYG8LzI` | All permissions from `fixed:library.panels:general.reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. | -| `fixed:library.panels:reader` | `fixed_tvTr9CnZ6La5vvUO_U_X1LPnhUs` | `library.panels:read` | Read all library panels. | -| `fixed:library.panels:writer` | `fixed_JTljAr21LWLTXCkgfBC4H0lhBC8` | All permissions from `fixed:library.panels:reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions. | -| `fixed:licensing:reader` | `fixed_OADpuXvNEylO2Kelu3GIuBXEAYE` | `licensing:read`
`licensing.reports:read` | Read licensing information and licensing reports. | -| `fixed:licensing:writer` | `fixed_gzbz3rJpQMdaKHt-E4q0PVaKMoE` | All permissions from `fixed:licensing:reader` and
`licensing:write`
`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. | -| `fixed:migrationassistant:migrator` | `fixed_LLk2p7TRuBztOAksTQb1Klc8YTk` | `migrationassistant:migrate` | Execute on-prem to cloud migrations through the Migration Assistant. | -| `fixed:org.users:reader` | `fixed_oCqNwlVHLOpw7-jAlwp4HzYqwGY` | `org.users:read` | Read users within a single organization. | -| `fixed:org.users:writer` | `fixed_VERj5nayasjgf_Yh0sWqqCkxWlw` | All permissions from `fixed:org.users:reader` and
`org.users:add`
`org.users:remove`
`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. | -| `fixed:organization:maintainer` | `fixed_CMm-uuBaPUBf4r8XG3jIvxo55bg` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs:create`
`orgs:delete`
`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. | -| `fixed:organization:reader` | `fixed_0SZPJlTHdNEe8zO91zv7Zwiwa2w` | `orgs:read`
`orgs.quotas:read` | Read an organization and its quotas. | -| `fixed:organization:writer` | `fixed_Y4jGqDd8w1yCrPwlik8z5Iu8-3M` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs.preferences:read`
`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. | -| `fixed:plugins:maintainer` | `fixed_yEOKidBcWgbm74x-nTa3lW5lOyY` | `plugins:install` | Install and uninstall plugins. Needs to be assigned globally. | -| `fixed:plugins:writer` | `fixed_MRYpGk7kpNNwt2VoVOXFiPnQziE` | `plugins:write` | Enable and disable plugins and edit plugins' settings. | -| `fixed:plugins.app:reader` | `fixed_AcZRiNYx7NueYkUqzw1o2OGGUAA` | `plugins.app:access` | Access application plugins (still enforcing the organization role). | -| `fixed:provisioning:writer` | `fixed_bgk1FCyR6OEDwhgirZlQgu5LlCA` | `provisioning:reload` | Reload provisioning. | -| `fixed:reports:reader` | `fixed_72_8LU_0ukfm6BdblOw8Z9q-GQ8` | `reports:read`
`reports:send`
`reports.settings:read` | Read all reports and shared report settings. | -| `fixed:reports:writer` | `fixed_jBW3_7g1EWOjGVBYeVRwtFxhUNw` | All permissions from `fixed:reports:reader` and
`reports:create`
`reports:write`
`reports:delete`
`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. | -| `fixed:roles:reader` | `fixed_GkfG-1NSwEGb4hpK3-E3qHyNltc` | `roles:read`
`teams.roles:read`
`users.roles:read`
`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. | -| `fixed:roles:resetter` | `fixed_WgPpC3qJRmVpVTJavFNwfS5RuzQ` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. | -| `fixed:roles:writer` | `fixed_W5aFaw8isAM27x_eWfElBhZ0iOc` | All permissions from `fixed:roles:reader` and
`roles:write`
`roles:delete`
`teams.roles:add`
`teams.roles:remove`
`users.roles:add`
`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. | -| `fixed:serviceaccounts:creator` | `fixed_Ikw60fckA0MyiiZ73BawSfOULy4` | `serviceaccounts:create` | Create Grafana service accounts. | -| `fixed:serviceaccounts:reader` | `fixed_QFjJAZ88iawMLInYOxPA1DB1w6I` | `serviceaccounts:read` | Read Grafana service accounts. | -| `fixed:serviceaccounts:writer` | `fixed_iBvUNUEZBZ7PUW0vdkN5iojc2sk` | `serviceaccounts:read`
`serviceaccounts:create`
`serviceaccounts:write`
`serviceaccounts:delete`
`serviceaccounts.permissions:read`
`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. | -| `fixed:settings:reader` | `fixed_0LaUt1x6PP8hsZzEBhqPQZFUd8Q` | `settings:read` | Read Grafana instance settings. | -| `fixed:settings:writer` | `fixed_joIHDgMrGg790hMhUufVzcU4j44` | All permissions from `fixed:settings:reader` and
`settings:write` | Read and update Grafana instance settings. | -| `fixed:stats:reader` | `fixed_OnRCXxZVINWpcKvTF5A1gecJ7pA` | `server.stats:read` | Read Grafana instance statistics. | -| `fixed:support.bundles:reader` | `fixed_gcPjI3PTUJwRx-GJZwDhNa7zbos` | `support.bundles:read` | List and download support bundles. | -| `fixed:support.bundles:writer` | `fixed_dTgCv9Wxrp_WHAhwHYIgeboxKpE` | `support.bundles:read`
`support.bundles:create`
`support.bundles:delete` | Create, delete, list and download support bundles. | -| `fixed:teams:creator` | `fixed_nzVQoNSDSn0fg1MDgO6XnZX2RZI` | `teams:create`
`org.users:read` | Create a team and list organization users (required to manage the created team). | -| `fixed:teams:read` | `fixed_Z8pB0GQlrqRt8IZBCJQxPWvJPgQ` | `teams:read` | List all teams. | -| `fixed:teams:writer` | `fixed_xw1T0579h620MOYi4L96GUs7fZY` | `teams:create`
`teams:delete`
`teams:read`
`teams:write`
`teams.permissions:read`
`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. | -| `fixed:usagestats:reader` | `fixed_eAM0azEvnWFCJAjNkUKnGL_1-bU` | `server.usagestats.report:read` | View usage statistics report. | -| `fixed:users:reader` | `fixed_buZastUG3reWyQpPemcWjGqPAd0` | `users:read`
`users.quotas:read`
`users.authtoken:read` | Read all users and their information, such as team memberships, authentication tokens, and quotas. | -| `fixed:users:writer` | `fixed_wjzgHHo_Ux25DJuELn_oiAdB_yM` | All permissions from `fixed:users:reader` and
`users:write`
`users:create`
`users:delete`
`users:enable`
`users:disable`
`users.password:write`
`users.permissions:write`
`users:logout`
`users.authtoken:write`
`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. | +| Fixed role | UUID | Permissions | Description | +| ----------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `fixed:alerting:reader` | `fixed_O2oP1_uBFozI2i93klAkcvEWR30` | All permissions from `fixed:alerting.rules:reader`
`fixed:alerting.instances:reader`
`fixed:alerting.notifications:reader` | Read-only permissions for all Grafana, Mimir, Loki and Alertmanager alert rules\*, alerts, contact points, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting:writer` | `fixed_-PAZgSJsDlRD8NUg-PFSeH_BkJY` | All permissions from `fixed:alerting.rules:writer`
`fixed:alerting.instances:writer`
`fixed:alerting.notifications:writer` | Create, update, and delete Grafana, Mimir, Loki and Alertmanager alert rules\*, silences, contact points, templates, mute timings, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting.instances:reader` | `fixed_ut5fVS-Ulh_ejFoskFhJT_rYg0Y` | `alert.instances:read` for organization scope
`alert.instances.external:read` for scope `datasources:*` | Read all alerts and silences in the organization produced by Grafana Alerts and Mimir and Loki alerts and silences.[\*](#alerting-roles) | +| `fixed:alerting.instances:writer` | `fixed_pKOBJE346uyqMLdgWbk1NsQfEl0` | All permissions from `fixed:alerting.instances:reader` and
`alert.instances:create`
`alert.instances:write` for organization scope
`alert.instances.external:write` for scope `datasources:*` | Create, update and expire all silences in the organization produced by Grafana, Mimir, and Loki.[\*](#alerting-roles) | +| `fixed:alerting.notifications:reader` | `fixed_hmBn0lX5h1RZXB9Vaot420EEdA0` | `alert.notifications:read` for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Read all Grafana and Alertmanager contact points, templates, and notification policies.[\*](#alerting-roles) | +| `fixed:alerting.notifications:writer` | `fixed_XplK6HPNxf9AP5IGTdB5Iun4tJc` | All permissions from `fixed:alerting.notifications:reader` and
`alert.notifications:write`for organization scope
`alert.notifications.external:read` for scope `datasources:*` | Create, update, and delete contact points, templates, mute timings and notification policies for Grafana and external Alertmanager.[\*](#alerting-roles) | +| `fixed:alerting.provisioning:writer` | `fixed_y7pFjdEkxpx5ETdcxPvp0AgRuUo` | `alert.provisioning:read` and `alert.provisioning:write` | Create, update and delete Grafana alert rules, notification policies, contact points, templates, etc via provisioning API. [\*](#alerting-roles) | +| `fixed:alerting.provisioning.secrets:reader` | `fixed_9fmzXXZZG-Od0Amy2ofEG8Uk--c` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read-only permissions for Provisioning API and let export resources with decrypted secrets [\*](#alerting-roles) | +| `fixed:alerting.provisioning.provenance:writer` | `fixed_eAxlzfkTuobvKEgXHveFMBZrOj8` | `alert.provisioning.provenance:write` | Set provenance status to alert rules, notification policies, contact points, etc. Should be used together with regular writer roles. [\*](#alerting-roles) | +| `fixed:alerting.rules:reader` | `fixed_fRGKL_vAqUsmUWq5EYKnOha9DcA` | `alert.rule:read`, `alert.silences:read` for scope `folders:*`
`alert.rules.external:read` for scope `datasources:*`
`alert.notifications.time-intervals:read`
`alert.notifications.receivers:list` | Read all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and read rule-specific silences | +| `fixed:alerting.rules:writer` | `fixed_YJJGwAalUwDZPrXSyFH8GfYBXAc` | All permissions from `fixed:alerting.rules:reader` and
`alert.rule:create`
`alert.rule:write`
`alert.rule:delete`
`alert.silences:create`
`alert.silences:write` for scope `folders:*`
`alert.rules.external:write` for scope `datasources:*` | Create, update, and delete all\* Grafana, Mimir, and Loki alert rules.[\*](#alerting-roles) and manage rule-specific silences | +| `fixed:annotations:reader` | `fixed_hpZnoizrfAJsrceNcNQqWYV-xNU` | `annotations:read` for scopes `annotations:type:*` | Read all annotations and annotation tags. | +| `fixed:annotations:writer` | `fixed_ZVW-Aa9Tzle6J4s2aUFcq1StKWE` | All permissions from `fixed:annotations:reader`
`annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:*` | Read, create, update and delete all annotations and annotation tags. | +| `fixed:annotations.dashboard:writer` | `fixed_8A775xenXeKaJk4Cr7bchP9yXOA` | `annotations:write`
`annotations.create`
`annotations:delete` for scope `annotations:type:dashboard` | Create, update and delete dashboard annotations and annotation tags. | +| `fixed:authentication.config:writer` | `fixed_0rYhZ2Qnzs8AdB1nX7gexk3fHDw` | `settings:read` for scope `settings:auth.saml:*`
`settings:write` for scope `settings:auth.saml:*` | Read and update authentication and SAML settings. | +| `fixed:general.auth.config:writer` | `fixed_QFxIT_FGtBqbIVJIwx1bLgI5z6c` | `settings:read` for scope `settings:auth:oauth_allow_insecure_email_lookup`
`settings:write` for scope `settings:auth:oauth_allow_insecure_email_lookup` | Read and update the Grafana instance's general authentication configuration settings. | +| `fixed:dashboards:creator` | `fixed_ZorKUcEPCM01A1fPakEzGBUyU64` | `dashboards:create`
`folders:read` | Create dashboards. | +| `fixed:dashboards:reader` | `fixed_Sgr67JTOhjQGFlzYRahOe45TdWM` | `dashboards:read` | Read all dashboards. | +| `fixed:dashboards:writer` | `fixed_OK2YOQGIoI1G031hVzJB6rAJQAs` | All permissions from `fixed:dashboards:reader` and
`dashboards:write`
`dashboards:delete`
`dashboards:create`
`dashboards.permissions:read`
`dashboards.permissions:write` | Read, create, update, and delete all dashboards. | +| `fixed:dashboards.insights:reader` | `fixed_JlBJ2_gizP8zhgaeGE2rjyZe2Rs` | `dashboards.insights:read` | Read dashboard insights data and see presence indicators. | +| `fixed:dashboards.permissions:reader` | `fixed_f17oxuXW_58LL8mYJsm4T_mCeIw` | `dashboards.permissions:read` | Read all dashboard permissions. | +| `fixed:dashboards.permissions:writer` | `fixed_CcznxhWX_Yqn8uWMXMQ-b5iFW9k` | All permissions from `fixed:dashboards.permissions:reader` and
`dashboards.permissions:write` | Read and update all dashboard permissions. | +| `fixed:dashboards.public:writer` | `fixed_f_GHHRBciaqESXfGz2oCcooqHxs` | `dashboards.public:write` | Create, update, delete or pause a shared dashboard. | +| `fixed:datasources:creator` | `fixed_XX8jHREgUt-wo1A-rPXIiFlX6Zw` | `datasources:create` | Create data sources. | +| `fixed:datasources:explorer` | `fixed_qDzW9mzx9yM91T5Bi8dHUM2muTw` | `datasources:explore` | Enable the Explore feature. Data source permissions still apply, you can only query data sources for which you have query permissions. | +| `fixed:datasources:reader` | `fixed_C2x8IxkiBc1KZVjyYH775T9jNMQ` | `datasources:read`
`datasources:query` | Read and query data sources. | +| `fixed:datasources:writer` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | All permissions from `fixed:datasources:reader` and
`datasources:create`
`datasources:write`
`datasources:delete` | Read, query, create, delete, or update a data source. | +| `fixed:datasources.builtin:reader` | `fixed_q8HXq8kjjA5IlHHgBJlKlUyaNik` | `datasources:read` and `datasources:query` scoped to `datasources:uid:grafana` | An internal role used to grant Viewers access to the builtin example data source in Grafana. | +| `fixed:datasources.caching:reader` | `fixed_D2ddpGxJYlw0mbsTS1ek9fj0kj4` | `datasources.caching:read` | Read data source query caching settings. | +| `fixed:datasources.caching:writer` | `fixed_JtFjHr7jd7hSqUYcktKvRvIOGRE` | `datasources.caching:read`
`datasources.caching:write` | Enable, disable, or update query caching settings. | +| `fixed:datasources.id:reader` | `fixed_entg--fHmDqWY2-69N0ocawK0Os` | `datasources.id:read` | Read the ID of a data source based on its name. | +| `fixed:datasources.insights:reader` | `fixed_EBZ3NwlfecNPp2p0XcZRC1nfEYk` | `datasources.insights:read` | Read data source insights data. | +| `fixed:datasources.permissions:reader` | `fixed_ErYA-cTN3yn4h4GxaVPcawRhiOY` | `datasources.permissions:read` | Read data source permissions. | +| `fixed:datasources.permissions:writer` | `fixed_aiQh9YDfLOKjQhYasF9_SFUjQiw` | All permissions from `fixed:datasources.permissions:reader` and
`datasources.permissions:write` | Create, read, or delete permissions of a data source. | +| `fixed:folders:creator` | `fixed_gGLRbZGAGB6n9uECqSh_W382RlQ` | `folders:create` | Create folders in the root level. | +| `fixed:folders:reader` | `fixed_yeW-5QPeo-i5PZUIUXMlAA97GnQ` | `folders:read`
`dashboards:read` | Read all folders and dashboards. | +| `fixed:folders:writer` | `fixed_wJXLoTzgE7jVuz90dryYoiogL0o` | All permissions from `fixed:dashboards:writer` and
`folders:read`
`folders:write`
`folders:create`
`folders:delete`
`folders.permissions:read`
`folders.permissions:write` | Read, update, and delete all folders and dashboards. Create folders and subfolders. | +| `fixed:folders.general:reader` | `fixed_rSASbkg8DvpG_gTX5s41d7uxRvI` | `folders:read` scoped to `folders:uid:general` | An internal role used to correctly display access to the folder tree for Viewer role. | +| `fixed:folders.permissions:reader` | `fixed_E06l4cx0JFm47EeLBE4nmv3pnSo` | `folders.permissions:read` | Read all folder permissions. | +| `fixed:folders.permissions:writer` | `fixed_3GAgpQ_hWG8o7-lwNb86_VB37eI` | All permissions from `fixed:folders.permissions:reader` and
`folders.permissions:write` | Read and update all folder permissions. | +| `fixed:ldap:reader` | `fixed_lMcOPwSkxKY-qCK8NMJc5k6izLE` | `ldap.user:read`
`ldap.status:read` | Read the LDAP configuration and LDAP status information. | +| `fixed:ldap:writer` | `fixed_p6AvnU4GCQyIh7-hbwI-bk3GYnU` | All permissions from `fixed:ldap:reader` and
`ldap.user:sync`
`ldap.config:reload` | Read and update the LDAP configuration, and read LDAP status information. | +| `fixed:library.panels:creator` | `fixed_6eX6ItfegCIY5zLmPqTDW8ZV7KY` | `library.panels:create`
`folders:read` | Create library panel at the root level. | +| `fixed:library.panels:general.reader` | `fixed_ct0DghiBWR_2BiQm3EvNPDVmpio` | `library.panels:read` | Read all library panels at the root level. | +| `fixed:library.panels:general.writer` | `fixed_DgprkmqfN_1EhZ2v1_d1fYG8LzI` | All permissions from `fixed:library.panels:general.reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions at the root level. | +| `fixed:library.panels:reader` | `fixed_tvTr9CnZ6La5vvUO_U_X1LPnhUs` | `library.panels:read` | Read all library panels. | +| `fixed:library.panels:writer` | `fixed_JTljAr21LWLTXCkgfBC4H0lhBC8` | All permissions from `fixed:library.panels:reader` plus
`library.panels:create`
`library.panels:delete`
`library.panels:write` | Create, read, write or delete all library panels and their permissions. | +| `fixed:licensing:reader` | `fixed_OADpuXvNEylO2Kelu3GIuBXEAYE` | `licensing:read`
`licensing.reports:read` | Read licensing information and licensing reports. | +| `fixed:licensing:writer` | `fixed_gzbz3rJpQMdaKHt-E4q0PVaKMoE` | All permissions from `fixed:licensing:reader` and
`licensing:write`
`licensing:delete` | Read licensing information and licensing reports, update and delete the license token. | +| `fixed:migrationassistant:migrator` | `fixed_LLk2p7TRuBztOAksTQb1Klc8YTk` | `migrationassistant:migrate` | Execute on-prem to cloud migrations through the Migration Assistant. | +| `fixed:org.users:reader` | `fixed_oCqNwlVHLOpw7-jAlwp4HzYqwGY` | `org.users:read` | Read users within a single organization. | +| `fixed:org.users:writer` | `fixed_VERj5nayasjgf_Yh0sWqqCkxWlw` | All permissions from `fixed:org.users:reader` and
`org.users:add`
`org.users:remove`
`org.users:write` | Within a single organization, add a user, invite a new user, read information about a user and their role, remove a user from that organization, or change the role of a user. | +| `fixed:organization:maintainer` | `fixed_CMm-uuBaPUBf4r8XG3jIvxo55bg` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs:create`
`orgs:delete`
`orgs.quotas:write` | Create, read, write, or delete an organization. Read or write its quotas. This role needs to be assigned globally. | +| `fixed:organization:reader` | `fixed_0SZPJlTHdNEe8zO91zv7Zwiwa2w` | `orgs:read`
`orgs.quotas:read` | Read an organization and its quotas. | +| `fixed:organization:writer` | `fixed_Y4jGqDd8w1yCrPwlik8z5Iu8-3M` | All permissions from `fixed:organization:reader` and
`orgs:write`
`orgs.preferences:read`
`orgs.preferences:write` | Read an organization, its quotas, or its preferences. Update organization properties, or its preferences. | +| `fixed:plugins:maintainer` | `fixed_yEOKidBcWgbm74x-nTa3lW5lOyY` | `plugins:install` | Install and uninstall plugins. Needs to be assigned globally. | +| `fixed:plugins:writer` | `fixed_MRYpGk7kpNNwt2VoVOXFiPnQziE` | `plugins:write` | Enable and disable plugins and edit plugins' settings. | +| `fixed:plugins.app:reader` | `fixed_AcZRiNYx7NueYkUqzw1o2OGGUAA` | `plugins.app:access` | Access application plugins (still enforcing the organization role). | +| `fixed:provisioning:writer` | `fixed_bgk1FCyR6OEDwhgirZlQgu5LlCA` | `provisioning:reload` | Reload provisioning. | +| `fixed:reports:reader` | `fixed_72_8LU_0ukfm6BdblOw8Z9q-GQ8` | `reports:read`
`reports:send`
`reports.settings:read` | Read all reports and shared report settings. | +| `fixed:reports:writer` | `fixed_jBW3_7g1EWOjGVBYeVRwtFxhUNw` | All permissions from `fixed:reports:reader` and
`reports:create`
`reports:write`
`reports:delete`
`reports.settings:write` | Create, read, update, or delete all reports and shared report settings. | +| `fixed:roles:reader` | `fixed_GkfG-1NSwEGb4hpK3-E3qHyNltc` | `roles:read`
`teams.roles:read`
`users.roles:read`
`users.permissions:read` | Read all access control roles, roles and permissions assigned to users, teams. | +| `fixed:roles:resetter` | `fixed_WgPpC3qJRmVpVTJavFNwfS5RuzQ` | `roles:write` with scope `permissions:type:escalate` | Reset basic roles to their default. | +| `fixed:roles:writer` | `fixed_W5aFaw8isAM27x_eWfElBhZ0iOc` | All permissions from `fixed:roles:reader` and
`roles:write`
`roles:delete`
`teams.roles:add`
`teams.roles:remove`
`users.roles:add`
`users.roles:remove` | Create, read, update, or delete all roles, assign or unassign roles to users, teams. | +| `fixed:serviceaccounts:creator` | `fixed_Ikw60fckA0MyiiZ73BawSfOULy4` | `serviceaccounts:create` | Create Grafana service accounts. | +| `fixed:serviceaccounts:reader` | `fixed_QFjJAZ88iawMLInYOxPA1DB1w6I` | `serviceaccounts:read` | Read Grafana service accounts. | +| `fixed:serviceaccounts:writer` | `fixed_iBvUNUEZBZ7PUW0vdkN5iojc2sk` | `serviceaccounts:read`
`serviceaccounts:create`
`serviceaccounts:write`
`serviceaccounts:delete`
`serviceaccounts.permissions:read`
`serviceaccounts.permissions:write` | Create, update, read and delete all Grafana service accounts and manage service account permissions. | +| `fixed:settings:reader` | `fixed_0LaUt1x6PP8hsZzEBhqPQZFUd8Q` | `settings:read` | Read Grafana instance settings. | +| `fixed:settings:writer` | `fixed_joIHDgMrGg790hMhUufVzcU4j44` | All permissions from `fixed:settings:reader` and
`settings:write` | Read and update Grafana instance settings. | +| `fixed:stats:reader` | `fixed_OnRCXxZVINWpcKvTF5A1gecJ7pA` | `server.stats:read` | Read Grafana instance statistics. | +| `fixed:support.bundles:reader` | `fixed_gcPjI3PTUJwRx-GJZwDhNa7zbos` | `support.bundles:read` | List and download support bundles. | +| `fixed:support.bundles:writer` | `fixed_dTgCv9Wxrp_WHAhwHYIgeboxKpE` | `support.bundles:read`
`support.bundles:create`
`support.bundles:delete` | Create, delete, list and download support bundles. | +| `fixed:teams:creator` | `fixed_nzVQoNSDSn0fg1MDgO6XnZX2RZI` | `teams:create`
`org.users:read` | Create a team and list organization users (required to manage the created team). | +| `fixed:teams:read` | `fixed_Z8pB0GQlrqRt8IZBCJQxPWvJPgQ` | `teams:read` | List all teams. | +| `fixed:teams:writer` | `fixed_xw1T0579h620MOYi4L96GUs7fZY` | `teams:create`
`teams:delete`
`teams:read`
`teams:write`
`teams.permissions:read`
`teams.permissions:write` | Create, read, update and delete teams and manage team memberships. | +| `fixed:usagestats:reader` | `fixed_eAM0azEvnWFCJAjNkUKnGL_1-bU` | `server.usagestats.report:read` | View usage statistics report. | +| `fixed:users:reader` | `fixed_buZastUG3reWyQpPemcWjGqPAd0` | `users:read`
`users.quotas:read`
`users.authtoken:read` | Read all users and their information, such as team memberships, authentication tokens, and quotas. | +| `fixed:users:writer` | `fixed_wjzgHHo_Ux25DJuELn_oiAdB_yM` | All permissions from `fixed:users:reader` and
`users:write`
`users:create`
`users:delete`
`users:enable`
`users:disable`
`users.password:write`
`users.permissions:write`
`users:logout`
`users.authtoken:write`
`users.quotas:write` | Read and update all attributes and settings for all users in Grafana: update user information, read user information, create or enable or disable a user, make a user a Grafana administrator, sign out a user, update a user’s authentication token, or update quotas for all users. | ### Alerting roles @@ -164,10 +164,20 @@ Access to Grafana alert rules is an intersection of many permissions: - Permission to read a folder. For example, the fixed role `fixed:folders:reader` includes the action `folders:read` and a folder scope `folders:id:`. - Permission to query **all** data sources that a given alert rule uses. If a user cannot query a given data source, they cannot see any alert rules that query that data source. -There is only one exclusion at this moment. Role `fixed:alerting.provisioning:writer` does not require user to have any additional permissions and provides access to all aspects of the alerting configuration via special provisioning API. +There is only one exclusion. Role `fixed:alerting.provisioning:writer` does not require user to have any additional permissions and provides access to all aspects of the alerting configuration via special provisioning API. For more information about the permissions required to access alert rules, refer to [Create a custom role to access alerts in a folder](ref:plan-rbac-rollout-strategy-create-a-custom-role-to-access-alerts-in-a-folder). +#### Alerting basic roles + +The following table lists the default RBAC alerting role assignments to the basic roles: + +| Basic role | Associated fixed roles | Description | +| ---------- | --------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | +| Admin | `fixed:alerting:writer`
`fixed:alerting.provisioning.secrets:reader`
`fixed:alerting.provisioning:writer` | Default [Grafana organization administrator](ref:rbac-basic-roles) assignments. | +| Editor | `fixed:alerting:writer`
`fixed:alerting.provisioning.provenance:writer` | Default [Editor](ref:rbac-basic-roles) assignments. | +| Viewer | `fixed:alerting:reader` | Default [Viewer](ref:rbac-basic-roles) assignments. | + ### Grafana OnCall roles If you are using [Grafana OnCall](ref:oncall), you can try out the integration between Grafana OnCall and RBAC. diff --git a/docs/sources/alerting/set-up/configure-rbac/_index.md b/docs/sources/alerting/set-up/configure-rbac/_index.md index 9c591e7361d..6e3338bab0b 100644 --- a/docs/sources/alerting/set-up/configure-rbac/_index.md +++ b/docs/sources/alerting/set-up/configure-rbac/_index.md @@ -17,55 +17,166 @@ weight: 155 # Configure RBAC -Role-based access control (RBAC) for Grafana Enterprise and Grafana Cloud provides a standardized way of granting, changing, and revoking access, so that users can view and modify Grafana resources. +[Role-based access control (RBAC)](/docs/grafana/latest/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/) for Grafana Enterprise and Grafana Cloud provides a standardized way of granting, changing, and revoking access, so that users can view and modify Grafana resources. -A user is any individual who can log in to Grafana. Each user is associated with a role that includes permissions. Permissions determine the tasks a user can perform in the system. +A user is any individual who can log in to Grafana. Each user has a role that includes permissions. Permissions determine the tasks a user can perform in the system. Each permission contains one or more actions and a scope. +## Role types + +Grafana has three types of roles for managing access: + +- **Basic roles**: Admin, Editor, Viewer, and No basic role. These are assigned to users and provide default access levels. +- **Fixed roles**: Predefined groups of permissions for specific use cases. Basic roles automatically include certain fixed roles. +- **Custom roles**: User-defined roles that combine specific permissions for granular access control. + +## Basic role permissions + +The following table summarizes the default alerting permissions for each basic role. + +| Capability | Admin | Editor | Viewer | +| ----------------------------------------- | :---: | :----: | :----: | +| View alert rules | ✓ | ✓ | ✓ | +| Create, edit, and delete alert rules | ✓ | ✓ | | +| View silences | ✓ | ✓ | ✓ | +| Create, edit, and expire silences | ✓ | ✓ | | +| View contact points and templates | ✓ | ✓ | ✓ | +| Create, edit, and delete contact points | ✓ | ✓ | | +| View notification policies | ✓ | ✓ | ✓ | +| Create, edit, and delete policies | ✓ | ✓ | | +| View mute timings | ✓ | ✓ | ✓ | +| Create, edit, and delete timing intervals | ✓ | ✓ | | +| Access provisioning API | ✓ | ✓ | | +| Export with decrypted secrets | ✓ | | | + +{{< admonition type="note" >}} +Access to alert rules also requires permission to read the folder containing the rules and permission to query the data sources used in the rules. +{{< /admonition >}} + ## Permissions -Grafana Alerting has the following permissions. +Grafana Alerting has the following permissions organized by resource type. -| Action | Applicable scope | Description | -| -------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `alert.instances.external:read` | `datasources:*`
`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | -| `alert.instances.external:write` | `datasources:*`
`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. | -| `alert.instances:create` | n/a | Create silences in the current organization. | -| `alert.instances:read` | n/a | Read alerts and silences in the current organization. | -| `alert.instances:write` | n/a | Update and expire silences in the current organization. | -| `alert.notifications.external:read` | `datasources:*`
`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications.external:write` | `datasources:*`
`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. | -| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. | -| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. | -| `alert.rules.external:read` | `datasources:*`
`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki) | -| `alert.rules.external:write` | `datasources:*`
`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | -| `alert.rules:create` | `folders:*`
`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | -| `alert.rules:delete` | `folders:*`
`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. | -| `alert.rules:read` | `folders:*`
`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. | -| `alert.rules:write` | `folders:*`
`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. To allow query modifications add `datasources:query` in the scope of data sources the user can query. | -| `alert.silences:create` | `folders:*`
`folders:uid:*` | Create rule-specific silences in a folder and its subfolders. | -| `alert.silences:read` | `folders:*`
`folders:uid:*` | Read all general silences and rule-specific silences in a folder and its subfolders. | -| `alert.silences:write` | `folders:*`
`folders:uid:*` | Update and expire rule-specific silences in a folder and its subfolders. | -| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. | -| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. | -| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. | -| `alert.provisioning.provenance:write` | n/a | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources | -| `alert.notifications.receivers:read` | `receivers:*`
`receivers:uid:*` | Read contact points. | -| `alert.notifications.receivers.secrets:read` | `receivers:*`
`receivers:uid:*` | Export contact points with decrypted secrets. | -| `alert.notifications.receivers:create` | n/a | Create a new contact points. The creator is automatically granted full access to the created contact point. | -| `alert.notifications.receivers:write` | `receivers:*`
`receivers:uid:*` | Update existing contact points. | -| `alert.notifications.receivers:delete` | `receivers:*`
`receivers:uid:*` | Update and delete existing contact points. | -| `receivers.permissions:read` | `receivers:*`
`receivers:uid:*` | Read permissions for contact points. | -| `receivers.permissions:write` | `receivers:*`
`receivers:uid:*` | Manage permissions for contact points. | -| `alert.notifications.time-intervals:read` | n/a | Read mute time intervals. | -| `alert.notifications.time-intervals:write` | n/a | Create new or update existing mute time intervals. | -| `alert.notifications.time-intervals:delete` | n/a | Delete existing time intervals. | -| `alert.notifications.templates:read` | n/a | Read templates. | -| `alert.notifications.templates:write` | n/a | Create new or update existing templates. | -| `alert.notifications.templates:delete` | n/a | Delete existing templates. | -| `alert.notifications.templates.test:write` | n/a | Test templates with custom payloads (preview and payload editor functionality). | -| `alert.notifications.routes:read` | n/a | Read notification policies. | -| `alert.notifications.routes:write` | n/a | Create new, update and update notification policies. | +### Alert rules + +Permissions for managing Grafana-managed alert rules. + +| Action | Applicable scope | Description | +| -------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `alert.rules:create` | `folders:*`
`folders:uid:*` | Create Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder and `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:read` | `folders:*`
`folders:uid:*` | Read Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. | +| `alert.rules:write` | `folders:*`
`folders:uid:*` | Update Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. To allow query modifications add `datasources:query` in the scope of data sources the user can query. | +| `alert.rules:delete` | `folders:*`
`folders:uid:*` | Delete Grafana alert rules in a folder and its subfolders. Combine this permission with `folders:read` in a scope that includes the folder. | + +### External alert rules + +Permissions for managing alert rules in external data sources that support alerting. + +| Action | Applicable scope | Description | +| ---------------------------- | -------------------------------------- | ---------------------------------------------------------------------------------------------- | +| `alert.rules.external:read` | `datasources:*`
`datasources:uid:*` | Read alert rules in data sources that support alerting (Prometheus, Mimir, and Loki). | +| `alert.rules.external:write` | `datasources:*`
`datasources:uid:*` | Create, update, and delete alert rules in data sources that support alerting (Mimir and Loki). | + +### Alert instances and silences + +Permissions for managing alert instances and silences in Grafana. + +| Action | Applicable scope | Description | +| ------------------------ | ------------------------------ | ------------------------------------------------------------------------------------ | +| `alert.instances:read` | n/a | Read alerts and silences in the current organization. | +| `alert.instances:create` | n/a | Create silences in the current organization. | +| `alert.instances:write` | n/a | Update and expire silences in the current organization. | +| `alert.silences:read` | `folders:*`
`folders:uid:*` | Read all general silences and rule-specific silences in a folder and its subfolders. | +| `alert.silences:create` | `folders:*`
`folders:uid:*` | Create rule-specific silences in a folder and its subfolders. | +| `alert.silences:write` | `folders:*`
`folders:uid:*` | Update and expire rule-specific silences in a folder and its subfolders. | + +### External alert instances + +Permissions for managing alert instances in external data sources. + +| Action | Applicable scope | Description | +| -------------------------------- | -------------------------------------- | ----------------------------------------------------------------- | +| `alert.instances.external:read` | `datasources:*`
`datasources:uid:*` | Read alerts and silences in data sources that support alerting. | +| `alert.instances.external:write` | `datasources:*`
`datasources:uid:*` | Manage alerts and silences in data sources that support alerting. | + +### Contact points + +Permissions for managing contact points (notification receivers). + +| Action | Applicable scope | Description | +| -------------------------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------- | +| `alert.notifications.receivers:list` | n/a | List contact points in the current organization. | +| `alert.notifications.receivers:read` | `receivers:*`
`receivers:uid:*` | Read contact points. | +| `alert.notifications.receivers.secrets:read` | `receivers:*`
`receivers:uid:*` | Export contact points with decrypted secrets. | +| `alert.notifications.receivers:create` | n/a | Create a new contact points. The creator is automatically granted full access to the created contact point. | +| `alert.notifications.receivers:write` | `receivers:*`
`receivers:uid:*` | Update existing contact points. | +| `alert.notifications.receivers:delete` | `receivers:*`
`receivers:uid:*` | Update and delete existing contact points. | +| `alert.notifications.receivers:test` | `receivers:*`
`receivers:uid:*` | Test contact points to verify their configuration. | +| `receivers.permissions:read` | `receivers:*`
`receivers:uid:*` | Read permissions for contact points. | +| `receivers.permissions:write` | `receivers:*`
`receivers:uid:*` | Manage permissions for contact points. | + +### Notification policies + +Permissions for managing notification policies (routing rules). + +| Action | Applicable scope | Description | +| ---------------------------------- | ---------------- | ----------------------------------------------------- | +| `alert.notifications.routes:read` | n/a | Read notification policies. | +| `alert.notifications.routes:write` | n/a | Create new, update, and delete notification policies. | + +### Time intervals + +Permissions for managing mute time intervals. + +| Action | Applicable scope | Description | +| ------------------------------------------- | ---------------- | -------------------------------------------------- | +| `alert.notifications.time-intervals:read` | n/a | Read mute time intervals. | +| `alert.notifications.time-intervals:write` | n/a | Create new or update existing mute time intervals. | +| `alert.notifications.time-intervals:delete` | n/a | Delete existing time intervals. | + +### Templates + +Permissions for managing notification templates. + +| Action | Applicable scope | Description | +| ------------------------------------------ | ---------------- | ------------------------------------------------------------------------------- | +| `alert.notifications.templates:read` | n/a | Read templates. | +| `alert.notifications.templates:write` | n/a | Create new or update existing templates. | +| `alert.notifications.templates:delete` | n/a | Delete existing templates. | +| `alert.notifications.templates.test:write` | n/a | Test templates with custom payloads (preview and payload editor functionality). | + +### General notifications + +Legacy permissions for managing all notification resources. + +| Action | Applicable scope | Description | +| --------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------- | +| `alert.notifications:read` | n/a | Read all templates, contact points, notification policies, and mute timings in the current organization. | +| `alert.notifications:write` | n/a | Manage templates, contact points, notification policies, and mute timings in the current organization. | + +### External notifications + +Permissions for managing notification resources in external data sources. + +| Action | Applicable scope | Description | +| ------------------------------------ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------- | +| `alert.notifications.external:read` | `datasources:*`
`datasources:uid:*` | Read templates, contact points, notification policies, and mute timings in data sources that support alerting. | +| `alert.notifications.external:write` | `datasources:*`
`datasources:uid:*` | Manage templates, contact points, notification policies, and mute timings in data sources that support alerting. | + +### Provisioning + +Permissions for managing alerting resources via the provisioning API. + +| Action | Applicable scope | Description | +| ---------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `alert.provisioning:read` | n/a | Read all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. | +| `alert.provisioning.secrets:read` | n/a | Same as `alert.provisioning:read` plus ability to export resources with decrypted secrets. | +| `alert.provisioning:write` | n/a | Update all Grafana alert rules, notification policies, etc via provisioning API. Permissions to folders and data source are not required. | +| `alert.rules.provisioning:read` | n/a | Read Grafana alert rules via provisioning API. More specific than `alert.provisioning:read`. | +| `alert.rules.provisioning:write` | n/a | Create, update, and delete Grafana alert rules via provisioning API. More specific than `alert.provisioning:write`. | +| `alert.notifications.provisioning:read` | n/a | Read notification resources (contact points, notification policies, templates, time intervals) via provisioning API. More specific than `alert.provisioning:read`. | +| `alert.notifications.provisioning:write` | n/a | Create, update, and delete notification resources via provisioning API. More specific than `alert.provisioning:write`. | +| `alert.provisioning.provenance:write` | n/a | Set provisioning status for alerting resources. Cannot be used alone. Requires user to have permissions to access resources. | To help plan your RBAC rollout strategy, refer to [Plan your RBAC rollout strategy](https://grafana.com/docs/grafana/next/administration/roles-and-permissions/access-control/plan-rbac-rollout-strategy/). diff --git a/docs/sources/alerting/set-up/configure-rbac/access-folders/index.md b/docs/sources/alerting/set-up/configure-rbac/access-folders/index.md index 10fb63385ff..825629089cb 100644 --- a/docs/sources/alerting/set-up/configure-rbac/access-folders/index.md +++ b/docs/sources/alerting/set-up/configure-rbac/access-folders/index.md @@ -16,7 +16,7 @@ title: Manage access using folders or data sources weight: 200 --- -## Manage access using folders or data sources +# Manage access using folders or data sources You can extend the access provided by a role to alert rules and rule-specific silences by assigning permissions to individual folders or data sources. diff --git a/docs/sources/alerting/set-up/configure-rbac/access-roles/index.md b/docs/sources/alerting/set-up/configure-rbac/access-roles/index.md index be1489eb1c4..b3c51d4f866 100644 --- a/docs/sources/alerting/set-up/configure-rbac/access-roles/index.md +++ b/docs/sources/alerting/set-up/configure-rbac/access-roles/index.md @@ -55,7 +55,7 @@ Details of the fixed roles and the access they provide for Grafana Alerting are | Full read-only access: `fixed:alerting:reader` | All permissions from `fixed:alerting.rules:reader`
`fixed:alerting.instances:reader`
`fixed:alerting.notifications:reader` | Read alert rules, alert instances, silences, contact points, and notification policies in Grafana and external providers. | | Read via Provisioning API + Export Secrets: `fixed:alerting.provisioning.secrets:reader` | `alert.provisioning:read` and `alert.provisioning.secrets:read` | Read alert rules, alert instances, silences, contact points, and notification policies using the provisioning API and use export with decrypted secrets. | | Access to alert rules provisioning API: `fixed:alerting.provisioning:writer` | `alert.provisioning:read` and `alert.provisioning:write` | Manage all alert rules, notification policies, contact points, templates, in the organization using the provisioning API. | -| Set provisioning status: `fixed:alerting.provisioning.status:writer` | `alert.provisioning.provenance:write` | Set provisioning rules for Alerting resources. Should be used together with other regular roles (Notifications Writer and/or Rules Writer.) | +| Set provisioning status: `fixed:alerting.provisioning.provenance:writer` | `alert.provisioning.provenance:write` | Set provisioning rules for Alerting resources. Should be used together with other regular roles (Notifications Writer and/or Rules Writer.) | | Contact Point Reader: `fixed:alerting.receivers:reader` | `alert.notifications.receivers:read` for scope `receivers:*` | Read all contact points. | | Contact Point Creator: `fixed:alerting.receivers:creator` | `alert.notifications.receivers:create` | Create a new contact point. The user is automatically granted full access to the created contact point. | | Contact Point Writer: `fixed:alerting.receivers:writer` | `alert.notifications.receivers:read`, `alert.notifications.receivers:write`, `alert.notifications.receivers:delete` for scope `receivers:*` and
`alert.notifications.receivers:create` | Create a new contact point and manage all existing contact points. | @@ -63,8 +63,8 @@ Details of the fixed roles and the access they provide for Grafana Alerting are | Templates Writer: `fixed:alerting.templates:writer` | `alert.notifications.templates:read`, `alert.notifications.templates:write`, `alert.notifications.templates:delete`, `alert.notifications.templates.test:write` | Create new and manage existing notification templates. Test templates with custom payloads. | | Time Intervals Reader: `fixed:alerting.time-intervals:reader` | `alert.notifications.time-intervals:read` | Read all time intervals. | | Time Intervals Writer: `fixed:alerting.time-intervals:writer` | `alert.notifications.time-intervals:read`, `alert.notifications.time-intervals:write`, `alert.notifications.time-intervals:delete` | Create new and manage existing time intervals. | -| Notification Policies Reader: `fixed:alerting.routes:reader` | `alert.notifications.routes:read` | Read all time intervals. | -| Notification Policies Writer: `fixed:alerting.routes:writer` | `alert.notifications.routes:read` `alert.notifications.routes:write` | Create new and manage existing time intervals. | +| Notification Policies Reader: `fixed:alerting.routes:reader` | `alert.notifications.routes:read` | Read all notification policies. | +| Notification Policies Writer: `fixed:alerting.routes:writer` | `alert.notifications.routes:read`
`alert.notifications.routes:write` | Create new and manage existing notification policies. | ## Create custom roles diff --git a/docs/sources/alerting/set-up/configure-roles/index.md b/docs/sources/alerting/set-up/configure-roles/index.md index 36adb865ab3..091d11de7bf 100644 --- a/docs/sources/alerting/set-up/configure-roles/index.md +++ b/docs/sources/alerting/set-up/configure-roles/index.md @@ -16,25 +16,27 @@ weight: 150 # Configure roles and permissions +This guide explains how to configure roles and permissions for Grafana Alerting for Grafana OSS users. You'll learn how to manage access using roles, folder permissions, and contact point permissions. + A user is any individual who can log in to Grafana. Each user is associated with a role that includes permissions. Permissions determine the tasks a user can perform in the system. For example, the Admin role includes permissions for an administrator to create and delete users. For more information, refer to [Organization roles](https://grafana.com/docs/grafana//administration/roles-and-permissions/#organization-roles). ## Manage access using roles -For Grafana OSS, there are three roles: Admin, Editor, and Viewer. +Grafana OSS has three roles: Admin, Editor, and Viewer. -Details of the roles and the access they provide for Grafana Alerting are below. +The following table describes the access each role provides for Grafana Alerting. -| Role | Access | -| ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Admin | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning. | -| Editor | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning. | -| Viewer | Read access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences). | +| Role | Access | +| ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Viewer | Read access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences). | +| Editor | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning. | +| Admin | Write access to alert rules, notification resources (notification API, contact points, templates, time intervals, notification policies, and silences), and provisioning, as well as assign roles. | ## Assign roles -To assign roles, admins need to complete the following steps. +To assign roles, an admin needs to complete the following steps. 1. Navigate to **Administration** > **Users and access** > **Users, Teams, or Service Accounts**. 1. Search for the user, team or service account you want to add a role for. @@ -58,32 +60,30 @@ Refer to the following table for details on the additional access provided by fo You can't use folders to customize access to notification resources. {{< /admonition >}} -To manage folder permissions, complete the following steps. +To manage folder permissions, complete the following steps: 1. In the left-side menu, click **Dashboards**. 1. Hover your mouse cursor over a folder and click **Go to folder**. 1. Click **Manage permissions** from the Folder actions menu. 1. Update or add permissions as required. -## Manage access using contact point permissions +## Manage access to contact points -### Before you begin - -Extend or limit the access provided by a role to contact points by assigning permissions to individual contact point. +Extend or limit the access provided by a role to contact points by assigning permissions to individual contact points. This allows different users, teams, or service accounts to have customized access to read or modify specific contact points. Refer to the following table for details on the additional access provided by contact point permissions. -| Folder permission | Additional Access | -| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| View | View and export contact point as well as select it on the Alert rule edit page | -| Edit | Update or delete the contact point | -| Admin | Same additional access as Edit and manage permissions for the contact point. User should have additional permissions to read users and teams. | +| Contact point permission | Additional Access | +| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | +| View | View and export contact point as well as select it on the Alert rule edit page | +| Edit | Update or delete the contact point | +| Admin | Same additional access as Edit and manage permissions for the contact point. User should have additional permissions to read users and teams. | -### Steps +### Assign contact point permissions -To contact point permissions, complete the following steps. +To manage contact point permissions, complete the following steps: 1. In the left-side menu, click **Contact points**. 1. Hover your mouse cursor over a contact point and click **More**. From 8979808e4a44702091a47e69f78062e9ce45496d Mon Sep 17 00:00:00 2001 From: Haris Rozajac <58232930+harisrozajac@users.noreply.github.com> Date: Mon, 15 Dec 2025 11:08:35 -0700 Subject: [PATCH 18/27] Dashboard V1 -> V2 conversion: Rows with hidden header should never be collapsed (#115290) * rows with hidden header should never be collapsed * fix test * shouldn't need to normalize this * fix frontend conversion * fix lint * Update public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts Co-authored-by: Ivan Ortega Alba --------- Co-authored-by: oscarkilhed Co-authored-by: Ivan Ortega Alba --- ...ings_and_tooltip_options.v42.v2alpha1.json | 2 +- ...pings_and_tooltip_options.v42.v2beta1.json | 2 +- ...v33.panel_ds_name_to_ref.v42.v2alpha1.json | 2 +- ...-v33.panel_ds_name_to_ref.v42.v2beta1.json | 2 +- ...ultiple_stats_cloudwatch.v42.v2alpha1.json | 2 +- ...multiple_stats_cloudwatch.v42.v2beta1.json | 2 +- ...mig-v42.hidefrom_tooltip.v42.v2alpha1.json | 2 +- ...-mig-v42.hidefrom_tooltip.v42.v2beta1.json | 2 +- .../conversion/v1beta1_to_v2alpha1.go | 6 +- .../panel-gauge/gauge_tests_new.v42.json | 110 +++++++++--------- .../gauge_tests_old_to_new.v42.json | 6 +- .../transformSaveModelToScene.ts | 3 +- 12 files changed, 72 insertions(+), 69 deletions(-) diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2alpha1.json index a045836b269..c657d8796c3 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2alpha1.json @@ -530,7 +530,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2beta1.json index 5ae7d7d5ef4..be92e718d44 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v30.value_mappings_and_tooltip_options.v42.v2beta1.json @@ -546,7 +546,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2alpha1.json index a7cccb454ae..6043004b0eb 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2alpha1.json @@ -548,7 +548,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2beta1.json index 55836cf469c..af689d56d45 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v33.panel_ds_name_to_ref.v42.v2beta1.json @@ -574,7 +574,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2alpha1.json index e5308bb6102..bc705379491 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2alpha1.json @@ -1663,7 +1663,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2beta1.json index 329585edd02..329e10bcd42 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v34.multiple_stats_cloudwatch.v42.v2beta1.json @@ -1727,7 +1727,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2alpha1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2alpha1.json index 3474345415f..f7d9a922468 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2alpha1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2alpha1.json @@ -328,7 +328,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2beta1.json b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2beta1.json index 6f0e6b08043..f5eaa04d6ab 100644 --- a/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2beta1.json +++ b/apps/dashboard/pkg/migration/conversion/testdata/migrated_dashboards_output/v1beta1-mig-v42.hidefrom_tooltip.v42.v2beta1.json @@ -335,7 +335,7 @@ "kind": "RowsLayoutRow", "spec": { "title": "", - "collapse": true, + "collapse": false, "hideHeader": true, "layout": { "kind": "GridLayout", diff --git a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go index f3f2f08ddb8..1135927ed7b 100644 --- a/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go +++ b/apps/dashboard/pkg/migration/conversion/v1beta1_to_v2alpha1.go @@ -501,11 +501,9 @@ func convertToRowsLayout(ctx context.Context, panels []interface{}, dsIndexProvi if currentRow != nil { // If currentRow is a hidden-header row (panels before first explicit row), - // set its collapse to match the first explicit row's collapsed value - // This matches frontend behavior: collapse: panel.collapsed + // it should not be collapsed because it will disappear and be visible only in edit mode if currentRow.Spec.HideHeader != nil && *currentRow.Spec.HideHeader { - rowCollapsed := getBoolField(panelMap, "collapsed", false) - currentRow.Spec.Collapse = &rowCollapsed + currentRow.Spec.Collapse = &[]bool{false}[0] } // Flush current row to layout rows = append(rows, *currentRow) 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 9f8cf76c9f7..a89d8744f39 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,9 +75,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": true, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -154,9 +154,9 @@ "effects": { "barGlow": false, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -233,9 +233,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -312,9 +312,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -391,9 +391,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -470,9 +470,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": false, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -549,9 +549,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": false, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -641,9 +641,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -720,9 +720,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -799,9 +799,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -878,9 +878,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": false, "rounded": true, - "spotlight": true, - "gradient": false + "spotlight": true }, "orientation": "auto", "reduceOptions": { @@ -974,9 +974,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1053,9 +1053,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1132,9 +1132,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1211,9 +1211,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1290,9 +1290,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1386,9 +1386,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1469,9 +1469,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1552,9 +1552,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": true, "rounded": false, - "spotlight": false, - "gradient": true + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1643,9 +1643,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1727,9 +1727,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1825,9 +1825,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1910,9 +1910,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -1994,9 +1994,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -2078,9 +2078,9 @@ "effects": { "barGlow": true, "centerGlow": true, + "gradient": true, "rounded": true, - "spotlight": true, - "gradient": true + "spotlight": true }, "glow": "both", "orientation": "auto", @@ -2172,7 +2172,9 @@ }, "orientation": "auto", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false }, @@ -2238,7 +2240,9 @@ }, "orientation": "auto", "reduceOptions": { - "calcs": ["lastNotNull"], + "calcs": [ + "lastNotNull" + ], "fields": "", "values": false }, @@ -2275,4 +2279,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 a3de6df336a..4a5ac97a6b5 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,9 @@ "effects": { "barGlow": false, "centerGlow": false, + "gradient": false, "rounded": false, - "spotlight": false, - "gradient": false + "spotlight": false }, "orientation": "auto", "reduceOptions": { @@ -1162,4 +1162,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/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts index 48763ba4363..3da62d6cdf4 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.ts @@ -108,7 +108,8 @@ export function createRowsFromPanels(oldPanels: PanelModel[]): RowsLayoutManager rowItems.push( new RowItem({ title: '', - collapse: panel.collapsed, + // Hidden header rows must stay expanded; collapsing them would hide the panels entirely. + collapse: false, layout: new DefaultGridLayoutManager({ grid: new SceneGridLayout({ children: currentRowPanels, From ca2babf1a30d75997f57d0dd22b197b73fa80d2a Mon Sep 17 00:00:00 2001 From: J Stickler Date: Mon, 15 Dec 2025 13:59:48 -0500 Subject: [PATCH 19/27] docs: update visualizations for logs (#115183) * docs: update visualizations for logs * ran prettier * vale errors --- .../explore/logs-integration.md | 49 ++++++++++++++----- .../visualizations/logs/index.md | 16 +++--- 2 files changed, 46 insertions(+), 19 deletions(-) diff --git a/docs/sources/visualizations/explore/logs-integration.md b/docs/sources/visualizations/explore/logs-integration.md index 8aa18f45616..a4f8fe7c64b 100644 --- a/docs/sources/visualizations/explore/logs-integration.md +++ b/docs/sources/visualizations/explore/logs-integration.md @@ -43,24 +43,36 @@ If the data source doesn't support loading the full range logs volume, the logs The following sections provide detailed explanations on how to visualize and interact with individual logs in Explore. -### Logs navigation +### Infinite scroll -Logs navigation, located at the right side of the log lines, can be used to easily request additional logs by clicking **Older logs** at the bottom of the navigation. This is especially useful when you reach the line limit and you want to see more logs. Each request run from the navigation displays in the navigation as separate page. Every page shows `from` and `to` timestamps of the incoming log lines. You can see previous results by clicking on each page. Explore caches the last five requests run from the logs navigation so you're not re-running the same queries when clicking on the pages, saving time and resources. + -![Navigate logs in Explore](/static/img/docs/explore/navigate-logs-8-0.png) +When you reach the bottom of the list of logs, you will see the message `Scroll to load more`. If you continue scrolling and the displayed logs are within the selected time interval, Grafana will load more logs. When the sort order is "newest first" you receive older logs, and when the sort order is "oldest first" you get newer logs. + + ### Visualization options You have the option to customize the display of logs and choose which columns to show. Following is a list of available options. -| Option | Description | -| ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| **Time** | Shows or hides the time column. This is the timestamp associated with the log line as reported from the data source. | -| **Unique labels** | Shows or hides the unique labels column that includes only non-common labels. All common labels are displayed above. | -| **Wrap lines** | Set this to `true` if you want the display to use line wrapping. If set to `false`, it will result in horizontal scrolling. | -| **Prettify JSON** | Set this to `true` to pretty print all JSON logs. This setting does not affect logs in any format other than JSON. | -| **Deduplication** | Log data can be very repetitive. Explore hides duplicate log lines using a few different deduplication algorithms. **Exact** matches are done on the whole line except for date fields. **Numbers** matches are done on the line after stripping out numbers such as durations, IP addresses, and so on. **Signature** is the most aggressive deduplication as it strips all letters and numbers and matches on the remaining whitespace and punctuation. | -| **Display results order** | You can change the order of received logs from the default descending order (newest first) to ascending order (oldest first). | + + +| Option | Description | +| ------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Expand / Collapse | Expand or collapse the controls toolbar. | +| Scroll to bottom | Jump to the bottom of the logs table. | +| Oldest Logs First / Newest logs first | Sort direction (ascending or descending). | +| Search logs / Close search | Click to open/close the client side string search of the displayed logs result. | +| Deduplication | **None** does not perform any deduplication, **Exact** matches are done on the whole line except for date fields. **Numbers** matches are done on the line after stripping out numbers such as durations, IP addresses, and so on. **Signature** is the most aggressive deduplication as it strips all letters and numbers and matches on the remaining whitespace and punctuation. | +| Filter levels | Filter logs in display by log level: All levels, Info, Debut, Warning, Error. | +| Set Timestamp format | Hide timestamps (disabled), Show milliseconds timestamps, Show nanoseconds timestamps. | +| Set line wrap | Disable line wrapping, Enable line wrapping, Enable line wrapping and prettify JSON. | +| Enable highlighting | Plain text, Highlight text. | +| Font size | Small font (default), Large font. | +| Unescaped newlines | Only displayed if the logs contain unescaped new lines. Click to unescape and display as new lines. | +| Download logs | Plain text (txt), JavaScript Object Notation (JSON), Comma-separated values (CSV) | + + ### Download log lines @@ -143,16 +155,31 @@ Click the **eye icon** to select a subset of fields to visualize in the logs lis Each field has a **stats icon**, which displays ad-hoc statistics in relation to all displayed logs. +For data sources that support log types, such as Loki, instead of a single view containing all fields, fields will be displayed grouped by their type: Indexed Labels, Parsed fields, and Structured Metadata. + #### Links Grafana provides data links or correlations, allowing you to convert any part of a log message into an internal or external link. These links enable you to navigate to related data or external resources, offering a seamless and convenient way to explore additional information. {{< figure src="/static/img/docs/explore/data-link-9-4.png" max-width="800px" caption="Data link in Explore" >}} +#### Log details modes + +There are two modes available to view log details: + +- **Inline** The default, displays log details below the log line. +- **Sidebar** Displays log details in a sidebar view. + +No matter which display mode you are currently viewing, you can change it by clicking the mode control icon. + ### Log context Log context is a feature that displays additional lines of context surrounding a log entry that matches a specific search query. This helps in understanding the context of the log entry and is similar to the `-C` parameter in the `grep` command. +If you're using Loki for your logs, to modify your log context queries, you can use the Loki log context query editor at the top of the table. You can activate this editor by clicking the menu for the log line, and selecting **Show context**. Within the **Log Context** view, you have the option to modify your search by removing one or more label filters from the log stream. If your original query used a parser, you can refine your search by leveraging extracted label filters. + +Change the **Context time window** option to look for logs within a specific time interval around your log line. + Toggle **Wrap lines** if you encounter long lines of text that make it difficult to read and analyze the context around log entries. By enabling this toggle, Grafana automatically wraps long lines of text to fit within the visible width of the viewer, making the log entries easier to read and understand. Click **Open in split view** to execute the context query for a log entry in a split screen in the Explore view. Clicking this button opens a new Explore pane with the context query displayed alongside the log entry, making it easier to analyze and understand the surrounding context. diff --git a/docs/sources/visualizations/panels-visualizations/visualizations/logs/index.md b/docs/sources/visualizations/panels-visualizations/visualizations/logs/index.md index 3af0eeda0d5..d3a93095e7a 100644 --- a/docs/sources/visualizations/panels-visualizations/visualizations/logs/index.md +++ b/docs/sources/visualizations/panels-visualizations/visualizations/logs/index.md @@ -31,7 +31,7 @@ refs: _Logs_ are structured records of events or messages generated by a system or application—that is, a series of text records with status updates from your system or app. They generally include timestamps, messages, and context information like the severity of the logged event. -The logs visualization displays these records from data sources that support logs, such as Elastic, Influx, and Loki. The logs visualization has colored indicators of log status, as well as collapsible log events that help you analyze the information generated. +The logs visualization displays these records from data sources that support logs, such as Elastic, Influx, and Loki. The logs visualization shows, by default, the timestamp, a colored string representing the log status, the log line body, as well as collapsible log events that help you analyze the information generated. {{< figure src="/media/docs/grafana/panels-visualizations/screenshot-logs-v12.3.png" max-width="750px" alt="Logs visualization" >}} @@ -100,16 +100,16 @@ Use these settings to refine your visualization: | Option | Description | | --------------- | --------------- | -| Time | Show or hide the time column. This is the timestamp associated with the log line as reported from the data source. | +| Show timestamps | Show or hide the time column. This is the timestamp associated with the log line as reported from the data source. | | Unique labels | Show or hide the unique labels column, which shows only non-common labels. | -| Common labels | Show or hide the common labels. | | Wrap lines | Turn line wrapping on or off. | -| Enable logs highlighting | Experimental. Use a predefined coloring scheme to highlight relevant parts of the log lines. Subtle colors are added to the log lines to improve readability and help with identifying important information faster. | +| Prettify JSON | Toggle the switch on to pretty print all JSON logs. This setting does not affect logs in any format other than JSON. | +| Enable highlighting | Use a predefined syntax coloring grammar to highlight relevant parts of the log lines | | Enable log details | Toggle the switch on to see an extendable area with log details including labels and detected fields. Each field or label has a stats icon to display ad-hoc statistics in relation to all displayed logs. The default setting is on. | -| Log details panel mode | Choose to display the log details in a sidebar panel or inline, below the log line. The default mode depends on viewport size: the default mode for smaller viewports is inline, while for larger ones, it's sidebar. You can also change mode dynamically in the panel by clicking the mode control. | -| Enable infinite scrolling | Request more results by scrolling to the bottom of the logs list. When you reach the bottom of the list of logs, if you continue scrolling and the displayed logs are within the selected time interval, you can request to load more logs. When the sort order is **Newest first**, you receive older logs, and when the sort order is **Oldest first** you get newer logs. | -| Show controls | Display controls to jump to the last or first log line, and filter by log level. | -| Font size | Select between the **Default** font size and **Small** font sizes.| +| Log Details panel mode | Choose to display the log details in a sidebar panel or inline, below the log line. | +| Enable infinite scrolling | Request more results by scrolling to the bottom of the logs list. | +| Show controls | Display controls to jump to the last or first log line, and filters by log level | +| Font size | Select between the default font size and small font size. | | Deduplication | Hide log messages that are duplicates of others shown, according to your selected criteria. Choose from:
  • **Exact** - Ignoring ISO datetimes.
  • **Numerical** - Ignoring only those that differ by numbers such as IPs or latencies.
  • **Signatures** - Removing successive lines with identical punctuation and white space.
| | Order | Set whether to show results **Newest first** or **Oldest first**. | From 956ab0514812531b3623053bbe92539988069bb1 Mon Sep 17 00:00:00 2001 From: Andrew Hackmann <5140848+bossinc@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:11:05 -0600 Subject: [PATCH 20/27] Elasticsearch: Raw query editor for DSL (#114066) * init * it works! but what a mess * nil ptr bug * split up client.go * split up search_request.go * split up data_query.go * split up response_parser * fix merge * update handling request * raw dsl agg parser * change rawQuery to rawDSLQuery * agg parser works but needs work * clean up agg parser * fix bugs with raw dsl parsers * feature toggle * fix tests * editor type selector * editor type added * add fix builder vs code by not using same query field * clean up * fix lint * pretty * editor type selection should be behind ft * adam's feedback * prettier --- .../src/types/featureToggles.gen.ts | 5 + .../x/ElasticsearchDataQuery_types.gen.ts | 8 + pkg/services/featuremgmt/registry.go | 7 + pkg/services/featuremgmt/toggles_gen.csv | 1 + pkg/services/featuremgmt/toggles_gen.go | 4 + pkg/services/featuremgmt/toggles_gen.json | 13 + pkg/tsdb/elasticsearch/client/models.go | 8 + .../elasticsearch/client/request_encoder.go | 23 +- .../elasticsearch/client/search_request.go | 21 + pkg/tsdb/elasticsearch/data_query.go | 13 +- .../elasticsearch/data_query_processor.go | 52 ++ pkg/tsdb/elasticsearch/data_query_test.go | 100 ++- .../elasticsearch/data_query_validator.go | 4 + .../kinds/dataquery/types_dataquery_gen.go | 4 + pkg/tsdb/elasticsearch/models.go | 2 + pkg/tsdb/elasticsearch/parse_query.go | 9 + .../raw_dsl_aggregation_parser.go | 628 ++++++++++++++++ .../raw_dsl_aggregation_parser_test.go | 706 ++++++++++++++++++ .../QueryEditor/EditorTypeSelector.tsx | 29 + .../QueryEditor/ElasticsearchQueryContext.tsx | 8 +- .../MetricAggregationsEditor/state/actions.ts | 3 + .../components/QueryEditor/RawQueryEditor.tsx | 108 +++ .../components/QueryEditor/index.tsx | 75 +- .../components/QueryEditor/state.ts | 44 ++ .../datasource/elasticsearch/dataquery.cue | 8 +- .../datasource/elasticsearch/dataquery.gen.ts | 8 + .../plugins/datasource/elasticsearch/types.ts | 1 + 27 files changed, 1851 insertions(+), 41 deletions(-) create mode 100644 pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go create mode 100644 pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser_test.go create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx create mode 100644 public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index 4aeb394b81f..2aeb8c06ada 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1189,6 +1189,11 @@ export interface FeatureToggles { */ panelTimeSettings?: boolean; /** + * Enables the raw DSL query editor in the Elasticsearch data source + * @default false + */ + elasticsearchRawDSLQuery?: boolean; + /** * Enables app platform API for annotations * @default false */ diff --git a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts index ad46e229611..8d06591b46b 100644 --- a/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/elasticsearch/dataquery/x/ElasticsearchDataQuery_types.gen.ts @@ -387,6 +387,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery { * List of bucket aggregations */ bucketAggs?: Array; + /** + * Editor type + */ + editorType?: string; /** * List of metric aggregations */ @@ -395,6 +399,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery { * Lucene query */ query?: string; + /** + * Raw DSL query + */ + rawDSLQuery?: string; /** * Name of time field */ diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index c1b318c97b4..15eadb55175 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -1962,6 +1962,13 @@ var ( RequiresRestart: false, HideFromDocs: false, }, + { + Name: "elasticsearchRawDSLQuery", + Description: "Enables the raw DSL query editor in the Elasticsearch data source", + Stage: FeatureStageExperimental, + Owner: grafanaPartnerPluginsSquad, + Expression: "false", + }, { Name: "kubernetesAnnotations", Description: "Enables app platform API for annotations", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index f05b0d41da3..f470a8b9c7a 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -266,6 +266,7 @@ pluginStoreServiceLoading,experimental,@grafana/plugins-platform-backend,false,f newPanelPadding,preview,@grafana/dashboards-squad,false,false,true onlyStoreActionSets,GA,@grafana/identity-access-team,false,false,false panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false +elasticsearchRawDSLQuery,experimental,@grafana/partner-datasources,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 6e106fd9950..fc7b8043dec 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -758,6 +758,10 @@ const ( // Enables a new panel time settings drawer FlagPanelTimeSettings = "panelTimeSettings" + // FlagElasticsearchRawDSLQuery + // Enables the raw DSL query editor in the Elasticsearch data source + FlagElasticsearchRawDSLQuery = "elasticsearchRawDSLQuery" + // FlagKubernetesAnnotations // Enables app platform API for annotations FlagKubernetesAnnotations = "kubernetesAnnotations" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 561ffd562ca..95e1f0ab34d 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -1206,6 +1206,19 @@ "codeowner": "@grafana/partner-datasources" } }, + { + "metadata": { + "name": "elasticsearchRawDSLQuery", + "resourceVersion": "1763508396079", + "creationTimestamp": "2025-11-18T23:26:36Z" + }, + "spec": { + "description": "Enables the raw DSL query editor in the Elasticsearch data source", + "stage": "experimental", + "codeowner": "@grafana/partner-datasources", + "expression": "false" + } + }, { "metadata": { "name": "enableAppChromeExtensions", diff --git a/pkg/tsdb/elasticsearch/client/models.go b/pkg/tsdb/elasticsearch/client/models.go index c8648f0bdeb..de8dda2b61c 100644 --- a/pkg/tsdb/elasticsearch/client/models.go +++ b/pkg/tsdb/elasticsearch/client/models.go @@ -20,10 +20,18 @@ type SearchRequest struct { Aggs AggArray CustomProps map[string]interface{} TimeRange backend.TimeRange + // RawBody contains the raw Elasticsearch Query DSL JSON for raw DSL queries + // When set, this takes precedence over all other fields during marshaling + RawBody map[string]interface{} } // MarshalJSON returns the JSON encoding of the request. func (r *SearchRequest) MarshalJSON() ([]byte, error) { + // If RawBody is set, use it directly for raw DSL queries + if len(r.RawBody) > 0 { + return json.Marshal(r.RawBody) + } + root := make(map[string]interface{}) root["size"] = r.Size diff --git a/pkg/tsdb/elasticsearch/client/request_encoder.go b/pkg/tsdb/elasticsearch/client/request_encoder.go index ae22c8e2694..0c6e2314d99 100644 --- a/pkg/tsdb/elasticsearch/client/request_encoder.go +++ b/pkg/tsdb/elasticsearch/client/request_encoder.go @@ -3,6 +3,7 @@ package es import ( "bytes" "encoding/json" + "fmt" "strconv" "strings" "time" @@ -25,6 +26,9 @@ func newRequestEncoder(logger log.Logger) *requestEncoder { // encodeBatchRequests encodes multiple requests into NDJSON format func (e *requestEncoder) encodeBatchRequests(requests []*multiRequest) ([]byte, error) { start := time.Now() + defer func() { + e.logger.Debug("Completed encoding of batch requests to json", "duration", time.Since(start)) + }() payload := bytes.Buffer{} for _, r := range requests { @@ -34,20 +38,25 @@ func (e *requestEncoder) encodeBatchRequests(requests []*multiRequest) ([]byte, } payload.WriteString(string(reqHeader) + "\n") - reqBody, err := json.Marshal(r.body) - if err != nil { - return nil, err + body := "" + switch r.body.(type) { + case *SearchRequest: + reqBody, err := json.Marshal(r.body) + if err != nil { + return nil, err + } + body = string(reqBody) + case string: + body = r.body.(string) + default: + return nil, fmt.Errorf("unknown request type: %T", r.body) } - body := string(reqBody) body = strings.ReplaceAll(body, "$__interval_ms", strconv.FormatInt(r.interval.Milliseconds(), 10)) body = strings.ReplaceAll(body, "$__interval", r.interval.String()) payload.WriteString(body + "\n") } - elapsed := time.Since(start) - e.logger.Debug("Completed encoding of batch requests to json", "duration", elapsed) - return payload.Bytes(), nil } diff --git a/pkg/tsdb/elasticsearch/client/search_request.go b/pkg/tsdb/elasticsearch/client/search_request.go index f898517ab07..8f47d240a28 100644 --- a/pkg/tsdb/elasticsearch/client/search_request.go +++ b/pkg/tsdb/elasticsearch/client/search_request.go @@ -30,6 +30,8 @@ type SearchRequestBuilder struct { aggBuilders []AggBuilder customProps map[string]any timeRange backend.TimeRange + // rawBody contains the raw Elasticsearch Query DSL JSON for raw DSL queries + rawBody map[string]any } // NewSearchRequestBuilder create a new search request builder @@ -53,6 +55,12 @@ func (b *SearchRequestBuilder) Build() (*SearchRequest, error) { Size: b.size, Sort: b.sort, CustomProps: b.customProps, + RawBody: b.rawBody, + } + + // If RawBody is set, skip building query and aggs as they're in the raw body + if len(b.rawBody) > 0 { + return &sr, nil } if b.queryBuilder != nil { @@ -141,6 +149,19 @@ func (b *SearchRequestBuilder) AddSearchAfter(value any) *SearchRequestBuilder { return b } +// AddCustomProp adds a custom property to the search request +func (b *SearchRequestBuilder) AddCustomProp(key string, value any) *SearchRequestBuilder { + b.customProps[key] = value + return b +} + +// SetRawBody sets the raw Elasticsearch Query DSL body directly +// This bypasses all builder logic and sends the query as-is to Elasticsearch +func (b *SearchRequestBuilder) SetRawBody(rawBody map[string]any) *SearchRequestBuilder { + b.rawBody = rawBody + return b +} + // Query creates and return a query builder func (b *SearchRequestBuilder) Query() *QueryBuilder { if b.queryBuilder == nil { diff --git a/pkg/tsdb/elasticsearch/data_query.go b/pkg/tsdb/elasticsearch/data_query.go index e883b3d769c..949b93fd148 100644 --- a/pkg/tsdb/elasticsearch/data_query.go +++ b/pkg/tsdb/elasticsearch/data_query.go @@ -20,11 +20,12 @@ const ( ) type elasticsearchDataQuery struct { - client es.Client - dataQueries []backend.DataQuery - logger log.Logger - ctx context.Context - keepLabelsInResponse bool + client es.Client + dataQueries []backend.DataQuery + logger log.Logger + ctx context.Context + keepLabelsInResponse bool + aggregationParserDSLRawQuery AggregationParser } var newElasticsearchDataQuery = func(ctx context.Context, client es.Client, req *backend.QueryDataRequest, logger log.Logger) *elasticsearchDataQuery { @@ -39,6 +40,8 @@ var newElasticsearchDataQuery = func(ctx context.Context, client es.Client, req // To maintain backward compatibility, it is necessary to keep labels in responses for alerting and expressions queries. // Historically, these labels have been used in alerting rules and transformations. keepLabelsInResponse: fromAlert || fromExpression, + + aggregationParserDSLRawQuery: NewAggregationParser(), } } diff --git a/pkg/tsdb/elasticsearch/data_query_processor.go b/pkg/tsdb/elasticsearch/data_query_processor.go index 5f6ea448ddd..1c4ec7b3cdd 100644 --- a/pkg/tsdb/elasticsearch/data_query_processor.go +++ b/pkg/tsdb/elasticsearch/data_query_processor.go @@ -1,6 +1,7 @@ package elasticsearch import ( + "encoding/json" "fmt" "strconv" @@ -23,6 +24,17 @@ 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 != "" { + 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")) + } + + if err := e.processRawDSLQuery(q, b); err != nil { + return err + } + } + if isLogsQuery(q) { processLogsQuery(q, b, from, to, defaultTimeField) } else if isDocumentQuery(q) { @@ -184,6 +196,46 @@ func processTimeSeriesQuery(q *Query, b *es.SearchRequestBuilder, from, to int64 } } +func (e *elasticsearchDataQuery) processRawDSLQuery(q *Query, b *es.SearchRequestBuilder) error { + if q.RawDSLQuery == "" { + return backend.DownstreamError(fmt.Errorf("raw DSL query is empty")) + } + + // Parse the raw DSL query JSON + var queryBody map[string]any + if err := json.Unmarshal([]byte(q.RawDSLQuery), &queryBody); err != nil { + return backend.DownstreamError(fmt.Errorf("invalid raw DSL query JSON: %w", err)) + } + + if len(q.Metrics) > 0 { + firstMetricType := q.Metrics[0].Type + if firstMetricType != logsType && firstMetricType != rawDataType && firstMetricType != rawDocumentType { + bucketAggs, metricAggs, err := e.aggregationParserDSLRawQuery.Parse(q.RawDSLQuery) + if err != nil { + return backend.DownstreamError(fmt.Errorf("failed to parse aggregations: %w", err)) + } + + // If there is no metric agg in the query, it is a count agg + if len(metricAggs) == 0 { + metricAggs = append(metricAggs, &MetricAgg{Type: "count"}) + } + + q.BucketAggs = bucketAggs + q.Metrics = metricAggs + + if queryPart, ok := queryBody["query"].(map[string]any); ok { + queryJSON, _ := json.Marshal(queryPart) + q.RawQuery = string(queryJSON) + } + return nil + } + } + + // For non-time-series queries (logs, raw data), pass through the raw body directly + b.SetRawBody(queryBody) + return nil +} + // getPipelineAggField returns the pipeline aggregation field func getPipelineAggField(m *MetricAgg) string { // In frontend we are using Field as pipelineAggField diff --git a/pkg/tsdb/elasticsearch/data_query_test.go b/pkg/tsdb/elasticsearch/data_query_test.go index 887b8ba661d..e2531766eaf 100644 --- a/pkg/tsdb/elasticsearch/data_query_test.go +++ b/pkg/tsdb/elasticsearch/data_query_test.go @@ -8,6 +8,7 @@ import ( "github.com/grafana/grafana-plugin-sdk-go/backend" "github.com/grafana/grafana-plugin-sdk-go/backend/log" + "github.com/grafana/grafana-plugin-sdk-go/experimental/featuretoggles" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1887,6 +1888,11 @@ func newDataQuery(body string) (backend.QueryDataRequest, error) { } func executeElasticsearchDataQuery(c es.Client, body string, from, to time.Time) ( + *backend.QueryDataResponse, error) { + return executeElasticsearchDataQueryWithContext(c, body, from, to, context.Background()) +} + +func executeElasticsearchDataQueryWithContext(c es.Client, body string, from, to time.Time, ctx context.Context) ( *backend.QueryDataResponse, error) { timeRange := backend.TimeRange{ From: from, @@ -1901,6 +1907,98 @@ func executeElasticsearchDataQuery(c es.Client, body string, from, to time.Time) }, }, } - query := newElasticsearchDataQuery(context.Background(), c, &dataRequest, log.New()) + query := newElasticsearchDataQuery(ctx, c, &dataRequest, log.New()) return query.execute() } + +func TestRawDSLQuery(t *testing.T) { + from := time.Date(2018, 5, 15, 17, 50, 0, 0, time.UTC) + to := time.Date(2018, 5, 15, 17, 55, 0, 0, time.UTC) + + // Create context with raw DSL query feature toggle enabled + cfg := backend.NewGrafanaCfg(map[string]string{ + featuretoggles.EnabledFeatures: "elasticsearchRawDSLQuery", + }) + ctx := backend.WithGrafanaConfig(context.Background(), cfg) + + t.Run("With raw DSL query", func(t *testing.T) { + t.Run("Basic raw DSL query with aggregations", func(t *testing.T) { + c := newFakeClient() + _, err := executeElasticsearchDataQueryWithContext(c, `{ + "editorType": "code", + "rawDSLQuery": "{\"query\":{\"bool\":{\"filter\":[{\"range\":{\"@timestamp\":{\"gte\":1526405400000,\"lte\":1526405700000,\"format\":\"epoch_millis\"}}}]}},\"aggs\":{\"date_histogram\":{\"date_histogram\":{\"field\":\"@timestamp\",\"interval\":\"1m\"}}},\"size\":0}" + }`, from, to, ctx) + require.NoError(t, err) + require.Len(t, c.multisearchRequests, 1) + require.Len(t, c.multisearchRequests[0].Requests, 1) + sr := c.multisearchRequests[0].Requests[0] + + // Verify RawBody contains the entire DSL query + require.NotNil(t, sr.RawBody) + require.Contains(t, sr.RawBody, "query") + require.Contains(t, sr.RawBody, "aggs") + + // Verify size from raw body + size, ok := sr.RawBody["size"].(float64) + require.True(t, ok) + require.Equal(t, float64(0), size) + }) + + t.Run("Raw DSL query with query_string", func(t *testing.T) { + c := newFakeClient() + _, err := executeElasticsearchDataQueryWithContext(c, `{ + "editorType": "code", + "rawDSLQuery": "{\"query\":{\"query_string\":{\"query\":\"status:200\",\"analyze_wildcard\":true}},\"size\":100}" + }`, from, to, ctx) + require.NoError(t, err) + require.Len(t, c.multisearchRequests, 1) + sr := c.multisearchRequests[0].Requests[0] + + // Verify RawBody contains the entire DSL query + require.NotNil(t, sr.RawBody) + require.Contains(t, sr.RawBody, "query") + + // Verify size from raw body + size, ok := sr.RawBody["size"].(float64) + require.True(t, ok) + require.Equal(t, float64(100), size) + + // Verify query object exists in raw body + query, ok := sr.RawBody["query"].(map[string]any) + require.True(t, ok) + require.Contains(t, query, "query_string") + }) + + t.Run("Raw DSL query with sort", func(t *testing.T) { + c := newFakeClient() + _, err := executeElasticsearchDataQueryWithContext(c, `{ + "editorType": "code", + "rawDSLQuery": "{\"query\":{\"match_all\":{}},\"sort\":[{\"@timestamp\":{\"order\":\"desc\"}}],\"size\":50}" + }`, from, to, ctx) + require.NoError(t, err) + require.Len(t, c.multisearchRequests, 1) + sr := c.multisearchRequests[0].Requests[0] + + // Verify RawBody contains the entire DSL query + require.NotNil(t, sr.RawBody) + require.Contains(t, sr.RawBody, "query") + require.Contains(t, sr.RawBody, "sort") + + // Verify sort in raw body + sort, ok := sr.RawBody["sort"].([]any) + require.True(t, ok) + require.NotEmpty(t, sort) + }) + + t.Run("Invalid JSON in raw DSL query returns error", func(t *testing.T) { + c := newFakeClient() + response, err := executeElasticsearchDataQueryWithContext(c, `{ + "editorType": "code", + "rawDSLQuery": "{ invalid json }" + }`, from, to, ctx) + require.NoError(t, err) + require.NotNil(t, response.Responses["A"].Error) + require.Contains(t, response.Responses["A"].Error.Error(), "invalid raw DSL query JSON") + }) + }) +} diff --git a/pkg/tsdb/elasticsearch/data_query_validator.go b/pkg/tsdb/elasticsearch/data_query_validator.go index def537c02da..648dbb53109 100644 --- a/pkg/tsdb/elasticsearch/data_query_validator.go +++ b/pkg/tsdb/elasticsearch/data_query_validator.go @@ -6,6 +6,10 @@ 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 != "" { + return nil + } if len(query.BucketAggs) == 0 { // If no aggregations, only document and logs queries are valid if len(query.Metrics) == 0 || (!isLogsQuery(query) && !isDocumentQuery(query)) { diff --git a/pkg/tsdb/elasticsearch/kinds/dataquery/types_dataquery_gen.go b/pkg/tsdb/elasticsearch/kinds/dataquery/types_dataquery_gen.go index 175a486589c..31583f96f79 100644 --- a/pkg/tsdb/elasticsearch/kinds/dataquery/types_dataquery_gen.go +++ b/pkg/tsdb/elasticsearch/kinds/dataquery/types_dataquery_gen.go @@ -775,8 +775,12 @@ type ElasticsearchDataQuery struct { Alias *string `json:"alias,omitempty"` // Lucene query Query *string `json:"query,omitempty"` + // Raw DSL query + RawDSLQuery *string `json:"rawDSLQuery,omitempty"` // Name of time field TimeField *string `json:"timeField,omitempty"` + // Editor type + EditorType *string `json:"editorType,omitempty"` // List of bucket aggregations BucketAggs []BucketAggregation `json:"bucketAggs,omitempty"` // List of metric aggregations diff --git a/pkg/tsdb/elasticsearch/models.go b/pkg/tsdb/elasticsearch/models.go index d03861d3943..adb18554339 100644 --- a/pkg/tsdb/elasticsearch/models.go +++ b/pkg/tsdb/elasticsearch/models.go @@ -10,6 +10,7 @@ import ( // Query represents the time series query model of the datasource type Query struct { RawQuery string `json:"query"` + RawDSLQuery string `json:"rawDSLQuery"` BucketAggs []*BucketAgg `json:"bucketAggs"` Metrics []*MetricAgg `json:"metrics"` Alias string `json:"alias"` @@ -18,6 +19,7 @@ type Query struct { RefID string MaxDataPoints int64 TimeRange backend.TimeRange + EditorType *string `json:"editorType"` } // BucketAgg represents a bucket aggregation of the time series query model of the datasource diff --git a/pkg/tsdb/elasticsearch/parse_query.go b/pkg/tsdb/elasticsearch/parse_query.go index 27b9bc9b2e8..e1bfa189ab9 100644 --- a/pkg/tsdb/elasticsearch/parse_query.go +++ b/pkg/tsdb/elasticsearch/parse_query.go @@ -21,6 +21,13 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err // please do not create a new field with that name, to avoid potential problems with old, persisted queries. rawQuery := model.Get("query").MustString() + rawDSLQuery := model.Get("rawDSLQuery").MustString() + + var editorType *string + if et := model.Get("editorType").MustString(); et != "" { + editorType = &et + } + bucketAggs, err := parseBucketAggs(model) if err != nil { logger.Error("Failed to parse bucket aggs in query", "error", err, "model", string(q.JSON)) @@ -37,6 +44,7 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err queries = append(queries, &Query{ RawQuery: rawQuery, + RawDSLQuery: rawDSLQuery, BucketAggs: bucketAggs, Metrics: metrics, Alias: alias, @@ -45,6 +53,7 @@ func parseQuery(tsdbQuery []backend.DataQuery, logger log.Logger) ([]*Query, err RefID: q.RefID, MaxDataPoints: q.MaxDataPoints, TimeRange: q.TimeRange, + EditorType: editorType, }) } diff --git a/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go new file mode 100644 index 00000000000..b092763b57d --- /dev/null +++ b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser.go @@ -0,0 +1,628 @@ +package elasticsearch + +import ( + "encoding/json" + "fmt" + "strconv" + + "github.com/grafana/grafana/pkg/components/simplejson" +) + +// AggregationParser parses raw Elasticsearch DSL aggregations +type AggregationParser interface { + Parse(rawQuery string) ([]*BucketAgg, []*MetricAgg, error) +} + +// aggregationTypeParser handles parsing of specific aggregation types +type aggregationTypeParser interface { + CanParse(aggType string) bool + Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) +} + +type AggType string + +const ( + aggTypeBucket = AggType("bucket") + aggTypeMetric = AggType("metric") +) + +type dslAgg struct { + Field string `json:"field"` + Hide bool `json:"hide"` + ID string `json:"id"` + PipelineAggregate string `json:"pipelineAgg"` + PipelineVariables map[string]string `json:"pipelineVariables"` + Settings *simplejson.Json `json:"settings"` + Meta *simplejson.Json `json:"meta"` + Type string `json:"type"` + AggType AggType +} + +func (a *dslAgg) toBucketAgg() *BucketAgg { + return &BucketAgg{ + Field: a.Field, + ID: a.ID, + Settings: a.Settings, + Type: a.Type, + } +} + +func (a *dslAgg) toMetricAgg() *MetricAgg { + return &MetricAgg{ + Field: a.Field, + Hide: a.Hide, + ID: a.ID, + PipelineAggregate: a.PipelineAggregate, + PipelineVariables: a.PipelineVariables, + Settings: a.Settings, + Meta: a.Meta, + Type: a.Type, + } +} + +// fieldExtractor handles extracting and converting field values +type fieldExtractor struct{} + +func (e *fieldExtractor) getString(data map[string]any, key string) string { + if val, ok := data[key]; ok { + if str, ok := val.(string); ok { + return str + } + } + return "" +} + +func (e *fieldExtractor) getInt(data map[string]any, key string) int { + if val, ok := data[key]; ok { + switch v := val.(type) { + case float64: + return int(v) + case int: + return v + case string: + if i, err := strconv.Atoi(v); err == nil { + return i + } + } + } + return 0 +} + +func (e *fieldExtractor) getFloat(data map[string]any, key string) float64 { + if val, ok := data[key]; ok { + switch v := val.(type) { + case float64: + return v + case int: + return float64(v) + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f + } + } + } + return 0 +} + +func (e *fieldExtractor) getMap(data map[string]any, key string) map[string]any { + if val, ok := data[key]; ok { + if m, ok := val.(map[string]any); ok { + return m + } + } + return nil +} + +func (e *fieldExtractor) getSettings(data map[string]any) *simplejson.Json { + settings := make(map[string]any) + for k, v := range data { + // Skip known non-setting fields + if k == "field" || k == "buckets_path" { + continue + } + settings[k] = v + } + return simplejson.NewFromAny(settings) +} + +// dateHistogramParser handles date_histogram aggregations +type dateHistogramParser struct { + extractor *fieldExtractor +} + +func (p *dateHistogramParser) CanParse(aggType string) bool { + return aggType == dateHistType +} + +func (p *dateHistogramParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + + settings := make(map[string]any) + if interval := p.extractor.getString(aggValue, "fixed_interval"); interval != "" { + settings["interval"] = interval + } else if interval := p.extractor.getString(aggValue, "calendar_interval"); interval != "" { + settings["interval"] = interval + } else if interval := p.extractor.getString(aggValue, "interval"); interval != "" { + settings["interval"] = interval + } + + if minDocCount := p.extractor.getInt(aggValue, "min_doc_count"); minDocCount > 0 { + settings["min_doc_count"] = strconv.Itoa(minDocCount) + } + + if timeZone := p.extractor.getString(aggValue, "time_zone"); timeZone != "" { + settings["time_zone"] = timeZone + } + + return &dslAgg{ + ID: id, + Type: dateHistType, + Field: field, + Settings: simplejson.NewFromAny(settings), + AggType: aggTypeBucket, + }, nil +} + +// termsParser handles terms aggregations +type termsParser struct { + extractor *fieldExtractor +} + +func (p *termsParser) CanParse(aggType string) bool { + return aggType == termsType +} + +func (p *termsParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + + settings := make(map[string]any) + if size := p.extractor.getInt(aggValue, "size"); size > 0 { + settings["size"] = strconv.Itoa(size) + } + + if order := p.extractor.getMap(aggValue, "order"); order != nil { + for k := range order { + settings["orderBy"] = k + orderJSON := p.extractor.getString(order, k) + settings["order"] = orderJSON + } + } + + if minDocCount := p.extractor.getInt(aggValue, "min_doc_count"); minDocCount != 0 { + minDocCountJSON, _ := json.Marshal(minDocCount) + settings["min_doc_count"] = string(minDocCountJSON) + } + + if missing := p.extractor.getString(aggValue, "missing"); missing != "" { + settings["missing"] = missing + } + + return &dslAgg{ + ID: id, + Type: termsType, + Field: field, + Settings: simplejson.NewFromAny(settings), + AggType: aggTypeBucket, + }, nil +} + +// histogramParser handles histogram aggregations +type histogramParser struct { + extractor *fieldExtractor +} + +func (p *histogramParser) CanParse(aggType string) bool { + return aggType == histogramType +} + +func (p *histogramParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + + settings := make(map[string]any) + if interval := p.extractor.getFloat(aggValue, "interval"); interval > 0 { + settings["interval"] = strconv.FormatFloat(interval, 'f', -1, 64) + } + + if minDocCount := p.extractor.getInt(aggValue, "min_doc_count"); minDocCount > 0 { + settings["min_doc_count"] = strconv.Itoa(minDocCount) + } + + return &dslAgg{ + ID: id, + Type: histogramType, + Field: field, + Settings: simplejson.NewFromAny(settings), + AggType: aggTypeBucket, + }, nil +} + +// simpleMetricParser handles simple metric aggregations (avg, sum, min, max, cardinality) +type simpleMetricParser struct { + extractor *fieldExtractor + types map[string]bool +} + +func newSimpleMetricParser() *simpleMetricParser { + return &simpleMetricParser{ + extractor: &fieldExtractor{}, + types: map[string]bool{ + "avg": true, + "sum": true, + "min": true, + "max": true, + "cardinality": true, + }, + } +} + +func (p *simpleMetricParser) CanParse(aggType string) bool { + return p.types[aggType] +} + +func (p *simpleMetricParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + settings := p.extractor.getSettings(aggValue) + + return &dslAgg{ + ID: id, + Type: aggType, + Field: field, + Settings: settings, + AggType: aggTypeMetric, + }, nil +} + +// filtersParser handles filters aggregations +type filtersParser struct { + extractor *fieldExtractor +} + +func (p *filtersParser) CanParse(aggType string) bool { + return aggType == filtersType +} + +func (p *filtersParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + settings := make(map[string]any) + + if filters := p.extractor.getMap(aggValue, "filters"); filters != nil { + filtersArray := make([]any, 0, len(filters)) + for k, v := range filters { + if queryString := p.extractor.getMap(v.(map[string]any), "query_string"); queryString != nil { + queryString["label"] = k + filtersArray = append(filtersArray, queryString) + } + } + settings["filters"] = filtersArray + } + + return &dslAgg{ + ID: id, + Type: filtersType, + Field: "", + Settings: simplejson.NewFromAny(settings), + AggType: aggTypeBucket, + }, nil +} + +// geohashGridParser handles geohash_grid aggregations +type geohashGridParser struct { + extractor *fieldExtractor +} + +func (p *geohashGridParser) CanParse(aggType string) bool { + return aggType == geohashGridType +} + +func (p *geohashGridParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + + settings := make(map[string]any) + if precision := p.extractor.getInt(aggValue, "precision"); precision > 0 { + settings["precision"] = strconv.Itoa(precision) + } + + return &dslAgg{ + ID: id, + Type: geohashGridType, + Field: field, + Settings: simplejson.NewFromAny(settings), + AggType: aggTypeBucket, + }, nil +} + +// nestedParser handles nested aggregations +type nestedParser struct { + extractor *fieldExtractor +} + +func (p *nestedParser) CanParse(aggType string) bool { + return aggType == nestedType +} + +func (p *nestedParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + path := p.extractor.getString(aggValue, "path") + + return &dslAgg{ + ID: id, + Type: nestedType, + Field: path, + Settings: simplejson.NewFromAny(map[string]any{}), + AggType: aggTypeBucket, + }, nil +} + +// extendedStatsParser handles extended_stats aggregations +type extendedStatsParser struct { + extractor *fieldExtractor +} + +func (p *extendedStatsParser) CanParse(aggType string) bool { + return aggType == extendedStatsType +} + +func (p *extendedStatsParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + settings := p.extractor.getSettings(aggValue) + + return &dslAgg{ + ID: id, + Type: extendedStatsType, + Field: field, + Settings: settings, + AggType: aggTypeMetric, + }, nil +} + +// percentilesParser handles percentiles aggregations +type percentilesParser struct { + extractor *fieldExtractor +} + +func (p *percentilesParser) CanParse(aggType string) bool { + return aggType == percentilesType +} + +func (p *percentilesParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + field := p.extractor.getString(aggValue, "field") + settings := p.extractor.getSettings(aggValue) + + return &dslAgg{ + ID: id, + Type: percentilesType, + Field: field, + Settings: settings, + AggType: aggTypeMetric, + }, nil +} + +// topMetricsParser handles top_metrics aggregations +type topMetricsParser struct { + extractor *fieldExtractor +} + +func (p *topMetricsParser) CanParse(aggType string) bool { + return aggType == topMetricsType +} + +func (p *topMetricsParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + settings := p.extractor.getSettings(aggValue) + + // Extract metrics field if present + field := "" + if metrics := p.extractor.getMap(aggValue, "metrics"); metrics != nil { + if metricsField := p.extractor.getString(metrics, "field"); metricsField != "" { + field = metricsField + } + } + + return &dslAgg{ + ID: id, + Type: topMetricsType, + Field: field, + Settings: settings, + AggType: aggTypeMetric, + }, nil +} + +// pipelineParser handles pipeline aggregations +type pipelineParser struct { + extractor *fieldExtractor + types map[string]bool +} + +func newPipelineParser() *pipelineParser { + return &pipelineParser{ + extractor: &fieldExtractor{}, + types: map[string]bool{ + "moving_avg": true, + "moving_fn": true, + "derivative": true, + "cumulative_sum": true, + "serial_diff": true, + }, + } +} + +func (p *pipelineParser) CanParse(aggType string) bool { + return p.types[aggType] +} + +func (p *pipelineParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + bucketsPath := p.extractor.getString(aggValue, "buckets_path") + settings := p.extractor.getSettings(aggValue) + + return &dslAgg{ + ID: id, + Type: aggType, + Field: bucketsPath, // For pipeline aggs, buckets_path goes in Field + Settings: settings, + AggType: aggTypeMetric, + }, nil +} + +// bucketScriptParser handles bucket_script aggregations +type bucketScriptParser struct { + extractor *fieldExtractor +} + +func (p *bucketScriptParser) CanParse(aggType string) bool { + return aggType == "bucket_script" +} + +func (p *bucketScriptParser) Parse(id, aggType string, aggValue map[string]any) (*dslAgg, error) { + settings := p.extractor.getSettings(aggValue) + + // Extract buckets_path (can be a string or map) + pipelineVariables := make(map[string]string) + if bucketsPath, ok := aggValue["buckets_path"]; ok { + switch bp := bucketsPath.(type) { + case string: + // Single string bucket path + pipelineVariables["var1"] = bp + case map[string]any: + // Map of variable names to bucket paths + for varName, path := range bp { + if pathStr, ok := path.(string); ok { + pipelineVariables[varName] = pathStr + } + } + } + } + + return &dslAgg{ + ID: id, + Type: "bucket_script", + Field: "", + PipelineVariables: pipelineVariables, + Settings: settings, + AggType: aggTypeMetric, + }, nil +} + +// compositeParser combines multiple parsers +type compositeParser struct { + parsers []aggregationTypeParser + extractor *fieldExtractor +} + +func newCompositeParser() *compositeParser { + extractor := &fieldExtractor{} + return &compositeParser{ + extractor: extractor, + parsers: []aggregationTypeParser{ + // Bucket aggregations + &dateHistogramParser{extractor: extractor}, + &termsParser{extractor: extractor}, + &histogramParser{extractor: extractor}, + &filtersParser{extractor: extractor}, + &geohashGridParser{extractor: extractor}, + &nestedParser{extractor: extractor}, + // Metric aggregations + newSimpleMetricParser(), + &extendedStatsParser{extractor: extractor}, + &percentilesParser{extractor: extractor}, + &topMetricsParser{extractor: extractor}, + + // Pipeline aggregations + newPipelineParser(), + &bucketScriptParser{extractor: extractor}, + }, + } +} + +func (p *compositeParser) findParser(aggType string) aggregationTypeParser { + for _, parser := range p.parsers { + if parser.CanParse(aggType) { + return parser + } + } + return nil +} + +func (p *compositeParser) Parse(rawQuery string) ([]*BucketAgg, []*MetricAgg, error) { + if rawQuery == "" { + return nil, nil, nil + } + + var queryBody map[string]any + if err := json.Unmarshal([]byte(rawQuery), &queryBody); err != nil { + return nil, nil, fmt.Errorf("failed to parse raw query JSON: %w", err) + } + + // Look for aggregations in both "aggs" and "aggregations" + var aggsData map[string]any + if aggs, ok := queryBody["aggs"].(map[string]any); ok { + aggsData = aggs + } else if aggs, ok := queryBody["aggregations"].(map[string]any); ok { + aggsData = aggs + } + + if aggsData == nil { + return nil, nil, nil + } + + b, m := p.parseAggregations(aggsData) + return b, m, nil +} + +func (p *compositeParser) parseAggregations(aggsData map[string]any) ([]*BucketAgg, []*MetricAgg) { + var bucketAggs []*BucketAgg + var metricAggs []*MetricAgg + + for aggID, aggData := range aggsData { + aggMap, ok := aggData.(map[string]any) + if !ok { + continue + } + + // Find the aggregation type (first key that's not "aggs" or "aggregations") + var aggType string + var aggValue map[string]any + for key, value := range aggMap { + if key != "aggs" && key != "aggregations" { + aggType = key + if val, ok := value.(map[string]any); ok { + aggValue = val + } + break + } + } + + if aggType == "" || aggValue == nil { + continue + } + + // Find the appropriate parser for this aggregation type + parser := p.findParser(aggType) + if parser == nil { + // Unknown aggregation type, skip it + continue + } + + // Try to parse as agg aggregation + if agg, err := parser.Parse(aggID, aggType, aggValue); err == nil && agg != nil { + switch agg.AggType { + case aggTypeBucket: + bucketAggs = append(bucketAggs, agg.toBucketAgg()) + case aggTypeMetric: + metricAggs = append(metricAggs, agg.toMetricAgg()) + } + } + + // Parse nested aggregations + nestedAggs := p.extractor.getMap(aggMap, "aggs") + if nestedAggs == nil { + nestedAggs = p.extractor.getMap(aggMap, "aggregations") + } + nestedBuckets, nestedMetrics := p.parseAggregations(nestedAggs) + bucketAggs = append(bucketAggs, nestedBuckets...) + metricAggs = append(metricAggs, nestedMetrics...) + } + + return bucketAggs, metricAggs +} + +// NewAggregationParser creates a new aggregation parser +func NewAggregationParser() AggregationParser { + return newCompositeParser() +} diff --git a/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser_test.go b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser_test.go new file mode 100644 index 00000000000..c8ad0c9ef6e --- /dev/null +++ b/pkg/tsdb/elasticsearch/raw_dsl_aggregation_parser_test.go @@ -0,0 +1,706 @@ +package elasticsearch + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestFieldExtractor tests the field extraction utility +func TestFieldExtractor(t *testing.T) { + extractor := &fieldExtractor{} + + t.Run("getString", func(t *testing.T) { + data := map[string]any{ + "field": "value", + "number": 42, + "missing": nil, + } + + assert.Equal(t, "value", extractor.getString(data, "field")) + assert.Equal(t, "", extractor.getString(data, "number")) + assert.Equal(t, "", extractor.getString(data, "missing")) + assert.Equal(t, "", extractor.getString(data, "nonexistent")) + }) + + t.Run("getInt", func(t *testing.T) { + data := map[string]any{ + "float": 42.0, + "int": 100, + "string": "200", + "bad": "notanumber", + } + + assert.Equal(t, 42, extractor.getInt(data, "float")) + assert.Equal(t, 100, extractor.getInt(data, "int")) + assert.Equal(t, 200, extractor.getInt(data, "string")) + assert.Equal(t, 0, extractor.getInt(data, "bad")) + assert.Equal(t, 0, extractor.getInt(data, "nonexistent")) + }) + + t.Run("getFloat", func(t *testing.T) { + data := map[string]any{ + "float": 42.5, + "int": 100, + "string": "3.14", + } + + assert.Equal(t, 42.5, extractor.getFloat(data, "float")) + assert.Equal(t, 100.0, extractor.getFloat(data, "int")) + assert.Equal(t, 3.14, extractor.getFloat(data, "string")) + assert.Equal(t, 0.0, extractor.getFloat(data, "nonexistent")) + }) + + t.Run("getMap", func(t *testing.T) { + data := map[string]any{ + "map": map[string]any{"key": "value"}, + "notmap": "string", + } + + result := extractor.getMap(data, "map") + require.NotNil(t, result) + assert.Equal(t, "value", result["key"]) + + assert.Nil(t, extractor.getMap(data, "notmap")) + assert.Nil(t, extractor.getMap(data, "nonexistent")) + }) +} + +// TestDateHistogramParser tests the date histogram parser +func TestDateHistogramParser(t *testing.T) { + parser := &dateHistogramParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse(dateHistType)) + assert.False(t, parser.CanParse("terms")) + }) + + t.Run("Parse with fixed_interval", func(t *testing.T) { + aggValue := map[string]any{ + "field": "@timestamp", + "fixed_interval": "30s", + "min_doc_count": 1, + } + + agg, err := parser.Parse("1", dateHistType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + bucket := agg.toBucketAgg() + assert.Equal(t, "1", bucket.ID) + assert.Equal(t, dateHistType, bucket.Type) + assert.Equal(t, "@timestamp", bucket.Field) + assert.Equal(t, "30s", bucket.Settings.Get("interval").MustString()) + assert.Equal(t, "1", bucket.Settings.Get("min_doc_count").MustString()) + }) + + t.Run("Parse with calendar_interval", func(t *testing.T) { + aggValue := map[string]any{ + "field": "@timestamp", + "calendar_interval": "1d", + "time_zone": "UTC", + } + + agg, err := parser.Parse("2", dateHistType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + bucket := agg.toBucketAgg() + assert.Equal(t, "1d", bucket.Settings.Get("interval").MustString()) + assert.Equal(t, "UTC", bucket.Settings.Get("time_zone").MustString()) + }) + + t.Run("Parse returns bucket aggregation", func(t *testing.T) { + agg, err := parser.Parse("1", dateHistType, map[string]any{"field": "@timestamp"}) + assert.NoError(t, err) + assert.NotNil(t, agg) + assert.Equal(t, aggTypeBucket, agg.AggType) + }) +} + +// TestTermsParser tests the terms parser +func TestTermsParser(t *testing.T) { + parser := &termsParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse(termsType)) + assert.False(t, parser.CanParse("histogram")) + }) + + t.Run("Parse", func(t *testing.T) { + aggValue := map[string]any{ + "field": "hostname.keyword", + "size": 10, + "order": map[string]any{"_count": "desc"}, + } + + agg, err := parser.Parse("3", termsType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + bucket := agg.toBucketAgg() + assert.Equal(t, "3", bucket.ID) + assert.Equal(t, termsType, bucket.Type) + assert.Equal(t, "hostname.keyword", bucket.Field) + assert.Equal(t, "10", bucket.Settings.Get("size").MustString()) + }) +} + +// TestHistogramParser tests the histogram parser +func TestHistogramParser(t *testing.T) { + parser := &histogramParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse(histogramType)) + assert.False(t, parser.CanParse("terms")) + }) + + t.Run("Parse", func(t *testing.T) { + aggValue := map[string]any{ + "field": "response_time", + "interval": 50.0, + } + + agg, err := parser.Parse("4", histogramType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + bucket := agg.toBucketAgg() + assert.Equal(t, "4", bucket.ID) + assert.Equal(t, histogramType, bucket.Type) + assert.Equal(t, "response_time", bucket.Field) + assert.Equal(t, "50", bucket.Settings.Get("interval").MustString()) + }) +} + +// TestFiltersParser tests the filters parser +func TestFiltersParser(t *testing.T) { + parser := &filtersParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse(filtersType)) + assert.False(t, parser.CanParse("terms")) + }) + + t.Run("Parse", func(t *testing.T) { + aggValue := map[string]any{ + "filters": map[string]any{ + "errors": map[string]any{"query_string": map[string]any{"query": "level:error"}}, + "warnings": map[string]any{"query_string": map[string]any{"query": "level:warning"}}, + }, + } + + agg, err := parser.Parse("filters", filtersType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + bucket := agg.toBucketAgg() + assert.Equal(t, "filters", bucket.ID) + assert.Equal(t, filtersType, bucket.Type) + filtersArray := bucket.Settings.Get("filters").MustArray() + assert.NotEmpty(t, filtersArray) + assert.Len(t, filtersArray, 2) + }) +} + +// TestSimpleMetricParser tests the simple metric parser +func TestSimpleMetricParser(t *testing.T) { + parser := newSimpleMetricParser() + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse("avg")) + assert.True(t, parser.CanParse("sum")) + assert.True(t, parser.CanParse("min")) + assert.True(t, parser.CanParse("max")) + assert.True(t, parser.CanParse("cardinality")) + assert.False(t, parser.CanParse("bucket_script")) + }) + + t.Run("Parse avg", func(t *testing.T) { + aggValue := map[string]any{ + "field": "response_time", + } + + agg, err := parser.Parse("1", "avg", aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + metric := agg.toMetricAgg() + assert.Equal(t, "1", metric.ID) + assert.Equal(t, "avg", metric.Type) + assert.Equal(t, "response_time", metric.Field) + }) + + t.Run("Parse returns metric aggregation", func(t *testing.T) { + agg, err := parser.Parse("1", "avg", map[string]any{}) + assert.NoError(t, err) + assert.NotNil(t, agg) + assert.Equal(t, aggTypeMetric, agg.AggType) + }) +} + +// TestExtendedStatsParser tests the extended stats parser +func TestExtendedStatsParser(t *testing.T) { + parser := &extendedStatsParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse(extendedStatsType)) + assert.False(t, parser.CanParse("avg")) + }) + + t.Run("Parse", func(t *testing.T) { + aggValue := map[string]any{ + "field": "response_time", + "sigma": 2, + } + + agg, err := parser.Parse("stats", extendedStatsType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + metric := agg.toMetricAgg() + assert.Equal(t, "stats", metric.ID) + assert.Equal(t, extendedStatsType, metric.Type) + assert.Equal(t, "response_time", metric.Field) + }) +} + +// TestPercentilesParser tests the percentiles parser +func TestPercentilesParser(t *testing.T) { + parser := &percentilesParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse(percentilesType)) + assert.False(t, parser.CanParse("avg")) + }) + + t.Run("Parse", func(t *testing.T) { + aggValue := map[string]any{ + "field": "response_time", + "percents": []any{50.0, 95.0, 99.0}, + } + + agg, err := parser.Parse("percentiles", percentilesType, aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + metric := agg.toMetricAgg() + assert.Equal(t, "percentiles", metric.ID) + assert.Equal(t, percentilesType, metric.Type) + assert.Equal(t, "response_time", metric.Field) + }) +} + +// TestPipelineParser tests the pipeline parser +func TestPipelineParser(t *testing.T) { + parser := newPipelineParser() + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse("moving_avg")) + assert.True(t, parser.CanParse("derivative")) + assert.True(t, parser.CanParse("cumulative_sum")) + assert.False(t, parser.CanParse("bucket_script")) + }) + + t.Run("Parse", func(t *testing.T) { + aggValue := map[string]any{ + "buckets_path": "1", + } + + agg, err := parser.Parse("moving", "moving_avg", aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + metric := agg.toMetricAgg() + assert.Equal(t, "moving", metric.ID) + assert.Equal(t, "moving_avg", metric.Type) + assert.Equal(t, "1", metric.Field) + }) +} + +// TestBucketScriptParser tests the bucket script parser +func TestBucketScriptParser(t *testing.T) { + parser := &bucketScriptParser{extractor: &fieldExtractor{}} + + t.Run("CanParse", func(t *testing.T) { + assert.True(t, parser.CanParse("bucket_script")) + assert.False(t, parser.CanParse("moving_avg")) + }) + + t.Run("Parse with map buckets_path", func(t *testing.T) { + aggValue := map[string]any{ + "buckets_path": map[string]any{ + "count": "total", + }, + "script": "params.count / 60", + } + + agg, err := parser.Parse("rate", "bucket_script", aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + metric := agg.toMetricAgg() + assert.Equal(t, "rate", metric.ID) + assert.Equal(t, "bucket_script", metric.Type) + assert.Equal(t, "total", metric.PipelineVariables["count"]) + assert.Equal(t, "params.count / 60", metric.Settings.Get("script").MustString()) + }) + + t.Run("Parse with string buckets_path", func(t *testing.T) { + aggValue := map[string]any{ + "buckets_path": "1", + } + + agg, err := parser.Parse("rate", "bucket_script", aggValue) + require.NoError(t, err) + require.NotNil(t, agg) + + metric := agg.toMetricAgg() + assert.Equal(t, "1", metric.PipelineVariables["var1"]) + }) +} + +// TestCompositeParser tests the full parser integration +func TestCompositeParser(t *testing.T) { + parser := NewAggregationParser() + + t.Run("Parse date histogram aggregation", func(t *testing.T) { + rawQuery := `{ + "query": { + "match_all": {} + }, + "aggs": { + "2": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "30s", + "min_doc_count": 1 + } + } + } + }` + + bucketAggs, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + require.Len(t, metricAggs, 0) + + assert.Equal(t, "2", bucketAggs[0].ID) + assert.Equal(t, dateHistType, bucketAggs[0].Type) + assert.Equal(t, "@timestamp", bucketAggs[0].Field) + assert.Equal(t, "30s", bucketAggs[0].Settings.Get("interval").MustString()) + }) + + t.Run("Parse nested aggregations with metrics", func(t *testing.T) { + rawQuery := `{ + "query": { + "match_all": {} + }, + "aggs": { + "2": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "30s" + }, + "aggs": { + "1": { + "avg": { + "field": "value" + } + }, + "3": { + "sum": { + "field": "total" + } + } + } + } + } + }` + + bucketAggs, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + require.Len(t, metricAggs, 2) + + // Check bucket aggregation + assert.Equal(t, "2", bucketAggs[0].ID) + assert.Equal(t, dateHistType, bucketAggs[0].Type) + + // Check metric aggregations + avgFound := false + sumFound := false + for _, m := range metricAggs { + if m.ID == "1" && m.Type == "avg" && m.Field == "value" { + avgFound = true + } + if m.ID == "3" && m.Type == "sum" && m.Field == "total" { + sumFound = true + } + } + assert.True(t, avgFound, "avg aggregation not found") + assert.True(t, sumFound, "sum aggregation not found") + }) + + t.Run("Parse terms aggregation", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "3": { + "terms": { + "field": "hostname.keyword", + "size": 10, + "order": { + "_count": "desc" + } + } + } + } + }` + + bucketAggs, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + require.Len(t, metricAggs, 0) + + assert.Equal(t, "3", bucketAggs[0].ID) + assert.Equal(t, termsType, bucketAggs[0].Type) + assert.Equal(t, "hostname.keyword", bucketAggs[0].Field) + assert.Equal(t, "10", bucketAggs[0].Settings.Get("size").MustString()) + }) + + t.Run("Parse histogram aggregation", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "4": { + "histogram": { + "field": "response_time", + "interval": 50 + } + } + } + }` + + bucketAggs, _, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + + assert.Equal(t, "4", bucketAggs[0].ID) + assert.Equal(t, histogramType, bucketAggs[0].Type) + assert.Equal(t, "response_time", bucketAggs[0].Field) + assert.Equal(t, "50", bucketAggs[0].Settings.Get("interval").MustString()) + }) + + t.Run("Parse extended stats aggregation", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "stats": { + "extended_stats": { + "field": "response_time", + "sigma": 2 + } + } + } + }` + + _, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, metricAggs, 1) + + assert.Equal(t, "stats", metricAggs[0].ID) + assert.Equal(t, extendedStatsType, metricAggs[0].Type) + assert.Equal(t, "response_time", metricAggs[0].Field) + }) + + t.Run("Parse percentiles aggregation", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "percentiles": { + "percentiles": { + "field": "response_time", + "percents": [50, 95, 99] + } + } + } + }` + + _, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, metricAggs, 1) + + assert.Equal(t, "percentiles", metricAggs[0].ID) + assert.Equal(t, percentilesType, metricAggs[0].Type) + }) + + t.Run("Parse pipeline aggregations", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "2": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "1m" + }, + "aggs": { + "1": { + "avg": { + "field": "value" + } + }, + "moving": { + "moving_avg": { + "buckets_path": "1" + } + }, + "deriv": { + "derivative": { + "buckets_path": "1" + } + } + } + } + } + }` + + bucketAggs, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + require.GreaterOrEqual(t, len(metricAggs), 2) // At least avg and one pipeline + + // Find pipeline aggregations + movingAvgFound := false + derivativeFound := false + for _, m := range metricAggs { + if m.ID == "moving" && m.Type == "moving_avg" { + movingAvgFound = true + assert.Equal(t, "1", m.Field) + } + if m.ID == "deriv" && m.Type == "derivative" { + derivativeFound = true + assert.Equal(t, "1", m.Field) + } + } + assert.True(t, movingAvgFound, "moving_avg aggregation not found") + assert.True(t, derivativeFound, "derivative aggregation not found") + }) + + t.Run("Parse bucket script aggregation", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "2": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "1m" + }, + "aggs": { + "total": { + "sum": { + "field": "bytes" + } + }, + "rate": { + "bucket_script": { + "buckets_path": { + "count": "total" + }, + "script": "params.count / 60" + } + } + } + } + } + }` + + _, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + + // Find bucket script + var bucketScriptAgg *MetricAgg + for _, m := range metricAggs { + if m.ID == "rate" && m.Type == "bucket_script" { + bucketScriptAgg = m + break + } + } + require.NotNil(t, bucketScriptAgg, "bucket_script aggregation not found") + assert.Equal(t, "params.count / 60", bucketScriptAgg.Settings.Get("script").MustString()) + assert.Equal(t, "total", bucketScriptAgg.PipelineVariables["count"]) + }) + + t.Run("Parse filters aggregation", func(t *testing.T) { + rawQuery := `{ + "aggs": { + "messages": { + "filters": { + "filters": { + "errors": { + "query_string": { + "query": "level:error" + } + }, + "warnings": { + "query_string": { + "query": "level:warning" + } + } + } + } + } + } + }` + + bucketAggs, _, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + + assert.Equal(t, "messages", bucketAggs[0].ID) + assert.Equal(t, filtersType, bucketAggs[0].Type) + }) + + t.Run("Handle empty query", func(t *testing.T) { + bucketAggs, metricAggs, err := parser.Parse("") + require.NoError(t, err) + assert.Nil(t, bucketAggs) + assert.Nil(t, metricAggs) + }) + + t.Run("Handle query without aggregations", func(t *testing.T) { + rawQuery := `{ + "query": { + "match_all": {} + } + }` + + bucketAggs, metricAggs, err := parser.Parse(rawQuery) + require.NoError(t, err) + assert.Nil(t, bucketAggs) + assert.Nil(t, metricAggs) + }) + + t.Run("Handle invalid JSON", func(t *testing.T) { + rawQuery := `{invalid json` + + _, _, err := parser.Parse(rawQuery) + require.Error(t, err) + }) + + t.Run("Use 'aggregations' instead of 'aggs'", func(t *testing.T) { + rawQuery := `{ + "query": { + "match_all": {} + }, + "aggregations": { + "2": { + "date_histogram": { + "field": "@timestamp", + "fixed_interval": "30s" + } + } + } + }` + + bucketAggs, _, err := parser.Parse(rawQuery) + require.NoError(t, err) + require.Len(t, bucketAggs, 1) + assert.Equal(t, "2", bucketAggs[0].ID) + }) +} diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx new file mode 100644 index 00000000000..c9d52ecd49d --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/EditorTypeSelector.tsx @@ -0,0 +1,29 @@ +import { SelectableValue } from '@grafana/data'; +import { RadioButtonGroup } from '@grafana/ui'; + +import { useDispatch } from '../../hooks/useStatelessReducer'; +import { EditorType } from '../../types'; + +import { useQuery } from './ElasticsearchQueryContext'; +import { changeEditorTypeAndResetQuery } from './state'; + +const BASE_OPTIONS: Array> = [ + { value: 'builder', label: 'Builder' }, + { value: 'code', label: 'Code' }, +]; + +export const EditorTypeSelector = () => { + const query = useQuery(); + const dispatch = useDispatch(); + + // Default to 'builder' if editorType is empty + const editorType: EditorType = query.editorType === 'code' ? 'code' : 'builder'; + + const onChange = (newEditorType: EditorType) => { + dispatch(changeEditorTypeAndResetQuery(newEditorType)); + }; + + return ( + fullWidth={false} options={BASE_OPTIONS} value={editorType} onChange={onChange} /> + ); +}; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx index 22102290fe1..44fac679a9e 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/ElasticsearchQueryContext.tsx @@ -8,7 +8,7 @@ import { combineReducers, useStatelessReducer, DispatchContext } from '../../hoo import { createReducer as createBucketAggsReducer } from './BucketAggregationsEditor/state/reducer'; import { reducer as metricsReducer } from './MetricAggregationsEditor/state/reducer'; -import { aliasPatternReducer, queryReducer, initQuery } from './state'; +import { aliasPatternReducer, queryReducer, rawDSLQueryReducer, editorTypeReducer, initQuery } from './state'; const DatasourceContext = createContext(undefined); const QueryContext = createContext(undefined); @@ -40,9 +40,13 @@ export const ElasticsearchProvider = ({ [onChange, onRunQuery] ); - const reducer = combineReducers>({ + const reducer = combineReducers< + Pick + >({ query: queryReducer, + rawDSLQuery: rawDSLQueryReducer, alias: aliasPatternReducer, + editorType: editorTypeReducer, metrics: metricsReducer, bucketAggs: createBucketAggsReducer(datasource.timeField), }); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts index f304e4876c1..9adff8781b1 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/MetricAggregationsEditor/state/actions.ts @@ -11,6 +11,9 @@ export const changeMetricField = createAction<{ id: MetricAggregation['id']; fie export const changeMetricType = createAction<{ id: MetricAggregation['id']; type: MetricAggregation['type'] }>( '@metrics/change_type' ); +export const changeEditorType = createAction<{ id: MetricAggregation['id']; type: MetricAggregation['type'] }>( + '@metrics/change_type' +); export const changeMetricAttribute = createAction<{ metric: MetricAggregation; attribute: string; newValue: unknown }>( '@metrics/change_attr' ); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx new file mode 100644 index 00000000000..92a5a8b0b9e --- /dev/null +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/RawQueryEditor.tsx @@ -0,0 +1,108 @@ +import { css } from '@emotion/css'; +import { useCallback, useRef } from 'react'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { CodeEditor, Monaco, CodeEditorMonacoOptions, monacoTypes, useStyles2, Button, Stack, Box } from '@grafana/ui'; + +interface Props { + value?: string; + onChange: (value: string) => void; + onRunQuery: () => void; +} + +export function RawQueryEditor({ value, onChange, onRunQuery }: Props) { + const styles = useStyles2(getStyles); + const editorRef = useRef(null); + + const handleEditorDidMount = useCallback( + (editor: monacoTypes.editor.IStandaloneCodeEditor, monaco: Monaco) => { + editorRef.current = editor; + + // Add keyboard shortcut for running query (Ctrl/Cmd+Enter) + editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.Enter, () => { + onRunQuery(); + }); + }, + [onRunQuery] + ); + + const handleFormat = useCallback(() => { + if (editorRef.current) { + editorRef.current.getAction('editor.action.formatDocument')?.run(); + } + }, []); + + const handleQueryChange = useCallback( + (newValue: string) => { + if (!newValue) { + return; + } + onChange(newValue); + }, + [onChange] + ); + + const monacoOptions: CodeEditorMonacoOptions = { + fontSize: 14, + lineNumbers: 'on', + minimap: { enabled: false }, + scrollBeyondLastLine: false, + wordWrap: 'on', + automaticLayout: true, + fixedOverflowWidgets: true, + folding: true, + renderLineHighlight: 'all', + suggest: { + showProperties: true, + showMethods: true, + showKeywords: true, + }, + quickSuggestions: { + other: true, + strings: true, + }, + }; + + return ( + +
+ + + + +
+ +
+ ); +} + +const getStyles = (theme: GrafanaTheme2) => ({ + container: css({ + display: 'flex', + flexDirection: 'column', + gap: theme.spacing(1), + }), + header: css({ + display: 'flex', + justifyContent: 'flex-end', + padding: theme.spacing(0.5, 0), + }), +}); diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx index 927ef6aa544..b54de44bb63 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/index.tsx @@ -3,6 +3,7 @@ import { useEffect, useId, useState } from 'react'; import { SemVer } from 'semver'; import { getDefaultTimeRange, GrafanaTheme2, QueryEditorProps } from '@grafana/data'; +import { config } from '@grafana/runtime'; import { Alert, InlineField, InlineLabel, Input, QueryField, useStyles2 } from '@grafana/ui'; import { ElasticsearchDataQuery } from '../../dataquery.gen'; @@ -13,11 +14,13 @@ import { ElasticsearchOptions } from '../../types'; import { isSupportedVersion, isTimeSeriesQuery, unsupportedVersionMessage } from '../../utils'; import { BucketAggregationsEditor } from './BucketAggregationsEditor'; +import { EditorTypeSelector } from './EditorTypeSelector'; import { ElasticsearchProvider } from './ElasticsearchQueryContext'; import { MetricAggregationsEditor } from './MetricAggregationsEditor'; import { metricAggregationConfig } from './MetricAggregationsEditor/utils'; import { QueryTypeSelector } from './QueryTypeSelector'; -import { changeAliasPattern, changeQuery } from './state'; +import { RawQueryEditor } from './RawQueryEditor'; +import { changeAliasPattern, changeQuery, changeRawDSLQuery } from './state'; export type ElasticQueryEditorProps = QueryEditorProps; @@ -59,7 +62,7 @@ export const QueryEditor = ({ query, onChange, onRunQuery, datasource, range }: range={range || getDefaultTimeRange()} > {showUnsupportedMessage && } - + ); }; @@ -88,7 +91,7 @@ export const ElasticSearchQueryField = ({ value, onChange }: { value?: string; o ); }; -const QueryEditorForm = ({ value }: Props) => { +const QueryEditorForm = ({ value, onRunQuery }: Props & { onRunQuery: () => void }) => { const dispatch = useDispatch(); const nextId = useNextId(); const inputId = useId(); @@ -96,6 +99,9 @@ const QueryEditorForm = ({ value }: Props) => { const isTimeSeries = isTimeSeriesQuery(value); + const isCodeEditor = value.editorType === 'code'; + const rawDSLFeatureEnabled = config.featureToggles.elasticsearchRawDSLQuery; + const showBucketAggregationsEditor = value.metrics?.every( (metric) => metricAggregationConfig[metric.type].impliedQueryType === 'metrics' ); @@ -108,29 +114,50 @@ const QueryEditorForm = ({ value }: Props) => { -
- Lucene Query - dispatch(changeQuery(query))} value={value?.query} /> + {rawDSLFeatureEnabled && ( +
+ Editor type +
+ +
+
+ )} - {isTimeSeries && ( - - dispatch(changeAliasPattern(e.currentTarget.value))} - defaultValue={value.alias} - /> - - )} -
+ {isCodeEditor && rawDSLFeatureEnabled && ( + dispatch(changeRawDSLQuery(rawDSLQuery))} + onRunQuery={onRunQuery} + /> + )} - - {showBucketAggregationsEditor && } + {!isCodeEditor && ( + <> +
+ Lucene Query + dispatch(changeQuery(query))} value={value?.query} /> + + {isTimeSeries && ( + + dispatch(changeAliasPattern(e.currentTarget.value))} + defaultValue={value.alias} + /> + + )} +
+ + + {showBucketAggregationsEditor && } + + )} ); }; diff --git a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts index a0ac6504049..a9ed51b39ff 100644 --- a/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts +++ b/public/app/plugins/datasource/elasticsearch/components/QueryEditor/state.ts @@ -11,13 +11,25 @@ export const initQuery = createAction('init'); export const changeQuery = createAction('change_query'); +export const changeRawDSLQuery = createAction('change_raw_dsl_query'); + export const changeAliasPattern = createAction('change_alias_pattern'); +export const changeEditorType = createAction('change_editor_type'); + +export const changeEditorTypeAndResetQuery = createAction( + 'change_editor_type_and_reset_query' +); + export const queryReducer = (prevQuery: ElasticsearchDataQuery['query'], action: Action) => { if (changeQuery.match(action)) { return action.payload; } + if (changeEditorTypeAndResetQuery.match(action)) { + return ''; + } + if (initQuery.match(action)) { return prevQuery || ''; } @@ -25,6 +37,22 @@ export const queryReducer = (prevQuery: ElasticsearchDataQuery['query'], action: return prevQuery; }; +export const rawDSLQueryReducer = (prevRawDSLQuery: ElasticsearchDataQuery['rawDSLQuery'], action: Action) => { + if (changeRawDSLQuery.match(action)) { + return action.payload; + } + + if (changeEditorTypeAndResetQuery.match(action)) { + return ''; + } + + if (initQuery.match(action)) { + return prevRawDSLQuery || ''; + } + + return prevRawDSLQuery; +}; + export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['alias'], action: Action) => { if (changeAliasPattern.match(action)) { return action.payload; @@ -36,3 +64,19 @@ export const aliasPatternReducer = (prevAliasPattern: ElasticsearchDataQuery['al return prevAliasPattern; }; + +export const editorTypeReducer = (prevEditorType: ElasticsearchDataQuery['editorType'], action: Action) => { + if (changeEditorType.match(action)) { + return action.payload; + } + + if (changeEditorTypeAndResetQuery.match(action)) { + return action.payload; + } + + if (initQuery.match(action)) { + return prevEditorType || 'builder'; + } + + return prevEditorType; +}; diff --git a/public/app/plugins/datasource/elasticsearch/dataquery.cue b/public/app/plugins/datasource/elasticsearch/dataquery.cue index 78dc139adf5..83b8dc09473 100644 --- a/public/app/plugins/datasource/elasticsearch/dataquery.cue +++ b/public/app/plugins/datasource/elasticsearch/dataquery.cue @@ -31,8 +31,12 @@ composableKinds: DataQuery: { alias?: string // Lucene query query?: string + // Raw DSL query + rawDSLQuery?: string // Name of time field timeField?: string + // Editor type + editorType?: string // List of bucket aggregations bucketAggs?: [...#BucketAggregation] // List of metric aggregations @@ -126,7 +130,7 @@ composableKinds: DataQuery: { precision?: string } @cuetsy(kind="interface") - #PipelineMetricAggregationType: "moving_avg" | "moving_fn" | "derivative" | "serial_diff" | "cumulative_sum" | "bucket_script" @cuetsy(kind="type") + #PipelineMetricAggregationType: "moving_avg" | "moving_fn" | "derivative" | "serial_diff" | "cumulative_sum" | "bucket_script" @cuetsy(kind="type") #MetricAggregationType: "count" | "avg" | "sum" | "min" | "max" | "extended_stats" | "percentiles" | "cardinality" | "raw_document" | "raw_data" | "logs" | "rate" | "top_metrics" | #PipelineMetricAggregationType @cuetsy(kind="type") #BaseMetricAggregation: { @@ -396,7 +400,7 @@ composableKinds: DataQuery: { } } @cuetsy(kind="interface") - #PipelineMetricAggregation: #MovingAverage | #Derivative | #CumulativeSum | #BucketScript @cuetsy(kind="type") + #PipelineMetricAggregation: #MovingAverage | #Derivative | #CumulativeSum | #BucketScript @cuetsy(kind="type") #MetricAggregationWithSettings: #BucketScript | #CumulativeSum | #Derivative | #SerialDiff | #RawData | #RawDocument | #UniqueCount | #Percentiles | #ExtendedStats | #Min | #Max | #Sum | #Average | #MovingAverage | #MovingFunction | #Logs | #Rate | #TopMetrics @cuetsy(kind="type") } }] diff --git a/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts b/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts index f18c10029c0..8046f1fdd4f 100644 --- a/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts +++ b/public/app/plugins/datasource/elasticsearch/dataquery.gen.ts @@ -385,6 +385,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery { * List of bucket aggregations */ bucketAggs?: Array; + /** + * Editor type + */ + editorType?: string; /** * List of metric aggregations */ @@ -393,6 +397,10 @@ export interface ElasticsearchDataQuery extends common.DataQuery { * Lucene query */ query?: string; + /** + * Raw DSL query + */ + rawDSLQuery?: string; /** * Name of time field */ diff --git a/public/app/plugins/datasource/elasticsearch/types.ts b/public/app/plugins/datasource/elasticsearch/types.ts index 7e2213232cc..c3a7c5f9da2 100644 --- a/public/app/plugins/datasource/elasticsearch/types.ts +++ b/public/app/plugins/datasource/elasticsearch/types.ts @@ -67,6 +67,7 @@ export interface ElasticsearchOptions extends DataSourceJsonData { } export type QueryType = 'metrics' | 'logs' | 'raw_data' | 'raw_document'; +export type EditorType = 'code' | 'builder'; interface MetricConfiguration { label: string; From eec4722372e3163dfcf21ccf72d63d0011fc18d3 Mon Sep 17 00:00:00 2001 From: Johnny Kartheiser <140559259+JohnnyK-Grafana@users.noreply.github.com> Date: Mon, 15 Dec 2025 13:24:03 -0600 Subject: [PATCH 21/27] alerting docs: restore config feature toggle info (#114056) * alerting docs: restore config feature toggle info * Update docs/sources/alerting/set-up/configure-alert-state-history/index.md Co-authored-by: Alexander Akhmetov --------- Co-authored-by: Alexander Akhmetov --- .../alerting/set-up/configure-alert-state-history/index.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/sources/alerting/set-up/configure-alert-state-history/index.md b/docs/sources/alerting/set-up/configure-alert-state-history/index.md index 6ab7f817d92..dfb476b69ef 100644 --- a/docs/sources/alerting/set-up/configure-alert-state-history/index.md +++ b/docs/sources/alerting/set-up/configure-alert-state-history/index.md @@ -62,6 +62,9 @@ The following steps describe a basic configuration: # The URL of the Loki server loki_remote_url = http://localhost:3100 + + [feature_toggles] + enable = alertingCentralAlertHistory ``` 1. **Configure the Loki data source in Grafana** From 9aa8fb183d0fa735edca18adfc13ea6246defd00 Mon Sep 17 00:00:00 2001 From: Kristina Demeshchik Date: Mon, 15 Dec 2025 14:25:21 -0500 Subject: [PATCH 22/27] Dashboards: Fix edit button visibility to respect editable flag in new layouts (#115372) Dashboard in `editable: false` mode --- .../scene/DashboardControls.test.tsx | 89 ++++++++++++++++++- .../scene/DashboardControls.tsx | 9 +- .../actions/MakeDashboardEditableButton.tsx | 1 - 3 files changed, 95 insertions(+), 4 deletions(-) diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx index c13e65574be..b71aaee816b 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.test.tsx @@ -1,11 +1,21 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { selectors } from '@grafana/e2e-selectors'; +import { config } from '@grafana/runtime'; import { SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes'; +import { playlistSrv } from 'app/features/playlist/PlaylistSrv'; import { DashboardControls, DashboardControlsState } from './DashboardControls'; import { DashboardScene } from './DashboardScene'; +jest.mock('app/features/playlist/PlaylistSrv', () => ({ + playlistSrv: { + useState: jest.fn().mockReturnValue({ isPlaying: false }), + state: { isPlaying: false }, + stop: jest.fn(), + }, +})); + describe('DashboardControls', () => { describe('Given a standard scene', () => { it('should initialize with default values', () => { @@ -221,8 +231,85 @@ describe('DashboardControls', () => { expect(setState).toHaveBeenCalledTimes(0); }); }); + + describe('DashboardControlActions editable flag', () => { + const originalFeatureToggles = { ...config.featureToggles }; + + beforeEach(() => { + config.featureToggles.dashboardNewLayouts = true; + jest.mocked(playlistSrv.useState).mockReturnValue({ isPlaying: false }); + }); + + afterEach(() => { + config.featureToggles = originalFeatureToggles; + jest.clearAllMocks(); + }); + + it('should show EditDashboardSwitch when editable is true', async () => { + const controls = buildTestSceneWithEditable({ editable: true, canEdit: true }); + render(); + + expect(await screen.findByRole('button', { name: /edit/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /make editable/i })).not.toBeInTheDocument(); + }); + + it('should show MakeDashboardEditableButton when editable is false', async () => { + const controls = buildTestSceneWithEditable({ editable: false, canEdit: false, canMakeEditable: true }); + render(); + + expect(await screen.findByRole('button', { name: /make editable/i })).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument(); + }); + + it('should not show edit buttons when canEditDashboard returns false', async () => { + const controls = buildTestSceneWithEditable({ + editable: true, + canEdit: false, + canMakeEditable: false, + isSnapshot: true, + }); + render(); + + expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /make editable/i })).not.toBeInTheDocument(); + }); + + it('should not show edit buttons when playlist is playing', async () => { + jest.mocked(playlistSrv.useState).mockReturnValue({ isPlaying: true }); + + const controls = buildTestSceneWithEditable({ editable: true, canEdit: true }); + render(); + + expect(screen.queryByRole('button', { name: /^edit$/i })).not.toBeInTheDocument(); + expect(await screen.findByTestId(selectors.pages.Dashboard.DashNav.playlistControls.stop)).toBeInTheDocument(); + }); + }); }); +function buildTestSceneWithEditable(options: { + editable: boolean; + canEdit?: boolean; + canMakeEditable?: boolean; + isSnapshot?: boolean; +}): DashboardControls { + const { editable, canEdit = true, canMakeEditable = false, isSnapshot = false } = options; + + const dashboard = new DashboardScene({ + uid: 'test-uid', + editable, + meta: { + canEdit, + canMakeEditable, + isSnapshot, + }, + controls: new DashboardControls({}), + }); + + dashboard.activate(); + + return dashboard.state.controls as DashboardControls; +} + function buildTestScene(state?: Partial): DashboardControls { const variable = new TextBoxVariable({ name: 'A', diff --git a/public/app/features/dashboard-scene/scene/DashboardControls.tsx b/public/app/features/dashboard-scene/scene/DashboardControls.tsx index 16be88595d9..32f7d5289f8 100644 --- a/public/app/features/dashboard-scene/scene/DashboardControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardControls.tsx @@ -31,6 +31,7 @@ import { VariableControls } from './VariableControls'; import { DashboardControlsButton } from './dashboard-controls-menu/DashboardControlsMenuButton'; import { hasDashboardControls, useHasDashboardControls } from './dashboard-controls-menu/utils'; import { EditDashboardSwitch } from './new-toolbar/actions/EditDashboardSwitch'; +import { MakeDashboardEditableButton } from './new-toolbar/actions/MakeDashboardEditableButton'; import { SaveDashboard } from './new-toolbar/actions/SaveDashboard'; import { ShareDashboardButton } from './new-toolbar/actions/ShareDashboardButton'; @@ -191,7 +192,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps {showShareButton && } {isEditing && } - {!isPlaying && canEditDashboard && } + {!isPlaying && canEditDashboard && isEditable && } + {!isPlaying && canEditDashboard && !isEditable && !isEditing && ( + + )} {isPlaying && (