mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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=...
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}}
|
||||
@@ -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 → running</span></h3>
|
||||
<button type="button" class="btn-close" aria-label="Close"
|
||||
data-close-modal="config-diff-dialog">×</button>
|
||||
</header>
|
||||
<div class="iface-modal-body">
|
||||
<div id="config-diff-body" class="config-diff-body">
|
||||
<div class="diff-status">Loading…</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}}
|
||||
|
||||
Reference in New Issue
Block a user