diff --git a/package/webui/webui.conf b/package/webui/webui.conf index 17111bc2..d6c44654 100644 --- a/package/webui/webui.conf +++ b/package/webui/webui.conf @@ -1,6 +1,6 @@ # Must be at server scope, not on an inner location: client_max_body_size # does not inherit into a nested location that declares its own proxy_pass, -# and the http-level 1m default would silently apply and reject firmware +# and the http-level 1m default would silently apply and reject bundle # uploads with 413. client_max_body_size 256m; @@ -19,11 +19,21 @@ location = /login { # the Go handler's response close, producing RST instead of FIN at # Content-Length and a client-visible 502. nginx spools the body to # /var/cache/nginx/client-body during the upload instead. -location = /firmware/upload { +location = /software/upload { proxy_read_timeout 600s; include /etc/nginx/webui-proxy.conf; } +# SSE progress stream: RAUC's Progress D-Bus property doesn't change while +# it's writing a slot, so the upstream goes minutes without a frame. The +# default 60 s proxy_read_timeout closes the stream and the browser sees +# a transient error. Raise to cover a slow image write end-to-end. +location = /software/progress { + proxy_read_timeout 1800s; + proxy_buffering off; + include /etc/nginx/webui-proxy.conf; +} + # Liveness probe — nginx-only, no upstream call. Used by the watchdog # div in base.html and the reboot-overlay poller. location = /device-status { diff --git a/src/webui/internal/handlers/common.go b/src/webui/internal/handlers/common.go index 679fe666..cc024852 100644 --- a/src/webui/internal/handlers/common.go +++ b/src/webui/internal/handlers/common.go @@ -5,6 +5,8 @@ package handlers import ( "context" "net/http" + "strconv" + "strings" "infix/webui/internal/restconf" "infix/webui/internal/security" @@ -30,7 +32,44 @@ func csrfToken(ctx context.Context) string { return security.TokenFromContext(ctx) } -func newPageData(r *http.Request, page, title string) PageData { +// pageContext returns the top-level nav group ("Status", "Configure", +// "Maintenance") for a given ActivePage slug. Used to build breadcrumb-style +// browser-tab titles ("Page · Context") without each handler having to know +// where it lives in the sidebar. +func pageContext(page string) string { + switch page { + case "software", "backup", "system-control": + return "Maintenance" + } + if strings.HasPrefix(page, "configure-") { + return "Configure" + } + return "Status" +} + +func newPageData(w http.ResponseWriter, r *http.Request, page, leaf string) PageData { + title := leaf + if ctx := pageContext(page); ctx != "" { + if leaf == "" { + title = ctx + } else { + title = leaf + " · " + ctx + } + } + + // On HTMX swaps only #content is replaced, leaving the element in + // <head> stale. Fire a setPageTitle event so the JS listener in app.js + // can update document.title. Safe to overwrite any prior HX-Trigger + // header: only GET handlers reach newPageData, and those don't share + // response paths with the save-side helpers (renderSaved / + // renderSaveError) that also use HX-Trigger. + // strconv.QuoteToASCII escapes non-ASCII as \uXXXX so the header value + // survives transit as 7-bit ASCII; browsers decode header bytes as + // ISO-8859-1, which would otherwise turn our middle-dot separator into + // mojibake on the JS side. + if r.Header.Get("HX-Request") == "true" { + w.Header().Set("HX-Trigger", `{"setPageTitle":`+strconv.QuoteToASCII(title)+`}`) + } return PageData{ Username: restconf.CredentialsFromContext(r.Context()).Username, CsrfToken: csrfToken(r.Context()), diff --git a/src/webui/internal/handlers/configure.go b/src/webui/internal/handlers/configure.go index 1fde087b..e7d7c054 100644 --- a/src/webui/internal/handlers/configure.go +++ b/src/webui/internal/handlers/configure.go @@ -119,7 +119,7 @@ func (h *ConfigureHandler) ApplyAndSave(w http.ResponseWriter, r *http.Request) } // DeleteLeaf removes a single leaf from the candidate datastore so the YANG -// default takes effect. Used by curated-page ↺ reset buttons. +// default takes effect. Used by curated-page reset buttons. // DELETE /configure/leaf?path=...&redirect=... func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { path := r.URL.Query().Get("path") @@ -128,7 +128,9 @@ func (h *ConfigureHandler) DeleteLeaf(w http.ResponseWriter, r *http.Request) { http.Error(w, "path and redirect required", http.StatusBadRequest) return } - if err := h.RC.Delete(r.Context(), candidatePath+path); err != nil { + // Swallow data-missing: the leaf was already absent, so the reset + // semantically succeeded — there was nothing left to remove. + if err := h.RC.Delete(r.Context(), candidatePath+path); err != nil && !restconf.IsDataMissing(err) { renderSaveError(w, err) return } diff --git a/src/webui/internal/handlers/configure_firewall.go b/src/webui/internal/handlers/configure_firewall.go index 6afae30b..1f477dfe 100644 --- a/src/webui/internal/handlers/configure_firewall.go +++ b/src/webui/internal/handlers/configure_firewall.go @@ -97,7 +97,7 @@ type ConfigureFirewallHandler struct { // GET /configure/firewall func (h *ConfigureFirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { data := cfgFirewallPageData{ - PageData: newPageData(r, "configure-firewall", "Configure: Firewall"), + PageData: newPageData(w, r, "configure-firewall", "Firewall"), } mgr := h.Schema.Manager() diff --git a/src/webui/internal/handlers/configure_hardware.go b/src/webui/internal/handlers/configure_hardware.go index d555666c..ec447656 100644 --- a/src/webui/internal/handlers/configure_hardware.go +++ b/src/webui/internal/handlers/configure_hardware.go @@ -102,7 +102,7 @@ type ConfigureHardwareHandler struct { // GET /configure/hardware func (h *ConfigureHardwareHandler) Overview(w http.ResponseWriter, r *http.Request) { data := cfgHardwarePageData{ - PageData: newPageData(r, "configure-hardware", "Configure: Hardware"), + PageData: newPageData(w, r, "configure-hardware", "Hardware"), } mgr := h.Schema.Manager() diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index 7fd4ad59..e9ceb900 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -123,6 +123,11 @@ type cfgIfaceRow struct { EthDuplex string // "" / "full" / "half" EthAdvertised []string // identityref leaf-list, empty = advertise all EthSupported []string // identityref leaf-list from operational data + // DHCP enabled flags — captured BEFORE the placeholder DHCP/DHCPv6 + // containers are seeded so the template can tell "configured" from + // "auto-injected placeholder" apart. + DHCPv4Enabled bool + DHCPv6Enabled bool } // ifaceRadioMirror is the subset of wifi-radio fields we expose on the @@ -204,7 +209,7 @@ type ConfigureInterfacesHandler struct { // GET /configure/interfaces func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { data := cfgIfacePageData{ - PageData: newPageData(r, "configure-interfaces", "Configure: Interfaces"), + PageData: newPageData(w, r, "configure-interfaces", "Interfaces"), } mgr := h.Schema.Manager() @@ -224,6 +229,7 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req "description": schema.DescriptionOf(mgr, ifPath+"/description"), "type": schema.DescriptionOf(mgr, ifPath+"/type"), "enabled": schema.DescriptionOf(mgr, ifPath+"/enabled"), + "mac": descOr(mgr, ifPath+"/infix-interfaces:custom-phys-address/static", "Override the interface's default physical (MAC) address with a static unicast value."), "bridge-type": descOr(mgr, ifPath+bPath+"/vlans", "Presence of bridge/vlans switches the bridge into IEEE 802.1Q VLAN-filtering mode. Pick this if downstream ports need PVID and tagged/untagged membership."), "stp-force": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/force-protocol"), "stp-hello": schema.DescriptionOf(mgr, ifPath+bPath+"/stp/hello-time"), @@ -242,10 +248,12 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req "ipv4-prefix": schema.DescriptionOf(mgr, ifPath+ip4+"/address/prefix-length"), "ipv4-dhcp": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp"), "ipv4-autoconf": schema.DescriptionOf(mgr, ifPath+ip4+"/infix-ip:autoconf"), + "ipv4-forwarding": schema.DescriptionOf(mgr, ifPath+ip4+"/forwarding"), "ipv6-address": schema.DescriptionOf(mgr, ifPath+ip6+"/address/ip"), "ipv6-prefix": schema.DescriptionOf(mgr, ifPath+ip6+"/address/prefix-length"), "ipv6-slaac": schema.DescriptionOf(mgr, ifPath+ip6+"/autoconf"), "ipv6-dhcp": schema.DescriptionOf(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp"), + "ipv6-forwarding": schema.DescriptionOf(mgr, ifPath+ip6+"/forwarding"), // Add Interface wizard — augment paths still don't resolve // through goyang (same gap as LAG mode), so each entry is @@ -273,6 +281,15 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req "eth-advertised": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/auto-negotiation/infix-ethernet-interface:advertised-pmd-types", "Restrict auto-negotiation to advertise only these PMD types. Leave empty to advertise every mode the PHY supports."), "eth-duplex": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/duplex", "Force half- or full-duplex. Leave on Auto to let auto-negotiation pick. Modern PMDs are full-duplex only."), "eth-mdix": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/infix-ethernet-interface:mdi-x", "Force the copper MDI/MDI-X crossover pinout. Leave on Auto-MDIX (default) for any link that negotiates. Force MDI/MDI-X only when negotiation is disabled and the two ends must use opposite values."), + "bp-bridge": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/bridge", "Bridge that this port joins as a member, carrying L2 traffic on its behalf."), + "bp-pvid": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/pvid", "Port VLAN ID — VLAN assigned to untagged frames arriving on this port. Only meaningful when the parent bridge is in IEEE 802.1Q VLAN-filtering mode."), + "bp-flood": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/flood", "Per-traffic-class control of how unknown destinations are flooded out this port. Unticking suppresses flooding of that traffic class."), + "bp-mc-router": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/router", "Multicast router behaviour on this port. Auto (default) lets IGMP/MLD snooping decide; permanent forces the port to always receive multicast; off blocks it."), + "bp-mc-fast-leave": descOr(mgr, ifPath+"/infix-interfaces:bridge-port/multicast/fast-leave", "Drop the port from a multicast group immediately on IGMP/MLD leave instead of waiting for the next query. Suitable when each port has at most one receiver."), + "lp-lag": descOr(mgr, ifPath+"/infix-interfaces:lag-port/lag", "LAG that this port joins as a slave. The LAG itself is configured on its own row."), + "mc-snoop": descOr(mgr, ifPath+bPath+"/multicast/snooping", "Enable IGMP/MLD snooping on the bridge so multicast is forwarded only to ports with active receivers."), + "mc-querier": descOr(mgr, ifPath+bPath+"/multicast/querier", "Querier role: auto (only when no other querier is heard), on (always send queries), or off."), + "mc-query-int": descOr(mgr, ifPath+bPath+"/multicast/query-interval", "Interval (seconds) between IGMP/MLD general queries when this bridge is acting as querier."), } if data.Desc["ipv6-slaac"] == "" { data.Desc["ipv6-slaac"] = "SLAAC (Stateless Address Autoconfiguration, RFC 4862) " + @@ -1101,7 +1118,9 @@ func (h *ConfigureInterfacesHandler) DeleteInterface(w http.ResponseWriter, r *h renderSavedRedirect(w, name+" deleted", "/configure/interfaces") } -// SaveGeneral saves description and enabled for any interface. +// 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. // POST /configure/interfaces/{name} func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { @@ -1122,7 +1141,26 @@ func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http. renderSaveError(w, err) return } - renderSavedRedirect(w, "Saved", "/configure/interfaces") + + macPath := ifacePath(name) + "/infix-interfaces:custom-phys-address" + if mac := strings.TrimSpace(r.FormValue("mac")); mac != "" { + macBody := map[string]any{ + "infix-interfaces:custom-phys-address": map[string]any{"static": mac}, + } + if err := h.RC.Put(r.Context(), macPath, macBody); err != nil { + log.Printf("configure interfaces %s mac: %v", name, err) + renderSaveError(w, err) + return + } + } else { + if err := h.RC.Delete(r.Context(), macPath); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure interfaces %s mac clear: %v", name, err) + renderSaveError(w, err) + return + } + } + + renderSaved(w, "Saved") } // AddIPv4 adds an IPv4 address to an interface. @@ -1233,7 +1271,7 @@ func (h *ConfigureInterfacesHandler) SaveEthernet(w http.ResponseWriter, r *http return } - renderSavedRedirect(w, "Ethernet saved", "/configure/interfaces") + renderSaved(w, "Ethernet saved") } // ResetEthernetAdvertised clears the advertised-pmd-types leaf-list while @@ -1269,7 +1307,7 @@ func (h *ConfigureInterfacesHandler) ResetEthernetAdvertised(w http.ResponseWrit renderSaveError(w, err) return } - renderSavedRedirect(w, "Reset to default", "/configure/interfaces") + renderSaved(w, "Reset to default") } func (h *ConfigureInterfacesHandler) SaveBridgePort(w http.ResponseWriter, r *http.Request) { @@ -1290,7 +1328,7 @@ func (h *ConfigureInterfacesHandler) SaveBridgePort(w http.ResponseWriter, r *ht renderSaveError(w, err) return } - renderSavedRedirect(w, "Bridge port saved", "/configure/interfaces") + renderSaved(w, "Bridge port saved") } // buildBridgePortBody assembles the bridge-port augment from form @@ -1384,20 +1422,9 @@ func unwrapSingleInterface(doc map[string]any) (map[string]any, error) { return iface, nil } -// SaveBridgeMembers performs a diff-and-write to set the bridge's member ports. -// POST /configure/interfaces/{name}/bridge/members -func (h *ConfigureInterfacesHandler) SaveBridgeMembers(w http.ResponseWriter, r *http.Request) { - if err := r.ParseForm(); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - h.saveMembersDiff(w, r, r.PathValue("name"), "bridge", - func(iface ifaceJSON, master string) bool { - return iface.BridgePort != nil && iface.BridgePort.Bridge == master - }, "Bridge members saved") -} - -// SaveBridge saves bridge STP settings and bridge type. +// SaveBridge saves bridge type and member ports in one round trip from the +// unified "Bridge Settings" form. STP/multicast keep their own foldout +// forms; this handler covers what used to be two side-by-side forms. // POST /configure/interfaces/{name}/bridge func (h *ConfigureInterfacesHandler) SaveBridge(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { @@ -1452,7 +1479,15 @@ func (h *ConfigureInterfacesHandler) SaveBridge(w http.ResponseWriter, r *http.R } } - renderSavedRedirect(w, "Bridge saved", "/configure/interfaces") + if err := h.applyMembersDiff(r, name, "bridge", + func(iface ifaceJSON, master string) bool { + return iface.BridgePort != nil && iface.BridgePort.Bridge == master + }); err != nil { + renderSaveError(w, err) + return + } + + renderSaved(w, "Bridge saved") } // AddVLAN creates a new VLAN on an ieee8021q bridge. @@ -1519,7 +1554,7 @@ func (h *ConfigureInterfacesHandler) SaveVLAN(w http.ResponseWriter, r *http.Req renderSaveError(w, err) return } - renderSavedRedirect(w, "VLAN saved", "/configure/interfaces") + renderSaved(w, "VLAN saved") } // DeleteVLAN removes a VLAN from an ieee8021q bridge. @@ -1557,7 +1592,7 @@ func (h *ConfigureInterfacesHandler) SaveLagPort(w http.ResponseWriter, r *http. renderSaveError(w, err) return } - renderSavedRedirect(w, "LAG port saved", "/configure/interfaces") + renderSaved(w, "LAG port saved") } // SaveBridgeSTP PATCHes the bridge STP container. Split out from @@ -1592,7 +1627,7 @@ func (h *ConfigureInterfacesHandler) SaveBridgeSTP(w http.ResponseWriter, r *htt renderSaveError(w, err) return } - renderSavedRedirect(w, "STP saved", "/configure/interfaces") + renderSaved(w, "STP saved") } // SaveBridgeMulticast PATCHes the bridge multicast snooping container. @@ -1621,7 +1656,7 @@ func (h *ConfigureInterfacesHandler) SaveBridgeMulticast(w http.ResponseWriter, renderSaveError(w, err) return } - renderSavedRedirect(w, "Multicast saved", "/configure/interfaces") + renderSaved(w, "Multicast saved") } // SaveWifi PATCHes the WiFi interface container plus, when the form @@ -1692,7 +1727,7 @@ func (h *ConfigureInterfacesHandler) SaveWifi(w http.ResponseWriter, r *http.Req renderSaveError(w, err) return } - renderSavedRedirect(w, "WiFi saved", "/configure/interfaces") + renderSaved(w, "WiFi saved") } // DeleteLagPort detaches an interface from its LAG. @@ -1737,7 +1772,7 @@ func (h *ConfigureInterfacesHandler) SaveLAG(w http.ResponseWriter, r *http.Requ renderSaveError(w, err) return } - renderSavedRedirect(w, "LAG saved", "/configure/interfaces") + renderSaved(w, "LAG saved") } // SaveLAGMembers performs a diff-and-write to set the LAG's member ports. @@ -1788,11 +1823,22 @@ func indexWifiRadios(comps []hwComponentJSON) map[string]*ifaceRadioMirror { // kind is "bridge" or "lag"; it determines the YANG augment path and body key. func (h *ConfigureInterfacesHandler) saveMembersDiff(w http.ResponseWriter, r *http.Request, masterName, kind string, isMember func(ifaceJSON, string) bool, successMsg string) { + if err := h.applyMembersDiff(r, masterName, kind, isMember); err != nil { + renderSaveError(w, err) + return + } + renderSaved(w, successMsg) +} + +// applyMembersDiff is the no-response-writing core of saveMembersDiff so it +// can be reused by callers that compose multiple save steps (e.g. SaveBridge +// which writes type + members in one form submission). +func (h *ConfigureInterfacesHandler) applyMembersDiff(r *http.Request, + masterName, kind string, isMember func(ifaceJSON, string) bool) error { ifaces, err := h.fetchAllInterfaces(r.Context()) if err != nil { - renderSaveError(w, err) - return + return err } submitted := make(map[string]bool) @@ -1813,18 +1859,16 @@ func (h *ConfigureInterfacesHandler) saveMembersDiff(w http.ResponseWriter, r *h body := map[string]any{portKey: map[string]any{kind: masterName}} if err := h.RC.Put(r.Context(), portPath, body); err != nil { log.Printf("configure interfaces %s members add %s→%s: %v", kind, iface.Name, masterName, err) - renderSaveError(w, err) - return + return err } } else if !wantMember && currentlyMember { if err := h.RC.Delete(r.Context(), portPath); err != nil { log.Printf("configure interfaces %s members remove %s from %s: %v", kind, iface.Name, masterName, err) - renderSaveError(w, err) - return + return err } } } - renderSavedRedirect(w, successMsg, "/configure/interfaces") + return nil } // unconfiguredPhysical returns physical Ethernet interfaces present in @@ -1953,6 +1997,31 @@ func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON, oper []ifaceJ row.ParentBridgeIs8021Q = bridgeIs8021Q[iface.BridgePort.Bridge] } row.HasIP = !row.IsBridgePort && !row.IsLagPort + // The DHCP/DHCPv6 foldouts are always rendered so users can + // discover the settings form before enabling the client. The + // foldout body iterates .IPv4.DHCP / .IPv6.DHCPv6 — populate + // placeholders here so the template can use .IPv4.DHCP.X without + // a forest of nil guards. Whether DHCP is actually configured is + // captured separately on .DHCPv4Enabled / .DHCPv6Enabled so the + // checkbox state and foldout-hidden gate still reflect candidate. + if row.HasIP { + if row.IPv4 == nil { + row.IPv4 = &ipCfg{} + } else { + row.DHCPv4Enabled = row.IPv4.DHCP != nil + } + if row.IPv4.DHCP == nil { + row.IPv4.DHCP = &dhcpv4CfgJSON{} + } + if row.IPv6 == nil { + row.IPv6 = &ipCfg{} + } else { + row.DHCPv6Enabled = row.IPv6.DHCPv6 != nil + } + if row.IPv6.DHCPv6 == nil { + row.IPv6.DHCPv6 = &dhcpv6CfgJSON{} + } + } row.AddrSummary = addrSummary(iface) if row.IsBridge { @@ -2076,40 +2145,67 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R renderSavedRedirect(w, "Address removed", "/configure/interfaces") } -// SaveIPv4DHCP enables or disables the DHCPv4 client presence container. -// POST /configure/interfaces/{name}/ipv4/dhcp -func (h *ConfigureInterfacesHandler) SaveIPv4DHCP(w http.ResponseWriter, r *http.Request) { - h.togglePresence(w, r, - ifacePath(r.PathValue("name"))+"/ietf-ip:ipv4/infix-dhcp-client:dhcp", - "infix-dhcp-client:dhcp", - "DHCP client") +// SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding +// leaf plus the DHCP-client and link-local autoconf presence containers — in +// a single round trip from the IPv4 settings form. Each presence container is +// PUT (enable) or DELETE (disable) per checkbox state; forwarding is PATCHed. +// POST /configure/interfaces/{name}/ipv4/settings +func (h *ConfigureInterfacesHandler) SaveIPv4Settings(w http.ResponseWriter, r *http.Request) { + h.saveIPSettings(w, r, "ietf-ip:ipv4", "IPv4", map[string]string{ + "dhcp": "infix-dhcp-client:dhcp", + "autoconf": "infix-ip:autoconf", + }) } -// SaveIPv4Autoconf enables or disables IPv4 link-local autoconfiguration. -// POST /configure/interfaces/{name}/ipv4/autoconf -func (h *ConfigureInterfacesHandler) SaveIPv4Autoconf(w http.ResponseWriter, r *http.Request) { - h.togglePresence(w, r, - ifacePath(r.PathValue("name"))+"/ietf-ip:ipv4/infix-ip:autoconf", - "infix-ip:autoconf", - "IPv4 link-local") +// SaveIPv6Settings is the IPv6 counterpart of SaveIPv4Settings. SLAAC lives at +// the standard ietf-ip "autoconf" container; DHCPv6 is the Infix augment. +// POST /configure/interfaces/{name}/ipv6/settings +func (h *ConfigureInterfacesHandler) SaveIPv6Settings(w http.ResponseWriter, r *http.Request) { + h.saveIPSettings(w, r, "ietf-ip:ipv6", "IPv6", map[string]string{ + "dhcp": "infix-dhcpv6-client:dhcp", + "slaac": "autoconf", + }) } -// SaveIPv6SLAAC enables or disables IPv6 SLAAC (autoconf). -// POST /configure/interfaces/{name}/ipv6/autoconf -func (h *ConfigureInterfacesHandler) SaveIPv6SLAAC(w http.ResponseWriter, r *http.Request) { - h.togglePresence(w, r, - ifacePath(r.PathValue("name"))+"/ietf-ip:ipv6/autoconf", - "autoconf", - "IPv6 SLAAC") -} +// saveIPSettings is the shared body of SaveIPv4Settings / SaveIPv6Settings. +// presenceMap maps form-field names (e.g. "dhcp") to their YANG presence +// container key (e.g. "infix-dhcp-client:dhcp"); each one is PUT when checked +// and DELETEd otherwise. Forwarding is always PATCHed. +func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *http.Request, container, family string, presenceMap map[string]string) { + if err := r.ParseForm(); err != nil { + http.Error(w, "bad request", http.StatusBadRequest) + return + } + name := r.PathValue("name") + base := ifacePath(name) + "/" + container -// SaveIPv6DHCP enables or disables the DHCPv6 client presence container. -// POST /configure/interfaces/{name}/ipv6/dhcp -func (h *ConfigureInterfacesHandler) SaveIPv6DHCP(w http.ResponseWriter, r *http.Request) { - h.togglePresence(w, r, - ifacePath(r.PathValue("name"))+"/ietf-ip:ipv6/infix-dhcpv6-client:dhcp", - "infix-dhcpv6-client:dhcp", - "DHCPv6 client") + forwarding := r.FormValue("forwarding") == "true" + body := map[string]any{container: map[string]any{"forwarding": forwarding}} + if err := h.RC.Patch(r.Context(), base, body); err != nil { + log.Printf("configure interfaces %s %s settings: forwarding: %v", name, family, err) + renderSaveError(w, err) + return + } + + for field, child := range presenceMap { + path := base + "/" + child + if r.FormValue(field) == "true" { + b := map[string]any{child: map[string]any{}} + if err := h.RC.Put(r.Context(), path, b); err != nil { + log.Printf("configure interfaces %s %s settings: enable %s: %v", name, family, field, err) + renderSaveError(w, err) + return + } + } else { + if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) { + log.Printf("configure interfaces %s %s settings: disable %s: %v", name, family, field, err) + renderSaveError(w, err) + return + } + } + } + + renderSaved(w, family+" settings saved") } func (h *ConfigureInterfacesHandler) SaveIPv4DHCPSettings(w http.ResponseWriter, r *http.Request) { @@ -2218,29 +2314,6 @@ func (h *ConfigureInterfacesHandler) DeleteIPv6DHCPOption(w http.ResponseWriter, h.deleteDHCPOption(w, r, "ietf-ip:ipv6/infix-dhcpv6-client:dhcp") } -func (h *ConfigureInterfacesHandler) togglePresence(w http.ResponseWriter, r *http.Request, path, bodyKey, label string) { - if err := r.ParseForm(); err != nil { - http.Error(w, "bad request", http.StatusBadRequest) - return - } - name := r.PathValue("name") - if r.FormValue("enabled") == "true" { - body := map[string]any{bodyKey: map[string]any{}} - if err := h.RC.Put(r.Context(), path, body); err != nil { - log.Printf("configure interfaces %s enable %s: %v", name, label, err) - renderSaveError(w, err) - return - } - } else { - if err := h.RC.Delete(r.Context(), path); err != nil && !restconf.IsNotFound(err) { - log.Printf("configure interfaces %s disable %s: %v", name, label, err) - renderSaveError(w, err) - return - } - } - renderSavedRedirect(w, label+" updated", "/configure/interfaces") -} - func typeSlug(yangType string) string { s := schema.StripModulePrefix(yangType) // Normalise iana-if-type identities to infix slugs where relevant. @@ -2339,7 +2412,7 @@ type wifiRadioOption struct { // pulldown. Sorted by typeOrder then alphabetical. Includes types whose // Create path is not yet implemented (gre, gretap, vxlan, wireguard, // wifi, …) — the modal's "unsupported" panel handles those by pointing -// the user at the Advanced YANG tree. +// the user at the Edit-all YANG tree. // buildWifiRadioOptions filters detected hardware components down to // configured WiFi radios (class=wifi with a wifi-radio container present // in running config) and returns picker entries with a label that hints diff --git a/src/webui/internal/handlers/configure_keystore.go b/src/webui/internal/handlers/configure_keystore.go index 88705d6c..9ca36f53 100644 --- a/src/webui/internal/handlers/configure_keystore.go +++ b/src/webui/internal/handlers/configure_keystore.go @@ -33,9 +33,15 @@ type cfgKeystorePageData struct { } type cfgSymKeyEntry struct { - Name string - Format string - Value string + Name string + Format string // short label shown in the read-only column ("passphrase") + FormatID string // raw identityref ("infix-crypto-types:passphrase-key-format") + // — Format is shortFormat-stripped for display, FormatID is what the + // edit-row <select> needs to match its <option value=...> against. + // Comparing the two in the template silently mismatches and the + // browser falls back to the first <option>, rewriting the key with + // the wrong format on Save. + Value string } type cfgCertEntry struct { @@ -63,7 +69,7 @@ type ConfigureKeystoreHandler struct { // GET /configure/keystore func (h *ConfigureKeystoreHandler) Overview(w http.ResponseWriter, r *http.Request) { data := cfgKeystorePageData{ - PageData: newPageData(r, "configure-keystore", "Configure: Keystore"), + PageData: newPageData(w, r, "configure-keystore", "Keystore"), } var ks keystoreWrapper @@ -79,9 +85,10 @@ func (h *ConfigureKeystoreHandler) Overview(w http.ResponseWriter, r *http.Reque for _, k := range ks.Keystore.SymmetricKeys.SymmetricKey { data.SymmetricKeys = append(data.SymmetricKeys, cfgSymKeyEntry{ - Name: k.Name, - Format: shortFormat(k.KeyFormat), - Value: decodeSymmetricValue(k), + Name: k.Name, + Format: shortFormat(k.KeyFormat), + FormatID: k.KeyFormat, + Value: decodeSymmetricValue(k), }) } for _, k := range ks.Keystore.AsymmetricKeys.AsymmetricKey { diff --git a/src/webui/internal/handlers/configure_routes.go b/src/webui/internal/handlers/configure_routes.go index 3a457ab3..e1f679d0 100644 --- a/src/webui/internal/handlers/configure_routes.go +++ b/src/webui/internal/handlers/configure_routes.go @@ -79,18 +79,18 @@ type cfgRoutesPageData struct { // ─── Handler ───────────────────────────────────────────────────────────────── -// ConfigureRoutesHandler serves the Configure > Routes page. +// ConfigureRoutesHandler serves the Configure > Routing page. type ConfigureRoutesHandler struct { Template *template.Template RC restconf.Fetcher Schema *schema.Cache } -// Overview renders the Configure > Routes page reading from the candidate. +// Overview renders the Configure > Routing page reading from the candidate. // GET /configure/routes func (h *ConfigureRoutesHandler) Overview(w http.ResponseWriter, r *http.Request) { data := cfgRoutesPageData{ - PageData: newPageData(r, "configure-routes", "Configure: Routes"), + PageData: newPageData(w, r, "configure-routes", "Routing"), } mgr := h.Schema.Manager() diff --git a/src/webui/internal/handlers/configure_system.go b/src/webui/internal/handlers/configure_system.go index be4b15f3..719240e1 100644 --- a/src/webui/internal/handlers/configure_system.go +++ b/src/webui/internal/handlers/configure_system.go @@ -9,6 +9,7 @@ import ( "net/http" "strconv" "strings" + "sync" "infix/webui/internal/restconf" "infix/webui/internal/schema" @@ -70,16 +71,15 @@ type cfgDNSAddrJSON struct { type cfgSystemPageData struct { PageData - Loading bool // true while YANG schema is still downloading - Error string - Hostname string - Contact string - Location string - Timezone string - NTP cfgNTPJSON - DNS cfgDNSJSON - MotdBanner string // decoded from YANG binary - TextEditor string // e.g. "infix-system:emacs" + Loading bool // true while YANG schema is still downloading + Error string + Hostname string + Contact string + Location string + Timezone string + CurrentDatetime string // device clock from system-state, empty when unavailable + MotdBanner string // decoded from YANG binary + TextEditor string // e.g. "infix-system:emacs" // Schema-enriched fields — only populated when Loading is false. TextEditorOptions []schema.IdentityOption @@ -87,47 +87,116 @@ type cfgSystemPageData struct { Desc map[string]string // leaf name → YANG description } +type cfgNTPPageData struct { + PageData + Error string + NTP cfgNTPJSON +} + +type cfgDNSPageData struct { + PageData + Error string + DNS cfgDNSJSON +} + // ─── Handler ───────────────────────────────────────────────────────────────── -// ConfigureSystemHandler serves the Configure > System page. +// ConfigureSystemHandler serves the Configure > General, NTP Client, and +// DNS Client pages, all of which share the same candidate-datastore source. type ConfigureSystemHandler struct { - Template *template.Template - RC restconf.Fetcher - Schema *schema.Cache + Template *template.Template + NTPTemplate *template.Template + DNSTemplate *template.Template + RC restconf.Fetcher + Schema *schema.Cache } const candidatePath = "/ds/ietf-datastores:candidate" -// Overview renders the Configure > System page reading from the candidate datastore. -// GET /configure/system -func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) { - data := cfgSystemPageData{ - PageData: newPageData(r, "configure-system", "Configure: System"), - } - +// loadSystem reads /ietf-system:system from the candidate datastore, falling +// back to running when candidate is uninitialised. The returned errMsg is +// non-empty only for real errors that should surface to the user. +func (h *ConfigureSystemHandler) loadSystem(r *http.Request) (cfgSystemJSON, string) { var raw cfgSystemWrapper if err := h.RC.Get(r.Context(), candidatePath+"/ietf-system:system", &raw); err != nil { if !restconf.IsNotFound(err) { log.Printf("configure system: %v", err) - data.Error = "Could not read candidate configuration" - } else if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) { - // Candidate not initialised — fall back to running; only real errors surface. + return cfgSystemJSON{}, "Could not read candidate configuration" + } + if fallErr := h.RC.Get(r.Context(), "/data/ietf-system:system", &raw); fallErr != nil && !restconf.IsNotFound(fallErr) { log.Printf("configure system (running fallback): %v", fallErr) - data.Error = "Could not read system configuration" + return cfgSystemJSON{}, "Could not read system configuration" } } - if data.Error == "" { - s := raw.System + return raw.System, "" +} + +// render swaps the root template for "content" on HTMX requests so a partial +// reply skips the base shell. pageName is the same key passed to newPageData +// (e.g. "configure-ntp") — the corresponding template file is "<pageName>.html". +func (h *ConfigureSystemHandler) render(w http.ResponseWriter, r *http.Request, tmpl *template.Template, pageName string, data any) { + tmplName := pageName + ".html" + if r.Header.Get("HX-Request") == "true" { + tmplName = "content" + } + if err := tmpl.ExecuteTemplate(w, tmplName, data); err != nil { + log.Printf("template error: %v", err) + http.Error(w, "Internal server error", http.StatusInternalServerError) + } +} + +// Overview renders the Configure > General page (identity, clock, preferences). +// GET /configure/system +func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request) { + data := cfgSystemPageData{ + PageData: newPageData(w, r, "configure-system", "General"), + } + + // Fetch candidate config and operational clock in parallel — they hit + // different RESTCONF resources and the round-trips are independent. + var ( + s cfgSystemJSON + errMsg string + clockResp struct { + SystemState struct { + Clock struct { + CurrentDatetime string `json:"current-datetime"` + } `json:"clock"` + } `json:"ietf-system:system-state"` + } + clockErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { defer wg.Done(); s, errMsg = h.loadSystem(r) }() + go func() { + defer wg.Done() + clockErr = h.RC.Get(r.Context(), "/data/ietf-system:system-state/clock", &clockResp) + }() + wg.Wait() + + data.Error = errMsg + if errMsg == "" { data.Hostname = s.Hostname data.Contact = s.Contact data.Location = s.Location data.Timezone = s.Clock.TimezoneName - data.NTP = s.NTP - data.DNS = s.DNS data.MotdBanner = string(s.MotdBanner) data.TextEditor = s.TextEditor } + // Operational clock for the Date & Time card. Best-effort; the + // template renders "Device clock unavailable" when CurrentDatetime + // stays empty. Truncate the RFC 3339 offset; the Timezone row + // below gives the user the zone context. + if clockErr == nil { + dt := clockResp.SystemState.Clock.CurrentDatetime + if len(dt) > 19 { + dt = dt[:19] + } + data.CurrentDatetime = dt + } + mgr := h.Schema.Manager() data.Loading = mgr == nil if mgr != nil { @@ -154,14 +223,35 @@ func (h *ConfigureSystemHandler) Overview(w http.ResponseWriter, r *http.Request } } - tmplName := "configure-system.html" - if r.Header.Get("HX-Request") == "true" { - tmplName = "content" + h.render(w, r, h.Template, "configure-system", data) +} + +// OverviewNTP renders the Configure > NTP Client page. +// GET /configure/ntp +func (h *ConfigureSystemHandler) OverviewNTP(w http.ResponseWriter, r *http.Request) { + data := cfgNTPPageData{ + PageData: newPageData(w, r, "configure-ntp", "NTP Client"), } - if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil { - log.Printf("template error: %v", err) - http.Error(w, "Internal server error", http.StatusInternalServerError) + s, errMsg := h.loadSystem(r) + data.Error = errMsg + if errMsg == "" { + data.NTP = s.NTP } + h.render(w, r, h.NTPTemplate, "configure-ntp", data) +} + +// OverviewDNS renders the Configure > DNS Client page. +// GET /configure/dns +func (h *ConfigureSystemHandler) OverviewDNS(w http.ResponseWriter, r *http.Request) { + data := cfgDNSPageData{ + PageData: newPageData(w, r, "configure-dns", "DNS Client"), + } + s, errMsg := h.loadSystem(r) + data.Error = errMsg + if errMsg == "" { + data.DNS = s.DNS + } + h.render(w, r, h.DNSTemplate, "configure-dns", data) } // SaveIdentity patches hostname / contact / location to the candidate datastore. @@ -195,6 +285,20 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques return } + // Empty value = the "UTC (default)" placeholder. Treat it as a leaf + // delete so the candidate matches Infix's "unset means UTC" convention; + // swallow data-missing for idempotency (the leaf may already be absent). + if r.FormValue("timezone") == "" { + err := h.RC.Delete(r.Context(), candidatePath+"/ietf-system:system/clock/timezone-name") + if err != nil && !restconf.IsDataMissing(err) { + log.Printf("configure system clock: %v", err) + renderSaveError(w, err) + return + } + renderSaved(w, "Timezone saved") + return + } + body := map[string]any{ "ietf-system:system": map[string]any{ "clock": map[string]any{ @@ -207,7 +311,7 @@ func (h *ConfigureSystemHandler) SaveClock(w http.ResponseWriter, r *http.Reques renderSaveError(w, err) return } - renderSaved(w, "Clock saved") + renderSaved(w, "Timezone saved") } // SaveNTP replaces the NTP server list in the candidate datastore. diff --git a/src/webui/internal/handlers/configure_users.go b/src/webui/internal/handlers/configure_users.go index b77e4737..4d674576 100644 --- a/src/webui/internal/handlers/configure_users.go +++ b/src/webui/internal/handlers/configure_users.go @@ -84,7 +84,7 @@ const nacmGroupsPath = candidatePath + "/ietf-netconf-acm:nacm/groups" // GET /configure/users func (h *ConfigureUsersHandler) Overview(w http.ResponseWriter, r *http.Request) { data := cfgUsersPageData{ - PageData: newPageData(r, "configure-users", "Configure: Users & Groups"), + PageData: newPageData(w, r, "configure-users", "Users & Groups"), } // Read via the full system path (same as configure-system) to avoid diff --git a/src/webui/internal/handlers/containers.go b/src/webui/internal/handlers/containers.go index 313c031c..14d55216 100644 --- a/src/webui/internal/handlers/containers.go +++ b/src/webui/internal/handlers/containers.go @@ -81,7 +81,7 @@ type ContainersHandler struct { // Overview renders the containers list page. func (h *ContainersHandler) Overview(w http.ResponseWriter, r *http.Request) { data := containersData{ - PageData: newPageData(r, "containers", "Containers"), + PageData: newPageData(w, r, "containers", "Containers"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 65b48d1f..6af9d739 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -205,7 +205,7 @@ type dashboardData struct { OSVersion string Machine string CurrentTime string - Firmware string + Software string Uptime string MemTotal int64 MemUsed int64 @@ -249,6 +249,7 @@ type diskEntry struct { Available string Percent int Class string // "" / "is-warn" / "is-crit" + ReadOnly bool } // DashboardHandler serves the main dashboard page. @@ -260,7 +261,7 @@ type DashboardHandler struct { // Index renders the dashboard (GET /). func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { data := dashboardData{ - PageData: newPageData(r, "dashboard", "Overview"), + PageData: newPageData(w, r, "dashboard", "Overview"), } // Detach from the request context so that RESTCONF calls survive @@ -312,7 +313,7 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { if data.Machine == "arm64" { data.Machine = "aarch64" } - data.Firmware = firmwareVersion(ss.Software) + data.Software = softwareVersion(ss.Software) data.Uptime = computeUptime(ss.Clock.BootDatetime, ss.Clock.CurrentDatetime) data.CurrentTime = formatCurrentTime(ss.Clock.CurrentDatetime) @@ -347,23 +348,33 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { for _, fs := range ss.Resource.Filesystem { size := int64(fs.Size) used := int64(fs.Used) + avail := int64(fs.Available) pct := 0 if size > 0 { pct = int(float64(used) / float64(size) * 100) } + // Read-only signature: used == size, no slack at all. + // Squashfs/erofs rootfs reports this — pinning it at 100 % + // for the lifetime of the running image, with nothing the + // operator can do about it. Skip the crit/warn coloring so + // it doesn't read as an actionable alert. + readOnly := size > 0 && used == size && avail == 0 diskClass := "" - switch { - case pct >= 90: - diskClass = "is-crit" - case pct >= 70: - diskClass = "is-warn" + if !readOnly { + switch { + case pct >= 90: + diskClass = "is-crit" + case pct >= 70: + diskClass = "is-warn" + } } data.Disks = append(data.Disks, diskEntry{ Mount: fs.MountPoint, Size: humanKiB(size), - Available: humanKiB(int64(fs.Available)), + Available: humanKiB(avail), Percent: pct, Class: diskClass, + ReadOnly: readOnly, }) } } @@ -412,8 +423,8 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { } } -// firmwareVersion returns the version string for the booted software slot. -func firmwareVersion(sw software) string { +// softwareVersion returns the version string for the booted software slot. +func softwareVersion(sw software) string { for _, slot := range sw.Slot { if slot.Name == sw.Booted { return slot.Version diff --git a/src/webui/internal/handlers/dhcp.go b/src/webui/internal/handlers/dhcp.go index 536ffcfb..c4c525c4 100644 --- a/src/webui/internal/handlers/dhcp.go +++ b/src/webui/internal/handlers/dhcp.go @@ -60,7 +60,7 @@ type DHCPHandler struct { // Overview renders the DHCP page (GET /dhcp). func (h *DHCPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := dhcpPageData{ - PageData: newPageData(r, "dhcp", "DHCP Server"), + PageData: newPageData(w, r, "dhcp", "DHCP"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/firewall.go b/src/webui/internal/handlers/firewall.go index ee66704c..9d2737ac 100644 --- a/src/webui/internal/handlers/firewall.go +++ b/src/webui/internal/handlers/firewall.go @@ -138,7 +138,7 @@ type FirewallHandler struct { // Overview renders the firewall overview (GET /firewall). func (h *FirewallHandler) Overview(w http.ResponseWriter, r *http.Request) { data := firewallData{ - PageData: newPageData(r, "firewall", "Firewall"), + PageData: newPageData(w, r, "firewall", "Firewall"), } var fw firewallWrapper diff --git a/src/webui/internal/handlers/hardware.go b/src/webui/internal/handlers/hardware.go index f53f647a..1ad93230 100644 --- a/src/webui/internal/handlers/hardware.go +++ b/src/webui/internal/handlers/hardware.go @@ -74,7 +74,7 @@ type HardwareHandler struct { func (h *HardwareHandler) Overview(w http.ResponseWriter, r *http.Request) { data := hardwarePageData{ - PageData: newPageData(r, "hardware", "Hardware"), + PageData: newPageData(w, r, "hardware", "Hardware"), } // Detach from r.Context() so the RESTCONF call (and yanger behind it) diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index e14339e5..5307a6bb 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -27,16 +27,17 @@ type interfacesWrapper struct { } type ifaceJSON struct { - Name string `json:"name"` - Description string `json:"description"` - Type string `json:"type"` - Enabled *bool `json:"enabled"` - OperStatus string `json:"oper-status"` - PhysAddress string `json:"phys-address"` - IfIndex int `json:"if-index"` + Name string `json:"name"` + Description string `json:"description"` + Type string `json:"type"` + Enabled *bool `json:"enabled"` + OperStatus string `json:"oper-status"` + PhysAddress string `json:"phys-address"` + CustomPhysAddress *customPhysAddress `json:"infix-interfaces:custom-phys-address"` + IfIndex int `json:"if-index"` // Operational link rate from ietf-interfaces (yang:gauge64 bits/s). // The same-named leaf inside the ethernet container is obsolete. - Speed yangInt64 `json:"speed"` + Speed yangInt64 `json:"speed"` IPv4 *ipCfg `json:"ietf-ip:ipv4"` IPv6 *ipCfg `json:"ietf-ip:ipv6"` Statistics *ifaceStats `json:"statistics"` @@ -50,6 +51,13 @@ type ifaceJSON struct { WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"` } +// customPhysAddress mirrors infix-interfaces:custom-phys-address. Only the +// static-MAC case is exposed in the WebUI; chassis-derived addresses are +// uncommon and editable from the Advanced YANG tree. +type customPhysAddress struct { + Static string `json:"static"` +} + type vlanCfgJSON struct { ID int `json:"id"` TagType string `json:"tag-type"` @@ -187,12 +195,13 @@ type dhcpv6CfgJSON struct { } type ipCfg struct { - Address []ipAddr `json:"address"` - MTU int `json:"mtu"` - DHCP *dhcpv4CfgJSON `json:"infix-dhcp-client:dhcp"` - Autoconf *struct{} `json:"infix-ip:autoconf"` - SLAACv6 *struct{} `json:"autoconf"` - DHCPv6 *dhcpv6CfgJSON `json:"infix-dhcpv6-client:dhcp"` + Address []ipAddr `json:"address"` + MTU int `json:"mtu"` + Forwarding bool `json:"forwarding"` + DHCP *dhcpv4CfgJSON `json:"infix-dhcp-client:dhcp"` + Autoconf *struct{} `json:"infix-ip:autoconf"` + SLAACv6 *struct{} `json:"autoconf"` + DHCPv6 *dhcpv6CfgJSON `json:"infix-dhcpv6-client:dhcp"` } type ipAddr struct { @@ -295,7 +304,7 @@ type InterfacesHandler struct { // Overview renders the interfaces page (GET /interfaces). func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { data := interfacesData{ - PageData: newPageData(r, "interfaces", "Interfaces"), + PageData: newPageData(w, r, "interfaces", "Interfaces"), } var ( @@ -948,7 +957,7 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) { } data := buildDetailData(r, iface) - data.PageData = newPageData(r, "interfaces", "Interface "+name) + data.PageData = newPageData(w, r, "interfaces", name) data.Desc = h.fieldDescriptions() tmplName := "iface-detail.html" diff --git a/src/webui/internal/handlers/lldp.go b/src/webui/internal/handlers/lldp.go index 9cec7a20..7893d18c 100644 --- a/src/webui/internal/handlers/lldp.go +++ b/src/webui/internal/handlers/lldp.go @@ -46,7 +46,7 @@ type LLDPHandler struct { // Overview renders the LLDP page (GET /lldp). func (h *LLDPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := lldpPageData{ - PageData: newPageData(r, "lldp", "LLDP Neighbors"), + PageData: newPageData(w, r, "lldp", "LLDP"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/mdns.go b/src/webui/internal/handlers/mdns.go index 191d3a2c..97b61119 100644 --- a/src/webui/internal/handlers/mdns.go +++ b/src/webui/internal/handlers/mdns.go @@ -97,7 +97,7 @@ type MDNSHandler struct { // Overview renders the mDNS page (GET /mdns). func (h *MDNSHandler) Overview(w http.ResponseWriter, r *http.Request) { data := mdnsPageData{ - PageData: newPageData(r, "mdns", "mDNS"), + PageData: newPageData(w, r, "mdns", "mDNS"), } var raw mdnsWrapper diff --git a/src/webui/internal/handlers/nacm.go b/src/webui/internal/handlers/nacm.go index 988bb742..0f84dc37 100644 --- a/src/webui/internal/handlers/nacm.go +++ b/src/webui/internal/handlers/nacm.go @@ -125,7 +125,7 @@ type NACMHandler struct { // Overview renders the NACM page (GET /nacm). func (h *NACMHandler) Overview(w http.ResponseWriter, r *http.Request) { data := nacmPageData{ - PageData: newPageData(r, "nacm", "NACM"), + PageData: newPageData(w, r, "nacm", "Users & Groups"), } var nacmRaw nacmWrapper diff --git a/src/webui/internal/handlers/ntp.go b/src/webui/internal/handlers/ntp.go index 7b714685..047c3729 100644 --- a/src/webui/internal/handlers/ntp.go +++ b/src/webui/internal/handlers/ntp.go @@ -56,7 +56,7 @@ type NTPHandler struct { // Overview renders the NTP page (GET /ntp). func (h *NTPHandler) Overview(w http.ResponseWriter, r *http.Request) { data := ntpPageData{ - PageData: newPageData(r, "ntp", "NTP"), + PageData: newPageData(w, r, "ntp", "NTP"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go index 8a89dc4f..fe96af0b 100644 --- a/src/webui/internal/handlers/routing.go +++ b/src/webui/internal/handlers/routing.go @@ -191,7 +191,7 @@ type ospfNeighborJSON struct { func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { data := routingData{ - PageData: newPageData(r, "routing", "Routing"), + PageData: newPageData(w, r, "routing", "Routes"), } ctx := context.WithoutCancel(r.Context()) diff --git a/src/webui/internal/handlers/services.go b/src/webui/internal/handlers/services.go index 403d4767..f16dc413 100644 --- a/src/webui/internal/handlers/services.go +++ b/src/webui/internal/handlers/services.go @@ -79,7 +79,7 @@ type ServicesHandler struct { // Overview renders the services page (GET /services). func (h *ServicesHandler) Overview(w http.ResponseWriter, r *http.Request) { data := servicesPageData{ - PageData: newPageData(r, "services", "Services"), + PageData: newPageData(w, r, "services", "Services"), } var raw servicesWrapper diff --git a/src/webui/internal/handlers/system.go b/src/webui/internal/handlers/system.go index b9e45a37..66d2df63 100644 --- a/src/webui/internal/handlers/system.go +++ b/src/webui/internal/handlers/system.go @@ -23,10 +23,10 @@ import ( // raucInstallationStatus reads RAUC's Operation/Progress/LastError D-Bus // properties directly via the rauc-installation-status helper. Used during -// installs because the RESTCONF path goes through yanger, which runs -// `rauc status` and blocks while RAUC is busy. -func raucInstallationStatus(ctx context.Context) (fwInstallerState, error) { - var inst fwInstallerState +// installs because the RESTCONF path goes through the operational-state +// machinery, which runs `rauc status` and blocks while RAUC is busy. +func raucInstallationStatus(ctx context.Context) (swInstallerState, error) { + var inst swInstallerState out, err := exec.CommandContext(ctx, "/usr/bin/rauc-installation-status").Output() if err != nil { return inst, err @@ -35,27 +35,26 @@ func raucInstallationStatus(ctx context.Context) (fwInstallerState, error) { return inst, err } -// SystemHandler provides reboot, config download, and firmware update actions. +// SystemHandler provides reboot, config download, and software install actions. type SystemHandler struct { RC *restconf.Client - Template *template.Template // firmware page template + Template *template.Template // software page template SysCtrlTmpl *template.Template // system control page template BackupTmpl *template.Template // backup & restore page template - // fwSlots caches the last successfully-fetched Software card payload - // so /firmware?installing=1 can keep rendering slot details — RESTCONF - // /data/ietf-system:system-state blocks on `rauc status` while RAUC - // is busy, and the user wants to read (and adjust) boot order even - // between install attempts. - fwSlots fwSlotSnapshot + // swSlots caches the last successfully-fetched Software card payload + // so /software?installing=1 can keep rendering slot details — the + // RESTCONF path blocks on `rauc status` while RAUC is busy, and the + // user wants to read (and adjust) boot order even between install + // attempts. + swSlots swSlotSnapshot } -// fwSlotSnapshot is a tiny RWMutex-guarded copy of the Firmware page's -// Software card body. Mirrors the schema.Cache shape (rw lock + payload) -// from internal/schema/refresh.go. -type fwSlotSnapshot struct { +// swSlotSnapshot is a tiny RWMutex-guarded copy of the Software page's +// card body. Mirrors the schema.Cache shape (rw lock + payload) from +// internal/schema/refresh.go. +type swSlotSnapshot struct { mu sync.RWMutex - machine string bootOrder []string slots []slotEntry } @@ -93,28 +92,12 @@ const rebootSpinnerHTML = `<div class="reboot-overlay"> type systemControlData struct { PageData - CurrentDatetime string // device clock, empty when unavailable } // SystemControl renders the System Control maintenance page. func (h *SystemHandler) SystemControl(w http.ResponseWriter, r *http.Request) { data := systemControlData{ - PageData: newPageData(r, "system-control", "System Control"), - } - - var clockResp struct { - SystemState struct { - Clock struct { - CurrentDatetime string `json:"current-datetime"` - } `json:"clock"` - } `json:"ietf-system:system-state"` - } - if err := h.RC.Get(r.Context(), "/data/ietf-system:system-state/clock", &clockResp); err == nil { - dt := clockResp.SystemState.Clock.CurrentDatetime - if len(dt) > 19 { - dt = dt[:19] - } - data.CurrentDatetime = strings.Replace(dt, "T", " ", 1) + " UTC" + PageData: newPageData(w, r, "system-control", "System Control"), } tmplName := "system-control.html" @@ -133,28 +116,32 @@ func (h *SystemHandler) SetDatetime(w http.ResponseWriter, r *http.Request) { http.Error(w, "bad request", http.StatusBadRequest) return } - raw := r.FormValue("datetime") // YYYY-MM-DDTHH:MM from datetime-local input + raw := r.FormValue("datetime") // YYYY-MM-DDTHH:MM or :SS, ISO 24h if raw == "" { http.Error(w, "datetime required", http.StatusBadRequest) return } + // Accept either YYYY-MM-DDTHH:MM (16 chars) or with :SS (19 chars). + if len(raw) == 16 { + raw += ":00" + } body := map[string]map[string]string{ - "ietf-system:input": {"current-datetime": raw + ":00+00:00"}, + "ietf-system:input": {"current-datetime": raw + "+00:00"}, } - err := h.RC.PostJSON(r.Context(), "/operations/ietf-system:set-current-datetime", body) - - w.Header().Set("Content-Type", "text/html") - if err != nil { + if err := h.RC.PostJSON(r.Context(), "/operations/ietf-system:set-current-datetime", body); err != nil { msg := err.Error() if strings.Contains(msg, "ntp-active") { - fmt.Fprint(w, `<span class="sc-fd-err">NTP is active — disable NTP first under Configure > System.</span>`) - } else { - fmt.Fprintf(w, `<span class="sc-fd-err">Failed: %s</span>`, template.HTMLEscapeString(msg)) + msg = "NTP is active — disable NTP first under Configure > System" } + log.Printf("set datetime: %v", err) + b, _ := json.Marshal(msg) + w.Header().Set("HX-Trigger", `{"cfgError":`+string(b)+`}`) + w.WriteHeader(http.StatusUnprocessableEntity) return } - fmt.Fprint(w, `<span class="sc-fd-ok">✓ System time updated</span>`) + w.Header().Set("HX-Trigger", `{"cfgSaved":"System time updated"}`) + w.WriteHeader(http.StatusOK) } // Shutdown triggers a device power-off via the ietf-system:system-shutdown RPC. @@ -211,7 +198,7 @@ const factoryResetSpinnerHTML = `<div class="reboot-overlay"> // Backup renders the Backup & Restore maintenance page. func (h *SystemHandler) Backup(w http.ResponseWriter, r *http.Request) { - data := newPageData(r, "backup", "Backup & Restore") + data := newPageData(w, r, "backup", "Backup & Restore") tmplName := "backup.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" @@ -311,62 +298,58 @@ func (h *SystemHandler) DownloadConfig(w http.ResponseWriter, r *http.Request) { // RESTCONF JSON structures for infix-system:software state. -type fwSoftwareWrapper struct { +type swStateWrapper struct { SystemState struct { - Platform struct { - Machine string `json:"machine"` - } `json:"platform"` - Software fwSoftwareState `json:"infix-system:software"` + Software swState `json:"infix-system:software"` } `json:"ietf-system:system-state"` } -type fwSoftwareState struct { +type swState struct { Compatible string `json:"compatible"` Variant string `json:"variant"` Booted string `json:"booted"` BootOrder []string `json:"boot-order"` - Installer fwInstallerState `json:"installer"` - Slots []fwSlot `json:"slot"` + Installer swInstallerState `json:"installer"` + Slots []swSlot `json:"slot"` } -type fwInstallerState struct { +type swInstallerState struct { Operation string `json:"operation"` - Progress fwInstallerProgress `json:"progress"` + Progress swInstallerProgress `json:"progress"` LastError string `json:"last-error"` } // IsIdle reports whether RAUC has no install in flight. The YANG model leaves // Operation empty when no install has run yet and "idle" once one has completed. -func (s fwInstallerState) IsIdle() bool { +func (s swInstallerState) IsIdle() bool { return s.Operation == "" || s.Operation == "idle" } -type fwInstallerProgress struct { +type swInstallerProgress struct { Percentage int `json:"percentage"` Message string `json:"message"` } -type fwSlot struct { +type swSlot struct { Name string `json:"name"` BootName string `json:"bootname"` Class string `json:"class"` State string `json:"state"` - Bundle fwSlotBundle `json:"bundle"` + Bundle swSlotBundle `json:"bundle"` Installed struct { Datetime string `json:"datetime"` } `json:"installed"` } -type fwSlotBundle struct { +type swSlotBundle struct { Compatible string `json:"compatible"` Version string `json:"version"` } -// Template data for the firmware page. +// Template data for the software page. -type firmwareData struct { +type softwareData struct { PageData - Machine string BootOrder []string Slots []slotEntry Installer *installerEntry @@ -394,42 +377,38 @@ type installerEntry struct { Success bool // Done with no error } -// Firmware renders the firmware overview page (GET /firmware). -func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) { - data := firmwareData{ - PageData: newPageData(r, "firmware", "Firmware"), +// Software renders the software overview page (GET /software). +func (h *SystemHandler) Software(w http.ResponseWriter, r *http.Request) { + data := softwareData{ + PageData: newPageData(w, r, "software", "Software"), Message: r.URL.Query().Get("msg"), Installing: r.URL.Query().Get("installing") == "1", AutoReboot: r.URL.Query().Get("auto-reboot") == "1", } - // When an install is in progress, RESTCONF/yanger blocks on `rauc status` - // until RAUC is done. Skip the slow path and just read the installer - // state directly so the progress card can render immediately; SSE then - // drives the visual update during the install. The Software card body - // falls back to the last cached slot snapshot so it doesn't go blank. + // When an install is in progress, the RESTCONF path blocks on + // `rauc status` until RAUC is done. Skip the slow path and read + // the installer state directly so the progress card can render + // immediately; SSE then drives the visual update during the + // install. The Software card body falls back to the last cached + // slot snapshot so it doesn't go blank. if data.Installing { if inst, err := raucInstallationStatus(r.Context()); err == nil { data.Installer = newInstallerEntry(inst) } else { - log.Printf("firmware page (installing): %v", err) + log.Printf("software page (installing): %v", err) } - h.fwSlots.mu.RLock() - data.Machine = h.fwSlots.machine - data.BootOrder = slices.Clone(h.fwSlots.bootOrder) - data.Slots = slices.Clone(h.fwSlots.slots) - h.fwSlots.mu.RUnlock() + h.swSlots.mu.RLock() + data.BootOrder = slices.Clone(h.swSlots.bootOrder) + data.Slots = slices.Clone(h.swSlots.slots) + h.swSlots.mu.RUnlock() } else { - var sw fwSoftwareWrapper + var sw swStateWrapper err := h.RC.Get(r.Context(), "/data/ietf-system:system-state", &sw) if err != nil { - log.Printf("restconf firmware: %v", err) - data.Error = "Could not fetch firmware status" + log.Printf("restconf software: %v", err) + data.Error = "Could not fetch software status" } else { - data.Machine = sw.SystemState.Platform.Machine - if data.Machine == "arm64" { - data.Machine = "aarch64" - } data.BootOrder = sw.SystemState.Software.BootOrder for _, s := range sw.SystemState.Software.Slots { if s.Class != "rootfs" { @@ -454,15 +433,14 @@ func (h *SystemHandler) Firmware(w http.ResponseWriter, r *http.Request) { data.Installer = newInstallerEntry(sw.SystemState.Software.Installer) - h.fwSlots.mu.Lock() - h.fwSlots.machine = data.Machine - h.fwSlots.bootOrder = slices.Clone(data.BootOrder) - h.fwSlots.slots = slices.Clone(data.Slots) - h.fwSlots.mu.Unlock() + h.swSlots.mu.Lock() + h.swSlots.bootOrder = slices.Clone(data.BootOrder) + h.swSlots.slots = slices.Clone(data.Slots) + h.swSlots.mu.Unlock() } } - tmplName := "firmware.html" + tmplName := "software.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" } @@ -502,13 +480,13 @@ func (h *SystemHandler) SetBootOrder(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// FirmwareUpload accepts a .pkg file upload, saves it to a temp file, and +// SoftwareUpload accepts a .pkg file upload, saves it to a temp file, and // kicks off the install-bundle RPC asynchronously so the response (a // plain-text redirect target) reaches the browser before RAUC starts // writing slots. Upload size is capped at the nginx layer. -func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) { +func (h *SystemHandler) SoftwareUpload(w http.ResponseWriter, r *http.Request) { if h.raucBusy(r.Context()) { - http.Error(w, "firmware install already in progress", http.StatusConflict) + http.Error(w, "software install already in progress", http.StatusConflict) return } @@ -531,9 +509,9 @@ func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) { } defer file.Close() - tmp, err := os.CreateTemp("", "webui-fw-*.pkg") + tmp, err := os.CreateTemp("", "webui-bundle-*.pkg") if err != nil { - log.Printf("firmware upload: create temp: %v", err) + log.Printf("software upload: create temp: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } @@ -542,8 +520,8 @@ func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) { if _, err := io.Copy(tmp, file); err != nil { tmp.Close() os.Remove(tmpPath) - log.Printf("firmware upload: write: %v", err) - http.Error(w, "failed to save firmware", http.StatusInternalServerError) + log.Printf("software upload: write: %v", err) + http.Error(w, "failed to save bundle", http.StatusInternalServerError) return } tmp.Close() @@ -554,7 +532,7 @@ func (h *SystemHandler) FirmwareUpload(w http.ResponseWriter, r *http.Request) { creds := restconf.CredentialsFromContext(r.Context()) go h.runInstall(creds, body, tmpPath) - target := "/firmware?installing=1" + target := "/software?installing=1" if r.FormValue("auto-reboot") == "1" { target += "&auto-reboot=1" } @@ -574,7 +552,7 @@ func (h *SystemHandler) runInstall(creds restconf.Credentials, body any, tmpPath defer os.Remove(tmpPath) if err := h.RC.PostJSON(ctx, "/operations/infix-system:install-bundle", body); err != nil { - log.Printf("firmware upload: install-bundle: %v", err) + log.Printf("software upload: install-bundle: %v", err) return } @@ -596,8 +574,8 @@ func (h *SystemHandler) runInstall(creds restconf.Credentials, body any, tmpPath } } -// FirmwareInstall triggers a firmware install via the install-bundle RPC (POST /firmware/install). -func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) { +// SoftwareInstall triggers a bundle install via the install-bundle RPC (POST /software/install). +func (h *SystemHandler) SoftwareInstall(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return @@ -617,13 +595,13 @@ func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) err := h.RC.PostJSON(r.Context(), "/operations/infix-system:install-bundle", body) if err != nil { - log.Printf("firmware install: %v", err) - w.Header().Set("HX-Redirect", "/firmware?msg=Install+failed:+"+err.Error()) + log.Printf("software install: %v", err) + w.Header().Set("HX-Redirect", "/software?msg=Install+failed:+"+err.Error()) w.WriteHeader(http.StatusNoContent) return } - target := "/firmware?installing=1" + target := "/software?installing=1" if r.FormValue("auto-reboot") == "1" { target += "&auto-reboot=1" } @@ -631,10 +609,10 @@ func (h *SystemHandler) FirmwareInstall(w http.ResponseWriter, r *http.Request) w.WriteHeader(http.StatusNoContent) } -// FirmwareProgress streams installer status as SSE so the Go server does the +// SoftwareProgress streams installer status as SSE so the Go server does the // polling and the browser just receives rendered HTML fragments. -// GET /firmware/progress -func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) { +// GET /software/progress +func (h *SystemHandler) SoftwareProgress(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming not supported", http.StatusInternalServerError) @@ -653,6 +631,7 @@ func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) defer ticker.Stop() var lastKey string // change-detection: suppress redundant SSE frames + lastFrame := time.Now() for { select { @@ -667,13 +646,27 @@ func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) key = fmt.Sprintf("%s|%d|%s|%s", data.Installer.Operation, data.Installer.Percentage, data.Installer.Message, data.Installer.LastError) } if key == lastKey && key != "" { + // RAUC sometimes parks Progress at the same percentage + // for tens of seconds (e.g., "Checking bundle" while + // verifying signatures, or quiet during slot write). + // Emit a comment as a keep-alive every 15 s so nginx's + // proxy_read_timeout and the browser EventSource both + // keep the stream warm — otherwise the connection gets + // torn down mid-install and the UI freezes on the last + // rendered frame until a manual page reload. + if time.Since(lastFrame) > 15*time.Second { + fmt.Fprint(w, ": keep-alive\n\n") + flusher.Flush() + lastFrame = time.Now() + } continue } lastKey = key + lastFrame = time.Now() var buf bytes.Buffer - if err := h.Template.ExecuteTemplate(&buf, "fw-progress-body", data); err != nil { - log.Printf("firmware progress template: %v", err) + if err := h.Template.ExecuteTemplate(&buf, "sw-progress-body", data); err != nil { + log.Printf("software progress template: %v", err) continue } @@ -701,10 +694,10 @@ func (h *SystemHandler) FirmwareProgress(w http.ResponseWriter, r *http.Request) // installerSnapshot reads RAUC's installer state via rauc-installation-status // (direct D-Bus property read) and builds the template data for the -// fw-progress-body fragment. The RESTCONF path is avoided because yanger runs +// sw-progress-body fragment. The RESTCONF path is avoided because it runs // `rauc status`, which blocks while an install is in progress. -func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firmwareProgressData { - data := firmwareProgressData{ +func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) softwareProgressData { + data := softwareProgressData{ AutoReboot: autoReboot, } @@ -712,7 +705,7 @@ func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firm if err != nil { // Leave Installer nil so the template renders an indeterminate // "Installing…" state on transient failures. - log.Printf("firmware progress poll: %v", err) + log.Printf("software progress poll: %v", err) return data } data.Installer = newInstallerEntry(inst) @@ -720,7 +713,7 @@ func (h *SystemHandler) installerSnapshot(r *http.Request, autoReboot bool) firm } // newInstallerEntry converts a raw YANG installer state to the template-facing struct. -func newInstallerEntry(inst fwInstallerState) *installerEntry { +func newInstallerEntry(inst swInstallerState) *installerEntry { idle := inst.IsIdle() done := idle && (inst.Progress.Percentage > 0 || inst.LastError != "") return &installerEntry{ @@ -734,8 +727,8 @@ func newInstallerEntry(inst fwInstallerState) *installerEntry { } } -// firmwareProgressData is the template data for the fw-progress-body fragment. -type firmwareProgressData struct { +// softwareProgressData is the template data for the sw-progress-body fragment. +type softwareProgressData struct { AutoReboot bool Installer *installerEntry } diff --git a/src/webui/internal/handlers/vpn.go b/src/webui/internal/handlers/vpn.go index f81bdad6..a00cac4e 100644 --- a/src/webui/internal/handlers/vpn.go +++ b/src/webui/internal/handlers/vpn.go @@ -45,23 +45,23 @@ type WGTunnel struct { Peers []WGPeer } -// vpnData is the template data struct for the VPN page. +// vpnData is the template data struct for the WireGuard status page. type vpnData struct { PageData Tunnels []WGTunnel Error string } -// VPNHandler serves the VPN/WireGuard status page. +// VPNHandler serves the WireGuard status page. type VPNHandler struct { Template *template.Template RC *restconf.Client } -// Overview renders the VPN page (GET /vpn). +// Overview renders the WireGuard page (GET /vpn). func (h *VPNHandler) Overview(w http.ResponseWriter, r *http.Request) { data := vpnData{ - PageData: newPageData(r, "vpn", "VPN"), + PageData: newPageData(w, r, "vpn", "WireGuard"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/wifi.go b/src/webui/internal/handlers/wifi.go index f7b8bebb..16d52be9 100644 --- a/src/webui/internal/handlers/wifi.go +++ b/src/webui/internal/handlers/wifi.go @@ -152,7 +152,7 @@ type WiFiHandler struct { // Overview renders the WiFi page (GET /wifi). func (h *WiFiHandler) Overview(w http.ResponseWriter, r *http.Request) { data := wifiData{ - PageData: newPageData(r, "wifi", "WiFi"), + PageData: newPageData(w, r, "wifi", "WiFi"), } // Detach from the request context so that RESTCONF calls survive diff --git a/src/webui/internal/handlers/yang_tree.go b/src/webui/internal/handlers/yang_tree.go index 88140baf..1f01ea8e 100644 --- a/src/webui/internal/handlers/yang_tree.go +++ b/src/webui/internal/handlers/yang_tree.go @@ -197,14 +197,14 @@ type leafGroupItem struct { // When path is set the right pane auto-loads the node on page load. func (h *TreeHandler) Overview(w http.ResponseWriter, r *http.Request) { activePage := "configure-tree" - title := "Advanced Configuration" + title := "Edit all" if h.ReadOnly { activePage = "status-tree" - title = "Advanced Status" + title = "View all" } base := h.treeBase() data := yangTreePageData{ - PageData: newPageData(r, activePage, title), + PageData: newPageData(w, r, activePage, title), InitialPath: r.URL.Query().Get("path"), ReadOnly: h.ReadOnly, TreeBase: base, diff --git a/src/webui/internal/restconf/errors.go b/src/webui/internal/restconf/errors.go index eb47a573..f96da246 100644 --- a/src/webui/internal/restconf/errors.go +++ b/src/webui/internal/restconf/errors.go @@ -19,10 +19,11 @@ func IsNotFound(err error) bool { return errors.As(err, &e) && e.StatusCode == http.StatusNotFound } -// IsDataMissing reports whether err is a RESTCONF "data-missing" tag error -// (RFC 8040 §7.6.2). Returned by DELETE on a leaf that is already absent. -// Useful when the caller is explicitly trying to reach an "absent" state -// and treats already-absent the same as just-deleted. +// IsDataMissing reports whether err carries the RESTCONF "data-missing" +// error-tag, returned when an operation targets a leaf or container that +// isn't present in the datastore (e.g. a reset on a leaf that was never +// set). Callers use this to swallow no-op failures so the UI stays +// consistent regardless of whether the leaf was already absent. func IsDataMissing(err error) bool { var e *Error return errors.As(err, &e) && e.Tag == "data-missing" diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index e04aafc9..d091c4e5 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -25,7 +25,7 @@ func New( ) (http.Handler, error) { // Parse templates per page so each can define its own "content" block // without collisions. - loginTmpl, err := template.ParseFS(templateFS, "pages/login.html") + loginTmpl, err := template.ParseFS(templateFS, "layouts/icons.html", "pages/login.html") if err != nil { return nil, err } @@ -37,7 +37,7 @@ func New( if err != nil { return nil, err } - ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-keystore.html") + ksTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-keystore.html") if err != nil { return nil, err } @@ -53,7 +53,7 @@ func New( if err != nil { return nil, err } - fwrTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/firmware.html") + swTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/software.html") if err != nil { return nil, err } @@ -109,23 +109,31 @@ func New( if err != nil { return nil, err } - cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-system.html") + cfgSysTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-system.html") if err != nil { return nil, err } - cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-users.html") + cfgNTPTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-ntp.html") if err != nil { return nil, err } - cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-routes.html") + cfgDNSTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-dns.html") if err != nil { return nil, err } - cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-firewall.html") + cfgUsersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-users.html") if err != nil { return nil, err } - cfgHwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "pages/configure-hardware.html") + cfgRoutesTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-routes.html") + if err != nil { + return nil, err + } + cfgFwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-firewall.html") + if err != nil { + return nil, err + } + cfgHwTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "pages/configure-hardware.html") if err != nil { return nil, err } @@ -166,7 +174,7 @@ func New( return m, nil }, } - cfgIfTmpl, err := template.New("").Funcs(ifFuncs).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/icons.html", "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html") + cfgIfTmpl, err := template.New("").Funcs(ifFuncs).ParseFS(templateFS, "layouts/*.html", "fragments/configure-toolbar.html", "fragments/wizard-psk-picker.html", "fragments/wizard-wgkey-picker.html", "fragments/wizard-radio-picker.html", "pages/configure-interfaces.html") if err != nil { return nil, err } @@ -184,7 +192,7 @@ func New( "fragments/yang-node-detail.html", "fragments/yang-leaf-group.html", "fragments/yang-list-table.html", - "fragments/icons.html") + "layouts/icons.html") if err != nil { return nil, err } @@ -223,7 +231,7 @@ func New( sys := &handlers.SystemHandler{ RC: rc, - Template: fwrTmpl, + Template: swTmpl, SysCtrlTmpl: sysCtrlTmpl, BackupTmpl: backupTmpl, } @@ -240,7 +248,13 @@ func New( services := &handlers.ServicesHandler{Template: servicesTmpl, RC: rc} containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc} cfg := &handlers.ConfigureHandler{RC: rc} - cfgSys := &handlers.ConfigureSystemHandler{Template: cfgSysTmpl, RC: rc, Schema: schemaCache} + cfgSys := &handlers.ConfigureSystemHandler{ + Template: cfgSysTmpl, + NTPTemplate: cfgNTPTmpl, + DNSTemplate: cfgDNSTmpl, + RC: rc, + Schema: schemaCache, + } cfgUsers := &handlers.ConfigureUsersHandler{Template: cfgUsersTmpl, RC: rc, Schema: schemaCache} cfgRoutes := &handlers.ConfigureRoutesHandler{Template: cfgRoutesTmpl, RC: rc, Schema: schemaCache} cfgFw := &handlers.ConfigureFirewallHandler{Template: cfgFwTmpl, RC: rc, Schema: schemaCache} @@ -282,12 +296,12 @@ func New( mux.HandleFunc("GET /keystore", func(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/configure/keystore", http.StatusMovedPermanently) }) - mux.HandleFunc("GET /firmware", sys.Firmware) - mux.HandleFunc("GET /firmware/progress", sys.FirmwareProgress) - mux.HandleFunc("POST /firmware/install", sys.FirmwareInstall) - mux.HandleFunc("POST /firmware/upload", sys.FirmwareUpload) - mux.HandleFunc("POST /firmware/boot-order", sys.SetBootOrder) - mux.HandleFunc("POST /reboot", sys.Reboot) // kept for firmware page "Reboot to activate" + mux.HandleFunc("GET /software", sys.Software) + mux.HandleFunc("GET /software/progress", sys.SoftwareProgress) + mux.HandleFunc("POST /software/install", sys.SoftwareInstall) + mux.HandleFunc("POST /software/upload", sys.SoftwareUpload) + mux.HandleFunc("POST /software/boot-order", sys.SetBootOrder) + mux.HandleFunc("POST /reboot", sys.Reboot) // kept for software page "Reboot to activate" mux.HandleFunc("GET /config", sys.DownloadConfig) mux.HandleFunc("GET /maintenance/backup", sys.Backup) mux.HandleFunc("POST /maintenance/backup/restore", sys.RestoreConfig) @@ -317,6 +331,8 @@ func New( mux.HandleFunc("POST /configure/save", cfg.Save) mux.HandleFunc("DELETE /configure/leaf", cfg.DeleteLeaf) mux.HandleFunc("GET /configure/system", cfgSys.Overview) + mux.HandleFunc("GET /configure/ntp", cfgSys.OverviewNTP) + mux.HandleFunc("GET /configure/dns", cfgSys.OverviewDNS) mux.HandleFunc("POST /configure/system/identity", cfgSys.SaveIdentity) mux.HandleFunc("POST /configure/system/clock", cfgSys.SaveClock) mux.HandleFunc("PUT /configure/system/ntp", cfgSys.SaveNTP) @@ -332,15 +348,13 @@ func New( mux.HandleFunc("DELETE /configure/interfaces/{name}", cfgIf.DeleteInterface) mux.HandleFunc("POST /configure/interfaces/{name}/ipv4", cfgIf.AddIPv4) mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv4/{ip}", cfgIf.DeleteIPv4) - mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp", cfgIf.SaveIPv4DHCP) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/settings", cfgIf.SaveIPv4Settings) mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp/settings", cfgIf.SaveIPv4DHCPSettings) mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/dhcp/options", cfgIf.AddIPv4DHCPOption) mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv4/dhcp/options/{id}", cfgIf.DeleteIPv4DHCPOption) - mux.HandleFunc("POST /configure/interfaces/{name}/ipv4/autoconf", cfgIf.SaveIPv4Autoconf) mux.HandleFunc("POST /configure/interfaces/{name}/ipv6", cfgIf.AddIPv6) mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv6/{ip}", cfgIf.DeleteIPv6) - mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/autoconf", cfgIf.SaveIPv6SLAAC) - mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp", cfgIf.SaveIPv6DHCP) + mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/settings", cfgIf.SaveIPv6Settings) mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp/settings", cfgIf.SaveIPv6DHCPSettings) mux.HandleFunc("POST /configure/interfaces/{name}/ipv6/dhcp/options", cfgIf.AddIPv6DHCPOption) mux.HandleFunc("DELETE /configure/interfaces/{name}/ipv6/dhcp/options/{id}", cfgIf.DeleteIPv6DHCPOption) @@ -351,7 +365,6 @@ func New( mux.HandleFunc("POST /configure/interfaces/{name}/wifi", cfgIf.SaveWifi) mux.HandleFunc("POST /configure/interfaces/{name}/bridge", cfgIf.SaveBridge) mux.HandleFunc("POST /configure/interfaces/{name}/bridge/stp", cfgIf.SaveBridgeSTP) - mux.HandleFunc("POST /configure/interfaces/{name}/bridge/members", cfgIf.SaveBridgeMembers) mux.HandleFunc("POST /configure/interfaces/{name}/bridge/multicast", cfgIf.SaveBridgeMulticast) mux.HandleFunc("POST /configure/interfaces/{name}/bridge/vlans", cfgIf.AddVLAN) mux.HandleFunc("POST /configure/interfaces/{name}/bridge/vlans/{vid}", cfgIf.SaveVLAN) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index a7f1eb18..9b68bdc8 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -543,6 +543,19 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { color: var(--fg); } .disk-pct { font-size: 0.8rem; color: var(--fg-muted); } +.disk-tag { + font-family: var(--font-body); + font-size: 0.65rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--fg-muted); + border: 1px solid var(--border); + border-radius: 3px; + padding: 0 0.3rem; + margin-left: 0.4rem; + vertical-align: middle; +} .disk-stats { font-size: 0.78rem; color: var(--fg-muted); @@ -944,7 +957,7 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { .page-content { display: contents; } /* ========================================================================== - Domain Specific: Firewall, Keystore, Firmware + Domain Specific: Firewall, Keystore, Software ========================================================================== */ /* Zone Matrix */ @@ -1439,34 +1452,34 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { border-left: 3px solid var(--primary); } -/* Firmware & Reboot */ -.fw-install-grid { +/* Software & Reboot */ +.sw-install-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(340px, 1fr)); gap: 1.5rem; margin-top: 1.5rem; } -.fw-install-grid > .info-card { margin: 0; } +.sw-install-grid > .info-card { margin: 0; } -.fw-card-muted { opacity: 0.6; pointer-events: none; } +.sw-card-muted { opacity: 0.6; pointer-events: none; } -.fw-help-text { +.sw-help-text { font-size: 0.875rem; color: var(--fg-muted); margin-bottom: 1rem; line-height: 1.6; } -.fw-help-text a { color: var(--primary); } -.fw-help-text code, .fw-hint-body code { font-family: var(--font-mono); font-size: 0.8em; } +.sw-help-text a { color: var(--primary); } +.sw-help-text code, .sw-hint-body code { font-family: var(--font-mono); font-size: 0.8em; } -.fw-hint { +.sw-hint { background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius-sm); margin-bottom: 1.25rem; font-size: 0.875rem; } -.fw-hint summary { +.sw-hint summary { cursor: pointer; padding: 0.55rem 0.8rem; color: var(--fg-muted); @@ -1476,16 +1489,16 @@ details[open].nav-group-top > summary.nav-group-summary-top::before { align-items: center; gap: 0.4rem; } -.fw-hint summary::-webkit-details-marker { display: none; } -.fw-hint summary::before { content: '›'; display: inline-block; transition: transform 0.15s; } -details.fw-hint[open] summary::before { transform: rotate(90deg); } -.fw-hint-body { +.sw-hint summary::-webkit-details-marker { display: none; } +.sw-hint summary::before { content: '›'; display: inline-block; transition: transform 0.15s; } +details.sw-hint[open] summary::before { transform: rotate(90deg); } +.sw-hint-body { padding: 0.75rem 0.8rem; border-top: 1px solid var(--border); } -.fw-hint-body p { margin: 0 0 0.5rem; color: var(--fg-muted); font-size: 0.85rem; } -.fw-hint-body p:last-child { margin-bottom: 0; } -.fw-hint-code { +.sw-hint-body p { margin: 0 0 0.5rem; color: var(--fg-muted); font-size: 0.85rem; } +.sw-hint-body p:last-child { margin-bottom: 0; } +.sw-hint-code { display: block; font-family: var(--font-mono); font-size: 0.8rem; @@ -1499,7 +1512,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); } margin-bottom: 0.5rem; } -.fw-boot-order-row { +.sw-boot-order-row { display: flex; align-items: center; gap: 0.4rem; @@ -1507,7 +1520,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); } border-bottom: 1px solid var(--border); font-size: 0.8rem; } -.fw-boot-order-label { +.sw-boot-order-label { color: var(--fg-muted); font-size: 0.7rem; text-transform: uppercase; @@ -1516,37 +1529,37 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); } margin-right: 0.2rem; } -.fw-boot-slots { display: flex; gap: 0.3rem; align-items: center; flex: 1; } -.fw-boot-badge { cursor: grab; user-select: none; } -.fw-boot-badge.fw-boot-dragging { opacity: 0.35; } -.fw-boot-badge.fw-boot-drop-before { box-shadow: -3px 0 0 var(--primary); } +.sw-boot-slots { display: flex; gap: 0.3rem; align-items: center; flex: 1; } +.sw-boot-badge { cursor: grab; user-select: none; } +.sw-boot-badge.sw-boot-dragging { opacity: 0.35; } +.sw-boot-badge.sw-boot-drop-before { box-shadow: -3px 0 0 var(--primary); } -.fw-slot-list { display: flex; flex-direction: column; } -.fw-slot-item { +.sw-slot-list { display: flex; flex-direction: column; } +.sw-slot-item { padding: 0.75rem 1.25rem; border-bottom: 1px solid var(--border); } -.fw-slot-item:last-child { border-bottom: none; } -.fw-slot-primary { +.sw-slot-item:last-child { border-bottom: none; } +.sw-slot-primary { display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.2rem; } -.fw-slot-name { font-weight: 600; font-size: 0.9rem; } -.fw-slot-version { +.sw-slot-name { font-weight: 600; font-size: 0.9rem; } +.sw-slot-version { font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg-muted); } -.fw-slot-date { +.sw-slot-date { font-size: 0.75rem; color: var(--fg-muted); opacity: 0.75; margin-top: 0.1rem; } -.fw-upload-placeholder { +.sw-upload-placeholder { border: 2px dashed var(--border); border-radius: var(--radius); padding: 2rem 1rem; @@ -1560,10 +1573,10 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); } font-size: 0.875rem; } -.firmware-form .form-group { margin-bottom: 0.75rem; } -.firmware-form .fw-checkbox-row { margin-bottom: 0.75rem; } +.software-form .form-group { margin-bottom: 0.75rem; } +.software-form .sw-checkbox-row { margin-bottom: 0.75rem; } -.fw-checkbox-row { +.sw-checkbox-row { display: flex; align-items: center; gap: 0.5rem; @@ -1572,7 +1585,7 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); } cursor: pointer; user-select: none; } -.fw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; } +.sw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; } /* Multi-select dropdown built on <details> + checkboxes so the summary row looks like a native <select> trigger and the body holds the @@ -1637,21 +1650,21 @@ details[open] > .cfg-multi-summary { } .cfg-multi-item input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; } -.fw-result { +.sw-result { display: flex; align-items: center; gap: 0.75rem; font-size: 0.9rem; font-weight: 500; } -.fw-result svg { flex-shrink: 0; } -.fw-result-ok { color: var(--success); } -.fw-result-err { color: var(--danger); } -.fw-result-body { display: flex; flex-direction: column; } -.fw-result-actions { margin-top: 1rem; } +.sw-result svg { flex-shrink: 0; } +.sw-result-ok { color: var(--success); } +.sw-result-err { color: var(--danger); } +.sw-result-body { display: flex; flex-direction: column; } +.sw-result-actions { margin-top: 1rem; } -/* Compact pill badges used in the firmware slot list and boot-order row. */ -.fw-install-grid .badge-neutral { +/* Compact pill badges used in the software slot list and boot-order row. */ +.sw-install-grid .badge-neutral { background: var(--slate-200); font-size: 0.65rem; font-weight: 600; @@ -1662,9 +1675,9 @@ details[open] > .cfg-multi-summary { vertical-align: middle; } @media (prefers-color-scheme: dark) { - .fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); } + .sw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); } } -.dark .fw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); } +.dark .sw-install-grid .badge-neutral { background: var(--slate-700); color: var(--slate-300); } .progress-bar-wrap--flush { margin-top: 0; } @@ -2217,6 +2230,13 @@ select.cfg-input { cursor: pointer; } .cfg-input-sm { max-width: 8rem; } +/* Inputs in info-table cells must shrink to the column; without this the + intrinsic min-width of a datetime-local (wide in Firefox) forces the table + past the card edge. min-width:0 isn't enough on its own for datetime-local, + so cards with one use .info-table-fixed (table-layout: fixed + <colgroup>) + to cap the columns and make the input shrink. */ +.info-table .cfg-input { min-width: 0; } +.info-table-fixed { table-layout: fixed; } .cfg-input-mono { font-family: var(--font-mono); font-size: 0.85rem; } .cfg-reset-col { width: 1%; white-space: nowrap; padding-left: 0.25rem; } .cfg-section-head { font-size: 0.85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); margin: 0 0 0.4rem; padding-bottom: 0.25rem; border-bottom: 1px solid var(--border); } @@ -2833,12 +2853,14 @@ details[open].yt-leaf-item .yt-leaf-name::before { transform: rotate(90deg); } .field-info { display: inline-block; cursor: help; - font-size: 0.72rem; + font-size: 0.85em; font-weight: normal; color: var(--fg-muted); margin-left: 0.3rem; vertical-align: middle; line-height: 1; + position: relative; + top: -2px; } #field-tip { position: fixed; diff --git a/src/webui/static/img/icons/README.md b/src/webui/static/img/icons/README.md new file mode 100644 index 00000000..06861d18 --- /dev/null +++ b/src/webui/static/img/icons/README.md @@ -0,0 +1,57 @@ +# Sidebar icons + +All icons in this directory are from [Lucide][1] (ISC-licensed), +vendored locally so the WebUI works on air-gapped industrial sites +without an external CDN. + +Each file is the upstream `<lucide>/icons/<name>.svg` with the `width` +and `height` attributes stripped so the icon scales to its CSS-defined +size in the sidebar. Stroke styling (`fill="none"`, +`stroke="currentColor"`, `stroke-width="2"`, rounded caps/joins) is left +untouched. + +| File | Lucide source | +|-------------------|-----------------------| +| advanced.svg | folder-git-2 | +| backup.svg | archive-restore | +| containers.svg | container | +| dashboard.svg | circle-gauge | +| dhcp.svg | network | +| dns.svg | globe | +| firewall.svg | shield-check | +| hardware.svg | cpu | +| interfaces.svg | ethernet-port | +| keystore.svg | key-round | +| lldp.svg | radio-tower | +| mdns.svg | radio | +| nacm.svg | users | +| ntp.svg | clock | +| routes.svg | git-compare-arrows | +| routing.svg | git-compare-arrows | +| services.svg | library-big | +| software.svg | hard-drive-download | +| status-tree.svg | text-search | +| system-control.svg| power | +| system.svg | settings | +| wifi.svg | wifi | +| wireguard.svg | globe-lock | + +## Replacing or adding an icon + +1. Pick the icon at [lucide.dev/icons][2]. +2. Download the SVG (or copy from `lucide-icons/lucide`'s `icons/` + directory on GitHub). +3. Drop the `width` and `height` attributes from the opening `<svg>` + tag. +4. Save under the destination filename (the sidebar template references + files by purpose, not by upstream Lucide name). +5. Update the table above. + +## License + +Lucide is distributed under the [ISC license][3]. The license text is +preserved in the upstream repository. + +[1]: https://lucide.dev +[2]: https://lucide.dev/icons/ +[3]: https://github.com/lucide-icons/lucide/blob/main/LICENSE diff --git a/src/webui/static/img/icons/advanced.svg b/src/webui/static/img/icons/advanced.svg index 8fcfe9a5..d16a1bbf 100644 --- a/src/webui/static/img/icons/advanced.svg +++ b/src/webui/static/img/icons/advanced.svg @@ -1,11 +1,6 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <line x1="21" y1="4" x2="14" y2="4"/> - <line x1="10" y1="4" x2="3" y2="4"/> - <line x1="21" y1="12" x2="12" y2="12"/> - <line x1="8" y1="12" x2="3" y2="12"/> - <line x1="21" y1="20" x2="16" y2="20"/> - <line x1="12" y1="20" x2="3" y2="20"/> - <line x1="14" y1="2" x2="14" y2="6"/> - <line x1="8" y1="10" x2="8" y2="14"/> - <line x1="16" y1="18" x2="16" y2="22"/> + <path d="M18 19a5 5 0 0 1-5-5v8" /> + <path d="M9 20H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H20a2 2 0 0 1 2 2v5" /> + <circle cx="13" cy="12" r="2" /> + <circle cx="20" cy="19" r="2" /> </svg> diff --git a/src/webui/static/img/icons/backup.svg b/src/webui/static/img/icons/backup.svg new file mode 100644 index 00000000..67640e73 --- /dev/null +++ b/src/webui/static/img/icons/backup.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect width="20" height="5" x="2" y="3" rx="1" /> + <path d="M4 8v11a2 2 0 0 0 2 2h2" /> + <path d="M20 8v11a2 2 0 0 1-2 2h-2" /> + <path d="m9 15 3-3 3 3" /> + <path d="M12 12v9" /> +</svg> diff --git a/src/webui/static/img/icons/containers.svg b/src/webui/static/img/icons/containers.svg index a6389999..cee19d2f 100644 --- a/src/webui/static/img/icons/containers.svg +++ b/src/webui/static/img/icons/containers.svg @@ -1,5 +1,7 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path> - <polyline points="3.27 6.96 12 12.01 20.73 6.96"></polyline> - <line x1="12" y1="22.08" x2="12" y2="12"></line> -</svg> \ No newline at end of file + <path d="M22 7.7c0-.6-.4-1.2-.8-1.5l-6.3-3.9a1.72 1.72 0 0 0-1.7 0l-10.3 6c-.5.2-.9.8-.9 1.4v6.6c0 .5.4 1.2.8 1.5l6.3 3.9a1.72 1.72 0 0 0 1.7 0l10.3-6c.5-.3.9-1 .9-1.5Z" /> + <path d="M10 21.9V14L2.1 9.1" /> + <path d="m10 14 11.9-6.9" /> + <path d="M14 19.8v-8.1" /> + <path d="M18 17.5V9.4" /> +</svg> diff --git a/src/webui/static/img/icons/dashboard.svg b/src/webui/static/img/icons/dashboard.svg index 0ec39729..3e5a8c62 100644 --- a/src/webui/static/img/icons/dashboard.svg +++ b/src/webui/static/img/icons/dashboard.svg @@ -1,6 +1,5 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="3" y="3" width="7" height="7"></rect> - <rect x="14" y="3" width="7" height="7"></rect> - <rect x="14" y="14" width="7" height="7"></rect> - <rect x="3" y="14" width="7" height="7"></rect> -</svg> \ No newline at end of file + <path d="M15.6 2.7a10 10 0 1 0 5.7 5.7" /> + <circle cx="12" cy="12" r="2" /> + <path d="M13.4 10.6 19 5" /> +</svg> diff --git a/src/webui/static/img/icons/dhcp.svg b/src/webui/static/img/icons/dhcp.svg index 67fc54cb..fe0c7d94 100644 --- a/src/webui/static/img/icons/dhcp.svg +++ b/src/webui/static/img/icons/dhcp.svg @@ -1,9 +1,7 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="6" y="3" width="12" height="5" rx="1"/> - <line x1="9" y1="8" x2="6" y2="15"/> - <line x1="12" y1="8" x2="12" y2="15"/> - <line x1="15" y1="8" x2="18" y2="15"/> - <circle cx="6" cy="17" r="2"/> - <circle cx="12" cy="17" r="2"/> - <circle cx="18" cy="17" r="2"/> + <rect x="16" y="16" width="6" height="6" rx="1" /> + <rect x="2" y="16" width="6" height="6" rx="1" /> + <rect x="9" y="2" width="6" height="6" rx="1" /> + <path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3" /> + <path d="M12 12V8" /> </svg> diff --git a/src/webui/static/img/icons/dns.svg b/src/webui/static/img/icons/dns.svg new file mode 100644 index 00000000..fe14646e --- /dev/null +++ b/src/webui/static/img/icons/dns.svg @@ -0,0 +1,5 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <circle cx="12" cy="12" r="10" /> + <path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20" /> + <path d="M2 12h20" /> +</svg> diff --git a/src/webui/static/img/icons/firewall.svg b/src/webui/static/img/icons/firewall.svg index e4058c25..77114bfd 100644 --- a/src/webui/static/img/icons/firewall.svg +++ b/src/webui/static/img/icons/firewall.svg @@ -1,3 +1,4 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path> -</svg> \ No newline at end of file + <path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" /> + <path d="m9 12 2 2 4-4" /> +</svg> diff --git a/src/webui/static/img/icons/firmware.svg b/src/webui/static/img/icons/firmware.svg deleted file mode 100644 index a7669492..00000000 --- a/src/webui/static/img/icons/firmware.svg +++ /dev/null @@ -1,5 +0,0 @@ -<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="4" y="4" width="16" height="16" rx="2" ry="2"></rect> - <rect x="9" y="9" width="6" height="6"></rect> - <path d="M9 1v3M15 1v3M9 20v3M15 20v3M20 9h3M20 15h3M1 9h3M1 15h3"></path> -</svg> \ No newline at end of file diff --git a/src/webui/static/img/icons/hardware.svg b/src/webui/static/img/icons/hardware.svg index 8701c064..6220b461 100644 --- a/src/webui/static/img/icons/hardware.svg +++ b/src/webui/static/img/icons/hardware.svg @@ -1,5 +1,16 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="4" y="4" width="16" height="16" rx="2"/> - <rect x="9" y="9" width="6" height="6"/> - <path d="M9 2v2M15 2v2M9 20v2M15 20v2M2 9h2M2 15h2M20 9h2M20 15h2"/> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 20v2" /> + <path d="M12 2v2" /> + <path d="M17 20v2" /> + <path d="M17 2v2" /> + <path d="M2 12h2" /> + <path d="M2 17h2" /> + <path d="M2 7h2" /> + <path d="M20 12h2" /> + <path d="M20 17h2" /> + <path d="M20 7h2" /> + <path d="M7 20v2" /> + <path d="M7 2v2" /> + <rect x="4" y="4" width="16" height="16" rx="2" /> + <rect x="8" y="8" width="8" height="8" rx="1" /> </svg> diff --git a/src/webui/static/img/icons/interfaces.svg b/src/webui/static/img/icons/interfaces.svg index 9d82f461..d0842d7b 100644 --- a/src/webui/static/img/icons/interfaces.svg +++ b/src/webui/static/img/icons/interfaces.svg @@ -1,8 +1,7 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="9" y="2" width="6" height="4"/> - <rect x="2" y="18" width="6" height="4"/> - <rect x="16" y="18" width="6" height="4"/> - <line x1="12" y1="6" x2="12" y2="13"/> - <line x1="5" y1="18" x2="12" y2="13"/> - <line x1="19" y1="18" x2="12" y2="13"/> + <path d="m15 20 3-3h2a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h2l3 3z" /> + <path d="M6 8v1" /> + <path d="M10 8v1" /> + <path d="M14 8v1" /> + <path d="M18 8v1" /> </svg> diff --git a/src/webui/static/img/icons/keystore.svg b/src/webui/static/img/icons/keystore.svg index cb9a9e93..2490743a 100644 --- a/src/webui/static/img/icons/keystore.svg +++ b/src/webui/static/img/icons/keystore.svg @@ -1,4 +1,4 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="5" y="11" width="14" height="11" rx="2" ry="2"/> - <path d="M7 11V7a5 5 0 0 1 10 0v4"/> + <path d="M2.586 17.414A2 2 0 0 0 2 18.828V21a1 1 0 0 0 1 1h3a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h1a1 1 0 0 0 1-1v-1a1 1 0 0 1 1-1h.172a2 2 0 0 0 1.414-.586l.814-.814a6.5 6.5 0 1 0-4-4z" /> + <circle cx="16.5" cy="7.5" r=".5" fill="currentColor" /> </svg> diff --git a/src/webui/static/img/icons/lldp.svg b/src/webui/static/img/icons/lldp.svg index b6e2ac80..33645edc 100644 --- a/src/webui/static/img/icons/lldp.svg +++ b/src/webui/static/img/icons/lldp.svg @@ -1,7 +1,9 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="6" cy="6" r="3"></circle> - <circle cx="6" cy="18" r="3"></circle> - <circle cx="18" cy="12" r="3"></circle> - <line x1="9" y1="6" x2="15" y2="10"></line> - <line x1="9" y1="18" x2="15" y2="14"></line> -</svg> \ No newline at end of file + <path d="M4.9 16.1C1 12.2 1 5.8 4.9 1.9" /> + <path d="M7.8 4.7a6.14 6.14 0 0 0-.8 7.5" /> + <circle cx="12" cy="9" r="2" /> + <path d="M16.2 4.8c2 2 2.26 5.11.8 7.47" /> + <path d="M19.1 1.9a9.96 9.96 0 0 1 0 14.1" /> + <path d="M9.5 18h5" /> + <path d="m8 22 4-11 4 11" /> +</svg> diff --git a/src/webui/static/img/icons/mdns.svg b/src/webui/static/img/icons/mdns.svg index 8f876fdf..e675a1a7 100644 --- a/src/webui/static/img/icons/mdns.svg +++ b/src/webui/static/img/icons/mdns.svg @@ -1,7 +1,7 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="12" cy="12" r="3"></circle> - <path d="M6.3 6.3a8 8 0 0 0 0 11.4"></path> - <path d="M17.7 6.3a8 8 0 0 1 0 11.4"></path> - <path d="M3.5 3.5a14 14 0 0 0 0 17"></path> - <path d="M20.5 3.5a14 14 0 0 1 0 17"></path> + <path d="M16.247 7.761a6 6 0 0 1 0 8.478" /> + <path d="M19.075 4.933a10 10 0 0 1 0 14.134" /> + <path d="M4.925 19.067a10 10 0 0 1 0-14.134" /> + <path d="M7.753 16.239a6 6 0 0 1 0-8.478" /> + <circle cx="12" cy="12" r="2" /> </svg> diff --git a/src/webui/static/img/icons/nacm.svg b/src/webui/static/img/icons/nacm.svg index c8a9b0cf..070f3c4a 100644 --- a/src/webui/static/img/icons/nacm.svg +++ b/src/webui/static/img/icons/nacm.svg @@ -1,6 +1,6 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/> - <circle cx="9" cy="7" r="4"/> - <path d="M23 21v-2a4 4 0 0 0-3-3.87"/> - <path d="M16 3.13a4 4 0 0 1 0 7.75"/> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /> + <path d="M16 3.128a4 4 0 0 1 0 7.744" /> + <path d="M22 21v-2a4 4 0 0 0-3-3.87" /> + <circle cx="9" cy="7" r="4" /> </svg> diff --git a/src/webui/static/img/icons/ntp.svg b/src/webui/static/img/icons/ntp.svg index b6461c17..5efa3567 100644 --- a/src/webui/static/img/icons/ntp.svg +++ b/src/webui/static/img/icons/ntp.svg @@ -1,4 +1,4 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="12" cy="12" r="10"></circle> - <path d="M12 6v6l4 2"></path> -</svg> \ No newline at end of file + <circle cx="12" cy="12" r="10" /> + <path d="M12 6v6l4 2" /> +</svg> diff --git a/src/webui/static/img/icons/routes.svg b/src/webui/static/img/icons/routes.svg index 158e36c5..d0745adc 100644 --- a/src/webui/static/img/icons/routes.svg +++ b/src/webui/static/img/icons/routes.svg @@ -1,7 +1,8 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="5" cy="12" r="2"/> - <circle cx="19" cy="6" r="2"/> - <circle cx="19" cy="18" r="2"/> - <line x1="7" y1="11" x2="17" y2="7"/> - <line x1="7" y1="13" x2="17" y2="17"/> + <circle cx="5" cy="6" r="3" /> + <path d="M12 6h5a2 2 0 0 1 2 2v7" /> + <path d="m15 9-3-3 3-3" /> + <circle cx="19" cy="18" r="3" /> + <path d="M12 18H7a2 2 0 0 1-2-2V9" /> + <path d="m9 15 3 3-3 3" /> </svg> diff --git a/src/webui/static/img/icons/routing.svg b/src/webui/static/img/icons/routing.svg index 19ae58e7..d0745adc 100644 --- a/src/webui/static/img/icons/routing.svg +++ b/src/webui/static/img/icons/routing.svg @@ -1,8 +1,8 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="4" cy="12" r="2"/> - <line x1="6" y1="12" x2="11" y2="12"/> - <line x1="11" y1="12" x2="18" y2="6"/> - <line x1="11" y1="12" x2="18" y2="18"/> - <circle cx="20" cy="5" r="2"/> - <circle cx="20" cy="19" r="2"/> + <circle cx="5" cy="6" r="3" /> + <path d="M12 6h5a2 2 0 0 1 2 2v7" /> + <path d="m15 9-3-3 3-3" /> + <circle cx="19" cy="18" r="3" /> + <path d="M12 18H7a2 2 0 0 1-2-2V9" /> + <path d="m9 15 3 3-3 3" /> </svg> diff --git a/src/webui/static/img/icons/services.svg b/src/webui/static/img/icons/services.svg index 1385c730..1d8f82dd 100644 --- a/src/webui/static/img/icons/services.svg +++ b/src/webui/static/img/icons/services.svg @@ -1,5 +1,5 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <rect x="2" y="3" width="20" height="4" rx="1"/> - <rect x="2" y="10" width="20" height="4" rx="1"/> - <rect x="2" y="17" width="20" height="4" rx="1"/> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <rect width="8" height="18" x="3" y="3" rx="1" /> + <path d="M7 3v18" /> + <path d="M20.4 18.9c.2.5-.1 1.1-.6 1.3l-1.9.7c-.5.2-1.1-.1-1.3-.6L11.1 5.1c-.2-.5.1-1.1.6-1.3l1.9-.7c.5-.2 1.1.1 1.3.6Z" /> </svg> diff --git a/src/webui/static/img/icons/software.svg b/src/webui/static/img/icons/software.svg new file mode 100644 index 00000000..451b8696 --- /dev/null +++ b/src/webui/static/img/icons/software.svg @@ -0,0 +1,7 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 2v8" /> + <path d="m16 6-4 4-4-4" /> + <rect width="20" height="8" x="2" y="14" rx="2" /> + <path d="M6 18h.01" /> + <path d="M10 18h.01" /> +</svg> diff --git a/src/webui/static/img/icons/status-tree.svg b/src/webui/static/img/icons/status-tree.svg index 650ff59a..558e5ffb 100644 --- a/src/webui/static/img/icons/status-tree.svg +++ b/src/webui/static/img/icons/status-tree.svg @@ -1,4 +1,7 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> - <circle cx="12" cy="12" r="3"></circle> + <path d="M21 5H3" /> + <path d="M10 12H3" /> + <path d="M10 19H3" /> + <circle cx="17" cy="15" r="3" /> + <path d="m21 19-1.9-1.9" /> </svg> diff --git a/src/webui/static/img/icons/system-control.svg b/src/webui/static/img/icons/system-control.svg new file mode 100644 index 00000000..a651b647 --- /dev/null +++ b/src/webui/static/img/icons/system-control.svg @@ -0,0 +1,4 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M12 2v10" /> + <path d="M18.4 6.6a9 9 0 1 1-12.77.04" /> +</svg> diff --git a/src/webui/static/img/icons/system.svg b/src/webui/static/img/icons/system.svg index 6f08138c..d7c22591 100644 --- a/src/webui/static/img/icons/system.svg +++ b/src/webui/static/img/icons/system.svg @@ -1,5 +1,4 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <circle cx="12" cy="12" r="3"/> - <path d="M19.07 4.93a10 10 0 0 1 0 14.14M4.93 4.93a10 10 0 0 0 0 14.14"/> - <path d="M12 2v2M12 20v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M2 12h2M20 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> + <path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> + <circle cx="12" cy="12" r="3" /> </svg> diff --git a/src/webui/static/img/icons/wifi.svg b/src/webui/static/img/icons/wifi.svg index a344e5a3..16dbd603 100644 --- a/src/webui/static/img/icons/wifi.svg +++ b/src/webui/static/img/icons/wifi.svg @@ -1,6 +1,6 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M5 12.55a11 11 0 0 1 14.08 0"></path> - <path d="M1.42 9a16 16 0 0 1 21.16 0"></path> - <path d="M8.53 16.11a6 6 0 0 1 6.95 0"></path> - <line x1="12" y1="20" x2="12.01" y2="20"></line> -</svg> \ No newline at end of file + <path d="M12 20h.01" /> + <path d="M2 8.82a15 15 0 0 1 20 0" /> + <path d="M5 12.859a10 10 0 0 1 14 0" /> + <path d="M8.5 16.429a5 5 0 0 1 7 0" /> +</svg> diff --git a/src/webui/static/img/icons/wireguard.svg b/src/webui/static/img/icons/wireguard.svg index b321cd2d..46ff2b6b 100644 --- a/src/webui/static/img/icons/wireguard.svg +++ b/src/webui/static/img/icons/wireguard.svg @@ -1,5 +1,6 @@ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path> - <circle cx="12" cy="11" r="2"></circle> - <path d="M12 13v4"></path> -</svg> \ No newline at end of file + <path d="M15.686 15A14.5 14.5 0 0 1 12 22a14.5 14.5 0 0 1 0-20 10 10 0 1 0 9.542 13" /> + <path d="M2 12h8.5" /> + <path d="M20 6V4a2 2 0 1 0-4 0v2" /> + <rect width="8" height="5" x="14" y="6" rx="1" /> +</svg> diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 4072b9f6..54b7b403 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -14,17 +14,69 @@ }); } + // HTMX page navigation swaps only #content, leaving the <title> in <head> + // stale. Each rendered page emits a setPageTitle HX-Trigger event (set in + // handlers/common.go newPageData) carrying the full "Page · Context" title + // string ready to apply. + function initPageTitle() { + document.body.addEventListener('setPageTitle', function (evt) { + if (evt.detail && typeof evt.detail.value === 'string') { + document.title = evt.detail.value; + } + }); + } + // The banner reflects htmx request outcomes — both user interactions and - // the `hx-trigger="every 30s"` watchdog div in base.html. + // the `hx-trigger="every 5s"` watchdog div in base.html. // // The watchdog request gets a 5 s timeout (via configRequest), bounding - // disconnect detection at ~30 s + 5 s. Without it the XHR would sit on the + // disconnect detection at ~5 s + 5 s. Without it the XHR would sit on the // OS TCP timeout (1–2 min) before any error fires. + // + // Once we see a failure, a dead-man timer counts down to a forced + // navigation to /login. The device may have been rebooted or upgraded + // out of band; in that case its in-memory session is gone and the next + // poll would 401 → HX-Redirect → /login automatically. But if the + // device is truly unreachable (cable pulled, route flap), no response + // ever comes, so we time out client-side instead of letting the user + // stare at a stale page. function initDeviceStatusBanner() { var banner = document.getElementById('conn-banner'); if (!banner) return; - var show = function () { banner.hidden = false; }; - var hide = function () { banner.hidden = true; }; + + var REDIRECT_AFTER_MS = 90 * 1000; + var firstFailureMs = null; + var tickerId = null; + + function tick() { + var remaining = Math.max(0, Math.ceil( + (firstFailureMs + REDIRECT_AFTER_MS - Date.now()) / 1000 + )); + banner.textContent = + 'Device unreachable — returning to login in ' + remaining + ' s'; + if (remaining === 0) { + clearInterval(tickerId); + window.location.replace('/login'); + } + } + + function show() { + banner.hidden = false; + if (tickerId !== null) return; + firstFailureMs = Date.now(); + tick(); + tickerId = setInterval(tick, 1000); + } + + function hide() { + banner.hidden = true; + banner.textContent = 'Device unreachable'; + firstFailureMs = null; + if (tickerId !== null) { + clearInterval(tickerId); + tickerId = null; + } + } document.addEventListener('htmx:configRequest', function (evt) { if (evt.detail && evt.detail.path === '/device-status') { @@ -38,7 +90,15 @@ if (s === 502 || s === 503 || s === 504) show(); }); document.addEventListener('htmx:afterRequest', function (evt) { - if (evt.detail && evt.detail.successful) hide(); + if (!evt.detail) return; + var xhr = evt.detail.xhr; + var status = xhr ? xhr.status : 0; + // Any HTTP response < 500 means the server is reachable — hide. + // (Pre-fix the banner stuck on after the next poll returned 404 + // because the old check used `successful`, which is true only for + // 2xx.) status === 0 = no response, leave the banner alone; + // sendError / timeout handles it. + if (status > 0 && status < 500) hide(); }); } @@ -89,8 +149,8 @@ initProgressBars(root); startRebootOverlay(root); initDatetimePicker(root); - fwBootInit(root); - fwUploadInit(root); + swBootInit(root); + swUploadInit(root); initRestoreCheckbox(root); initYangTree(root); initMultiDropdown(root); @@ -165,11 +225,11 @@ }); } - function fwUploadInit(scope) { - var btn = (scope || document).querySelector('#fw-upload-btn'); + function swUploadInit(scope) { + var btn = (scope || document).querySelector('#sw-upload-btn'); if (!btn || btn.dataset.init) return; btn.dataset.init = 'true'; - btn.addEventListener('click', window.fwUpload); + btn.addEventListener('click', window.swUpload); } function initRestoreCheckbox(scope) { @@ -179,36 +239,36 @@ cb.addEventListener('change', function () { window.scRestoreCheckbox(cb); }); } - // ─── Firmware: boot order drag-and-drop ────────────────────────────────── - function fwBootInit(scope) { - var slots = (scope || document).querySelector('#fw-boot-slots'); + // ─── Software: boot order drag-and-drop ────────────────────────────────── + function swBootInit(scope) { + var slots = (scope || document).querySelector('#sw-boot-slots'); if (!slots || slots.dataset.dndInit) return; slots.dataset.dndInit = 'true'; // Stash the page-load order so Reset can restore it without a page refresh. // Slot names are a fixed enum (primary | secondary | net), so comma-joining is safe. var initialOrder = []; - slots.querySelectorAll('.fw-boot-badge').forEach(function (b) { initialOrder.push(b.dataset.slot); }); + slots.querySelectorAll('.sw-boot-badge').forEach(function (b) { initialOrder.push(b.dataset.slot); }); slots.dataset.originalOrder = initialOrder.join(','); var dragging = null; var insertRef = undefined; // node to insertBefore; undefined = not set, null = append function clearIndicators() { - slots.querySelectorAll('.fw-boot-drop-before').forEach(function (el) { - el.classList.remove('fw-boot-drop-before'); + slots.querySelectorAll('.sw-boot-drop-before').forEach(function (el) { + el.classList.remove('sw-boot-drop-before'); }); } slots.addEventListener('dragstart', function (e) { - dragging = e.target.closest('.fw-boot-badge'); + dragging = e.target.closest('.sw-boot-badge'); if (!dragging) return; - dragging.classList.add('fw-boot-dragging'); + dragging.classList.add('sw-boot-dragging'); e.dataTransfer.effectAllowed = 'move'; }); slots.addEventListener('dragend', function () { - if (dragging) dragging.classList.remove('fw-boot-dragging'); + if (dragging) dragging.classList.remove('sw-boot-dragging'); dragging = null; insertRef = undefined; clearIndicators(); @@ -220,17 +280,17 @@ e.preventDefault(); e.dataTransfer.dropEffect = 'move'; if (!dragging) return; - var target = e.target.closest('.fw-boot-badge'); + var target = e.target.closest('.sw-boot-badge'); clearIndicators(); if (!target || target === dragging) return; var rect = target.getBoundingClientRect(); if (e.clientX < rect.left + rect.width / 2) { insertRef = target; - target.classList.add('fw-boot-drop-before'); + target.classList.add('sw-boot-drop-before'); } else { insertRef = target.nextElementSibling || null; - if (insertRef && insertRef.classList.contains('fw-boot-badge')) { - insertRef.classList.add('fw-boot-drop-before'); + if (insertRef && insertRef.classList.contains('sw-boot-badge')) { + insertRef.classList.add('sw-boot-drop-before'); } } }); @@ -243,23 +303,23 @@ insertRef = undefined; }); - var saveBtn = document.getElementById('fw-boot-save-btn'); - if (saveBtn) saveBtn.addEventListener('click', function () { window.fwBootSave(saveBtn); }); + var saveBtn = document.getElementById('sw-boot-save-btn'); + if (saveBtn) saveBtn.addEventListener('click', function () { window.swBootSave(saveBtn); }); - var resetBtn = document.getElementById('fw-boot-reset-btn'); - if (resetBtn) resetBtn.addEventListener('click', window.fwBootReset); + var resetBtn = document.getElementById('sw-boot-reset-btn'); + if (resetBtn) resetBtn.addEventListener('click', window.swBootReset); } // Restore the boot-order row to the page-load order — i.e. what RAUC // reported as the current device boot order before any drag/drop. // Set will push the displayed order to the device; Reset just undoes // local rearrangement without a server round-trip. - window.fwBootReset = function () { - var slots = document.getElementById('fw-boot-slots'); + window.swBootReset = function () { + var slots = document.getElementById('sw-boot-slots'); if (!slots) return; var original = (slots.dataset.originalOrder || '').split(','); var existing = {}; - slots.querySelectorAll('.fw-boot-badge').forEach(function (b) { + slots.querySelectorAll('.sw-boot-badge').forEach(function (b) { existing[b.dataset.slot] = b; }); original.forEach(function (slot) { @@ -267,8 +327,8 @@ }); }; - window.fwBootSave = function (btn) { - var badges = document.querySelectorAll('#fw-boot-slots .fw-boot-badge'); + window.swBootSave = function (btn) { + var badges = document.querySelectorAll('#sw-boot-slots .sw-boot-badge'); var params = new URLSearchParams(); badges.forEach(function (b) { params.append('boot-order', b.dataset.slot); }); @@ -280,7 +340,7 @@ btnSet('Setting\u2026', true); - fetch('/firmware/boot-order', { + fetch('/software/boot-order', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', @@ -291,7 +351,7 @@ if (r.ok) { // Device now holds the displayed order; refresh the Reset baseline // so a follow-up \u21ba doesn't revert to a state the device no longer has. - var slots = document.getElementById('fw-boot-slots'); + var slots = document.getElementById('sw-boot-slots'); if (slots) { var saved = []; badges.forEach(function (b) { saved.push(b.dataset.slot); }); @@ -311,28 +371,95 @@ }); }; - // ─── System Control: datetime picker ───────────────────────────────────── - // Pre-fills #sc-dt-input with the browser's current UTC time on page load - // and after HTMX swaps. Also exposed as window.scSyncTime() for the - // "Browser time" button. - function utcDatetimeLocal() { + // ─── Configure > Date & Time card ──────────────────────────────────────── + // Two pieces working together: + // 1. #sc-dt-input is pre-filled with the browser's current local time so + // "Set now" defaults to "synchronize device to my clock". + // 2. #sc-system-time is a JS-driven live clock seeded from the server's + // data-server-time attribute (ISO string). We compute the offset + // between device clock and browser clock once, then tick locally and + // render via toLocaleString() so each user sees their own locale. + // After a successful POST /maintenance/system/datetime we recompute + // the offset from the value the user submitted so the visible clock + // jumps to the new time and continues ticking. + var systemTimeOffsetMs = null; // device - browser + + function localDatetimeLocal() { + // Format Date as YYYY-MM-DDTHH:MM:SS in *local* time, matching what + // <input type="datetime-local"> stores in its .value field. var d = new Date(); - return d.getUTCFullYear() + '-' + - String(d.getUTCMonth() + 1).padStart(2, '0') + '-' + - String(d.getUTCDate()).padStart(2, '0') + 'T' + - String(d.getUTCHours()).padStart(2, '0') + ':' + - String(d.getUTCMinutes()).padStart(2, '0'); + return d.getFullYear() + '-' + + String(d.getMonth() + 1).padStart(2, '0') + '-' + + String(d.getDate()).padStart(2, '0') + 'T' + + String(d.getHours()).padStart(2, '0') + ':' + + String(d.getMinutes()).padStart(2, '0') + ':' + + String(d.getSeconds()).padStart(2, '0'); + } + + function renderSystemTime(span) { + if (systemTimeOffsetMs === null) return; + var d = new Date(Date.now() + systemTimeOffsetMs); + span.textContent = d.toLocaleString(); } function initDatetimePicker(root) { - var el = (root || document).querySelector('#sc-dt-input:not([data-init])'); - if (!el) return; - el.dataset.init = 'true'; - el.value = utcDatetimeLocal(); - var btn = (root || document).querySelector('#sc-sync-time-btn'); - if (btn) btn.addEventListener('click', function () { el.value = utcDatetimeLocal(); }); + // Always pre-fill an empty picker; htmx swaps replace #content so each + // visit gets a fresh element, and overwriting an empty value is a no-op. + var input = (root || document).querySelector('#sc-dt-input'); + if (input && !input.value) { + input.value = localDatetimeLocal(); + } + + var span = (root || document).querySelector('#sc-system-time'); + if (span && span.dataset.serverTime) { + // Server omits the timezone offset; treat the value as the device's + // local wall clock, which is what the Timezone row tells the user. + var deviceMs = new Date(span.dataset.serverTime).getTime(); + systemTimeOffsetMs = deviceMs - Date.now(); + renderSystemTime(span); + // Cancel any prior tick from an earlier htmx navigation — htmx + // replaces #content but doesn't notify JS to clean up timers, + // so without this each visit would leak a 1 Hz interval pinning + // an orphaned span. + if (window.scSystemTimeTimer) clearInterval(window.scSystemTimeTimer); + window.scSystemTimeTimer = setInterval(function () { renderSystemTime(span); }, 1000); + } } + // The <input type="datetime-local"> reports its value in the user's local + // time without a zone suffix; the server appends "+00:00" unconditionally, + // so without this hook a CEST user typing 18:55 would set the device to + // 18:55 UTC instead of 16:55 UTC. Convert the parameter to UTC just + // before it goes on the wire so the device clock matches the moment the + // user actually selected. Attached to `document` (not document.body) + // because this code runs at IIFE parse time before <body> exists. + document.addEventListener('htmx:configRequest', function (evt) { + if (!evt.detail || evt.detail.path !== '/maintenance/system/datetime') return; + var local = evt.detail.parameters && evt.detail.parameters.datetime; + if (!local) return; + var d = new Date(local); + if (isNaN(d.getTime())) return; + // toISOString gives "YYYY-MM-DDTHH:MM:SS.sssZ"; strip millis so the + // server's "+00:00" append produces valid RFC 3339. + evt.detail.parameters.datetime = d.toISOString().slice(0, 19); + }); + + // After a successful Set POST, re-sync the live clock to the value the + // user just submitted so the display jumps instead of waiting for the next + // page load. + document.addEventListener('htmx:afterRequest', function (evt) { + if (!evt.detail || !evt.detail.successful) return; + var path = evt.detail.requestConfig && evt.detail.requestConfig.path; + if (path !== '/maintenance/system/datetime') return; + var input = document.getElementById('sc-dt-input'); + var span = document.getElementById('sc-system-time'); + if (!input || !span || !input.value) return; + var submittedMs = new Date(input.value).getTime(); + if (isNaN(submittedMs)) return; + systemTimeOffsetMs = submittedMs - Date.now(); + renderSystemTime(span); + }); + window.scRestoreCheckbox = function (cb) { var form = document.getElementById('restore-form'); if (!form) return; @@ -341,16 +468,16 @@ : 'Apply this configuration to the running system?'); }; - // Locate or inject the shared #fw-progress-card. Server-rendered when the + // Locate or inject the shared #sw-progress-card. Server-rendered when the // page loads with ?installing=1; injected here when the upload flow starts - // from /firmware. The same DOM element later receives SSE-driven swaps. + // from /software. The same DOM element later receives SSE-driven swaps. function ensureProgressCard(headerText, message) { - var card = document.getElementById('fw-progress-card'); + var card = document.getElementById('sw-progress-card'); if (!card) { card = document.createElement('section'); - card.id = 'fw-progress-card'; + card.id = 'sw-progress-card'; card.className = 'info-card'; - var grid = document.querySelector('.fw-install-grid'); + var grid = document.querySelector('.sw-install-grid'); var parent = grid && grid.parentNode; if (parent) parent.insertBefore(card, grid); else (document.getElementById('content') || document.body).appendChild(card); @@ -368,16 +495,16 @@ return card; } - window.fwUpload = function () { - var fileInput = document.getElementById('fw-file'); + window.swUpload = function () { + var fileInput = document.getElementById('sw-file'); if (!fileInput || !fileInput.files.length) return; - if (!confirm('Upload and install this firmware? The current installation may be overwritten.')) return; + if (!confirm('Upload and install this software bundle? The current installation may be overwritten.')) return; var file = fileInput.files[0]; - var autoReboot = !!document.querySelector('#fw-upload-auto-reboot:checked'); - var btn = document.getElementById('fw-upload-btn'); + var autoReboot = !!document.querySelector('#sw-upload-auto-reboot:checked'); + var btn = document.getElementById('sw-upload-btn'); - var sseURL = new URL((btn && btn.getAttribute('data-sse-url')) || '/firmware/progress', window.location.origin); + var sseURL = new URL((btn && btn.getAttribute('data-sse-url')) || '/software/progress', window.location.origin); if (autoReboot) sseURL.searchParams.set('auto-reboot', '1'); var formData = new FormData(); @@ -385,7 +512,7 @@ if (autoReboot) formData.append('auto-reboot', '1'); if (btn) btn.disabled = true; - var card = ensureProgressCard('Uploading Firmware', 'Uploading firmware image… 0%'); + var card = ensureProgressCard('Uploading Bundle', 'Uploading bundle… 0%'); var bar = card.querySelector('.progress-bar'); var text = card.querySelector('.progress-text'); @@ -395,7 +522,7 @@ if (!e.lengthComputable) return; var pct = Math.round(e.loaded / e.total * 100); bar.style.width = pct + '%'; - text.textContent = 'Uploading firmware image\u2026 ' + pct + '%'; + text.textContent = 'Uploading bundle\u2026 ' + pct + '%'; }; xhr.onload = function () { @@ -405,13 +532,13 @@ return; } // pushState lets a mid-install reload resume the progress card. - var target = xhr.responseText.trim() || '/firmware?installing=1'; + var target = xhr.responseText.trim() || '/software?installing=1'; if (window.history && window.history.pushState) { window.history.pushState({}, '', target); } text.textContent = 'Starting installation\u2026'; card.setAttribute('data-sse-src', sseURL.pathname + sseURL.search); - initFirmwareProgress(document); + initSoftwareProgress(document); }; xhr.onerror = function () { @@ -419,25 +546,25 @@ if (btn) btn.disabled = false; }; - xhr.open('POST', '/firmware/upload'); + xhr.open('POST', '/software/upload'); xhr.setRequestHeader('X-CSRF-Token', getCSRFToken()); xhr.send(formData); }; - // SSE-driven firmware progress card. + // SSE-driven software install progress card. // The Go server polls RESTCONF and streams rendered HTML fragments; we just // swap them into the card and let the server close the stream when done. - var fwEventSource = null; + var swEventSource = null; - function initFirmwareProgress(root) { + function initSoftwareProgress(root) { var scope = root || document; - var card = scope.querySelector('#fw-progress-card[data-sse-src]'); + var card = scope.querySelector('#sw-progress-card[data-sse-src]'); if (!card || card.dataset.sseInit) return; card.dataset.sseInit = 'true'; var src = card.getAttribute('data-sse-src'); - if (fwEventSource) { fwEventSource.close(); } - fwEventSource = new EventSource(src); + if (swEventSource) { swEventSource.close(); } + swEventSource = new EventSource(src); function swap(html) { card.innerHTML = html; @@ -446,24 +573,24 @@ } function endStream() { - fwEventSource.close(); - fwEventSource = null; + swEventSource.close(); + swEventSource = null; // Drop the stream URL and re-arm the upload button so a follow-up // install can run on the same page without a reload. card.removeAttribute('data-sse-src'); delete card.dataset.sseInit; - var btn = document.getElementById('fw-upload-btn'); + var btn = document.getElementById('sw-upload-btn'); if (btn) btn.disabled = false; } - fwEventSource.addEventListener('progress', function(e) { swap(e.data); }); + swEventSource.addEventListener('progress', function(e) { swap(e.data); }); - fwEventSource.addEventListener('done', function(e) { + swEventSource.addEventListener('done', function(e) { swap(e.data); endStream(); }); - fwEventSource.addEventListener('reboot', function(e) { + swEventSource.addEventListener('reboot', function(e) { swap(e.data); endStream(); // Auto-reboot: POST /reboot and show the reboot overlay. @@ -478,14 +605,23 @@ }); }); - fwEventSource.onerror = endStream; + // Let EventSource auto-reconnect on transient errors. Only tear down + // when the browser gives up (readyState === CLOSED) — otherwise an + // nginx read-timeout or a brief network blip would silently kill the + // stream and leave the progress card stuck on stale data. + swEventSource.onerror = function () { + if (swEventSource && swEventSource.readyState === EventSource.CLOSED) { + endStream(); + } + }; } document.addEventListener('DOMContentLoaded', function() { initCSRF(); + initPageTitle(); initDeviceStatusBanner(); initDynamicUI(document); - initFirmwareProgress(document); + initSoftwareProgress(document); // Attach the htmx swap listener here, not at IIFE parse time — at parse // time the script runs inside <head> before <body> exists, so the @@ -493,14 +629,14 @@ // navigations wouldn't re-init dynamic UI. if (window.htmx) { document.body.addEventListener('htmx:afterSwap', function (evt) { - // Close any open SSE stream if the firmware progress card is no longer present. - if (fwEventSource && !document.getElementById('fw-progress-card')) { - fwEventSource.close(); - fwEventSource = null; + // Close any open SSE stream if the software install progress card is no longer present. + if (swEventSource && !document.getElementById('sw-progress-card')) { + swEventSource.close(); + swEventSource = null; } var scope = (evt.detail && evt.detail.target) || document; initDynamicUI(scope); - initFirmwareProgress(scope); + initSoftwareProgress(scope); }); } }); @@ -619,18 +755,17 @@ // Interface page glue (replaces inline hx-on / inline <script>, which CSP // blocks under the `script-src 'self'` policy in middleware.go). (function () { - // After an htmx checkbox marked [data-toggle-details] completes its request, - // show/hide the next-sibling <details> element based on its checked state. - // Used for DHCP/DHCPv6 settings fold-out on the Configure → Interface page. - // Only react to successful requests so a server rejection doesn't lie about - // the new state. - document.addEventListener('htmx:afterRequest', function (evt) { - if (!evt.detail || !evt.detail.successful) return; - var cb = evt.detail.elt; - if (!cb || !cb.matches || !cb.matches('input[type="checkbox"][data-toggle-details]')) return; - var wrapper = cb.closest('div'); - var details = wrapper && wrapper.nextElementSibling; - if (details && details.tagName === 'DETAILS') details.hidden = !cb.checked; + // A checkbox marked [data-fold-target="<id>"] toggles the matching <details> + // (or any element with that id) immediately on change. Used for the DHCP / + // DHCPv6 settings foldouts on Configure → Interface: the foldout is always + // in the DOM but starts hidden when the client isn't enabled. This lets the + // user see the settings form before clicking Save IPvX Settings and confirms + // the section exists even when DHCP is off. + document.addEventListener('change', function (evt) { + var cb = evt.target; + if (!cb || !cb.matches || !cb.matches('input[type="checkbox"][data-fold-target]')) return; + var target = document.getElementById(cb.getAttribute('data-fold-target')); + if (target) target.hidden = !cb.checked; }); // Add Interface modal — open via data-show-modal, close via @@ -1617,11 +1752,34 @@ function renderCfgLog() { } }); + // findSaveStatusSpan locates the status span associated with the form that + // triggered an htmx event. Lookup order: + // 1. .cfg-save-status inside the form + // 2. [data-cfg-status-for="<form-id>"] anywhere on the page — used when the + // Save button is bound to the form via the HTML5 `form` attribute and + // lives outside the form element + // 3. .cfg-save-status inside the enclosing .info-card (shared feedback slot) + function findSaveStatusSpan(e) { + var form = e.target && e.target.closest('form'); + if (form) { + var inside = form.querySelector('.cfg-save-status'); + if (inside) return inside; + if (form.id) { + var bound = document.querySelector('.cfg-save-status[data-cfg-status-for="' + form.id + '"]'); + if (bound) return bound; + } + } + var card = e.target && e.target.closest('.info-card'); + return card ? card.querySelector('.cfg-save-status') : null; + } + // cfgError: show error message in the .cfg-save-status span of the submitting form. + // Falls back to the enclosing .info-card when the form itself has no status + // span — useful when multiple forms in one card share a single feedback slot + // (e.g. Date & Time's Set-now and Save-timezone forms). document.addEventListener('cfgError', function(e) { var msg = e.detail && e.detail.value ? e.detail.value : 'Save failed'; - var form = e.target && e.target.closest('form'); - var span = form ? form.querySelector('.cfg-save-status') : null; + var span = findSaveStatusSpan(e); if (span) { span.textContent = '✗ ' + msg; span.classList.add('error'); @@ -1642,7 +1800,7 @@ function renderCfgLog() { // Show "Saved ✓" feedback when a card Save succeeds. (function() { - var LS_KEY = 'fw-url-history'; + var LS_KEY = 'sw-url-history'; var MAX_HIST = 10; function loadHistory() { @@ -1658,7 +1816,7 @@ function renderCfgLog() { } function populateDatalist() { - var dl = document.getElementById('fw-url-history'); + var dl = document.getElementById('sw-url-history'); if (!dl) return; dl.innerHTML = loadHistory().map(function(u) { return '<option value="' + u.replace(/&/g, '&').replace(/"/g, '"') + '">'; @@ -1669,7 +1827,7 @@ function renderCfgLog() { document.addEventListener('htmx:afterSettle', populateDatalist); document.addEventListener('submit', function(e) { - var form = e.target.closest('.firmware-form'); + var form = e.target.closest('.software-form'); if (!form) return; var input = form.querySelector('input[name="url"]'); if (input && input.value) saveURL(input.value); @@ -1679,9 +1837,7 @@ function renderCfgLog() { document.addEventListener('cfgSaved', function(e) { var msg = e.detail && e.detail.value ? e.detail.value : 'Saved'; cfgLog('ok', msg); - // Find the status span closest to the form that triggered the event. - var form = e.target && e.target.closest('form'); - var span = form ? form.querySelector('.cfg-save-status') : null; + var span = findSaveStatusSpan(e); if (span) { span.textContent = '✓ ' + msg; span.classList.add('saved'); diff --git a/src/webui/templates/fragments/icons.html b/src/webui/templates/fragments/icons.html deleted file mode 100644 index 50660dba..00000000 --- a/src/webui/templates/fragments/icons.html +++ /dev/null @@ -1,3 +0,0 @@ -{{/* Shared inline SVG icons. Loaded via fragments/*.html in server.go. */}} - -{{define "icon-trash"}}<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"></path><path d="M10 11v6M14 11v6"></path></svg>{{end}} diff --git a/src/webui/templates/fragments/yang-leaf-group.html b/src/webui/templates/fragments/yang-leaf-group.html index e02538e4..0bb3556b 100644 --- a/src/webui/templates/fragments/yang-leaf-group.html +++ b/src/webui/templates/fragments/yang-leaf-group.html @@ -82,11 +82,7 @@ <div class="yt-binary-row"> <span class="yt-binary-hint">binary data</span> <button type="button" class="btn-icon yt-binary-eye" title="Show/hide"> - <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" - stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> - <circle cx="12" cy="12" r="3"></circle> - </svg> + {{template "icon-eye"}} </button> </div> <code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code> @@ -139,7 +135,7 @@ hx-delete="/configure/tree/node?path={{.Path | urlquery}}" hx-target="#yang-detail" hx-swap="innerHTML" - hx-confirm="Reset {{.Name}} to its YANG default?">↺</button> + hx-confirm="Reset {{.Name}} to its YANG default?">{{template "icon-reset"}}</button> {{end}} </summary> {{if $hasHelp}} @@ -191,10 +187,7 @@ hx-swap="innerHTML"> {{if $list.Complex}} <td class="yt-nav-col"> - <svg class="yt-row-arrow" width="12" height="12" viewBox="0 0 24 24" fill="none" - stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> - <polyline points="9 18 15 12 9 6"></polyline> - </svg> + {{template "icon-yt-row-arrow"}} </td> {{end}} {{range $list.Columns}}<td>{{index $row.Values .Name}}</td>{{end}} @@ -303,11 +296,7 @@ <div class="yt-binary-row"> <span class="yt-binary-hint">binary data</span> <button type="button" class="btn-icon yt-binary-eye" title="Show/hide"> - <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" - stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> - <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> - <circle cx="12" cy="12" r="3"></circle> - </svg> + {{template "icon-eye"}} </button> </div> <code class="yt-binary-content yt-binary-pem">{{if $cv}}{{$cv}}{{else}}{{.RawBase64}}{{end}}</code> @@ -357,7 +346,7 @@ hx-delete="/configure/tree/node?path={{.Path | urlquery}}" hx-target="#yang-detail" hx-swap="innerHTML" - hx-confirm="Reset {{.Name}} to its YANG default?">↺</button> + hx-confirm="Reset {{.Name}} to its YANG default?">{{template "icon-reset"}}</button> {{end}} </summary> {{if $hasHelp}} @@ -399,11 +388,7 @@ hx-target="#yang-detail" hx-swap="innerHTML"> <span class="yt-subsection-name">{{.Name}}</span> - <svg class="yt-subsection-arrow" width="14" height="14" viewBox="0 0 24 24" - fill="none" stroke="currentColor" stroke-width="2.5" - stroke-linecap="round" stroke-linejoin="round"> - <polyline points="9 18 15 12 9 6"></polyline> - </svg> + {{template "icon-yt-subsection-arrow"}} </button> {{end}} </div> diff --git a/src/webui/templates/fragments/yang-tree-node.html b/src/webui/templates/fragments/yang-tree-node.html index 0fb1ef7b..22edb052 100644 --- a/src/webui/templates/fragments/yang-tree-node.html +++ b/src/webui/templates/fragments/yang-tree-node.html @@ -17,10 +17,7 @@ hx-get="{{.TreeBase}}/node?path={{.Path | urlquery}}" hx-target="#yang-detail" hx-swap="innerHTML"> - <svg class="yt-chevron" width="10" height="10" viewBox="0 0 24 24" fill="none" - stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"> - <polyline points="9 18 15 12 9 6"></polyline> - </svg> + {{template "icon-yt-chevron"}} <span class="yt-name">{{.Name}}</span> </summary> <ul class="yt-children"></ul> diff --git a/src/webui/templates/layouts/base.html b/src/webui/templates/layouts/base.html index a12ad2cc..5cb7a47e 100644 --- a/src/webui/templates/layouts/base.html +++ b/src/webui/templates/layouts/base.html @@ -8,7 +8,7 @@ <meta name="csrf-token" content="{{.CsrfToken}}"> <meta name="htmx-config" content='{"includeIndicatorStyles": false}'> {{if .RetryAfter}}<meta http-equiv="refresh" content="{{.RetryAfter}}">{{end}} - <title>{{if .PageTitle}}{{.PageTitle}} — {{end}}Management + {{.PageTitle}} @@ -20,69 +20,60 @@
@@ -96,12 +87,7 @@
{{if .CfgUnsaved}}
- + {{template "icon-triangle-alert"}} Running config has unsaved changes — will be lost on reboot. +
+ +
Servers
+
+ + + + + + + + + + + {{range $i, $s := .DNS.Servers}} + + + + + + + {{end}} + +
Name / KeyAddressPort
+ +
+
+ + +{{template "configure-toolbar" .}} +
+{{end}} diff --git a/src/webui/templates/pages/configure-firewall.html b/src/webui/templates/pages/configure-firewall.html index 98d1c19c..011798bb 100644 --- a/src/webui/templates/pages/configure-firewall.html +++ b/src/webui/templates/pages/configure-firewall.html @@ -54,7 +54,7 @@ + hx-confirm="Reset enabled to its YANG default?">{{template "icon-reset"}} @@ -73,7 +73,7 @@ + hx-confirm="Reset default zone to its YANG default?">{{template "icon-reset"}} @@ -90,7 +90,7 @@ + hx-confirm="Reset logging to its YANG default?">{{template "icon-reset"}} @@ -166,7 +166,7 @@ + hx-confirm="Reset action to its YANG default?">{{template "icon-reset"}} @@ -177,7 +177,7 @@ + hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}} {{if .Network}} @@ -396,7 +396,7 @@ + hx-confirm="Reset action to its YANG default?">{{template "icon-reset"}} @@ -443,7 +443,7 @@ + hx-confirm="Reset masquerade to its YANG default?">{{template "icon-reset"}} @@ -732,4 +732,3 @@ {{end}} -{{define "field-info"}}{{if .}} {{end}}{{end}} diff --git a/src/webui/templates/pages/configure-hardware.html b/src/webui/templates/pages/configure-hardware.html index c6fdde30..7096185f 100644 --- a/src/webui/templates/pages/configure-hardware.html +++ b/src/webui/templates/pages/configure-hardware.html @@ -155,7 +155,7 @@ + hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}} @@ -170,7 +170,7 @@ + hx-confirm="Reset state to its YANG default?">{{template "icon-reset"}} @@ -196,7 +196,7 @@ + hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}} @@ -222,7 +222,7 @@ + hx-confirm="Reset band to its YANG default?">{{template "icon-reset"}} @@ -236,7 +236,7 @@ + hx-confirm="Reset channel to its YANG default?">{{template "icon-reset"}} @@ -261,7 +261,7 @@ + hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}} @@ -272,4 +272,3 @@ {{end}} -{{define "field-info"}}{{if .}} {{end}}{{end}} diff --git a/src/webui/templates/pages/configure-interfaces.html b/src/webui/templates/pages/configure-interfaces.html index 59f9b026..250f8c17 100644 --- a/src/webui/templates/pages/configure-interfaces.html +++ b/src/webui/templates/pages/configure-interfaces.html @@ -87,7 +87,7 @@ + hx-confirm="Reset description to its YANG default?">{{template "icon-reset"}} @@ -102,7 +102,23 @@ + hx-confirm="Reset enabled to its YANG default?">{{template "icon-reset"}} + + + + MAC address{{template "field-info" (index $d "mac")}} + + + + + @@ -118,31 +134,38 @@

