Compare commits

...

9 Commits

Author SHA1 Message Date
Kristina Demeshchik 00e11fa4d2 tests cleanup 2026-01-13 11:29:20 -05:00
Kristina Demeshchik c3502eed0b max id calculation 2026-01-13 11:16:41 -05:00
Kristina Demeshchik 34e8cfef52 fix frontend logic 2026-01-13 11:10:39 -05:00
Kristina Demeshchik fbf4be69ae udpapte tests 2026-01-13 10:35:48 -05:00
Kristina Demeshchik 5df7d43f8c Update migration function to look into rows 2026-01-13 10:08:51 -05:00
Will Assis 69ccfd6bfc unified-storage: fix sharedwithme search not returning folders (#116089)
* unified-storage: fix dashboard sharedwithme search not returning folders shared with the user
2026-01-12 15:33:34 -05:00
Nick Richmond 53aa5e8f7f MetricsDrilldown: Remove exploreMetricsRelatedLogs feature toggle (#116090)
chore: remove unused exploreMetricsRelatedLogs feature toggle
2026-01-12 12:52:40 -05:00
Ida Štambuk 69bf3068b3 Dashboards: Never show scopes variables (#116132) 2026-01-12 18:52:23 +01:00
Will Assis 1263a3d364 unified-storage: HappyPath and notifier tests + couple of bugfixes (#116087)
* unified-storage: couple of bugfixes and enable HappyPath and notifier sqlkv tests
2026-01-12 12:17:41 -05:00
22 changed files with 375 additions and 192 deletions
@@ -46,7 +46,7 @@
"x": 0,
"y": 0
},
"id": 1,
"id": 23,
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
@@ -77,7 +77,7 @@
"x": 0,
"y": 0
},
"id": 23,
"id": 24,
"panels": [],
"targets": [
{
@@ -31,53 +31,6 @@
"cursorSync": "Off",
"editable": false,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-10": {
"kind": "Panel",
"spec": {
@@ -1024,6 +977,53 @@
}
}
},
"panel-23": {
"kind": "Panel",
"spec": {
"id": 23,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "prometheus",
"spec": {}
},
"datasource": {
"type": "prometheus",
"uid": "default-ds-uid"
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "text",
"spec": {
"pluginVersion": "",
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-6": {
"kind": "Panel",
"spec": {
@@ -1259,7 +1259,7 @@
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-1"
"name": "panel-23"
}
}
}
@@ -32,55 +32,6 @@
"cursorSync": "Off",
"editable": false,
"elements": {
"panel-1": {
"kind": "Panel",
"spec": {
"id": 1,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "default-ds-uid"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "text",
"version": "",
"spec": {
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-10": {
"kind": "Panel",
"spec": {
@@ -1067,6 +1018,55 @@
}
}
},
"panel-23": {
"kind": "Panel",
"spec": {
"id": 23,
"title": "Application Monitoring",
"description": "",
"links": [],
"data": {
"kind": "QueryGroup",
"spec": {
"queries": [
{
"kind": "PanelQuery",
"spec": {
"query": {
"kind": "DataQuery",
"group": "prometheus",
"version": "v0",
"datasource": {
"name": "default-ds-uid"
},
"spec": {}
},
"refId": "A",
"hidden": false
}
}
],
"transformations": [],
"queryOptions": {}
}
},
"vizConfig": {
"kind": "VizConfig",
"group": "text",
"version": "",
"spec": {
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
},
"fieldConfig": {
"defaults": {},
"overrides": []
}
}
}
}
},
"panel-6": {
"kind": "Panel",
"spec": {
@@ -1310,7 +1310,7 @@
"height": 3,
"element": {
"kind": "ElementReference",
"name": "panel-1"
"name": "panel-23"
}
}
}
@@ -432,6 +432,21 @@ func getPanels(dashboard map[string]interface{}) []map[string]interface{} {
}
}
// Also get panels from rows
if rows, ok := dashboard["rows"].([]interface{}); ok {
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
if rowPanels, ok := row["panels"].([]interface{}); ok {
for _, panelInterface := range rowPanels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
panels = append(panels, panel)
}
}
}
}
}
}
return panels
}
@@ -46,7 +46,8 @@ func upgradeToGridLayout(dashboard map[string]interface{}) {
widthFactor := gridColumnCount / 12.0
// Find max panel ID (lines 1014-1021 in TS)
maxPanelID := getMaxPanelID(rows)
// Also check top-level panels which may have been assigned IDs by ensurePanelsHaveUniqueIds
maxPanelID := getMaxPanelID(dashboard, rows)
nextRowID := maxPanelID + 1
// Match frontend: dashboard.panels already exists with top-level panels
@@ -269,10 +270,25 @@ func (r *rowArea) getPanelPosition(panelHeight int, panelWidth int) map[string]i
return r.getPanelPosition(panelHeight, panelWidth)
}
func getMaxPanelID(rows []interface{}) int {
func getMaxPanelID(dashboard map[string]interface{}, rows []interface{}) int {
maxID := 0
hasValidID := false
// Check top-level panels first (these may have been assigned IDs by ensurePanelsHaveUniqueIds)
if panels, ok := dashboard["panels"].([]interface{}); ok {
for _, panelInterface := range panels {
if panel, ok := panelInterface.(map[string]interface{}); ok {
if id := GetIntValue(panel, "id", 0); id > 0 {
hasValidID = true
if id > maxID {
maxID = id
}
}
}
}
}
// Also check panels inside rows
for _, rowInterface := range rows {
if row, ok := rowInterface.(map[string]interface{}); ok {
if panels, ok := row["panels"].([]interface{}); ok {
@@ -40,7 +40,7 @@
"x": 0,
"y": 0
},
"id": 1,
"id": 23,
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
@@ -71,7 +71,7 @@
"x": 0,
"y": 0
},
"id": 23,
"id": 24,
"panels": [],
"targets": [
{
@@ -35,7 +35,7 @@
"x": 0,
"y": 0
},
"id": 1,
"id": 23,
"options": {
"content": "This dashboard demonstrates various monitoring components for application observability and performance metrics.\n",
"mode": "markdown"
@@ -51,7 +51,7 @@
"x": 0,
"y": 0
},
"id": 23,
"id": 24,
"panels": [],
"title": "Application Service",
"type": "row"
-4
View File
@@ -695,10 +695,6 @@ export interface FeatureToggles {
*/
passwordlessMagicLinkAuthentication?: boolean;
/**
* Display Related Logs in Grafana Metrics Drilldown
*/
exploreMetricsRelatedLogs?: boolean;
/**
* Adds support for quotes and special characters in label values for Prometheus queries
*/
prometheusSpecialCharsInLabelValues?: boolean;
+17 -8
View File
@@ -552,6 +552,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
// gets dashboards that the user was granted read access to
permissions := user.GetPermissions()
dashboardPermissions := permissions[dashboards.ActionDashboardsRead]
folderPermissions := permissions[dashboards.ActionFoldersRead]
dashboardUids := make([]string, 0)
sharedDashboards := make([]string, 0)
@@ -562,6 +563,13 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
}
for _, folderPermission := range folderPermissions {
if folderUid, found := strings.CutPrefix(folderPermission, dashboards.ScopeFoldersPrefix); found {
if !slices.Contains(dashboardUids, folderUid) && folderUid != foldermodel.SharedWithMeFolderUID && folderUid != foldermodel.GeneralFolderUID {
dashboardUids = append(dashboardUids, folderUid)
}
}
}
if len(dashboardUids) == 0 {
return sharedDashboards, nil
@@ -572,9 +580,15 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
return sharedDashboards, err
}
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
dashboardSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Federated: []*resourcepb.ResourceKey{folderKey},
Fields: []string{"folder"},
Limit: int64(len(dashboardUids)),
Options: &resourcepb.ListOptions{
Key: key,
Fields: []*resourcepb.Requirement{{
@@ -610,12 +624,6 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}
}
// only folders the user has access to will be returned here
folderKey, err := asResourceKey(user.GetNamespace(), folders.RESOURCE)
if err != nil {
return sharedDashboards, err
}
folderSearchRequest := &resourcepb.ResourceSearchRequest{
Fields: []string{"folder"},
Limit: int64(len(allFolders)),
@@ -628,6 +636,7 @@ func (s *SearchHandler) getDashboardsUIDsSharedWithUser(ctx context.Context, use
}},
},
}
// only folders the user has access to will be returned here
foldersResult, err := s.client.Search(ctx, folderSearchRequest)
if err != nil {
return sharedDashboards, err
+27 -3
View File
@@ -507,6 +507,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("publicfolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -550,6 +559,15 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
[]byte("privatefolder"), // folder uid
},
},
{
Key: &resourcepb.ResourceKey{
Name: "sharedfolder",
Resource: "folder",
},
Cells: [][]byte{
[]byte("privatefolder"), // folder uid
},
},
},
},
}
@@ -571,6 +589,7 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
allPermissions := make(map[int64]map[string][]string)
permissions := make(map[string][]string)
permissions[dashboards.ActionDashboardsRead] = []string{"dashboards:uid:dashboardinroot", "dashboards:uid:dashboardinprivatefolder", "dashboards:uid:dashboardinpublicfolder"}
permissions[dashboards.ActionFoldersRead] = []string{"folders:uid:sharedfolder"}
allPermissions[1] = permissions
// "Permissions" is where we store the uid of dashboards shared with the user
req = req.WithContext(identity.WithRequester(req.Context(), &user.SignedInUser{Namespace: "test", OrgID: 1, Permissions: allPermissions}))
@@ -581,14 +600,19 @@ func TestSearchHandlerSharedDashboards(t *testing.T) {
// first call gets all dashboards user has permission for
firstCall := mockClient.MockCalls[0]
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder"})
assert.Equal(t, firstCall.Options.Fields[0].Values, []string{"dashboardinroot", "dashboardinprivatefolder", "dashboardinpublicfolder", "sharedfolder"})
// verify federated field is set to include folders
assert.NotNil(t, firstCall.Federated)
assert.Equal(t, 1, len(firstCall.Federated))
assert.Equal(t, "folder.grafana.app", firstCall.Federated[0].Group)
assert.Equal(t, "folders", firstCall.Federated[0].Resource)
// second call gets folders associated with the previous dashboards
secondCall := mockClient.MockCalls[1]
assert.Equal(t, secondCall.Options.Fields[0].Values, []string{"privatefolder", "publicfolder"})
// lastly, search ONLY for dashboards user has permission to read that are within folders the user does NOT have
// lastly, search ONLY for dashboards and folders user has permission to read that are within folders the user does NOT have
// permission to read
thirdCall := mockClient.MockCalls[2]
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder"})
assert.Equal(t, thirdCall.Options.Fields[0].Values, []string{"dashboardinprivatefolder", "sharedfolder"})
resp := rr.Result()
defer func() {
-8
View File
@@ -1148,14 +1148,6 @@ var (
Owner: identityAccessTeam,
HideFromDocs: true,
},
{
Name: "exploreMetricsRelatedLogs",
Description: "Display Related Logs in Grafana Metrics Drilldown",
Stage: FeatureStageExperimental,
Owner: grafanaObservabilityMetricsSquad,
FrontendOnly: true,
HideFromDocs: false,
},
{
Name: "prometheusSpecialCharsInLabelValues",
Description: "Adds support for quotes and special characters in label values for Prometheus queries",
-1
View File
@@ -159,7 +159,6 @@ newTimeRangeZoomShortcuts,experimental,@grafana/dataviz-squad,false,false,true
azureMonitorDisableLogLimit,GA,@grafana/partner-datasources,false,false,false
playlistsReconciler,experimental,@grafana/grafana-app-platform-squad,false,true,false
passwordlessMagicLinkAuthentication,experimental,@grafana/identity-access-team,false,false,false
exploreMetricsRelatedLogs,experimental,@grafana/observability-metrics,false,false,true
prometheusSpecialCharsInLabelValues,experimental,@grafana/oss-big-tent,false,false,true
enableExtensionsAdminPage,experimental,@grafana/plugins-platform-backend,false,true,false
enableSCIM,preview,@grafana/identity-access-team,false,false,false
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
159 azureMonitorDisableLogLimit GA @grafana/partner-datasources false false false
160 playlistsReconciler experimental @grafana/grafana-app-platform-squad false true false
161 passwordlessMagicLinkAuthentication experimental @grafana/identity-access-team false false false
exploreMetricsRelatedLogs experimental @grafana/observability-metrics false false true
162 prometheusSpecialCharsInLabelValues experimental @grafana/oss-big-tent false false true
163 enableExtensionsAdminPage experimental @grafana/plugins-platform-backend false true false
164 enableSCIM preview @grafana/identity-access-team false false false
+2 -1
View File
@@ -1408,7 +1408,8 @@
"metadata": {
"name": "exploreMetricsRelatedLogs",
"resourceVersion": "1764664939750",
"creationTimestamp": "2024-11-05T16:28:43Z"
"creationTimestamp": "2024-11-05T16:28:43Z",
"deletionTimestamp": "2026-01-09T22:14:53Z"
},
"spec": {
"description": "Display Related Logs in Grafana Metrics Drilldown",
+4 -4
View File
@@ -78,13 +78,13 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
cache := gocache.New(cacheTTL, cacheCleanupInterval)
events := make(chan Event, opts.BufferSize)
initialRV, err := n.lastEventResourceVersion(ctx)
lastRV, err := n.lastEventResourceVersion(ctx)
if errors.Is(err, ErrNotFound) {
initialRV = snowflakeFromTime(time.Now()) // No events yet, start from the beginning
lastRV = 0 // No events yet, start from the beginning
} else if err != nil {
n.log.Error("Failed to get last event resource version", "error", err)
}
lastRV := initialRV + 1 // We want to start watching from the next event
lastRV = lastRV + 1 // We want to start watching from the next event
go func() {
defer close(events)
@@ -110,7 +110,7 @@ func (n *notifier) Watch(ctx context.Context, opts watchOptions) <-chan Event {
}
// Skip old events lower than the requested resource version
if evt.ResourceVersion <= initialRV {
if evt.ResourceVersion < lastRV {
continue
}
+7 -15
View File
@@ -25,7 +25,6 @@ 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)
@@ -60,8 +59,7 @@ func runNotifierTestWith(t *testing.T, storeName string, newStoreFn func(*testin
func TestNotifier_lastEventResourceVersion(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierLastEventResourceVersion)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierLastEventResourceVersion)
}
func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -112,8 +110,7 @@ func testNotifierLastEventResourceVersion(t *testing.T, ctx context.Context, not
func TestNotifier_cachekey(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierCachekey)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierCachekey)
}
func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -167,8 +164,7 @@ func testNotifierCachekey(t *testing.T, ctx context.Context, notifier *notifier,
func TestNotifier_Watch_NoEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchNoEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchNoEvents)
}
func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -209,8 +205,7 @@ func testNotifierWatchNoEvents(t *testing.T, ctx context.Context, notifier *noti
func TestNotifier_Watch_WithExistingEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchWithExistingEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchWithExistingEvents)
}
func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -284,8 +279,7 @@ func testNotifierWatchWithExistingEvents(t *testing.T, ctx context.Context, noti
func TestNotifier_Watch_EventDeduplication(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchEventDeduplication)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchEventDeduplication)
}
func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -351,8 +345,7 @@ func testNotifierWatchEventDeduplication(t *testing.T, ctx context.Context, noti
func TestNotifier_Watch_ContextCancellation(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchContextCancellation)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchContextCancellation)
}
func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -398,8 +391,7 @@ func testNotifierWatchContextCancellation(t *testing.T, ctx context.Context, not
func TestNotifier_Watch_MultipleEvents(t *testing.T) {
runNotifierTestWith(t, "badger", setupTestNotifier, testNotifierWatchMultipleEvents)
// enable this when sqlkv is ready
// runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
runNotifierTestWith(t, "sqlkv", setupTestNotifierSqlKv, testNotifierWatchMultipleEvents)
}
func testNotifierWatchMultipleEvents(t *testing.T, ctx context.Context, notifier *notifier, eventStore *eventStore) {
@@ -346,7 +346,8 @@ func (k *kvStorageBackend) WriteEvent(ctx context.Context, event WriteEvent) (in
return 0, fmt.Errorf("failed to write data: %w", err)
}
dataKey.ResourceVersion = rvmanager.SnowflakeFromRv(rv)
rv = rvmanager.SnowflakeFromRv(rv)
dataKey.ResourceVersion = rv
} else {
err := k.dataStore.Save(ctx, dataKey, bytes.NewReader(event.Value))
if err != nil {
@@ -9,7 +9,6 @@ import (
"testing"
"time"
"github.com/bwmarrin/snowflake"
"github.com/stretchr/testify/require"
claims "github.com/grafana/authlib/types"
@@ -187,13 +186,30 @@ func runKeyPathTest(t *testing.T, backend resource.StorageBackend, nsPrefix stri
// verifyKeyPath is a helper function to verify key_path generation
func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resourcepb.ResourceKey, action string, resourceVersion int64, expectedFolder string) {
// For SQL backend (namespace contains "-sql"), resourceVersion is in microsecond format
// but key_path stores snowflake RV, so convert to snowflake
// For KV backend (namespace contains "-kv"), resourceVersion is already in snowflake format
isSqlBackend := strings.Contains(key.Namespace, "-sql")
var keyPathRV int64
if isSqlBackend {
// Convert microsecond RV to snowflake for key_path construction
keyPathRV = rvmanager.SnowflakeFromRv(resourceVersion)
} else {
// KV backend already provides snowflake RV
keyPathRV = resourceVersion
}
// Build the expected key_path using DataKey format: unified/data/group/resource/namespace/name/resourceVersion~action~folder
expectedKeyPath := fmt.Sprintf("unified/data/%s/%s/%s/%s/%d~%s~%s", key.Group, key.Resource, key.Namespace, key.Name, keyPathRV, action, expectedFolder)
var query string
if db.DriverName() == "postgres" {
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = $1 AND name = $2 AND resource_version = $3"
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = $1"
} else {
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE namespace = ? AND name = ? AND resource_version = ?"
query = "SELECT key_path, resource_version, action, folder FROM resource_history WHERE key_path = ?"
}
rows, err := db.QueryContext(ctx, query, key.Namespace, key.Name, resourceVersion)
rows, err := db.QueryContext(ctx, query, expectedKeyPath)
require.NoError(t, err)
require.True(t, rows.Next(), "Resource not found in resource_history table - both SQL and KV backends should write to this table")
@@ -220,10 +236,6 @@ func verifyKeyPath(t *testing.T, db sqldb.DB, ctx context.Context, key *resource
// Verify action suffix
require.Contains(t, keyPath, fmt.Sprintf("~%s~", action))
// Verify snowflake calculation
expectedSnowflake := (((resourceVersion / 1000) - snowflake.Epoch) << (snowflake.NodeBits + snowflake.StepBits)) + (resourceVersion % 1000)
require.Contains(t, keyPath, fmt.Sprintf("/%d~", expectedSnowflake), "actual RV: %d", actualRV)
// Verify folder if specified
if expectedFolder != "" {
require.Equal(t, expectedFolder, actualFolder)
@@ -492,10 +504,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
}
// Validate previous_resource_version
// For KV backend operations, resource versions are stored as snowflake format
// but expectedPrevRV is in microsecond format, so we need to use IsRvEqual for comparison
// For KV backend operations, expectedPrevRV is now in snowflake format (returned by KV backend)
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
if strings.Contains(record.Namespace, "-kv") {
require.True(t, rvmanager.IsRvEqual(record.PreviousResourceVersion, expectedPrevRV),
require.True(t, rvmanager.IsRvEqual(expectedPrevRV, record.PreviousResourceVersion),
"Previous resource version should match (KV backend snowflake format)")
} else {
require.Equal(t, expectedPrevRV, record.PreviousResourceVersion)
@@ -505,9 +517,10 @@ func verifyResourceHistoryRecord(t *testing.T, record ResourceHistoryRecord, exp
require.Equal(t, expectedGeneration, record.Generation)
// Validate resource_version
// For KV backend operations, resource versions are stored as snowflake format
// For KV backend operations, expectedRV is now in snowflake format (returned by KV backend)
// but resource_history table stores microsecond RV, so we need to use IsRvEqual for comparison
if strings.Contains(record.Namespace, "-kv") {
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
"Resource version should match (KV backend snowflake format)")
} else {
require.Equal(t, expectedRV, record.ResourceVersion)
@@ -574,7 +587,7 @@ func verifyResourceTable(t *testing.T, db sqldb.DB, namespace string, resources
// Resource version should match the expected version for test-resource-3 (updated version)
expectedRV := resourceVersions[2][1] // test-resource-3's update version
if strings.Contains(namespace, "-kv") {
require.True(t, rvmanager.IsRvEqual(record.ResourceVersion, expectedRV),
require.True(t, rvmanager.IsRvEqual(expectedRV, record.ResourceVersion),
"Resource version should match (KV backend snowflake format)")
} else {
require.Equal(t, expectedRV, record.ResourceVersion)
@@ -625,9 +638,16 @@ func verifyResourceVersionTable(t *testing.T, db sqldb.DB, namespace string, res
// The resource_version table should contain the latest RV for the group+resource
// It might be slightly higher due to RV manager operations, so check it's at least our max
require.GreaterOrEqual(t, record.ResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
// But it shouldn't be too much higher (within a reasonable range)
require.LessOrEqual(t, record.ResourceVersion, maxRV+100, "resource_version shouldn't be much higher than expected")
// For KV backend, maxRV is in snowflake format but record.ResourceVersion is in microsecond format
// Use IsRvEqual for proper comparison between different RV formats
isKvBackend := strings.Contains(namespace, "-kv")
recordResourceVersion := record.ResourceVersion
if isKvBackend {
recordResourceVersion = rvmanager.SnowflakeFromRv(record.ResourceVersion)
}
require.Less(t, recordResourceVersion, int64(9223372036854775807), "resource_version should be reasonable")
require.Greater(t, recordResourceVersion, maxRV, "resource_version should be at least the latest RV we tracked")
}
// runTestCrossBackendConsistency tests basic consistency between SQL and KV backends (lightweight)
@@ -38,7 +38,6 @@ func TestBadgerKVStorageBackend(t *testing.T) {
func TestSQLKVStorageBackend(t *testing.T) {
skipTests := map[string]bool{
TestHappyPath: true,
TestWatchWriteEvents: true,
TestList: true,
TestBlobSupport: true,
@@ -51,21 +50,24 @@ func TestSQLKVStorageBackend(t *testing.T) {
TestGetResourceLastImportTime: true,
TestOptimisticLocking: true,
}
// without RvManager
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, false)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-test",
SkipTests: skipTests,
t.Run("Without RvManager", func(t *testing.T) {
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, false)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-test",
SkipTests: skipTests,
})
})
// with RvManager
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, true)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-withrvmanager-test",
SkipTests: skipTests,
t.Run("With RvManager", func(t *testing.T) {
RunStorageBackendTest(t, func(ctx context.Context) resource.StorageBackend {
backend, _ := NewTestSqlKvBackend(t, ctx, true)
return backend
}, &TestOptions{
NSPrefix: "sqlkvstorage-withrvmanager-test",
SkipTests: skipTests,
})
})
}
@@ -0,0 +1,84 @@
import { render, screen } from '@testing-library/react';
import { VariableHide } from '@grafana/data';
import { SceneGridLayout, SceneVariable, SceneVariableSet, ScopesVariable, TextBoxVariable } from '@grafana/scenes';
import { DashboardScene } from './DashboardScene';
import { VariableControls } from './VariableControls';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
jest.mock('@grafana/runtime', () => {
const runtime = jest.requireActual('@grafana/runtime');
return {
...runtime,
config: {
...runtime.config,
featureToggles: {
dashboardNewLayouts: true,
},
},
};
});
describe('VariableControls', () => {
it('should not render scopes variable', () => {
const variables = [new ScopesVariable({})];
const dashboard = buildScene(variables);
dashboard.activate();
render(<VariableControls dashboard={dashboard} />);
expect(screen.queryByText('__scopes')).not.toBeInTheDocument();
});
it('should not render regular hidden variables', () => {
const hiddenVariable = new TextBoxVariable({
name: 'HiddenVar',
hide: VariableHide.hideVariable,
});
const variables = [hiddenVariable];
const dashboard = buildScene(variables);
dashboard.activate();
render(<VariableControls dashboard={dashboard} />);
expect(screen.queryByText('HiddenVar')).not.toBeInTheDocument();
});
it('should render regular hidden variables in edit mode', async () => {
const hiddenVariable = new TextBoxVariable({
name: 'HiddenVar',
hide: VariableHide.hideVariable,
});
const variables = [hiddenVariable];
const dashboard = buildScene(variables);
dashboard.activate();
dashboard.setState({ isEditing: true });
render(<VariableControls dashboard={dashboard} />);
expect(await screen.findByText('HiddenVar')).toBeInTheDocument();
});
it('should not render variables hidden in controls menu in edit mode', async () => {
const dashboard = buildScene([new TextBoxVariable({ name: 'TextVarControls', hide: VariableHide.inControlsMenu })]);
dashboard.activate();
dashboard.setState({ isEditing: true });
render(<VariableControls dashboard={dashboard} />);
expect(screen.queryByText('TextVarControls')).not.toBeInTheDocument();
});
});
function buildScene(variables: SceneVariable[] = []) {
const dashboard = new DashboardScene({
$variables: new SceneVariableSet({ variables }),
body: new DefaultGridLayoutManager({
grid: new SceneGridLayout({
children: [],
}),
}),
});
return dashboard;
}
@@ -39,8 +39,9 @@ export function VariableControls({ dashboard }: { dashboard: DashboardScene }) {
? restVariables.filter((v) => v.state.hide !== VariableHide.inControlsMenu)
: variables.filter(
(v) =>
// used for scopes variables, should always be hidden
// if we're editing in dynamic dashboards, still shows hidden variable but greyed out
(isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
(!v.UNSAFE_renderAsHidden && isEditingNewLayouts && v.state.hide === VariableHide.hideVariable) ||
v.state.hide !== VariableHide.inControlsMenu
);
@@ -816,14 +816,17 @@ export class DashboardMigrator {
let yPos = 0;
const widthFactor = GRID_COLUMN_COUNT / 12;
const maxPanelId =
max(
flattenDeep(
map(old.rows, (row) => {
return map(row.panels, 'id');
})
).filter((id) => id != null)
) || 0;
// Find max panel ID from both rows and existing top-level panels
// Top-level panels may have been assigned IDs by ensurePanelsHaveUniqueIds
const rowPanelIds = flattenDeep(
map(old.rows, (row) => {
return map(row.panels, 'id');
})
).filter((id) => id != null);
const topLevelPanelIds = map(this.dashboard.panels, 'id').filter((id) => id != null);
const maxPanelId = max([...rowPanelIds, ...topLevelPanelIds]) || 0;
let nextRowId = maxPanelId + 1;
if (!old.rows) {
@@ -525,6 +525,14 @@ export class DashboardModel implements TimeModel {
}
}
// Also check panels in legacy rows (pre-v16 dashboard format)
// This ensures unique IDs are assigned before the row upgrade migration runs
for (const panel of this.rawPanelIterator()) {
if (panel.id > max) {
max = panel.id;
}
}
return max + 1;
}
@@ -539,6 +547,26 @@ export class DashboardModel implements TimeModel {
}
}
/**
* Iterates over panels from the original raw dashboard data, including legacy rows.
* This is needed to find panel IDs before row upgrade migration runs.
*/
private *rawPanelIterator() {
// @ts-expect-error - rows is a legacy property not included in the modern Dashboard schema
const rows = this.originalDashboard?.rows;
if (Array.isArray(rows)) {
for (const row of rows) {
const rowPanels = row?.panels;
if (Array.isArray(rowPanels)) {
for (const panel of rowPanels) {
yield panel;
}
}
}
}
}
forEachPanel(callback: (panel: PanelModel, index: number) => void) {
for (let i = 0; i < this.panels.length; i++) {
callback(this.panels[i], i);