Compare commits

...

1 Commits

Author SHA1 Message Date
Victor Marin
4d4dda58ce fix conflict warning when multiple users edit same dashboard
Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
2025-12-09 15:24:25 +02:00
5 changed files with 56 additions and 12 deletions

View File

@@ -79,6 +79,15 @@ type dashboardRow struct {
token *continueToken
}
// DashboardActivityChannel is a service to advertise dashboard activity via Grafana Live.
// This interface is duplicated from pkg/services/live to avoid circular imports.
type DashboardActivityChannel interface {
// Called when a dashboard is saved
DashboardSaved(orgID int64, requester identity.Requester, message string, dashboard *dashboards.Dashboard, err error) error
// Called when a dashboard is deleted
DashboardDeleted(orgID int64, requester identity.Requester, uid string) error
}
type dashboardSqlAccess struct {
sql legacysql.LegacyDatabaseProvider
namespacer request.NamespaceMapper
@@ -92,6 +101,9 @@ type dashboardSqlAccess struct {
accessControl accesscontrol.AccessControl
libraryPanelSvc librarypanels.Service // only used for save dashboard
// For broadcasting save/delete events to Grafana Live (optional, may be nil)
dashboardActivityChannel DashboardActivityChannel
// Typically one... the server wrapper
subscribers []chan *resource.WrittenEvent
mutex sync.Mutex
@@ -125,17 +137,19 @@ func NewDashboardSQLAccess(sql legacysql.LegacyDatabaseProvider,
dashboardPermissionSvc accesscontrol.DashboardPermissionsService,
accessControl accesscontrol.AccessControl,
features featuremgmt.FeatureToggles,
dashboardActivityChannel DashboardActivityChannel,
) *dashboardSqlAccess {
dashboardSearchClient := legacysearcher.NewDashboardSearchClient(dashStore, sorter)
return &dashboardSqlAccess{
sql: sql,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
dashboardSearchClient: *dashboardSearchClient,
dashboardPermissionSvc: dashboardPermissionSvc,
libraryPanelSvc: libraryPanelSvc,
accessControl: accessControl,
sql: sql,
namespacer: namespacer,
dashStore: dashStore,
provisioning: provisioning,
dashboardSearchClient: *dashboardSearchClient,
dashboardPermissionSvc: dashboardPermissionSvc,
libraryPanelSvc: libraryPanelSvc,
accessControl: accessControl,
dashboardActivityChannel: dashboardActivityChannel,
}
}
@@ -882,6 +896,17 @@ func (a *dashboardSqlAccess) DeleteDashboard(ctx context.Context, orgId int64, u
if err != nil {
return nil, false, err
}
// Broadcast the delete event to Grafana Live
if a.dashboardActivityChannel != nil {
requester, reqErr := identity.GetRequester(ctx)
if reqErr == nil {
if broadcastErr := a.dashboardActivityChannel.DashboardDeleted(orgId, requester, uid); broadcastErr != nil {
a.log.Warn("Failed to broadcast dashboard delete event", "error", broadcastErr, "dashboard", uid)
}
}
}
return dash, true, nil
}
@@ -1002,6 +1027,14 @@ func (a *dashboardSqlAccess) SaveDashboard(ctx context.Context, orgId int64, das
if access != nil {
access.DashboardID = finalMeta.GetDeprecatedInternalID() // nolint:staticcheck
}
// Broadcast the save event to Grafana Live
if a.dashboardActivityChannel != nil {
if broadcastErr := a.dashboardActivityChannel.DashboardSaved(orgId, requester, cmd.Message, out, nil); broadcastErr != nil {
a.log.Warn("Failed to broadcast dashboard save event", "error", broadcastErr, "dashboard", out.UID)
}
}
return dash, created, err
}

View File

@@ -55,6 +55,7 @@ import (
"github.com/grafana/grafana/pkg/services/featuremgmt"
"github.com/grafana/grafana/pkg/services/libraryelements"
"github.com/grafana/grafana/pkg/services/librarypanels"
"github.com/grafana/grafana/pkg/services/live"
"github.com/grafana/grafana/pkg/services/provisioning"
"github.com/grafana/grafana/pkg/services/publicdashboards"
"github.com/grafana/grafana/pkg/services/quota"
@@ -150,6 +151,7 @@ func RegisterAPIService(
libraryPanels libraryelements.Service,
publicDashboardService publicdashboards.Service,
snapshotService dashboardsnapshots.Service,
dashboardActivityChannel live.DashboardActivityChannel,
) *DashboardsAPIBuilder {
dbp := legacysql.NewDatabaseProvider(sql)
namespacer := request.GetNamespaceMapper(cfg)
@@ -185,7 +187,7 @@ func RegisterAPIService(
snapshotOptions: snapshotOptions,
namespacer: namespacer,
legacy: &DashboardStorage{
Access: legacy.NewDashboardSQLAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features),
Access: legacy.NewDashboardSQLAccess(dbp, namespacer, dashStore, provisioning, libraryPanelSvc, sorter, dashboardPermissionsSvc, accessControl, features, dashboardActivityChannel),
DashboardService: dashboardService,
},
}

View File

@@ -280,6 +280,7 @@ var wireBasicSet = wire.NewSet(
store.ProvideService,
store.ProvideSystemUsersService,
live.ProvideService,
live.ProvideDashboardActivityChannel,
pushhttp.ProvideService,
contexthandler.ProvideService,
ldapservice.ProvideService,

File diff suppressed because one or more lines are too long

View File

@@ -527,6 +527,12 @@ type DashboardActivityChannel interface {
HasGitOpsObserver(orgID int64) bool
}
// ProvideDashboardActivityChannel extracts the DashboardActivityChannel from GrafanaLive.
// This is used by wire to inject the channel into the dashboard API service.
func ProvideDashboardActivityChannel(live *GrafanaLive) DashboardActivityChannel {
return live.GrafanaScope.Dashboards
}
func (g *GrafanaLive) getStreamPlugin(ctx context.Context, pluginID string) (backend.StreamHandler, error) {
plugin, exists := g.pluginStore.Plugin(ctx, pluginID)
if !exists {