webui: add "Show diff" to the unsaved-config banner

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 <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-17 10:32:45 +02:00
parent 584d6c57df
commit bbc01a5e54
5 changed files with 196 additions and 2 deletions
+125 -1
View File
@@ -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=...
+6 -1
View File
@@ -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)
+26
View File
@@ -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;
@@ -0,0 +1,9 @@
{{define "config-diff.html"}}
{{- if .Err}}
<div class="diff-status diff-status-error">Could not produce diff: {{.Err}}</div>
{{- else if .Lines}}
<div class="config-diff">{{range .Lines}}<span class="diff-line {{.Class}}">{{.Text}}</span>{{end}}</div>
{{- else}}
<div class="diff-status">Running and startup configuration are identical.</div>
{{- end}}
{{end}}
+30
View File
@@ -144,6 +144,14 @@
hx-confirm="Save running configuration to startup?">
Save to startup
</button>
<button type="button" class="btn btn-sm cfg-unsaved-diff-btn"
title="Show what differs between running and startup configuration."
hx-get="/configure/diff"
hx-target="#config-diff-body"
hx-swap="innerHTML"
data-show-modal="config-diff-dialog">
Show diff
</button>
</div>
{{end}}
<main id="content">
@@ -158,6 +166,28 @@
<button class="btn btn-outline" id="dialog-cancel">Cancel</button>
</div>
</dialog>
<dialog id="config-diff-dialog" class="iface-modal diff-modal" aria-modal="true">
<header class="iface-modal-header">
<h3>Configuration diff <span class="diff-subtitle">startup &rarr; running</span></h3>
<button type="button" class="btn-close" aria-label="Close"
data-close-modal="config-diff-dialog">&times;</button>
</header>
<div class="iface-modal-body">
<div id="config-diff-body" class="config-diff-body">
<div class="diff-status">Loading&hellip;</div>
</div>
</div>
<footer class="iface-modal-footer">
<button type="button" class="btn btn-sm btn-primary"
title="Copy running config to startup config so changes survive a reboot."
hx-post="/configure/save"
hx-swap="none">
Save to startup
</button>
<button type="button" class="btn btn-sm"
data-close-modal="config-diff-dialog">Close</button>
</footer>
</dialog>
</body>
</html>
{{end}}