webui: re-render interface IP block in place after save

Surface confd-inferred values (DHCP option lists, etc.) on the curated
Interfaces page: SaveIPv4/IPv6Settings re-render just the interface's IP
block from the fresh candidate, swapped in place via outerHTML, so the
row stays expanded.  The DHCP foldout auto-expands after a save to reveal
the inferred options.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:27 +02:00
parent cd19b626a7
commit 1c3086be94
2 changed files with 349 additions and 261 deletions
@@ -128,6 +128,18 @@ type cfgIfaceRow struct {
// "auto-injected placeholder" apart.
DHCPv4Enabled bool
DHCPv6Enabled bool
// Page-level bits copied onto each row so the per-interface IPv4/IPv6
// blocks can render as standalone fragments — both inline on the full
// page and on their own when a save handler re-renders just one block
// (to surface confd-inferred values without collapsing the page).
Desc map[string]string
DHCPv4Options []schema.IdentityOption
DHCPv6Options []schema.IdentityOption
// JustSaved marks a post-save re-render (renderIPBlock) so the DHCP
// foldout auto-expands to reveal the freshly-inferred options. False
// on the normal page render, so foldouts stay collapsed there.
JustSaved bool
}
// ifaceRadioMirror is the subset of wifi-radio fields we expose on the
@@ -469,6 +481,13 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req
sort.Strings(data.WizardAvailableRadios)
data.Interfaces = h.buildRows(ifaces, operWrap.Interfaces.Interface)
// Copy page-level descriptions + DHCP option enums onto each row so the
// IPv4/IPv6 blocks render standalone (see cfgIfaceRow.Desc).
for i := range data.Interfaces {
data.Interfaces[i].Desc = data.Desc
data.Interfaces[i].DHCPv4Options = data.DHCPv4Options
data.Interfaces[i].DHCPv6Options = data.DHCPv6Options
}
// Populate the mirrored radio editor for WiFi interface rows from
// the already-fetched candidate hardware tree (no extra fetch).
radios := indexWifiRadios(hwCand.Hardware.Component)
@@ -1166,25 +1185,25 @@ func (h *ConfigureInterfacesHandler) SaveGeneral(w http.ResponseWriter, r *http.
// AddIPv4 adds an IPv4 address to an interface.
// POST /configure/interfaces/{name}/ipv4
func (h *ConfigureInterfacesHandler) AddIPv4(w http.ResponseWriter, r *http.Request) {
h.addAddr(w, r, "ipv4")
h.addAddr(w, r, "ipv4", "IPv4", "iface-ipv4-block")
}
// DeleteIPv4 removes an IPv4 address from an interface.
// DELETE /configure/interfaces/{name}/ipv4/{ip}
func (h *ConfigureInterfacesHandler) DeleteIPv4(w http.ResponseWriter, r *http.Request) {
h.deleteAddr(w, r, "ipv4")
h.deleteAddr(w, r, "ipv4", "IPv4", "iface-ipv4-block")
}
// AddIPv6 adds an IPv6 address to an interface.
// POST /configure/interfaces/{name}/ipv6
func (h *ConfigureInterfacesHandler) AddIPv6(w http.ResponseWriter, r *http.Request) {
h.addAddr(w, r, "ipv6")
h.addAddr(w, r, "ipv6", "IPv6", "iface-ipv6-block")
}
// DeleteIPv6 removes an IPv6 address from an interface.
// DELETE /configure/interfaces/{name}/ipv6/{ip}
func (h *ConfigureInterfacesHandler) DeleteIPv6(w http.ResponseWriter, r *http.Request) {
h.deleteAddr(w, r, "ipv6")
h.deleteAddr(w, r, "ipv6", "IPv6", "iface-ipv6-block")
}
// SaveBridgePort assigns or updates an interface's bridge membership.
@@ -2103,7 +2122,7 @@ func portCandidatesFor(masterName string, ifaces []ifaceJSON, memberOf map[strin
return out
}
func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Request, family string) {
func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
@@ -2130,10 +2149,12 @@ func (h *ConfigureInterfacesHandler) addAddr(w http.ResponseWriter, r *http.Requ
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Address added", "/configure/interfaces")
// Re-render the IP block in place so the new address appears without
// collapsing the interface foldout — ready to add another straight away.
h.renderIPBlock(w, r, name, famCap, frag)
}
func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.Request, family string) {
func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.Request, family, famCap, frag string) {
name := r.PathValue("name")
ip := r.PathValue("ip")
path := ifacePath(name) + "/ietf-ip:" + family + "/address=" + restconf.EscapeKey(ip)
@@ -2142,7 +2163,7 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Address removed", "/configure/interfaces")
h.renderIPBlock(w, r, name, famCap, frag)
}
// SaveIPv4Settings PATCHes the per-interface IPv4 group settings — forwarding
@@ -2151,7 +2172,7 @@ func (h *ConfigureInterfacesHandler) deleteAddr(w http.ResponseWriter, r *http.R
// 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{
h.saveIPSettings(w, r, "ietf-ip:ipv4", "IPv4", "iface-ipv4-block", map[string]string{
"dhcp": "infix-dhcp-client:dhcp",
"autoconf": "infix-ip:autoconf",
})
@@ -2161,7 +2182,7 @@ func (h *ConfigureInterfacesHandler) SaveIPv4Settings(w http.ResponseWriter, r *
// 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{
h.saveIPSettings(w, r, "ietf-ip:ipv6", "IPv6", "iface-ipv6-block", map[string]string{
"dhcp": "infix-dhcpv6-client:dhcp",
"slaac": "autoconf",
})
@@ -2171,7 +2192,7 @@ func (h *ConfigureInterfacesHandler) SaveIPv6Settings(w http.ResponseWriter, r *
// 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) {
func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *http.Request, container, family, fragName string, presenceMap map[string]string) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
@@ -2220,9 +2241,63 @@ func (h *ConfigureInterfacesHandler) saveIPSettings(w http.ResponseWriter, r *ht
}
}
// Re-render just this interface's IP block from the fresh candidate so
// confd-inferred values (the DHCP option list, etc.) appear in place,
// without collapsing the page. fragName empty → fall back to a toast
// (e.g. families whose block fragment isn't wired up yet).
if fragName != "" {
h.renderIPBlock(w, r, name, family, fragName)
return
}
renderSaved(w, family+" settings saved")
}
// renderIPBlock re-renders one interface's IPv4/IPv6 block fragment from the
// fresh candidate. Falls back to a plain saved-toast if the interface or
// schema can't be read, so a save is never reported as failed just because
// the in-place refresh couldn't be built.
func (h *ConfigureInterfacesHandler) renderIPBlock(w http.ResponseWriter, r *http.Request, name, family, fragName string) {
ifaces, err := h.fetchAllInterfaces(r.Context())
if err != nil {
renderSaved(w, family+" settings saved")
return
}
rows := h.buildRows(ifaces, nil)
var row *cfgIfaceRow
for i := range rows {
if rows[i].Name == name {
row = &rows[i]
break
}
}
if row == nil {
renderSaved(w, family+" settings saved")
return
}
if mgr := h.Schema.Manager(); mgr != nil {
ifPath := "/ietf-interfaces:interfaces/interface"
ip4, ip6 := "/ietf-ip:ipv4", "/ietf-ip:ipv6"
row.DHCPv4Options = schema.OptionsFor(mgr, ifPath+ip4+"/infix-dhcp-client:dhcp/option/id")
row.DHCPv6Options = schema.OptionsFor(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp/option/id")
row.Desc = map[string]string{
"ipv4-address": schema.DescriptionOf(mgr, ifPath+ip4+"/address/ip"),
"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-dhcp": schema.DescriptionOf(mgr, ifPath+ip6+"/infix-dhcpv6-client:dhcp"),
"ipv6-slaac": schema.DescriptionOf(mgr, ifPath+ip6+"/autoconf"),
"ipv6-forwarding": schema.DescriptionOf(mgr, ifPath+ip6+"/forwarding"),
}
}
row.JustSaved = true // expand the DHCP foldout to reveal inferred options
// Keep the cfgSaved toast behaviour even though we swap the block.
w.Header().Set("HX-Trigger", `{"cfgSaved":"`+family+` settings saved"}`)
if err := h.Template.ExecuteTemplate(w, fragName, row); err != nil {
log.Printf("configure interfaces %s: render %s: %v", name, fragName, err)
}
}
func (h *ConfigureInterfacesHandler) SaveIPv4DHCPSettings(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -138,259 +138,11 @@
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}}
<h5 class="cfg-subsection-head">IPv4{{template "field-info" (index $d "ipv4-address")}}</h5>
<form id="{{$ipv4Form}}" hx-post="/configure/interfaces/{{.Name}}/ipv4/settings" hx-swap="none">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="forwarding" value="true"
{{if and .IPv4 .IPv4.Forwarding}}checked{{end}}>
Forwarding{{template "field-info" (index $d "ipv4-forwarding")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="dhcp" value="true" data-fold-target="{{$ipv4Fold}}"
{{if .DHCPv4Enabled}}checked{{end}}>
DHCP client{{template "field-info" (index $d "ipv4-dhcp")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="autoconf" value="true"
{{if and .IPv4 .IPv4.Autoconf}}checked{{end}}>
Link-Local (Zeroconf){{template "field-info" (index $d "ipv4-autoconf")}}
</label>
</div>
</form>
{{/* 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. */}}
<details id="{{$ipv4Fold}}" class="cfg-fold" {{if not .DHCPv4Enabled}}hidden{{end}}>
<summary>DHCP Settings &amp; Options</summary>
<div class="cfg-fold-body">
<form hx-post="/configure/interfaces/{{.Name}}/ipv4/dhcp/settings" hx-swap="none">
<table class="info-table">
<tr>
<th>Client ID</th>
<td><input class="cfg-input cfg-input-sm" type="text" name="client-id"
value="{{.IPv4.DHCP.ClientID}}" placeholder="default (MAC address)"></td>
</tr>
<tr>
<th>ARP check</th>
<td><label><input type="checkbox" name="arping" value="true"
{{if or (not .IPv4.DHCP.Arping) (deref .IPv4.DHCP.Arping)}}checked{{end}}> Probe for address collision before use</label></td>
</tr>
<tr>
<th>Route preference</th>
<td><input class="cfg-input cfg-input-sm" type="number" name="route-preference"
{{if .IPv4.DHCP.RoutePreference}}value="{{deref .IPv4.DHCP.RoutePreference}}"{{end}}
placeholder="5" min="0" max="255" style="width:6rem"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
<h6 class="cfg-subsection-head" style="margin-top:0.75rem">DHCP Options</h6>
{{if .IPv4.DHCP.Options}}
<table class="data-table cfg-table" style="margin-bottom:0.5rem">
<thead><tr><th>Option</th><th>Value</th><th></th></tr></thead>
<tbody>
{{range .IPv4.DHCP.Options}}
<tr>
<td>{{.ID}}</td>
<td>{{if .Value}}{{.Value}}{{else if .Hex}}<span class="text-muted">hex:{{.Hex}}</span>{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$r.Name}}/ipv4/dhcp/options/{{.ID}}"
hx-confirm="Remove option {{.ID}}?"
hx-target="closest tr" hx-swap="delete" title="Remove option">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if $.DHCPv4Options}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv4/dhcp/options" hx-swap="none"
class="user-add-inline">
<select class="cfg-input cfg-input-sm" name="id">
{{range $.DHCPv4Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<input class="cfg-input cfg-input-sm" type="text" name="value"
placeholder="value (optional)" style="flex:2">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
{{end}}
</div>
</details>
{{if and .IPv4 .IPv4.Address}}
<table class="info-table" style="margin-bottom:0.5rem">
{{range .IPv4.Address}}
<tr>
<td>{{.IP}}/{{.PrefixLength}}</td>
<td>{{if .Origin}}<span class="text-muted">{{.Origin}}</span>{{end}}</td>
<td class="cfg-reset-col">
{{if or (eq .Origin "") (eq .Origin "static")}}
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$r.Name}}/ipv4/{{.IP}}"
hx-confirm="Remove {{.IP}}?"
hx-swap="none" title="Remove address">
{{template "icon-trash"}}
</button>
{{end}}
</td>
</tr>
{{end}}
</table>
{{else}}
<p class="text-muted" style="font-size:0.85em;margin-bottom:0.5rem">No IPv4 addresses configured.</p>
{{end}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv4" hx-swap="none" class="user-add-inline">
<input class="cfg-input cfg-input-sm" type="text" name="ip"
placeholder="192.168.1.1" style="flex:2">
<span style="padding:0 0.25rem">/</span>
<input class="cfg-input cfg-input-sm" type="number" name="prefix-length"
placeholder="24" min="0" max="32" style="flex:0 0 5rem">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
<div class="cfg-card-footer" style="padding-left:0">
<button form="{{$ipv4Form}}" type="submit" class="btn btn-primary btn-sm">Save IPv4 Settings</button>
<span class="cfg-save-status" data-cfg-status-for="{{$ipv4Form}}"></span>
</div>
{{template "iface-ipv4-block" .}}
{{/* 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}}
<h5 class="cfg-subsection-head" style="margin-top:0.75rem">IPv6{{template "field-info" (index $d "ipv6-address")}}</h5>
<form id="{{$ipv6Form}}" hx-post="/configure/interfaces/{{.Name}}/ipv6/settings" hx-swap="none">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="forwarding" value="true"
{{if and .IPv6 .IPv6.Forwarding}}checked{{end}}>
Forwarding{{template "field-info" (index $d "ipv6-forwarding")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="dhcp" value="true" data-fold-target="{{$ipv6Fold}}"
{{if .DHCPv6Enabled}}checked{{end}}>
DHCPv6 client{{template "field-info" (index $d "ipv6-dhcp")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="slaac" value="true"
{{if and .IPv6 .IPv6.SLAACv6}}checked{{end}}>
SLAAC{{template "field-info" (index $d "ipv6-slaac")}}
</label>
</div>
</form>
<details id="{{$ipv6Fold}}" class="cfg-fold" {{if not .DHCPv6Enabled}}hidden{{end}}>
<summary>DHCPv6 Settings &amp; Options</summary>
<div class="cfg-fold-body">
<form hx-post="/configure/interfaces/{{.Name}}/ipv6/dhcp/settings" hx-swap="none">
<table class="info-table">
<tr>
<th>DUID</th>
<td><input class="cfg-input cfg-input-sm cfg-input-mono" type="text" name="duid"
value="{{.IPv6.DHCPv6.DUID}}" placeholder="default (auto-generated)"></td>
</tr>
<tr>
<th>Information only</th>
<td><label><input type="checkbox" name="information-only" value="true"
{{if and .IPv6.DHCPv6.InformationOnly (deref .IPv6.DHCPv6.InformationOnly)}}checked{{end}}> Request config only, no address (requires SLAAC)</label></td>
</tr>
<tr>
<th>Route preference</th>
<td><input class="cfg-input cfg-input-sm" type="number" name="route-preference"
{{if .IPv6.DHCPv6.RoutePreference}}value="{{deref .IPv6.DHCPv6.RoutePreference}}"{{end}}
placeholder="5" min="0" max="255" style="width:6rem"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
<h6 class="cfg-subsection-head" style="margin-top:0.75rem">DHCPv6 Options</h6>
{{if .IPv6.DHCPv6.Options}}
<table class="data-table cfg-table" style="margin-bottom:0.5rem">
<thead><tr><th>Option</th><th>Value</th><th></th></tr></thead>
<tbody>
{{range .IPv6.DHCPv6.Options}}
<tr>
<td>{{.ID}}</td>
<td>{{if .Value}}{{.Value}}{{else if .Hex}}<span class="text-muted">hex:{{.Hex}}</span>{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$r.Name}}/ipv6/dhcp/options/{{.ID}}"
hx-confirm="Remove option {{.ID}}?"
hx-target="closest tr" hx-swap="delete" title="Remove option">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if $.DHCPv6Options}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv6/dhcp/options" hx-swap="none"
class="user-add-inline">
<select class="cfg-input cfg-input-sm" name="id">
{{range $.DHCPv6Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<input class="cfg-input cfg-input-sm" type="text" name="value"
placeholder="value (optional)" style="flex:2">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
{{end}}
</div>
</details>
{{if and .IPv6 .IPv6.Address}}
<table class="info-table" style="margin-bottom:0.5rem">
{{range .IPv6.Address}}
<tr>
<td class="cfg-input-mono">{{.IP}}/{{.PrefixLength}}</td>
<td>{{if .Origin}}<span class="text-muted">{{.Origin}}</span>{{end}}</td>
<td class="cfg-reset-col">
{{if or (eq .Origin "") (eq .Origin "static")}}
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$r.Name}}/ipv6/{{.IP}}"
hx-confirm="Remove {{.IP}}?"
hx-swap="none" title="Remove address">
{{template "icon-trash"}}
</button>
{{end}}
</td>
</tr>
{{end}}
</table>
{{else}}
<p class="text-muted" style="font-size:0.85em;margin-bottom:0.5rem">No IPv6 addresses configured.</p>
{{end}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv6" hx-swap="none" class="user-add-inline">
<input class="cfg-input" type="text" name="ip"
placeholder="2001:db8::1" style="flex:2">
<span style="padding:0 0.25rem">/</span>
<input class="cfg-input cfg-input-sm" type="number" name="prefix-length"
placeholder="64" min="0" max="128" style="flex:0 0 5rem">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
<div class="cfg-card-footer" style="padding-left:0">
<button form="{{$ipv6Form}}" type="submit" class="btn btn-primary btn-sm">Save IPv6 Settings</button>
<span class="cfg-save-status" data-cfg-status-for="{{$ipv6Form}}"></span>
</div>
{{template "iface-ipv6-block" .}}
</div>
{{end}}{{/* HasIP */}}
@@ -1839,3 +1591,264 @@
{{end}}
{{define "iface-ipv4-block"}}
<div id="ipv4-block-{{.Name}}" class="ipv4-block">
{{$ipv4Form := printf "ipv4-form-%s" $.Name}}
{{$ipv4Fold := printf "ipv4-dhcp-%s" $.Name}}
<h5 class="cfg-subsection-head">IPv4{{template "field-info" (index $.Desc "ipv4-address")}}</h5>
<form id="{{$ipv4Form}}" hx-post="/configure/interfaces/{{.Name}}/ipv4/settings" hx-target="#ipv4-block-{{.Name}}" hx-swap="outerHTML">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="forwarding" value="true"
{{if and .IPv4 .IPv4.Forwarding}}checked{{end}}>
Forwarding{{template "field-info" (index $.Desc "ipv4-forwarding")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="dhcp" value="true" data-fold-target="{{$ipv4Fold}}"
{{if .DHCPv4Enabled}}checked{{end}}>
DHCP client{{template "field-info" (index $.Desc "ipv4-dhcp")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="autoconf" value="true"
{{if and .IPv4 .IPv4.Autoconf}}checked{{end}}>
Link-Local (Zeroconf){{template "field-info" (index $.Desc "ipv4-autoconf")}}
</label>
</div>
</form>
{{/* 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. */}}
<details id="{{$ipv4Fold}}" class="cfg-fold" {{if .JustSaved}}open{{end}} {{if not .DHCPv4Enabled}}hidden{{end}}>
<summary>DHCP Settings &amp; Options</summary>
<div class="cfg-fold-body">
<form hx-post="/configure/interfaces/{{.Name}}/ipv4/dhcp/settings" hx-swap="none">
<table class="info-table">
<tr>
<th>Client ID</th>
<td><input class="cfg-input cfg-input-sm" type="text" name="client-id"
value="{{.IPv4.DHCP.ClientID}}" placeholder="default (MAC address)"></td>
</tr>
<tr>
<th>ARP check</th>
<td><label><input type="checkbox" name="arping" value="true"
{{if or (not .IPv4.DHCP.Arping) (deref .IPv4.DHCP.Arping)}}checked{{end}}> Probe for address collision before use</label></td>
</tr>
<tr>
<th>Route preference</th>
<td><input class="cfg-input cfg-input-sm" type="number" name="route-preference"
{{if .IPv4.DHCP.RoutePreference}}value="{{deref .IPv4.DHCP.RoutePreference}}"{{end}}
placeholder="5" min="0" max="255" style="width:6rem"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
<h6 class="cfg-subsection-head" style="margin-top:0.75rem">DHCP Options</h6>
{{if .IPv4.DHCP.Options}}
<table class="data-table cfg-table" style="margin-bottom:0.5rem">
<thead><tr><th>Option</th><th>Value</th><th></th></tr></thead>
<tbody>
{{range .IPv4.DHCP.Options}}
<tr>
<td>{{.ID}}</td>
<td>{{if .Value}}{{.Value}}{{else if .Hex}}<span class="text-muted">hex:{{.Hex}}</span>{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$.Name}}/ipv4/dhcp/options/{{.ID}}"
hx-confirm="Remove option {{.ID}}?"
hx-target="closest tr" hx-swap="delete" title="Remove option">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if $.DHCPv4Options}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv4/dhcp/options" hx-swap="none"
class="user-add-inline">
<select class="cfg-input cfg-input-sm" name="id">
{{range $.DHCPv4Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<input class="cfg-input cfg-input-sm" type="text" name="value"
placeholder="value (optional)" style="flex:2">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
{{end}}
</div>
</details>
{{if and .IPv4 .IPv4.Address}}
<table class="info-table" style="margin-bottom:0.5rem">
{{range .IPv4.Address}}
<tr>
<td>{{.IP}}/{{.PrefixLength}}</td>
<td>{{if .Origin}}<span class="text-muted">{{.Origin}}</span>{{end}}</td>
<td class="cfg-reset-col">
{{if or (eq .Origin "") (eq .Origin "static")}}
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$.Name}}/ipv4/{{.IP}}"
hx-confirm="Remove {{.IP}}?"
hx-target="#ipv4-block-{{$.Name}}" hx-swap="outerHTML" title="Remove address">
{{template "icon-trash"}}
</button>
{{end}}
</td>
</tr>
{{end}}
</table>
{{else}}
<p class="text-muted" style="font-size:0.85em;margin-bottom:0.5rem">No IPv4 addresses configured.</p>
{{end}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv4" hx-target="#ipv4-block-{{.Name}}" hx-swap="outerHTML" class="user-add-inline">
<input class="cfg-input cfg-input-sm" type="text" name="ip"
placeholder="192.168.1.1" style="flex:2">
<span style="padding:0 0.25rem">/</span>
<input class="cfg-input cfg-input-sm" type="number" name="prefix-length"
placeholder="24" min="0" max="32" style="flex:0 0 5rem">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
<div class="cfg-card-footer" style="padding-left:0">
<button form="{{$ipv4Form}}" type="submit" class="btn btn-primary btn-sm">Save IPv4 Settings</button>
<span class="cfg-save-status" data-cfg-status-for="{{$ipv4Form}}"></span>
</div>
</div>
{{end}}
{{define "iface-ipv6-block"}}
<div id="ipv6-block-{{.Name}}" class="ipv6-block">
{{$ipv6Form := printf "ipv6-form-%s" $.Name}}
{{$ipv6Fold := printf "ipv6-dhcp-%s" $.Name}}
<h5 class="cfg-subsection-head" style="margin-top:0.75rem">IPv6{{template "field-info" (index $.Desc "ipv6-address")}}</h5>
<form id="{{$ipv6Form}}" hx-post="/configure/interfaces/{{.Name}}/ipv6/settings" hx-target="#ipv6-block-{{.Name}}" hx-swap="outerHTML">
<div style="display:flex;gap:1.5rem;flex-wrap:wrap;margin-bottom:0.5rem">
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="forwarding" value="true"
{{if and .IPv6 .IPv6.Forwarding}}checked{{end}}>
Forwarding{{template "field-info" (index $.Desc "ipv6-forwarding")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="dhcp" value="true" data-fold-target="{{$ipv6Fold}}"
{{if .DHCPv6Enabled}}checked{{end}}>
DHCPv6 client{{template "field-info" (index $.Desc "ipv6-dhcp")}}
</label>
<label style="display:flex;align-items:center;gap:0.35rem;cursor:pointer;font-weight:normal">
<input type="checkbox" name="slaac" value="true"
{{if and .IPv6 .IPv6.SLAACv6}}checked{{end}}>
SLAAC{{template "field-info" (index $.Desc "ipv6-slaac")}}
</label>
</div>
</form>
<details id="{{$ipv6Fold}}" class="cfg-fold" {{if .JustSaved}}open{{end}} {{if not .DHCPv6Enabled}}hidden{{end}}>
<summary>DHCPv6 Settings &amp; Options</summary>
<div class="cfg-fold-body">
<form hx-post="/configure/interfaces/{{.Name}}/ipv6/dhcp/settings" hx-swap="none">
<table class="info-table">
<tr>
<th>DUID</th>
<td><input class="cfg-input cfg-input-sm cfg-input-mono" type="text" name="duid"
value="{{.IPv6.DHCPv6.DUID}}" placeholder="default (auto-generated)"></td>
</tr>
<tr>
<th>Information only</th>
<td><label><input type="checkbox" name="information-only" value="true"
{{if and .IPv6.DHCPv6.InformationOnly (deref .IPv6.DHCPv6.InformationOnly)}}checked{{end}}> Request config only, no address (requires SLAAC)</label></td>
</tr>
<tr>
<th>Route preference</th>
<td><input class="cfg-input cfg-input-sm" type="number" name="route-preference"
{{if .IPv6.DHCPv6.RoutePreference}}value="{{deref .IPv6.DHCPv6.RoutePreference}}"{{end}}
placeholder="5" min="0" max="255" style="width:6rem"></td>
</tr>
</table>
<div class="cfg-card-footer" style="padding-left:0">
<button class="btn btn-primary btn-sm" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
<h6 class="cfg-subsection-head" style="margin-top:0.75rem">DHCPv6 Options</h6>
{{if .IPv6.DHCPv6.Options}}
<table class="data-table cfg-table" style="margin-bottom:0.5rem">
<thead><tr><th>Option</th><th>Value</th><th></th></tr></thead>
<tbody>
{{range .IPv6.DHCPv6.Options}}
<tr>
<td>{{.ID}}</td>
<td>{{if .Value}}{{.Value}}{{else if .Hex}}<span class="text-muted">hex:{{.Hex}}</span>{{else}}<span class="text-muted"></span>{{end}}</td>
<td style="text-align:right">
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$.Name}}/ipv6/dhcp/options/{{.ID}}"
hx-confirm="Remove option {{.ID}}?"
hx-target="closest tr" hx-swap="delete" title="Remove option">
{{template "icon-trash"}}
</button>
</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
{{if $.DHCPv6Options}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv6/dhcp/options" hx-swap="none"
class="user-add-inline">
<select class="cfg-input cfg-input-sm" name="id">
{{range $.DHCPv6Options}}<option value="{{.Value}}">{{.Label}}</option>{{end}}
</select>
<input class="cfg-input cfg-input-sm" type="text" name="value"
placeholder="value (optional)" style="flex:2">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
{{end}}
</div>
</details>
{{if and .IPv6 .IPv6.Address}}
<table class="info-table" style="margin-bottom:0.5rem">
{{range .IPv6.Address}}
<tr>
<td class="cfg-input-mono">{{.IP}}/{{.PrefixLength}}</td>
<td>{{if .Origin}}<span class="text-muted">{{.Origin}}</span>{{end}}</td>
<td class="cfg-reset-col">
{{if or (eq .Origin "") (eq .Origin "static")}}
<button type="button" class="btn-icon btn-icon-danger"
hx-delete="/configure/interfaces/{{$.Name}}/ipv6/{{.IP}}"
hx-confirm="Remove {{.IP}}?"
hx-target="#ipv6-block-{{$.Name}}" hx-swap="outerHTML" title="Remove address">
{{template "icon-trash"}}
</button>
{{end}}
</td>
</tr>
{{end}}
</table>
{{else}}
<p class="text-muted" style="font-size:0.85em;margin-bottom:0.5rem">No IPv6 addresses configured.</p>
{{end}}
<form hx-post="/configure/interfaces/{{.Name}}/ipv6" hx-target="#ipv6-block-{{.Name}}" hx-swap="outerHTML" class="user-add-inline">
<input class="cfg-input" type="text" name="ip"
placeholder="2001:db8::1" style="flex:2">
<span style="padding:0 0.25rem">/</span>
<input class="cfg-input cfg-input-sm" type="number" name="prefix-length"
placeholder="64" min="0" max="128" style="flex:0 0 5rem">
<button class="btn btn-primary btn-sm" type="submit">Add</button>
<span class="cfg-save-status"></span>
</form>
<div class="cfg-card-footer" style="padding-left:0">
<button form="{{$ipv6Form}}" type="submit" class="btn btn-primary btn-sm">Save IPv6 Settings</button>
<span class="cfg-save-status" data-cfg-status-for="{{$ipv6Form}}"></span>
</div>
</div>
{{end}}