Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00e11fa4d2 | |||
| c3502eed0b | |||
| 34e8cfef52 | |||
| fbf4be69ae | |||
| 5df7d43f8c | |||
| 69ccfd6bfc | |||
| 53aa5e8f7f | |||
| 69bf3068b3 | |||
| 1263a3d364 |
+2
-2
@@ -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": [
|
||||
{
|
||||
|
||||
+48
-48
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+50
-50
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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": [
|
||||
{
|
||||
|
||||
+2
-2
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Generated
-1
@@ -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
|
||||
|
||||
|
+2
-1
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user