IP Addresses

- {{/* IPv4 */}} + {{/* IPv4. Toggles, DHCP foldout, address list, and add-address + row visually compose one IPv4 group; the Save button at the + bottom is bound to the toggles form via the HTML5 `form` + attribute, so users see the whole group before clicking Save. */}} + {{$ipv4Form := printf "ipv4-form-%s" $r.Name}} + {{$ipv4Fold := printf "ipv4-dhcp-%s" $r.Name}}
IPv4{{template "field-info" (index $d "ipv4-address")}}
-
- - -
- {{if and .IPv4 .IPv4.DHCP}} -
+
+
+ + + +
+
+ + {{/* DHCPv4 foldout — always in the DOM, hidden when DHCP is off. + The data-fold-target hook on the DHCP checkbox toggles + this without waiting for a server round-trip so the + foldout is discoverable before clicking Save. */}} +
DHCP Settings & Options
@@ -205,7 +228,6 @@ {{end}}
- {{end}} {{if and .IPv4 .IPv4.Address}} @@ -239,31 +261,37 @@ - {{/* IPv6 */}} -
IPv6{{template "field-info" (index $d "ipv6-address")}}
-
- - + - {{if and .IPv6 .IPv6.DHCPv6}} -
+ + {{/* IPv6 — mirrors the IPv4 form structure above. */}} + {{/* IPv6 — mirrors the IPv4 layout above. */}} + {{$ipv6Form := printf "ipv6-form-%s" $r.Name}} + {{$ipv6Fold := printf "ipv6-dhcp-%s" $r.Name}} +
IPv6{{template "field-info" (index $d "ipv6-address")}}
+
+
+ + + +
+ + +
DHCPv6 Settings & Options
@@ -326,7 +354,6 @@ {{end}}
- {{end}} {{if and .IPv6 .IPv6.Address}}
@@ -359,6 +386,11 @@ + + {{end}}{{/* HasIP */}} @@ -370,14 +402,14 @@
- + {{$bpPath := printf "/ietf-interfaces:interfaces/interface=%s/infix-interfaces:bridge-port" .Name}} {{if .ParentBridgeIs8021Q}} - + @@ -385,12 +417,12 @@ + hx-confirm="Reset PVID to its YANG default?">{{template "icon-reset"}} {{end}} - + - + - +
BridgeBridge{{template "field-info" (index $d "bp-bridge")}}
PVIDPVID{{template "field-info" (index $d "bp-pvid")}}
Flood unknownFlood unknown{{template "field-info" (index $d "bp-flood")}}
Multicast routerMulticast router{{template "field-info" (index $d "bp-mc-router")}} {{/* YANG default is auto. */}} {{$mr := "auto"}}{{if and .BridgePort .BridgePort.Multicast .BridgePort.Multicast.Router}}{{$mr = .BridgePort.Multicast.Router}}{{end}} @@ -423,11 +455,11 @@ + hx-confirm="Reset multicast router to YANG default?">{{template "icon-reset"}}
Multicast fast-leaveMulticast fast-leave{{template "field-info" (index $d "bp-mc-fast-leave")}} {{/* Hidden companion lets the handler distinguish "form omitted the field" from "user unchecked". */}} @@ -440,7 +472,7 @@ + hx-confirm="Reset fast-leave to YANG default?">{{template "icon-reset"}}
@@ -467,7 +499,7 @@ - + @@ -609,7 +641,7 @@
LAGLAG{{template "field-info" (index $d "lp-lag")}}
- + - + - + - + - + - + {{end}} - + + + + +
RadioRadio{{template "field-info" (index $d "wifi-radio")}}
{{template "wizard-radio-picker.html" (dict "Radios" $.WizardWifiRadios "Selected" $curRadio "ID" $radioPickerID)}} @@ -679,12 +711,12 @@ {{if and .WiFi .WiFi.AccessPoint}} {{$ap := .WiFi.AccessPoint}}
SSIDSSID{{template "field-info" (index $d "wifi-ssid")}}
Hidden SSIDHidden SSID{{template "field-info" (index $d "wifi-hidden")}}
Security modeSecurity mode{{template "field-info" (index $d "wifi-sec-mode")}} {{$sm := "wpa2-wpa3-personal"}}{{if and $ap.Security $ap.Security.Mode}}{{$sm = $ap.Security.Mode}}{{end}}
SSIDSSID{{template "field-info" (index $d "wifi-ssid")}}
Security modeSecurity mode{{template "field-info" (index $d "wifi-sec-mode")}} {{$sm := "auto"}}{{if and $sta.Security $sta.Security.Mode}}{{$sm = $sta.Security.Mode}}{{end}}
PSKPSK{{template "field-info" (index $d "wifi-secret")}}
{{template "wizard-psk-picker.html" (dict "Keys" $.WizardSymKeys "Selected" $curSecret "ID" $pskPickerID)}} @@ -809,10 +841,27 @@
Ports + {{if $r.PortCandidates}} +
+
+ {{range $r.PortCandidates}} + + {{end}} +
+
+ {{else}} + No candidate port interfaces available. + {{end}} +
@@ -821,8 +870,9 @@ {{/* STP — own form, save inside the fold-out so the section is self-contained. YANG defaults are rendered inline (value="..." not a placeholder) - so the user sees the actual default; the ↺ button - drops the candidate value and lets YANG re-apply. */}} + so the user sees the actual default; the reset + button drops the candidate value and lets YANG + re-apply. */}}
@@ -843,7 +893,7 @@ + hx-confirm="Reset force-protocol to YANG default (rstp)?">{{template "icon-reset"}} @@ -857,7 +907,7 @@ + hx-confirm="Reset hello time to YANG default (2)?">{{template "icon-reset"}} @@ -871,7 +921,7 @@ + hx-confirm="Reset forward delay to YANG default (15)?">{{template "icon-reset"}} @@ -885,7 +935,7 @@ + hx-confirm="Reset max age to YANG default (20)?">{{template "icon-reset"}} @@ -899,7 +949,7 @@ + hx-confirm="Reset transmit hold count to YANG default (6)?">{{template "icon-reset"}} @@ -913,7 +963,7 @@ + hx-confirm="Reset max hops to YANG default (20)?">{{template "icon-reset"}} @@ -933,7 +983,7 @@ - + - + - +
SnoopingSnooping{{template "field-info" (index $d "mc-snoop")}}
QuerierQuerier{{template "field-info" (index $d "mc-querier")}} {{$q := "auto"}}{{if and .Bridge .Bridge.Multicast .Bridge.Multicast.Querier}}{{$q = .Bridge.Multicast.Querier}}{{end}}
Query interval (s)Query interval (s){{template "field-info" (index $d "mc-query-int")}} ↺ + hx-confirm="Reset query interval to YANG default (125)?">{{template "icon-reset"}}
@@ -985,27 +1035,6 @@
- {{/* Bridge Ports */}} -

