From d326939e10fa7343fdfa3d5c543962f7dfb5e412 Mon Sep 17 00:00:00 2001 From: Joachim Wiberg Date: Mon, 15 Jun 2026 10:44:29 +0200 Subject: [PATCH] webui: final touches - Add missing Configure -> shortcuts - For shortcuts into YANG tree, expand and mark the tree node - Revamp the /mdns status page to mimic /ntp => more vertical space Signed-off-by: Joachim Wiberg --- src/webui/internal/handlers/configure.go | 39 ++++-- .../internal/handlers/configure_interfaces.go | 90 +++++++++++-- src/webui/static/css/style.css | 8 ++ src/webui/static/js/app.js | 124 ++++++++++++++++- .../templates/pages/configure-interfaces.html | 4 +- src/webui/templates/pages/containers.html | 16 ++- src/webui/templates/pages/firewall.html | 8 +- src/webui/templates/pages/interfaces.html | 16 ++- src/webui/templates/pages/mdns.html | 127 ++++++++---------- src/webui/templates/pages/ntp.html | 12 +- src/webui/templates/pages/vpn.html | 13 +- src/webui/templates/pages/wifi.html | 5 + src/webui/templates/pages/yang-tree.html | 2 +- 13 files changed, 363 insertions(+), 101 deletions(-) diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index e7d7c054..75f9d9cc 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -3,6 +3,8 @@ package handlers import ( + "encoding/json" + "errors" "log" "net/http" "strconv" @@ -74,13 +76,37 @@ func (h *ConfigureHandler) Enter(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } +// applyError surfaces a failed datastore copy from Apply / Apply&Save / Save. +// A RESTCONF error means the device answered and rejected the config — almost +// always a validation failure (e.g. deleting an interface other nodes still +// reference). The device is reachable, so report the reason inline via an +// HX-Trigger and a 200, NOT a 502: the front-end's connection monitor reads +// 502/503/504 as "device disconnected", which is exactly wrong here. Only a +// transport failure (no RESTCONF response at all) is a real gateway error. +func applyError(w http.ResponseWriter, op string, err error) { + log.Printf("configure %s: %v", op, err) + var re *restconf.Error + if errors.As(err, &re) { + // parseError always populates Message (server text, else HTTP status + // text), so a single fallback covers the otherwise-unreachable empty. + msg := re.Message + if msg == "" { + msg = "the device rejected the configuration" + } + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgApplyError":`+string(b)+`}`) + w.WriteHeader(http.StatusOK) + return + } + 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. // POST /configure/apply func (h *ConfigureHandler) Apply(w http.ResponseWriter, r *http.Request) { if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { - log.Printf("configure apply: %v", err) - http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "apply", err) return } setCfgUnsaved(w) @@ -104,13 +130,11 @@ func (h *ConfigureHandler) Abort(w http.ResponseWriter, r *http.Request) { // POST /configure/apply-and-save func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) { if err := h.RC.CopyDatastore(r.Context(), "candidate", "running"); err != nil { - log.Printf("configure apply-and-save: %v", err) - http.Error(w, "Could not apply configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "apply-and-save", err) return } if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { - log.Printf("configure apply-and-save (save): %v", err) - http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "apply-and-save (save)", err) return } clearCfgUnsaved(w) @@ -142,8 +166,7 @@ func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { // POST /configure/save func (h *ConfigureHandler) Save(w http.ResponseWriter, r *http.Request) { if err := h.RC.CopyDatastore(r.Context(), "running", "startup"); err != nil { - log.Printf("configure save: %v", err) - http.Error(w, "Could not save configuration: "+err.Error(), http.StatusBadGateway) + applyError(w, "save", err) return } clearCfgUnsaved(w) diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index f8fa4ca6..f2073427 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -141,6 +141,11 @@ type cfgIfaceRow struct { // foldout auto-expands to reveal the freshly-inferred options. False // on the normal page render, so foldouts stay collapsed there. JustSaved bool + // SavedMsg carries a confirmation rendered inline in the block footer on + // a post-save re-render. The block is swapped via outerHTML, so the + // triggering form (and its status span) is gone by the time any JS event + // fires — server-rendering the confirmation is the only reliable place. + SavedMsg string } // ifaceRadioMirror is the subset of wifi-radio fields we expose on the @@ -1130,6 +1135,15 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h renderSaveError(w, fmt.Errorf("loopback interface cannot be deleted")) return } + // Clear other interfaces' references to this one first: detach enslaved + // bridge/LAG members, and refuse outright if a VLAN is stacked on top. + // Otherwise the dangling leafref fails the next Apply with an opaque + // "Invalid input data" and no hint which node broke. + if err := h.resolveDependents(r.Context(), name); err != nil { + log.Printf("configure interfaces %s resolve dependents: %v", name, err) + renderSaveError(w, err) + return + } if err := h.RC.Delete(r.Context(), ifacePath(name)); err != nil && !restconf.IsNotFound(err) { log.Printf("configure interfaces %s delete: %v", name, err) renderSaveError(w, err) @@ -1138,6 +1152,56 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h renderSavedRedirect(w, name+" deleted", "/configure/interfaces") } +// resolveDependents prepares the named interface for deletion by clearing every +// other interface's reference to it, so the delete doesn't leave a dangling +// leafref that fails the next Apply with an opaque "Invalid input data" and no +// hint which node broke. +// +// - Enslaved bridge/LAG members are detached; they survive as standalone +// interfaces, only the membership is dropped. +// - VLANs stacked on the master via lower-layer-if can't survive without it, +// so rather than silently deleting an interface the user didn't name, the +// delete is refused and the VLANs named — removing them first is the user's +// call. +// +// The VLAN check runs before any detach, so a refused delete leaves the +// candidate untouched. +func (h *ConfigureInterfacesHandler) resolveDependents(ctx context.Context, master string) error { + ifaces, err := h.fetchAllInterfaces(ctx) + if err != nil { + return err + } + + var vlans []string + for _, iface := range ifaces { + if iface.Vlan != nil && iface.Vlan.LowerLayerIf == master { + vlans = append(vlans, iface.Name) + } + } + if len(vlans) > 0 { + noun, verb, pron := "VLAN", "is", "it" + if len(vlans) > 1 { + noun, verb, pron = "VLANs", "are", "them" + } + return fmt.Errorf("cannot delete %s: %s %s %s built on it — delete %s first", + master, noun, strings.Join(vlans, ", "), verb, pron) + } + + for _, iface := range ifaces { + switch { + case iface.BridgePort != nil && iface.BridgePort.Bridge == master: + if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:bridge-port"); err != nil { + return fmt.Errorf("detach %s from bridge %s: %w", iface.Name, master, err) + } + case iface.LagPort != nil && iface.LagPort.LAG == master: + if err := h.removeInterfaceAugment(ctx, iface.Name, "infix-interfaces:lag-port"); err != nil { + return fmt.Errorf("detach %s from lag %s: %w", iface.Name, master, err) + } + } + } + return nil +} + // SaveGeneral saves description, enabled, and optional custom MAC for any // interface. Empty MAC clears the override (DELETE on custom-phys-address); // non-empty installs it as a static override. @@ -1149,12 +1213,14 @@ func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http. } name := r.PathValue("name") enabled := r.FormValue("enabled") != "false" + // PATCH on a list element needs the entry wrapped in a single-element + // array, not a bare object — a bare object fails YANG validation (LY_EVALID). body := map[string]any{ - "ietf-interfaces:interface": map[string]any{ + "ietf-interfaces:interface": []map[string]any{{ "name": name, "enabled": enabled, "description": strings.TrimSpace(r.FormValue("description")), - }, + }}, } if err := h.RC.Patch(r.Context(), ifacePath(name), body); err != nil { log.Printf("configure interfaces %s general: %v", name, err) @@ -2153,7 +2219,7 @@ func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Requ } // Re-render the IP block in place so the new address appears without // collapsing the interface foldout — ready to add another straight away. - h.renderIPBlock(w, r, name, famCap, frag) + h.renderIPBlock(w, r, name, famCap, frag, "Address added") } func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) { @@ -2165,7 +2231,7 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R renderSaveError(w, err) return } - h.renderIPBlock(w, r, name, famCap, frag) + h.renderIPBlock(w, r, name, famCap, frag, "Address removed") } // SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding @@ -2248,7 +2314,7 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht // without collapsing the page. fragName empty → fall back to a toast // (e.g. families whose block fragment isn't wired up yet). if fragName != "" { - h.renderIPBlock(w, r, name, family, fragName) + h.renderIPBlock(w, r, name, family, fragName, family+" settings saved") return } renderSaved(w, family+" settings saved") @@ -2258,10 +2324,10 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht // fresh candidate. Falls back to a plain saved-toast if the interface or // schema can't be read, so a save is never reported as failed just because // the in-place refresh couldn't be built. -func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName string) { +func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName, savedMsg string) { ifaces, err := h.fetchAllInterfaces(r.Context()) if err != nil { - renderSaved(w, family+" settings saved") + renderSaved(w, savedMsg) return } rows := h.buildRows(ifaces, nil) @@ -2273,7 +2339,7 @@ func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *htt } } if row == nil { - renderSaved(w, family+" settings saved") + renderSaved(w, savedMsg) return } if mgr := h.Schema.Manager(); mgr != nil { @@ -2293,8 +2359,12 @@ func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *htt } } row.JustSaved = true // expand the DHCP foldout to reveal inferred options - // Keep the cfgSaved toast behaviour even though we swap the block. - w.Header().Set("HX-Trigger", `{"cfgSaved":"`+family+` settings saved"}`) + row.SavedMsg = savedMsg + // The block is swapped via outerHTML, so the confirmation is rendered into + // the block footer (above) rather than chased by JS across the swap; the + // log-only event just records it in the Configure activity panel. + msgJSON, _ := json.Marshal(savedMsg) + w.Header().Set("HX-Trigger", `{"cfgLogged":`+string(msgJSON)+`}`) if err := h.Template.ExecuteTemplate(w, fragName, row); err != nil { log.Printf("configure interfaces %s: render %s: %v", name, fragName, err) } diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 6fdbe6f5..37e24315 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -3020,6 +3020,14 @@ details.cfg-fold[open] > summary::before { transform: rotate(90deg); } background: var(--border-subtle); color: var(--primary); } +/* Currently-selected node: set on click and when arriving via a status-page + "Configure →" deep link, so the left tree shows where the right pane is. */ +.yt-label.yt-active { + background: var(--border-subtle); + color: var(--primary); + font-weight: 600; + box-shadow: inset 2px 0 0 var(--primary); +} /* Chevron rotates on open */ .yt-chevron { diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 19d9425e..6b5da539 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -1679,20 +1679,33 @@ // ─── Confirm dialog ──────────────────────────────────────────────────────── // openModal(message, onConfirm) shows the shared and calls onConfirm // if the user clicks Confirm, or does nothing on Cancel. -function openModal(message, onConfirm) { +// openModal shows the shared confirm dialog. With opts.alert it becomes a +// single-button alert (one "Dismiss", no Cancel) for errors the user can only +// acknowledge — e.g. a rejected Apply. The Cancel button and OK label are +// restored on close so the dialog returns to confirm() defaults. +function openModal(message, onConfirm, opts) { + opts = opts || {}; var dlg = document.getElementById('confirm-dialog'); + if (!dlg) { // fallback if dialog missing + if (opts.alert) window.alert(message); + else if (onConfirm) onConfirm(); + return; + } var msg = document.getElementById('dialog-message'); var ok = document.getElementById('dialog-confirm'); var no = document.getElementById('dialog-cancel'); - if (!dlg) { onConfirm(); return; } // fallback if dialog missing msg.textContent = message; + var okLabel = ok.textContent; + if (opts.alert) { no.hidden = true; ok.textContent = 'Dismiss'; } + function cleanup() { ok.removeEventListener('click', handleOK); no.removeEventListener('click', handleNo); + if (opts.alert) { no.hidden = false; ok.textContent = okLabel; } dlg.close(); } - function handleOK() { cleanup(); onConfirm(); } + function handleOK() { cleanup(); if (onConfirm) onConfirm(); } function handleNo() { cleanup(); } ok.addEventListener('click', handleOK); @@ -1878,6 +1891,89 @@ function openModal(message, onConfirm) { }, true); })(); +// ─── YANG tree: selected-node highlight + deep-link reveal ────────────────── +// Two related orientation fixes for the tree editor: +// 1. Clicking a node loads it in the right pane but left no cue of where you +// are — mark the clicked summary .yt-active. +// 2. A status-page "Configure →" deep link (/configure/tree?path=…) only +// filled the right pane, leaving the left tree collapsed and the user +// disoriented. Walk the ancestor chain, opening each
in turn — +// children load lazily over htmx on toggle, so wait for each level's swap +// before descending — then highlight and scroll the target into view. +(function() { + function setActive(summary) { + document.querySelectorAll('.yt-label.yt-active').forEach(function (s) { + if (s !== summary) s.classList.remove('yt-active'); + }); + if (summary) summary.classList.add('yt-active'); + } + + document.addEventListener('click', function (e) { + var summary = e.target.closest && e.target.closest('summary[data-yang-path]'); + if (summary) setActive(summary); + }); + + // Load a node's children into its .yt-children with a direct htmx.ajax + // request, resolving with the children
    . We deliberately do NOT route + // through the node's lazy "toggle once" trigger: the toggle event from a + // programmatic .open is async and, on a freshly htmx-swapped tree, did not + // reliably reach htmx — the node opened but its body stayed empty until a + // manual reload. htmx.ajax sidesteps all of that. + function loadChildren(details) { + var childUl = details.querySelector(':scope > .yt-children'); + if (!childUl || childUl.children.length) return Promise.resolve(childUl); + var url = details.getAttribute('hx-get'); + if (!url || !window.htmx || !window.htmx.ajax) return Promise.resolve(childUl); + return window.htmx.ajax('GET', url, { target: childUl, swap: 'innerHTML' }) + .then(function () { return childUl; }); + } + + function step(ul, target) { + var best = null; + ul.querySelectorAll(':scope > li > details.yt-node > summary[data-yang-path]').forEach(function (s) { + var p = s.getAttribute('data-yang-path'); + if (p === target || target.indexOf(p + '/') === 0) { + if (!best || p.length > best.getAttribute('data-yang-path').length) best = s; + } + }); + if (!best) return; + + var details = best.parentElement; + var isTarget = best.getAttribute('data-yang-path') === target; + details.open = true; // chevron + reveal the children area + + if (isTarget) { + // Highlight and scroll right away — neither depends on the child load — + // then pull in the target's own children so its subtree shows. + setActive(best); + best.scrollIntoView({ block: 'center' }); + loadChildren(details); + return; + } + // Ancestor: load its children, then descend toward the target. + loadChildren(details).then(function (childUl) { + if (childUl) step(childUl, target); + }); + } + + function initReveal() { + var tree = document.querySelector('.yang-tree[data-initial-path]'); + if (!tree) return; + var target = tree.getAttribute('data-initial-path'); + tree.removeAttribute('data-initial-path'); // process once + var rootChildren = tree.querySelector('.yt-children'); + if (!target || !rootChildren) return; + // Defer a tick: on an htmx navigation the tree was just swapped in and htmx + // is still settling, so a child load triggered now is dropped (the node + // then highlights only after a manual reload). On a full reload it's + // already settled, so the tick is harmless. + setTimeout(function () { step(rootChildren, target); }, 0); + } + + document.addEventListener('DOMContentLoaded', initReveal); + document.addEventListener('htmx:afterSwap', initReveal); +})(); + // ─── ⓘ field-info tooltip (position:fixed to escape overflow clipping) ─────── (function() { var tip = null; @@ -2109,6 +2205,28 @@ function renderCfgLog() { }); })(); + // cfgLogged records a save in the activity panel only — used when the + // handler swaps the whole block via outerHTML and renders its own inline + // confirmation, so there's no span for the page JS to chase. + document.addEventListener('cfgLogged', function(e) { + cfgLog('ok', e.detail && e.detail.value ? e.detail.value : 'Saved'); + }); + + // cfgApplyError fires when Apply / Apply & Save reaches the device but the + // candidate is rejected (e.g. deleting a still-referenced interface). The + // backend returns 200 + this trigger instead of 502 so the connection + // monitor stays quiet — the box is fine, the config isn't. The toolbar + // optimistically logged success before issuing the request; drop that entry + // and show the real reason. + document.addEventListener('cfgApplyError', function(e) { + var msg = (e.detail && e.detail.value) || 'The device rejected the configuration.'; + if (cfgLogEntries.length && cfgLogEntries[cfgLogEntries.length - 1].level === 'ok') { + cfgLogEntries.pop(); + } + cfgLog('error', msg); + openModal('Configuration not applied: ' + msg, null, { alert: true }); + }); + document.addEventListener('cfgSaved', function(e) { var msg = e.detail && e.detail.value ? e.detail.value : 'Saved'; cfgLog('ok', msg); diff --git a/src/webui/templates/pages/configure-interfaces.html b/src/webui/templates/pages/configure-interfaces.html index 366caa92..6f175a7e 100644 --- a/src/webui/templates/pages/configure-interfaces.html +++ b/src/webui/templates/pages/configure-interfaces.html @@ -1724,7 +1724,7 @@ {{end}} @@ -1852,7 +1852,7 @@ {{end}} \ No newline at end of file diff --git a/src/webui/templates/pages/containers.html b/src/webui/templates/pages/containers.html index d7a3496a..df6abe27 100644 --- a/src/webui/templates/pages/containers.html +++ b/src/webui/templates/pages/containers.html @@ -5,7 +5,13 @@ {{if .Containers}}
    -
    Containers
    +
    Containers + Configure → +
    @@ -45,7 +51,13 @@ {{else if not .Error}}
    -
    Containers
    +
    Containers + Configure → +

    No containers configured.

    {{end}} diff --git a/src/webui/templates/pages/firewall.html b/src/webui/templates/pages/firewall.html index 701ffee5..41701d20 100644 --- a/src/webui/templates/pages/firewall.html +++ b/src/webui/templates/pages/firewall.html @@ -10,7 +10,13 @@
    -
    Firewall Status
    +
    Firewall Status + Configure → +
    diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html index 7892771e..a64789a5 100644 --- a/src/webui/templates/pages/interfaces.html +++ b/src/webui/templates/pages/interfaces.html @@ -9,7 +9,13 @@ {{if .Interfaces}}
    -
    Interfaces
    +
    Interfaces + Configure → +
    Firewall
    @@ -43,7 +49,13 @@ {{else if not .Error}}
    -
    Interfaces
    +
    Interfaces + Configure → +

    No interfaces found.

    {{end}} diff --git a/src/webui/templates/pages/mdns.html b/src/webui/templates/pages/mdns.html index c4a5d124..64770fff 100644 --- a/src/webui/templates/pages/mdns.html +++ b/src/webui/templates/pages/mdns.html @@ -7,77 +7,68 @@
    {{.Error}}
    {{end}} -
    +
    +
    mDNS + Configure → +
    +
    + + + + + + {{if .Allow}}{{end}} + {{if .Deny}}{{end}} + {{if .Reflector}} + + + + + {{end}} +
    Status + + {{.EnabledText}} +
    Domain{{.Domain}}
    Allow{{.Allow}}
    Deny{{.Deny}}
    Reflector Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}
    -
    -
    mDNS Status - Configure → -
    - - - - - - - {{if .Allow}}{{end}} - {{if .Deny}}{{end}} - {{if .Reflector}} - - - - - {{end}} +

    Neighbors

    + {{if .Neighbors}} +
    +
    mDNS - - {{.EnabledText}} -
    Domain{{.Domain}}
    Allow{{.Allow}}
    Deny{{.Deny}}
    Reflector Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}
    + + + + + + + + + + {{range .Neighbors}} + {{$host := .Hostname}} + + + + + + + {{range .ExtraAddrs}} + + + + + + + {{end}} + {{end}} +
    HostnameAddressLast SeenServices
    {{if .ExtraAddrs}} {{end}}{{.Hostname}}{{.PrimaryAddr}}{{.LastSeen}}{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}{{$svc.Label}}{{else}}{{$svc.Label}}({{$svc.Port}}){{end}}{{end}}
    {{.}}
    - - {{if .Neighbors}} -
    -
    Neighbors
    -
    - - - - - - - - - - - {{range .Neighbors}} - {{$host := .Hostname}} - - - - - - - {{range .ExtraAddrs}} - - - - - - - {{end}} - {{end}} - -
    HostnameAddressLast SeenServices
    {{if .ExtraAddrs}} {{end}}{{.Hostname}}{{.PrimaryAddr}}{{.LastSeen}}{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}{{$svc.Label}}{{else}}{{$svc.Label}}({{$svc.Port}}){{end}}{{end}}
    {{.}}
    -
    -
    - {{else if not .Error}} -
    -
    Neighbors
    -

    No mDNS neighbors discovered.

    -
    + {{else}} +

    No mDNS neighbors discovered.

    {{end}} -
    {{end}} diff --git a/src/webui/templates/pages/ntp.html b/src/webui/templates/pages/ntp.html index 17934c79..4d24f7b6 100644 --- a/src/webui/templates/pages/ntp.html +++ b/src/webui/templates/pages/ntp.html @@ -9,7 +9,13 @@ {{if .NTP}}
    -

    NTP

    +
    NTP + Configure → +
    @@ -69,8 +75,8 @@ {{else if not .Error}}
    NTP - Configure → diff --git a/src/webui/templates/pages/vpn.html b/src/webui/templates/pages/vpn.html index b272d58c..e1f0fde6 100644 --- a/src/webui/templates/pages/vpn.html +++ b/src/webui/templates/pages/vpn.html @@ -11,6 +11,11 @@
    {{.Name}}{{if .ListenPort}} :{{.ListenPort}}{{end}} + Configure →
    {{if .Addresses}}
    @@ -51,7 +56,13 @@ {{end}} {{else if not .Error}}
    -
    WireGuard
    +
    WireGuard + Configure → +

    No WireGuard tunnels configured.

    {{end}} diff --git a/src/webui/templates/pages/wifi.html b/src/webui/templates/pages/wifi.html index 58b48553..b6d8a3bd 100644 --- a/src/webui/templates/pages/wifi.html +++ b/src/webui/templates/pages/wifi.html @@ -15,6 +15,11 @@
    {{.Name}}
    + Configure →
    Sync Status
    diff --git a/src/webui/templates/pages/yang-tree.html b/src/webui/templates/pages/yang-tree.html index a21009e4..3da28bdd 100644 --- a/src/webui/templates/pages/yang-tree.html +++ b/src/webui/templates/pages/yang-tree.html @@ -18,7 +18,7 @@ {{/* ── Left pane: navigation tree ───────────────────────── */}}
    {{if .ReadOnly}}View all{{else}}Edit all{{end}}
    -
      +