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
+ // 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 dd6d0883..6e5058b4 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 {
@@ -2074,40 +2143,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) {
@@ -2216,29 +2312,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.
@@ -2337,7 +2410,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..e8e06cf0 100644
--- a/src/webui/internal/handlers/configure_keystore.go
+++ b/src/webui/internal/handlers/configure_keystore.go
@@ -63,7 +63,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
diff --git a/src/webui/internal/handlers/configure_routes.go b/src/webui/internal/handlers/configure_routes.go
index 428273fb..f6a135d1 100644
--- a/src/webui/internal/handlers/configure_routes.go
+++ b/src/webui/internal/handlers/configure_routes.go
@@ -80,18 +80,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 ".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 b4b95e7f..cb106635 100644
--- a/src/webui/internal/handlers/mdns.go
+++ b/src/webui/internal/handlers/mdns.go
@@ -96,7 +96,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 = `
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, `NTP is active — disable NTP first under Configure > System.`)
- } else {
- fmt.Fprintf(w, `Failed: %s`, 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, `✓ System time updated`)
+ 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 = `
// 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..92ad2f89 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 + checkboxes so the summary
row looks like a native