webui: only flag unsaved changes when running differs from startup

The "unsaved changes" banner was set unconditionally at every site that
touches config, so it could appear when running-config actually equals
startup — contradicting the "Show diff" modal, which correctly showed
nothing.  A shared updateCfgUnsaved helper now compares running against
startup (the same datastore pair the diff uses) and sets or clears the
banner cookie to match:

 - Apply and restore-to-running clear it when an Apply/restore reverts
   an out-of-band (e.g. CLI) change back to match startup, instead of
   always showing it.
 - The advanced-tree presence toggle writes only the candidate
   datastore, so it no longer sets the running-vs-startup banner at all
   (matching the other candidate-only tree handlers).

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-17 12:48:06 +02:00
parent 93db076d72
commit a47e141dc4
3 changed files with 41 additions and 11 deletions
+35 -9
View File
@@ -109,19 +109,49 @@ func applyError(w http.ResponseWriter, op string, err error) {
http.Error(w, "Could not reach the device: "+err.Error(), http.StatusBadGateway)
}
// Apply copies candidate → running, activating all staged changes atomically.
// Sets the cfg-unsaved cookie so the persistent banner appears until startup is saved.
// Apply copies candidate → running, activating all staged changes atomically,
// then updates the unsaved-changes banner to match the running-vs-startup state.
// POST /configure/apply
func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) {
if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil {
applyError(w, "apply", err)
return
}
setCfgUnsaved(w)
updateCfgUnsaved(r.Context(), h.RC, w)
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
}
// configPair fetches the startup and running datastores — the shared basis for
// the unsaved-state check (updateCfgUnsaved) and the config diff (configDiff).
func configPair(ctx context.Context, rc restconf.Fetcher) (startup, running json.RawMessage, err error) {
if startup, err = rc.GetDatastore(ctx, "startup"); err != nil {
return nil, nil, fmt.Errorf("read startup-config: %w", err)
}
if running, err = rc.GetDatastore(ctx, "running"); err != nil {
return nil, nil, fmt.Errorf("read running-config: %w", err)
}
return startup, running, nil
}
// updateCfgUnsaved sets or clears the cfg-unsaved banner cookie to match the
// actual state: set when running-config differs from startup, cleared when they
// are byte-identical (an Apply or restore can revert an out-of-band change so
// nothing is unsaved). Byte comparison is reliable given the shared serializer
// (the basis the config diff uses); a read error fails open and shows the
// banner. Call after any operation that writes running-config.
func updateCfgUnsaved(ctx context.Context, rc restconf.Fetcher, w http.ResponseWriter) {
startup, running, err := configPair(ctx, rc)
if err != nil {
log.Printf("configure: unsaved-state check: %v", err)
}
if err == nil && bytes.Equal(running, startup) {
clearCfgUnsaved(w)
} else {
setCfgUnsaved(w)
}
}
// Abort copies running → candidate, discarding all staged changes.
// POST /configure/abort
func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) {
@@ -188,13 +218,9 @@ func (h *ConfigureHandler) ConfigDiff(w http.ResponseWriter, r *http.Request) {
// configDiff fetches startup and running config, writes each to a temp file,
// and returns the `diff -u` output (empty when the two are identical).
func (h *ConfigureHandler) configDiff(ctx context.Context) (string, error) {
startup, err := h.RC.GetDatastore(ctx, "startup")
startup, running, err := configPair(ctx, h.RC)
if err != nil {
return "", fmt.Errorf("read startup-config: %w", err)
}
running, err := h.RC.GetDatastore(ctx, "running")
if err != nil {
return "", fmt.Errorf("read running-config: %w", err)
return "", err
}
startupFile, err := writeTempConfig("cfgdiff-startup-*.json", startup)
+3 -1
View File
@@ -395,7 +395,9 @@ func (h *SystemHandler) RestoreConfig(w http.ResponseWriter, r *http.Request) {
}
if target == "running" {
setCfgUnsaved(w)
// Restoring the same config that's already in startup leaves nothing
// unsaved, so reflect the actual running-vs-startup state.
updateCfgUnsaved(r.Context(), h.RC, w)
w.Header().Set("HX-Refresh", "true")
w.WriteHeader(http.StatusNoContent)
return
+3 -1
View File
@@ -1728,7 +1728,9 @@ func (h *TreeHandler) TogglePresence(w http.ResponseWriter, r *http.Request) {
exists = true
}
setCfgUnsaved(w)
// No setCfgUnsaved here — this only modifies candidate. The cfg-unsaved
// banner is specifically about "running differs from startup," set after
// Apply / RestoreConfig-to-running (matching the other tree-edit handlers).
// Re-render the right-pane leaf group with the updated presence state.
gd := h.buildLeafGroup(r, mgr, path, node.Name, "container")