Provisioning: Ignore dashboard change warning after save (#115401)

This commit is contained in:
Ryan McKinley
2025-12-17 10:17:57 +00:00
committed by GitHub
parent e4202db28f
commit d02b2a35cd
8 changed files with 78 additions and 13 deletions
@@ -8,6 +8,7 @@ import (
"k8s.io/apiserver/pkg/registry/rest"
"github.com/grafana/grafana-app-sdk/logging"
"github.com/grafana/grafana/pkg/apimachinery/utils"
grafanarest "github.com/grafana/grafana/pkg/apiserver/rest"
"github.com/grafana/grafana/pkg/services/accesscontrol"
"github.com/grafana/grafana/pkg/services/apiserver/endpoints/request"
@@ -33,8 +34,11 @@ func (d dashboardStorageWrapper) Update(ctx context.Context, name string, objInf
obj, created, err := d.Storage.Update(ctx, name, objInfo, createValidation, updateValidation, forceAllowCreate, options)
if err == nil && ns.OrgID > 0 && d.live != nil {
if err := d.live.DashboardSaved(ns.OrgID, name); err != nil {
logging.FromContext(ctx).Info("live dashboard update failed", "err", err)
m, err := utils.MetaAccessor(obj)
if err == nil {
if err := d.live.DashboardSaved(ns.OrgID, name, m.GetResourceVersion()); err != nil {
logging.FromContext(ctx).Info("live dashboard update failed", "err", err)
}
}
}
return obj, created, err
@@ -277,6 +277,16 @@ func (r *DualReadWriter) createOrUpdate(ctx context.Context, create bool, opts D
// FIXME: to make sure if behaves in the same way as in sync, we should
// we should refactor the code to use the same function.
if r.shouldUpdateGrafanaDB(opts, parsed) {
// HACK: Get the has from repository -- this will avoid an additional RV increment
// we should change the signature of Create and Update to return FileInfo instead
info, _ = r.repo.Read(ctx, opts.Path, opts.Ref)
if info != nil {
parsed.Meta.SetSourceProperties(utils.SourceProperties{
Path: opts.Path,
Checksum: info.Hash,
})
}
if _, err := r.folders.EnsureFolderPathExist(ctx, opts.Path); err != nil {
return nil, fmt.Errorf("ensure folder path exists: %w", err)
}
+8 -6
View File
@@ -26,9 +26,10 @@ const (
// DashboardEvent events related to dashboards
type dashboardEvent struct {
UID string `json:"uid"`
Action actionType `json:"action"` // saved, editing, deleted
SessionID string `json:"sessionId,omitempty"`
UID string `json:"uid"`
Action actionType `json:"action"` // saved, editing, deleted
SessionID string `json:"sessionId,omitempty"`
ResourceVersion string `json:"rv,omitempty"`
}
// DashboardHandler manages all the `grafana/dashboard/*` channels
@@ -105,10 +106,11 @@ func (h *DashboardHandler) publish(orgID int64, event dashboardEvent) error {
}
// DashboardSaved will broadcast to all connected dashboards
func (h *DashboardHandler) DashboardSaved(orgID int64, uid string) error {
func (h *DashboardHandler) DashboardSaved(orgID int64, uid string, rv string) error {
return h.publish(orgID, dashboardEvent{
UID: uid,
Action: ActionSaved,
UID: uid,
Action: ActionSaved,
ResourceVersion: rv,
})
}
+1 -1
View File
@@ -482,7 +482,7 @@ type GrafanaLive struct {
// DashboardActivityChannel is a service to advertise dashboard activity
type DashboardActivityChannel interface {
// Called when a dashboard is saved
DashboardSaved(orgID int64, uid string) error
DashboardSaved(orgID int64, uid string, rv string) error
// Called when a dashboard is deleted
DashboardDeleted(orgID int64, uid string) error
@@ -2,6 +2,7 @@ package apistore
import (
"context"
"encoding/json"
"math/rand/v2"
"strings"
"testing"
@@ -19,6 +20,7 @@ import (
authlib "github.com/grafana/authlib/types"
dashv1 "github.com/grafana/grafana/apps/dashboard/pkg/apis/dashboard/v1beta1"
"github.com/grafana/grafana/pkg/apimachinery/apis/common/v0alpha1"
"github.com/grafana/grafana/pkg/apimachinery/identity"
"github.com/grafana/grafana/pkg/apimachinery/utils"
)
@@ -195,6 +197,42 @@ func TestPrepareObjectForStorage(t *testing.T) {
require.Equal(t, int64(2), meta2.GetGeneration())
})
t.Run("Update should skip incrementing generation when content is unchanged", func(t *testing.T) {
dashboard := dashv1.Dashboard{
ObjectMeta: v1.ObjectMeta{
Name: "test",
Generation: 123,
Annotations: map[string]string{
"A": "B",
utils.AnnoKeyUpdatedTimestamp: "2025-12-17T01:01:00Z",
},
UID: "XXX",
},
Spec: v0alpha1.Unstructured{
Object: map[string]any{
"hello": "world",
},
},
}
dashboard.Name = "test-name"
obj := dashboard.DeepCopyObject()
tmp, err := utils.MetaAccessor(obj)
tmp.SetGeneration(2)
tmp.SetUpdatedTimestampMillis(12345)
require.NoError(t, err)
v, err := s.prepareObjectForUpdate(ctx, obj, &dashboard)
require.NoError(t, err)
require.False(t, v.hasChanged, "no changes")
out := &unstructured.Unstructured{}
err = json.Unmarshal(v.raw.Bytes(), out)
require.NoError(t, err)
require.Equal(t, int64(123), tmp.GetGeneration())
require.Equal(t, "2025-12-17T01:01:00Z", tmp.GetAnnotation(utils.AnnoKeyUpdatedTimestamp))
})
s.opts.RequireDeprecatedInternalID = true
t.Run("Should generate internal id", func(t *testing.T) {
dashboard := dashv1.Dashboard{}
@@ -25,9 +25,11 @@ import { DashboardEvent, DashboardEventAction } from './types';
const sessionId = uuidv4();
class DashboardWatcher {
private static readonly IGNORE_SAVE_WINDOW_MS = 5000;
channel?: LiveChannelAddress; // path to the channel
uid?: string;
ignoreSave?: boolean;
ignoreSave = 0; // save any events until this time passes
editing = false;
lastEditing?: DashboardEvent;
subscription?: Unsubscribable;
@@ -84,8 +86,9 @@ class DashboardWatcher {
this.uid = undefined;
}
// ignore the next 5 seconds of save events
ignoreNextSave() {
this.ignoreSave = true;
this.ignoreSave = Date.now() + DashboardWatcher.IGNORE_SAVE_WINDOW_MS;
}
getRecentEditingEvent() {
@@ -115,8 +118,11 @@ class DashboardWatcher {
case DashboardEventAction.EditingStarted:
case DashboardEventAction.Saved: {
if (this.ignoreSave) {
this.ignoreSave = false;
return;
if (this.ignoreSave < Date.now()) {
this.ignoreSave = 0; // process the event
} else {
return;
}
}
const dash = getDashboardSrv().getCurrent();
@@ -11,4 +11,5 @@ export interface DashboardEvent {
message?: string;
sessionId?: string;
timestamp?: number;
rv?: string;
}
@@ -12,6 +12,7 @@ import kbn from 'app/core/utils/kbn';
import { Resource } from 'app/features/apiserver/types';
import { SaveDashboardFormCommonOptions } from 'app/features/dashboard-scene/saving/SaveDashboardForm';
import { getDashboardUrl } from 'app/features/dashboard-scene/utils/getDashboardUrl';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { validationSrv } from 'app/features/manage-dashboards/services/ValidationSrv';
import { PROVISIONING_URL } from 'app/features/provisioning/constants';
import { useCreateOrUpdateRepositoryFile } from 'app/features/provisioning/hooks/useCreateOrUpdateRepositoryFile';
@@ -204,6 +205,9 @@ export function SaveProvisionedDashboardForm({
repositoryType: repository?.type ?? 'unknown',
});
// ignore incoming save events
dashboardWatcher.ignoreNextSave();
createOrUpdateFile({
// Skip adding ref to the default branch request
ref: ref === repository?.branch ? undefined : ref,