Bridge Ports

- {{if $r.PortCandidates}} -
-
-
- {{range $r.PortCandidates}} - - {{end}} -
-
- -
- {{else}} -

No candidate port interfaces available.

- {{end}} - {{/* 802.1Q VLANs — matrix layout: rows = VLAN, columns = bridge ports + self, cells = U/T/-. Click VID to expand the existing per-port checkbox editor. */}} @@ -1810,4 +1839,3 @@ {{end}} -{{define "field-info"}}{{if .}} {{end}}{{end}} diff --git a/src/webui/templates/pages/configure-keystore.html b/src/webui/templates/pages/configure-keystore.html index 0edb47f5..1e5efc0c 100644 --- a/src/webui/templates/pages/configure-keystore.html +++ b/src/webui/templates/pages/configure-keystore.html @@ -74,10 +74,10 @@ Format diff --git a/src/webui/templates/pages/configure-ntp.html b/src/webui/templates/pages/configure-ntp.html new file mode 100644 index 00000000..28cc6772 --- /dev/null +++ b/src/webui/templates/pages/configure-ntp.html @@ -0,0 +1,59 @@ +{{define "configure-ntp.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +
+{{if .Error}} +
{{.Error}}
+{{end}} + +
+
NTP Servers
+
+ + + + + + + + + + + + {{range $i, $s := .NTP.Servers}} + + + + + + + + {{end}} + +
Name / KeyAddressPortPrefer
+ + + +
+
+ +
+{{template "configure-toolbar" .}} +
+{{end}} diff --git a/src/webui/templates/pages/configure-routes.html b/src/webui/templates/pages/configure-routes.html index 7fd052d1..927747e2 100644 --- a/src/webui/templates/pages/configure-routes.html +++ b/src/webui/templates/pages/configure-routes.html @@ -220,4 +220,3 @@ {{end}} -{{define "field-info"}}{{if .}} {{end}}{{end}} diff --git a/src/webui/templates/pages/configure-system.html b/src/webui/templates/pages/configure-system.html index 59984882..3c7e11ad 100644 --- a/src/webui/templates/pages/configure-system.html +++ b/src/webui/templates/pages/configure-system.html @@ -40,7 +40,7 @@ + hx-confirm="Reset contact to its YANG default?">{{template "icon-reset"}} @@ -50,7 +50,7 @@ + hx-confirm="Reset location to its YANG default?">{{template "icon-reset"}} @@ -60,17 +60,59 @@
- {{/* ── Clock ───────────────────────────────────────────────────────── */}} -
-
Clock
- + {{/* ── Date & Time ────────────────────────────────────────────────── + One table for all three rows so the value and action columns + line up. The two write paths have different semantics (Set time + = immediate RPC, Timezone = candidate save), so they're declared + as sibling s and bound to the table's inputs / buttons via + the HTML5 form="…" attribute. The System time row's + data-server-time seeds the JS-driven live tick (initDatetimePicker + in app.js). */}} +
+
Date & Time
+ + + +
+ + + + + + + + + + + + + + @@ -78,134 +120,18 @@ + hx-confirm="Reset timezone to its YANG default?">{{template "icon-reset"}}
System time + {{/* Span pads itself to match the Set time input's border+left + padding (1px + 0.6rem), so plain text and the input text + start at the same x-position. */}} + {{if .CurrentDatetime}}{{else}}unavailable{{end}} +
Set time{{template "field-info" "Manually set the device system clock. The time you enter is interpreted in your browser's local timezone and converted to UTC on submit."}} + + + +
Timezone{{template "field-info" (index $d "timezone")}} - + {{/* The IANA YANG enum has no UTC / Etc-UTC entry, so when + timezone-name isn't configured we surface that explicitly + instead of letting the browser default to the + alphabetically-first option. Infix interprets an unset + leaf as UTC. */}} + {{if eq $tz ""}}{{end}} {{range .TimezoneOptions}}{{end}}
- - - {{/* ── NTP servers ──────────────────────────────────────────────────── */}} -
-
NTP Servers
-
- - - - - - - - - - - - {{range $i, $s := .NTP.Servers}} - - - - - - - - {{end}} - -
Name / KeyAddressPortPrefer
- - - -
-
- -
- - {{/* ── DNS ─────────────────────────────────────────────────────────── */}} -
-
DNS
- -
Search domains
-
- - - - - - {{range $i, $d := .DNS.Search}} - - - - - {{end}} - -
Domain
- -
-
-
- -
- -
Servers
-
- - - - - - - - - - - {{range $i, $s := .DNS.Servers}} - - - - - - - {{end}} - -
Name / KeyAddressPort
- -
-
- -
+
+ +
+
{{/* ── Preferences ─────────────────────────────────────────────────── */}}
↺ + hx-confirm="Reset text editor to its YANG default?">{{template "icon-reset"}} @@ -239,7 +165,7 @@ + hx-confirm="Reset login banner to its YANG default?">{{template "icon-reset"}} @@ -256,5 +182,3 @@ {{end}} -{{/* ── ⓘ tooltip helper ─────────────────────────────────────────────────── */}} -{{define "field-info"}}{{if .}} {{end}}{{end}} diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html index 40817cde..505a76d9 100644 --- a/src/webui/templates/pages/dashboard.html +++ b/src/webui/templates/pages/dashboard.html @@ -17,7 +17,7 @@ {{if .Location}}Location{{.Location}}{{end}} {{if .OSName}}OS{{.OSName}} {{.OSVersion}}{{end}} {{if .Machine}}Architecture{{.Machine}}{{end}} - {{if .Firmware}}Boot Partition{{.Firmware}}{{end}} + {{if .Software}}Boot Partition{{.Software}}{{end}}
@@ -90,7 +90,7 @@ {{range .Disks}}
- {{.Mount}} + {{.Mount}}{{if .ReadOnly}} read-only{{end}} {{.Percent}}%
diff --git a/src/webui/templates/pages/firmware.html b/src/webui/templates/pages/firmware.html deleted file mode 100644 index 14e87875..00000000 --- a/src/webui/templates/pages/firmware.html +++ /dev/null @@ -1,159 +0,0 @@ -{{define "firmware.html"}} -{{template "base" .}} -{{end}} - -{{define "content"}} -{{if .Error}} -
{{.Error}}
-{{end}} - -{{if .Message}} -
{{.Message}}
-{{end}} - -{{$sseURL := "/firmware/progress"}}{{if .AutoReboot}}{{$sseURL = "/firmware/progress?auto-reboot=1"}}{{end}} - -{{if .Installing}} -
- {{template "fw-progress-body" .}} -
-{{end}} - -
- -
-
Software
- {{if .BootOrder}} -
- Boot order - - {{range .BootOrder}}{{.}}{{end}} - - - -
- {{end}} - {{if .Slots}} -
- {{range .Slots}} -
-
- {{.Name}} - {{if .Booted}}booted - {{else}}{{.State}}{{end}} -
-
{{if .Version}}{{.Version}}{{else}}—{{end}}
- {{if .InstallDate}}
{{.InstallDate}}
{{end}} -
- {{end}} -
- {{else}} -

No software information available.

- {{end}} -
- -
-
Install from URL
-
-

- Get the latest firmware from - GitHub Releases. - Download the .tar.gz tarball, extract the .pkg file, and serve it over FTP/HTTP. -

-
- How to host the firmware from your PC -
-

Extract and serve the package with Python (Linux/macOS/Windows):

- tar xf infix-{{if .Machine}}{{.Machine}}{{else}}aarch64{{end}}-vVERSION.tar.gz -python3 -m http.server 8080 -

Then enter the URL below pointing at the .pkg file on your machine.

-
-
- - -
- - - -
- - - -
-
- -
-
Upload & Install
-
-

- Select a .pkg firmware file from your computer to upload and install directly. -

-
- - -
- -
- -
-
-
- - -
-{{end}} - -{{define "fw-progress-body"}} -{{if and .Installer .Installer.Done}} - {{if .Installer.Success}} -
Install Complete
-
-
- - Firmware installed successfully. -
- {{if not .AutoReboot}} -
- -
- {{end}} -
- {{else}} -
Install Failed
-
-
- - {{.Installer.LastError}} -
-
- {{end}} -{{else}} -
Install in Progress
-
-
-
-
-

{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}

-
-{{end}} -{{end}} diff --git a/src/webui/templates/pages/login.html b/src/webui/templates/pages/login.html index efd7b985..7c5d7ac3 100644 --- a/src/webui/templates/pages/login.html +++ b/src/webui/templates/pages/login.html @@ -17,10 +17,7 @@ diff --git a/src/webui/templates/pages/software.html b/src/webui/templates/pages/software.html new file mode 100644 index 00000000..94500601 --- /dev/null +++ b/src/webui/templates/pages/software.html @@ -0,0 +1,147 @@ +{{define "software.html"}} +{{template "base" .}} +{{end}} + +{{define "content"}} +{{if .Error}} +
{{.Error}}
+{{end}} + +{{if .Message}} +
{{.Message}}
+{{end}} + +{{$sseURL := "/software/progress"}}{{if .AutoReboot}}{{$sseURL = "/software/progress?auto-reboot=1"}}{{end}} + +{{if .Installing}} +
+ {{template "sw-progress-body" .}} +
+{{end}} + +
+ +
+
Software
+ {{if .BootOrder}} +
+ Boot order + + {{range .BootOrder}}{{.}}{{end}} + + + +
+ {{end}} + {{if .Slots}} +
+ {{range .Slots}} +
+
+ {{.Name}} + {{if .Booted}}booted + {{else}}{{.State}}{{end}} +
+
{{if .Version}}{{.Version}}{{else}}—{{end}}
+ {{if .InstallDate}}
{{.InstallDate}}
{{end}} +
+ {{end}} +
+ {{else}} +

No software information available.

+ {{end}} +
+ +
+
Install from URL
+
+

+ Fetch a .pkg software bundle from a URL and install directly. +

+
+ +
+ + + +
+ + +
+
+
+ +
+
Upload & Install
+
+

+ Select a .pkg software bundle from your computer to upload and install directly. +

+
+ + +
+ +
+ +
+
+
+ + +
+{{end}} + +{{define "sw-progress-body"}} +{{if and .Installer .Installer.Done}} + {{if .Installer.Success}} +
Install Complete
+
+
+ {{template "icon-check-lg"}} + Software installed successfully. +
+ {{if not .AutoReboot}} +
+ +
+ {{end}} +
+ {{else}} +
Install Failed
+
+
+ {{template "icon-circle-x"}} + {{.Installer.LastError}} +
+
+ {{end}} +{{else}} +
Install in Progress
+
+
+
+
+

{{if .Installer}}{{.Installer.Percentage}}% — {{.Installer.Message}}{{else}}Installing…{{end}}

+
+{{end}} +{{end}} diff --git a/src/webui/templates/pages/system-control.html b/src/webui/templates/pages/system-control.html index 5ee02889..aa74be04 100644 --- a/src/webui/templates/pages/system-control.html +++ b/src/webui/templates/pages/system-control.html @@ -6,67 +6,43 @@
-
Reboot
-
-

Restart the device. The running configuration is preserved. - Any unsaved changes (running config not yet copied to startup) will be lost on reboot.

-
+
Power
+
+ +
+
+ Reboot{{template "field-info" "Restart the device. The running configuration is preserved. Any unsaved changes (running config not yet copied to startup) will be lost on reboot."}} +
-
-
-
-
Shutdown
-
-

Power off the device. Physical access is required to bring it back online.

-
+
+ +
+
+ Shutdown{{template "field-info" "Power off the device. Physical access is required to bring it back online."}} +
+
-
-
Date & Time
-
-

- {{if .CurrentDatetime}}Device clock: {{.CurrentDatetime}}.{{else}}Device clock unavailable.{{end}} -

-
- -
- - -
-
-
- -
-
-
-
- -
-
Factory Reset
+
+
Danger Zone
- Reset running config -

Replaces the running configuration with factory defaults. - No reboot — takes effect immediately. Use the persistent notification to save to startup.

+ Reset running config{{template "field-info" "Replaces the running configuration with factory defaults. No reboot — takes effect immediately. Use the persistent notification to save to startup."}}