From bbc01a5e548ebed10c0a313fb52a4f026233b4d6 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Tue, 16 Jun 2026 18:30:20 +0200 Subject: [PATCH] webui: add "Show diff" to the unsaved-config banner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a button next to "Save to startup" that opens a modal showing a unified diff between startup-config and running-config, so the pending changes can be reviewed before persisting them. The handler fetches both datastores over RESTCONF (same serializer → low-noise diff), writes them to temp files, and runs busybox `diff -u`. Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/configure.go | 126 +++++++++++++++++- src/webui/internal/server/server.go | 7 +- src/webui/static/css/style.css | 26 ++++ .../templates/fragments/config-diff.html | 9 ++ src/webui/templates/layouts/base.html | 30 +++++ 5 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 src/webui/templates/fragments/config-diff.html diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index 75f9d9cc..27eb1523 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -3,11 +3,18 @@ package handlers import ( + "bytes" + "context" "encoding/json" "errors" + "fmt" + "html/template" "log" "net/http" + "os" + "os/exec" "strconv" + "strings" "time" "infix/webui/internal/restconf" @@ -28,7 +35,8 @@ var webuiStartTime = time.Now() // ConfigureHandler manages the candidate datastore lifecycle. type ConfigureHandler struct { - RC restconf.Fetcher + RC restconf.Fetcher + Template *template.Template } func setCfgUnsaved(w http.ResponseWriter) { @@ -142,6 +150,122 @@ func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } +// configDiffLine is one classified line of unified-diff output. +type configDiffLine struct { + Class string + Text string +} + +// configDiffData is the template payload for the diff modal body. Neither +// field set means the configs are identical (rendered as such by the template). +type configDiffData struct { + Lines []configDiffLine + Err string +} + +// ConfigDiff renders a unified diff between startup-config and running-config +// for the unsaved-changes modal. Both datastores are fetched over RESTCONF +// (same serializer → deterministic, low-noise diff), written to temp files, +// and compared with busybox `diff -u`. Always responds 200 with an HTML +// fragment — including on error — so the connection monitor never reads a 5xx +// as "device disconnected" (same reasoning as applyError). +// GET /configure/diff +func (h *ConfigureHandler) ConfigDiff(w http.ResponseWriter, r *http.Request) { + var data configDiffData + out, err := h.configDiff(r.Context()) + switch { + case err != nil: + log.Printf("configure diff: %v", err) + data.Err = err.Error() + case strings.TrimSpace(out) != "": + data.Lines = classifyDiff(out) + } + if err := h.Template.ExecuteTemplate(w, "config-diff.html", data); err != nil { + log.Printf("configure diff: render: %v", err) + } +} + +// 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") + 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) + } + + startupFile, err := writeTempConfig("cfgdiff-startup-*.json", startup) + if err != nil { + return "", err + } + defer os.Remove(startupFile) + runningFile, err := writeTempConfig("cfgdiff-running-*.json", running) + if err != nil { + return "", err + } + defer os.Remove(runningFile) + + var stdout, stderr bytes.Buffer + // busybox diff supports -L (not --label); repeat it for each file. + cmd := exec.CommandContext(ctx, "diff", "-u", + "-L", "startup-config", "-L", "running-config", startupFile, runningFile) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + err = cmd.Run() + // diff exit codes: 0 = identical, 1 = differences (normal), >1 = error. + var exit *exec.ExitError + if err != nil && (!errors.As(err, &exit) || exit.ExitCode() > 1) { + msg := strings.TrimSpace(stderr.String()) + if msg == "" { + msg = err.Error() + } + return "", fmt.Errorf("diff failed: %s", msg) + } + return stdout.String(), nil +} + +// writeTempConfig writes data to a fresh 0600 temp file and returns its path. +func writeTempConfig(pattern string, data []byte) (string, error) { + f, err := os.CreateTemp("", pattern) + if err != nil { + return "", err + } + if _, err := f.Write(data); err != nil { + f.Close() + os.Remove(f.Name()) + return "", err + } + if err := f.Close(); err != nil { + os.Remove(f.Name()) + return "", err + } + return f.Name(), nil +} + +// classifyDiff tags each unified-diff line with a CSS class for colorization. +func classifyDiff(out string) []configDiffLine { + raw := strings.Split(strings.TrimRight(out, "\n"), "\n") + lines := make([]configDiffLine, 0, len(raw)) + for _, ln := range raw { + class := "diff-ctx" + switch { + case strings.HasPrefix(ln, "+++"), strings.HasPrefix(ln, "---"): + class = "diff-meta" + case strings.HasPrefix(ln, "@@"): + class = "diff-hunk" + case strings.HasPrefix(ln, "+"): + class = "diff-add" + case strings.HasPrefix(ln, "-"): + class = "diff-del" + } + lines = append(lines, configDiffLine{Class: class, Text: ln}) + } + return lines +} + // DeleteLeaf removes a single leaf from the candidate datastore so the YANG // default takes effect. Used by curated-page reset buttons. // DELETE /configure/leaf?path=...&redirect=... diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 0db03db6..46ad20ee 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -149,6 +149,10 @@ func New( if err != nil { return nil, err } + cfgDiffTmpl, err := template.ParseFS(templateFS, "fragments/config-diff.html") + if err != nil { + return nil, err + } ifFuncs := template.FuncMap{ "shortPMD": handlers.ShortenPMD, "add": func(a, b int) int { return a + b }, @@ -262,7 +266,7 @@ func New( nacm := &handlers.NACMHandler{Template: nacmTmpl, RC: rc} services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc} containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc} - cfg := &handlers.ConfigureHandler{RC: rc} + cfg := &handlers.ConfigureHandler{RC: rc, Template: cfgDiffTmpl} cfgSys := &handlers.ConfigureSystemHandler{ Template: cfgSysTmpl, NTPTemplate: cfgNTPTmpl, @@ -353,6 +357,7 @@ func New( mux.HandleFunc("POST /configure/apply-and-save", cfg.ApplyAndSave) mux.HandleFunc("POST /configure/abort", cfg.Abort) mux.HandleFunc("POST /configure/save", cfg.Save) + mux.HandleFunc("GET /configure/diff", cfg.ConfigDiff) mux.HandleFunc("DELETE /configure/leaf", cfg.DeleteLeaf) mux.HandleFunc("GET /configure/system", cfgSys.Overview) mux.HandleFunc("GET /configure/ntp", cfgSys.OverviewNTP) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 1cc9924b..3c060ea0 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2555,6 +2555,17 @@ details[open] > .cfg-multi-summary { font-size: 0.875rem; flex-shrink: 0; } +/* "Show diff" pairs with the filled "Save to startup" — same primary hue, + outlined so the save action stays visually dominant; fills on hover. */ +.cfg-unsaved-diff-btn { + background: transparent; + color: var(--primary); + border-color: var(--primary); +} +.cfg-unsaved-diff-btn:hover { + background: var(--primary); + color: #fff; +} /* cfgError inline feedback */ .cfg-save-status.error { color: var(--danger); cursor: pointer; } @@ -2755,6 +2766,21 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } padding: 0.6rem 1rem; border-top: 1px solid var(--border); } + +/* Configuration diff modal (startup vs running) */ +.diff-modal { min-width: 52rem; } +.diff-subtitle { color: var(--fg-muted); font-weight: normal; font-size: 0.8em; } +.diff-modal .iface-modal-body { padding: 0; max-height: 70vh; overflow: auto; } +.config-diff-body { font-size: 0.8rem; } +.config-diff { font-family: var(--font-mono); } +.diff-line { display: block; white-space: pre; padding: 0 0.75rem; } +.diff-meta { color: var(--fg-muted); } +.diff-hunk { color: var(--fg-muted); background: var(--border-subtle); } +.diff-add { background: rgba(34, 197, 94, 0.14); color: var(--green-600); } +.diff-del { background: rgba(239, 68, 68, 0.14); color: var(--red-600); } +.diff-status { padding: 1rem; color: var(--fg-muted); } +.diff-status-error { color: var(--danger); } + .iface-fields { border: none; padding: 0; diff --git a/src/webui/templates/fragments/config-diff.html b/src/webui/templates/fragments/config-diff.html new file mode 100644 index 00000000..7b3635dc --- /dev/null +++ b/src/webui/templates/fragments/config-diff.html @@ -0,0 +1,9 @@ +{{define "config-diff.html"}} +{{- if .Err}} +
Could not produce diff: {{.Err}}
+{{- else if .Lines}} +
{{range .Lines}}{{.Text}}{{end}}
+{{- else}} +
Running and startup configuration are identical.
+{{- end}} +{{end}} diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index 31ca736f..6e263666 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -144,6 +144,14 @@ hx-confirm="Save running configuration to startup?"> Save to startup + {{end}}
@@ -158,6 +166,28 @@ + +
+

Configuration diff startup → running

+ +
+
+
+
Loading…
+
+
+ +
{{end}}