diff --git a/package/confd/confd.mk b/package/confd/confd.mk index ccb50d2b..428e85b9 100644 --- a/package/confd/confd.mk +++ b/package/confd/confd.mk @@ -91,6 +91,12 @@ define CONFD_INSTALL_YANG_MODULES_GPS $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/gps.inc endef endif +ifeq ($(BR2_PACKAGE_WEBUI),y) +define CONFD_INSTALL_YANG_MODULES_WEBUI + $(COMMON_SYSREPO_ENV) \ + $(BR2_EXTERNAL_INFIX_PATH)/utils/srload $(@D)/yang/web.inc +endef +endif # PER_PACKAGE_DIR # Since the last package in the dependency chain that runs sysrepoctl is confd, we need to @@ -121,6 +127,7 @@ CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_CONTAINERS CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WIFI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_GPS +CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_YANG_MODULES_WEBUI CONFD_POST_INSTALL_TARGET_HOOKS += CONFD_INSTALL_IN_ROMFS CONFD_TARGET_FINALIZE_HOOKS += CONFD_CLEANUP diff --git a/src/confd/yang/confd.inc b/src/confd/yang/confd.inc index c4b23343..44c29d4f 100644 --- a/src/confd/yang/confd.inc +++ b/src/confd/yang/confd.inc @@ -43,7 +43,7 @@ MODULES=( "infix-firewall-icmp-types@2025-04-26.yang" "infix-meta@2025-12-10.yang" "infix-system@2026-03-09.yang" - "infix-services@2026-03-20.yang" + "infix-services@2026-06-17.yang" "ieee802-ethernet-interface@2025-09-10.yang" "ieee802-ethernet-phy-type@2025-09-10.yang" "infix-ethernet-interface@2026-05-21.yang" diff --git a/src/confd/yang/confd/infix-services.yang b/src/confd/yang/confd/infix-services.yang index 7d64f848..c23651ec 100644 --- a/src/confd/yang/confd/infix-services.yang +++ b/src/confd/yang/confd/infix-services.yang @@ -31,6 +31,11 @@ module infix-services { contact "kernelkit@googlegroups.com"; description "Infix services, generic."; + revision 2026-06-17 { + description "Add web-ui feature, advertised when the web management + interface (webui) is built into the image."; + reference "internal"; + } revision 2026-03-20 { description "Add hostname leaf to mdns container for avahi host-name override. Add neighbors container to mdns for mDNS-SD neighbor table. @@ -71,6 +76,13 @@ module infix-services { reference "internal"; } + feature web-ui { + description "The web management interface (webui) is an optional build-time + feature in Infix. Advertised when it is built; it gates no + data nodes — the /web service tree (including restconf) is + present regardless, so the feature is a pure capability flag."; + } + /* * Data nodes */ diff --git a/src/confd/yang/confd/infix-services@2026-03-20.yang b/src/confd/yang/confd/infix-services@2026-06-17.yang similarity index 100% rename from src/confd/yang/confd/infix-services@2026-03-20.yang rename to src/confd/yang/confd/infix-services@2026-06-17.yang diff --git a/src/confd/yang/web.inc b/src/confd/yang/web.inc new file mode 100644 index 00000000..f9eb6130 --- /dev/null +++ b/src/confd/yang/web.inc @@ -0,0 +1,3 @@ +MODULES=( + "infix-services -e web-ui" +) diff --git a/src/webui/internal/auth/login.go b/src/webui/internal/auth/login.go index 75b61f67..d2928563 100644 --- a/src/webui/internal/auth/login.go +++ b/src/webui/internal/auth/login.go @@ -79,9 +79,7 @@ func (h *LoginHandler) DoLogin(w http.ResponseWriter, r *http.Request) { // The external web-app shortcuts (console/ttyd, netbrowse) are // config-gated; fold them into the same feature map so templates gate // on .Capabilities.Has "console" / "netbrowse". - console, netbrowse := handlers.DetectWebShortcuts(ctx, h.RC) - caps.Features()["console"] = console - caps.Features()["netbrowse"] = netbrowse + handlers.ApplyWebShortcuts(ctx, h.RC, caps.Features()) // The User's Guide is bundled at build time (a filesystem check, not // config); gate the Help entry on its presence. caps.Features()["docs"] = handlers.DetectDocs() diff --git a/src/webui/internal/handlers/capabilities.go b/src/webui/internal/handlers/capabilities.go index 403df1cc..655fb04b 100644 --- a/src/webui/internal/handlers/capabilities.go +++ b/src/webui/internal/handlers/capabilities.go @@ -109,6 +109,14 @@ func DetectWebShortcuts(ctx context.Context, rc restconf.Fetcher) (console, netb return cfg.Web.Console.Enabled, cfg.Web.Netbrowse.Enabled } +// ApplyWebShortcuts writes the console/netbrowse capability flags into features +// from running-config. These two are the only config-toggleable shortcuts, so +// keeping the keys in one place lets both login (baking into the session) and +// the per-page-load refresh share them. +func ApplyWebShortcuts(ctx context.Context, rc restconf.Fetcher, features map[string]bool) { + features["console"], features["netbrowse"] = DetectWebShortcuts(ctx, rc) +} + func DetectCapabilities(ctx context.Context, rc restconf.Fetcher) *Capabilities { var lib yangLibrary if err := rc.Get(ctx, "/data/ietf-yang-library:yang-library", &lib); err != nil { diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index 75f9d9cc..efd26056 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) { @@ -101,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) { @@ -142,6 +180,118 @@ 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, running, err := configPair(ctx, h.RC) + if err != nil { + return "", 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/handlers/configure_users.go b/src/webui/internal/handlers/configure_users.go index 4d674576..03209a15 100644 --- a/src/webui/internal/handlers/configure_users.go +++ b/src/webui/internal/handlers/configure_users.go @@ -66,6 +66,8 @@ type cfgUsersPageData struct { Groups []cfgGroupDisplay Error string ShellOptions []schema.IdentityOption + CryptMethods []CryptMethod + Desc map[string]string // YANG field descriptions for hover tooltips } // ─── Handler ───────────────────────────────────────────────────────────────── @@ -101,16 +103,42 @@ func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) data.Error = "Could not read user configuration" } } - const shellPath = "/ietf-system:system/authentication/user/infix-system:shell" + const userPath = "/ietf-system:system/authentication/user" + const shellPath = userPath + "/infix-system:shell" + data.CryptMethods = CryptMethods mgr := h.Schema.Manager() data.Loading = mgr == nil + var defaultShell string if mgr != nil { data.ShellOptions = schema.OptionsFor(mgr, shellPath) + for _, o := range data.ShellOptions { + if o.IsDefault { + defaultShell = o.Label + break + } + } + data.Desc = map[string]string{ + "username": schema.DescriptionOf(mgr, userPath+"/name"), + "password": schema.DescriptionOf(mgr, userPath+"/password"), + "shell": schema.DescriptionOf(mgr, shellPath), + // No YANG leaf models the hash algorithm (it's a property of the + // stored crypt-hash); describe the offered set, verified against + // the infix-system:crypt-hash typedef. + "crypt": "Password hashing algorithm. yescrypt (recommended) is the strongest; " + + "SHA-512, SHA-256, and MD5 are offered for compatibility.", + } } for _, u := range raw.System.Auth.Users { + // Effective shell: the user's explicit shell, else the YANG default. + // Kept bare (no module prefix) so the static cell and the editor's + // selected-option test compare directly against the option .Label. + shell := schema.StripModulePrefix(u.Shell) + if shell == "" { + shell = defaultShell + } data.Users = append(data.Users, cfgUserDisplay{ cfgUserJSON: u, - ShellLabel: schema.StripModulePrefix(u.Shell), + ShellLabel: shell, KeyCount: len(u.AuthorizedKeys), }) } @@ -171,7 +199,7 @@ func (h *ConfigureUsersHandler) AddUser(w http.ResponseWriter, r *http.Request) return } - hash, err := HashPassword(password) + hash, err := HashPassword(password, r.FormValue("crypt")) if err != nil { log.Printf("configure users add: hash: %v", err) http.Error(w, "Internal server error", http.StatusInternalServerError) @@ -245,7 +273,7 @@ func (h *ConfigureUsersHandler) ChangePassword(w http.ResponseWriter, r *http.Re return } - hash, err := HashPassword(password) + hash, err := HashPassword(password, r.FormValue("crypt")) if err != nil { log.Printf("configure users password %q: hash: %v", name, err) http.Error(w, "Internal server error", http.StatusInternalServerError) diff --git a/src/webui/internal/handlers/crypt.go b/src/webui/internal/handlers/crypt.go index 430ac96a..a9eef94d 100644 --- a/src/webui/internal/handlers/crypt.go +++ b/src/webui/internal/handlers/crypt.go @@ -8,15 +8,52 @@ import ( "strings" ) -// HashPassword returns a yescrypt crypt hash of password using mkpasswd(1). -// mkpasswd is available on Infix target systems and uses the system's libcrypt, -// so the output matches whatever the device expects (default: yescrypt $y$). -func HashPassword(password string) (string, error) { +// CryptMethod is a password-hashing algorithm offered in the UI. +type CryptMethod struct { + Value string // form value and mkpasswd --method argument + Label string + Default bool +} + +// CryptMethods is the single source of truth for the hashes the UI offers, +// ordered strongest first. Only the four the infix-system:crypt-hash YANG type +// accepts ($y$/$6$/$5$/$1$) are listed — mkpasswd supports more (bcrypt, +// scrypt, …), but the password leaf's pattern would reject those on save. +var CryptMethods = []CryptMethod{ + {"yescrypt", "yescrypt (recommended)", true}, + {"sha512crypt", "SHA-512", false}, + {"sha256crypt", "SHA-256", false}, + {"md5crypt", "MD5", false}, +} + +// normalizeCryptMethod returns method if it's one we offer, otherwise the +// default (the entry flagged Default). This guards an empty or tampered form +// value from reaching mkpasswd --method. +func normalizeCryptMethod(method string) string { + def := CryptMethods[0].Value + for _, m := range CryptMethods { + if m.Default { + def = m.Value + } + if m.Value == method { + return method + } + } + return def +} + +// HashPassword returns a crypt hash of password using mkpasswd(1) with the +// given method. mkpasswd is available on Infix target systems and uses the +// system's libcrypt. An empty or unrecognised method (e.g. one the YANG type +// would reject) falls back to the default, so callers can pass the raw form +// value safely. +func HashPassword(password, method string) (string, error) { + method = normalizeCryptMethod(method) path, err := exec.LookPath("mkpasswd") if err != nil { return "", fmt.Errorf("mkpasswd not found: %w", err) } - cmd := exec.Command(path, "--method=yescrypt", "--password-fd=0") + cmd := exec.Command(path, "--method="+method, "--password-fd=0") cmd.Stdin = strings.NewReader(password) out, err := cmd.Output() if err != nil { diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index f7a20dec..9305f313 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -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 diff --git a/src/webui/internal/handlers/yang_tree.go b/src/webui/internal/handlers/yang_tree.go index e5fe380f..c9c76527 100644 --- a/src/webui/internal/handlers/yang_tree.go +++ b/src/webui/internal/handlers/yang_tree.go @@ -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") diff --git a/src/webui/internal/server/middleware.go b/src/webui/internal/server/middleware.go index 1db03dee..2d5175f0 100644 --- a/src/webui/internal/server/middleware.go +++ b/src/webui/internal/server/middleware.go @@ -3,6 +3,7 @@ package server import ( + "maps" "net/http" "net/url" "strings" @@ -19,7 +20,7 @@ const cookieName = "session" // the token up in the in-memory store, and attaches the session's // credentials to the context. Unauthenticated requests are // redirected to /login (or get a 401 if the request comes from HTMX). -func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler { +func authMiddleware(store *auth.SessionStore, rc restconf.Fetcher, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if isPublicPath(r.URL.Path) { next.ServeHTTP(w, r) @@ -38,11 +39,13 @@ func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler { return } + polling := isPollingPath(r.URL.Path) + // Sliding window: extend the session on user-driven requests. // Background polling endpoints are excluded so an idle user // (just leaving the dashboard open in a tab) still hits the // timeout. - if !isPollingPath(r.URL.Path) { + if !polling { store.Refresh(cookie.Value) } @@ -51,6 +54,19 @@ func authMiddleware(store *auth.SessionStore, next http.Handler) http.Handler { Password: password, }) ctx = security.WithToken(ctx, csrf) + + // The console / netbrowse shortcuts are toggleable in running-config at + // runtime, unlike the other (per-boot) capabilities baked into the + // session at login. Re-read them on full page loads — the only time + // the topbar/user-menu that host them re-render — so toggling takes + // effect on reload without re-login. htmx fragment swaps and pollers + // never re-draw them, so they're skipped (no read per request). + fullPageLoad := !polling && r.Header.Get("HX-Request") != "true" + if rc != nil && fullPageLoad { + features = maps.Clone(features) // don't mutate the shared session map + handlers.ApplyWebShortcuts(ctx, rc, features) + } + ctx = handlers.ContextWithCapabilities(ctx, handlers.NewCapabilities(features)) next.ServeHTTP(w, r.WithContext(ctx)) }) diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 0db03db6..c8f20ef3 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) @@ -475,7 +480,7 @@ func New( mux.HandleFunc("GET /status/tree/children", statusTreeH.TreeChildren) mux.HandleFunc("GET /status/tree/node", statusTreeH.TreeNode) - handler := authMiddleware(store, mux) + handler := authMiddleware(store, rc, mux) handler = csrfMiddleware(handler) handler = securityHeadersMiddleware(handler) return handler, nil diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index e63cb44e..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; } @@ -2691,13 +2702,12 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } .btn-add-row:hover { text-decoration: underline; } /* Inline forms inside users table */ -.user-shell-form, .user-add-inline { +.user-add-inline { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; } -.user-shell-form .cfg-input { flex: 1; min-width: 0; } .user-add-inline .cfg-input { width: auto; flex: 1; min-width: 8rem; } /* Add Interface modal dialog */ @@ -2756,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}} diff --git a/src/webui/templates/pages/configure-users.html b/src/webui/templates/pages/configure-users.html index 447beddb..6dfccd4b 100644 --- a/src/webui/templates/pages/configure-users.html +++ b/src/webui/templates/pages/configure-users.html @@ -43,17 +43,7 @@ {{.Name}} - -
- - - -
- + {{.ShellLabel}} {{.KeyCount}} + + + + + {{/* Change password. type=text + CSS mask (cfg-input-mask) + dodges the browser password-manager save prompt, same as + the keystore secret fields. */}} +
Change Password
- - + + + + + +
New passwordNew password{{template "field-info" (index $.Desc "password")}} + + + + +
Hash{{template "field-info" (index $.Desc "crypt")}}