webui: ethernet status and configuration

Status overview gains a Speed column reading ietf-interfaces:speed +
ethernet/duplex.  Interface detail page surfaces PHY type, PMD type,
supported and advertised PMDs.

Configure > Interfaces gains a per-row Ethernet panel: auto-negotiation
toggle, advertised PMDs picker, duplex tri-state, mdi-x tri-state.  Each
has a reset-to-YANG-default button.  Backend writes leaf-by-leaf via
PATCH+DELETE since a single PUT silently drops the duplex leaf.

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-15 19:45:21 +02:00
parent beabc1fe5b
commit c40c1d0c10
9 changed files with 555 additions and 14 deletions
@@ -116,6 +116,13 @@ type cfgIfaceRow struct {
// from the WiFi interface page without bouncing to Configure >
// Hardware. Nil when the wifi/radio reference is empty or unknown.
PortRadio *ifaceRadioMirror
// MDIXState renders the *bool ethernet/mdi-x as a template-friendly
// string: "" = absent (Auto-MDIX), "true" / "false" = explicit force.
MDIXState string
EthAutoneg bool // current candidate value; defaults to YANG default (true)
EthDuplex string // "" / "full" / "half"
EthAdvertised []string // identityref leaf-list, empty = advertise all
EthSupported []string // identityref leaf-list from operational data
}
// ifaceRadioMirror is the subset of wifi-radio fields we expose on the
@@ -262,6 +269,10 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req
"wifi-sec-mode": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/mode", "Security mode. Open is unencrypted (insecure). For AP: wpa2-wpa3-personal is recommended for compatibility + security."),
"wifi-secret": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/security/secret", "Pre-shared key reference — a symmetric key in the keystore. 863 characters per the WPA spec."),
"wifi-hidden": descOr(mgr, ifPath+"/infix-interfaces:wifi/access-point/hidden", "Hide the SSID from broadcast beacons. Minimal security benefit and may cause compatibility issues with some clients."),
"eth-autoneg": descOr(mgr, ifPath+"/ieee802-ethernet-interface:ethernet/auto-negotiation/enable", "Enable IEEE 802.3 auto-negotiation. When off, the link must come up via parallel detection or the forced PMD picked below."),
"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."),
}
if data.Desc["ipv6-slaac"] == "" {
data.Desc["ipv6-slaac"] = "SLAAC (Stateless Address Autoconfiguration, RFC 4862) " +
@@ -440,7 +451,7 @@ func (h *ConfigureInterfacesHandler) Overview(w http.ResponseWriter, r *http.Req
}
sort.Strings(data.WizardAvailableRadios)
data.Interfaces = h.buildRows(ifaces)
data.Interfaces = h.buildRows(ifaces, operWrap.Interfaces.Interface)
// Populate the mirrored radio editor for WiFi interface rows from
// the already-fetched candidate hardware tree (no extra fetch).
radios := indexWifiRadios(hwCand.Hardware.Component)
@@ -1143,6 +1154,124 @@ func (h *ConfigureInterfacesHandler) DeleteIPv6(w http.ResponseWriter, r *http.R
// field it wants preserved. Changing the `bridge` field from one name
// to another effectively moves the port — the PUT is atomic.
// POST /configure/interfaces/{name}/bridge-port
// SaveEthernet writes ethernet settings via a sequence of PATCH and DELETE
// ops rather than a single PUT. A PUT that includes every leaf in the
// ethernet container silently drops the duplex leaf on rousette/confd
// (verified with curl); the per-leaf approach matches what manual PATCH
// curls land reliably. Tri-state fields (duplex, mdi-x) DELETE on "Auto"
// so the candidate ends with an absent leaf, which is how the YANG model
// expresses the default. data-missing on DELETE is swallowed for
// idempotency.
// POST /configure/interfaces/{name}/ethernet
func (h *ConfigureInterfacesHandler) SaveEthernet(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
ctx := r.Context()
name := r.PathValue("name")
base := ifacePath(name) + "/ieee802-ethernet-interface:ethernet"
autoneg := r.FormValue("autoneg") == "on"
var adv []string
for _, v := range r.Form["advertised"] {
if v != "" {
adv = append(adv, v)
}
}
// auto-negotiation: PUT the whole container so an empty advertised list
// actually clears stale entries. DELETE on an unqualified leaf-list
// path fails ("requires exactly one key") under RFC 8040, so omitting
// the leaf-list from a container PUT is the cleanest clear. Safe here
// because the container only carries enable + advertised-pmd-types
// (negotiation-status is deviate-not-supported in Infix).
an := map[string]any{"enable": autoneg}
if len(adv) > 0 {
an["infix-ethernet-interface:advertised-pmd-types"] = adv
}
body := map[string]any{"ieee802-ethernet-interface:auto-negotiation": an}
if err := h.RC.Put(ctx, base+"/auto-negotiation", body); err != nil {
log.Printf("configure interfaces %s ethernet autoneg: %v", name, err)
renderSaveError(w, err)
return
}
// duplex: PATCH if user picked full/half, DELETE on Auto
if d := r.FormValue("duplex"); d != "" {
body := map[string]any{
"ieee802-ethernet-interface:ethernet": map[string]any{"duplex": d},
}
if err := h.RC.Patch(ctx, base, body); err != nil {
log.Printf("configure interfaces %s ethernet duplex: %v", name, err)
renderSaveError(w, err)
return
}
} else if err := h.RC.Delete(ctx, base+"/duplex"); err != nil && !restconf.IsDataMissing(err) {
log.Printf("configure interfaces %s ethernet duplex delete: %v", name, err)
renderSaveError(w, err)
return
}
// mdi-x: only valid when autoneg is off (YANG when). On autoneg=true
// or user picks Auto-MDIX, DELETE so the leaf stays absent.
mdix := r.FormValue("mdix")
if !autoneg && (mdix == "true" || mdix == "false") {
body := map[string]any{
"ieee802-ethernet-interface:ethernet": map[string]any{
"infix-ethernet-interface:mdi-x": mdix == "true",
},
}
if err := h.RC.Patch(ctx, base, body); err != nil {
log.Printf("configure interfaces %s ethernet mdi-x: %v", name, err)
renderSaveError(w, err)
return
}
} else if err := h.RC.Delete(ctx, base+"/infix-ethernet-interface:mdi-x"); err != nil && !restconf.IsDataMissing(err) {
log.Printf("configure interfaces %s ethernet mdi-x delete: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Ethernet saved", "/configure/interfaces")
}
// ResetEthernetAdvertised clears the advertised-pmd-types leaf-list while
// preserving auto-negotiation/enable. Routed off the per-row reset
// button because the generic /configure/leaf path can't DELETE a leaf-list
// without per-entry key predicates.
// DELETE /configure/interfaces/{name}/ethernet/advertised
func (h *ConfigureInterfacesHandler) ResetEthernetAdvertised(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
base := ifacePath(name) + "/ieee802-ethernet-interface:ethernet"
var resp struct {
AN struct {
Enable *bool `json:"enable"`
} `json:"ieee802-ethernet-interface:auto-negotiation"`
}
enable := true // YANG default
if err := h.RC.Get(r.Context(), base+"/auto-negotiation", &resp); err == nil {
if resp.AN.Enable != nil {
enable = *resp.AN.Enable
}
} else if !restconf.IsNotFound(err) {
log.Printf("configure interfaces %s reset advertised get: %v", name, err)
renderSaveError(w, err)
return
}
body := map[string]any{
"ieee802-ethernet-interface:auto-negotiation": map[string]any{"enable": enable},
}
if err := h.RC.Put(r.Context(), base+"/auto-negotiation", body); err != nil {
log.Printf("configure interfaces %s reset advertised put: %v", name, err)
renderSaveError(w, err)
return
}
renderSavedRedirect(w, "Reset to default", "/configure/interfaces")
}
func (h *ConfigureInterfacesHandler) SaveBridgePort(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
@@ -1739,7 +1868,13 @@ func (h *ConfigureInterfacesHandler) fetchAllInterfaces(ctx context.Context) ([]
type membership struct{ kind, master string }
func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON) []cfgIfaceRow {
func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON, oper []ifaceJSON) []cfgIfaceRow {
// Index operational so each row can pull supported-pmd-types — that
// leaf is config false, so the candidate read doesn't carry it.
operByName := make(map[string]*ifaceJSON, len(oper))
for i := range oper {
operByName[oper[i].Name] = &oper[i]
}
// Build a set of current bridge/lag members for fast lookup.
memberOf := make(map[string]membership, len(ifaces))
for _, iface := range ifaces {
@@ -1793,6 +1928,24 @@ func (h *ConfigureInterfacesHandler) buildRows(ifaces []ifaceJSON) []cfgIfaceRow
row.WifiMode = "station"
}
}
row.EthAutoneg = true // YANG default when no candidate value is set
if iface.Ethernet != nil {
row.EthDuplex = iface.Ethernet.Duplex
if iface.Ethernet.AutoNegotiation != nil {
row.EthAutoneg = iface.Ethernet.AutoNegotiation.Enable
row.EthAdvertised = iface.Ethernet.AutoNegotiation.AdvertisedPMDs
}
if iface.Ethernet.MDIX != nil {
if *iface.Ethernet.MDIX {
row.MDIXState = "true"
} else {
row.MDIXState = "false"
}
}
}
if op := operByName[iface.Name]; op != nil && op.Ethernet != nil {
row.EthSupported = op.Ethernet.SupportedPMDs
}
if m, ok := memberOf[iface.Name]; ok {
row.MemberOf = m.master
}
+128 -7
View File
@@ -8,11 +8,14 @@ import (
"log"
"math"
"net/http"
"slices"
"sort"
"strconv"
"strings"
"sync"
"infix/webui/internal/restconf"
"infix/webui/internal/schema"
)
// RESTCONF JSON structures for ietf-interfaces:interfaces.
@@ -31,6 +34,9 @@ type ifaceJSON struct {
OperStatus string `json:"oper-status"`
PhysAddress string `json:"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"`
IPv4 *ipCfg `json:"ietf-ip:ipv4"`
IPv6 *ipCfg `json:"ietf-ip:ipv6"`
Statistics *ifaceStats `json:"statistics"`
@@ -211,10 +217,16 @@ type ifaceStats struct {
}
type ethernetJSON struct {
Speed string `json:"speed"`
Duplex string `json:"duplex"`
PhyType string `json:"phy-type"`
PMDType string `json:"pmd-type"`
SupportedPMDs []string `json:"infix-ethernet-interface:supported-pmd-types"`
// *bool because the YANG model uses absent as the Auto-MDIX signal —
// collapsing nil and false here would lose the tri-state.
MDIX *bool `json:"infix-ethernet-interface:mdi-x"`
AutoNegotiation *struct {
Enable bool `json:"enable"`
Enable bool `json:"enable"`
AdvertisedPMDs []string `json:"infix-ethernet-interface:advertised-pmd-types"`
} `json:"auto-negotiation"`
Statistics *struct {
Frame *ethFrameStats `json:"frame"`
@@ -261,6 +273,7 @@ type ifaceEntry struct {
PhysAddr string
Addresses []addrEntry
Detail string // extra info: wifi AP, wireguard peers, etc.
Link string // ethernet speed + duplex e.g. "1 Gbps full"; empty for non-ethernet
RxBytes string
TxBytes string
}
@@ -276,6 +289,7 @@ type InterfacesHandler struct {
DetailTemplate *template.Template
CountersTemplate *template.Template
RC *restconf.Client
Schema *schema.Cache
}
// Overview renders the interfaces page (GET /interfaces).
@@ -454,6 +468,14 @@ func makeIfaceEntry(iface ifaceJSON, fwdSet map[string]bool) ifaceEntry {
PhysAddr: iface.PhysAddress,
}
if prettyIfType(iface.Type) == ifTypeEthernet {
duplex := ""
if iface.Ethernet != nil {
duplex = iface.Ethernet.Duplex
}
e.Link = formatEthernetLink(uint64(iface.Speed), duplex)
}
if iface.Statistics != nil {
e.RxBytes = humanBytes(int64(iface.Statistics.InOctets))
e.TxBytes = humanBytes(int64(iface.Statistics.OutOctets))
@@ -520,6 +542,10 @@ type ifaceDetailData struct {
Speed string
Duplex string
AutoNeg string
PhyType string // e.g. "1000BASE-T"
PMDType string // e.g. "1000BASE-T"
SupportedPMDs []string // short names (what the PHY can do)
AdvertisedPMDs []string // short names (what autoneg announces)
Addresses []addrEntry
WiFiMode string // "Access Point" or "Station"
WiFiSSID string
@@ -533,6 +559,7 @@ type ifaceDetailData struct {
WGPeers []wgPeerEntry
WiFiStations []wifiStaEntry
ScanResults []wifiScanEntry
Desc map[string]string
}
type ifaceCounters struct {
@@ -632,15 +659,21 @@ func buildDetailData(r *http.Request, iface *ifaceJSON) ifaceDetailData {
}
}
if prettyIfType(iface.Type) == ifTypeEthernet {
d.Speed = formatEthernetLink(uint64(iface.Speed), "")
}
if iface.Ethernet != nil && prettyIfType(iface.Type) == ifTypeEthernet {
d.Speed = prettySpeed(iface.Ethernet.Speed)
d.Duplex = iface.Ethernet.Duplex
d.PhyType = ShortenPMD(iface.Ethernet.PhyType)
d.PMDType = ShortenPMD(iface.Ethernet.PMDType)
d.SupportedPMDs = shortenPMDs(iface.Ethernet.SupportedPMDs)
if iface.Ethernet.AutoNegotiation != nil {
if iface.Ethernet.AutoNegotiation.Enable {
d.AutoNeg = "on"
} else {
d.AutoNeg = "off"
}
d.AdvertisedPMDs = shortenPMDs(iface.Ethernet.AutoNegotiation.AdvertisedPMDs)
}
if iface.Ethernet.Statistics != nil && iface.Ethernet.Statistics.Frame != nil {
d.EthFrameStats = buildEthFrameStats(iface.Ethernet.Statistics.Frame)
@@ -745,10 +778,75 @@ func buildEthFrameStats(f *ethFrameStats) []kvEntry {
}
}
// prettySpeed converts YANG ethernet speed identities to display strings.
func prettySpeed(s string) string {
if i := strings.LastIndex(s, ":"); i >= 0 {
s = s[i+1:]
// ShortenPMD turns a PHY/PMD identityref into its IEEE shorthand,
// e.g. "ieee802-ethernet-phy-type:pmd-type-1000BASE-T" → "1000BASE-T".
// Exported so the configure-interfaces template can call it via funcmap.
func ShortenPMD(s string) string {
s = schema.StripModulePrefix(s)
for _, prefix := range []string{"pmd-type-", "phy-type-"} {
if strings.HasPrefix(s, prefix) {
return s[len(prefix):]
}
}
return s
}
func shortenPMDs(pmds []string) []string {
if len(pmds) == 0 {
return nil
}
out := make([]string, len(pmds))
for i, p := range pmds {
out[i] = ShortenPMD(p)
}
// Sort by line rate so Supported and Advertised line up
// row-by-row in the comparison view — ethtool --json doesn't
// guarantee a consistent order between the two lists.
slices.SortStableFunc(out, func(a, b string) int {
return pmdSpeedMbps(a) - pmdSpeedMbps(b)
})
return out
}
// pmdSpeedMbps extracts the line rate in megabits per second from a short
// PMD name like "10BASE-T", "2.5GBASE-T", or "100GBASE-CR4". Unknown
// strings sort as 0.
func pmdSpeedMbps(name string) int {
i := 0
for i < len(name) && (name[i] >= '0' && name[i] <= '9' || name[i] == '.') {
i++
}
if i == 0 {
return 0
}
n, err := strconv.ParseFloat(name[:i], 64)
if err != nil {
return 0
}
if i < len(name) && name[i] == 'G' {
n *= 1000
}
return int(n)
}
// formatEthernetLink renders the ietf-interfaces:speed (yang:gauge64 bits/s)
// plus duplex as "1 Gbps full" / "100 Mbps half"; empty when speed is 0.
func formatEthernetLink(bps uint64, duplex string) string {
if bps == 0 {
return ""
}
var s string
switch {
case bps >= 1_000_000_000:
gbps := float64(bps) / 1_000_000_000
s = strconv.FormatFloat(gbps, 'f', -1, 64) + " Gbps"
case bps >= 1_000_000:
s = strconv.FormatUint(bps/1_000_000, 10) + " Mbps"
default:
s = strconv.FormatUint(bps/1_000, 10) + " kbps"
}
if duplex != "" {
s += " " + duplex
}
return s
}
@@ -851,6 +949,7 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
data := buildDetailData(r, iface)
data.PageData = newPageData(r, "interfaces", "Interface "+name)
data.Desc = h.fieldDescriptions()
tmplName := "iface-detail.html"
if r.Header.Get("HX-Request") == "true" {
@@ -862,6 +961,28 @@ func (h *InterfacesHandler) Detail(w http.ResponseWriter, r *http.Request) {
}
}
// fieldDescriptions returns YANG-sourced hover descriptions for the rows
// rendered on the interface-detail page. The keys correspond to the
// field-info template invocations in iface-detail.html.
func (h *InterfacesHandler) fieldDescriptions() map[string]string {
mgr := h.Schema.Manager()
if mgr == nil {
return nil
}
ifPath := "/ietf-interfaces:interfaces/interface"
ethPath := ifPath + "/ieee802-ethernet-interface:ethernet"
return map[string]string{
"mtu": schema.DescriptionOf(mgr, ifPath+"/ietf-ip:ipv4/mtu"),
"speed": schema.DescriptionOf(mgr, ifPath+"/speed"),
"duplex": schema.DescriptionOf(mgr, ethPath+"/duplex"),
"autoneg": schema.DescriptionOf(mgr, ethPath+"/auto-negotiation/enable"),
"phy-type": schema.DescriptionOf(mgr, ethPath+"/phy-type"),
"pmd-type": schema.DescriptionOf(mgr, ethPath+"/pmd-type"),
"supported": schema.DescriptionOf(mgr, ethPath+"/infix-ethernet-interface:supported-pmd-types"),
"advertised": schema.DescriptionOf(mgr, ethPath+"/auto-negotiation/infix-ethernet-interface:advertised-pmd-types"),
}
}
// Counters renders the counters fragment for htmx polling (GET /interfaces/{name}/counters).
func (h *InterfacesHandler) Counters(w http.ResponseWriter, r *http.Request) {
name := r.PathValue("name")
+9
View File
@@ -19,6 +19,15 @@ 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.
func IsDataMissing(err error) bool {
var e *Error
return errors.As(err, &e) && e.Tag == "data-missing"
}
// AuthError is returned when RESTCONF rejects credentials (401/403).
type AuthError struct {
Code int
+5 -1
View File
@@ -130,7 +130,8 @@ func New(
return nil, err
}
ifFuncs := template.FuncMap{
"add": func(a, b int) int { return a + b },
"shortPMD": handlers.ShortenPMD,
"add": func(a, b int) int { return a + b },
"deref": func(v any) any {
switch p := v.(type) {
case *bool:
@@ -217,6 +218,7 @@ func New(
DetailTemplate: ifDetailTmpl,
CountersTemplate: ifCountersTmpl,
RC: rc,
Schema: schemaCache,
}
sys := &handlers.SystemHandler{
@@ -342,6 +344,8 @@ func New(
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)
mux.HandleFunc("POST /configure/interfaces/{name}/ethernet", cfgIf.SaveEthernet)
mux.HandleFunc("DELETE /configure/interfaces/{name}/ethernet/advertised", cfgIf.ResetEthernetAdvertised)
mux.HandleFunc("POST /configure/interfaces/{name}/bridge-port", cfgIf.SaveBridgePort)
mux.HandleFunc("DELETE /configure/interfaces/{name}/bridge-port", cfgIf.DeleteBridgePort)
mux.HandleFunc("POST /configure/interfaces/{name}/wifi", cfgIf.SaveWifi)
+90
View File
@@ -1398,6 +1398,33 @@ details[open].nav-group-top > summary.nav-group-summary-top::before {
.eth-stats dt { color: var(--fg-muted); font-family: var(--font-mono); }
.eth-stats dd { text-align: right; font-family: var(--font-mono); font-weight: 600; }
/* Side-by-side comparison of supported vs advertised PMDs on iface-detail.
Equal-width columns keep the lists visually parallel even when one side
is empty (no auto-negotiation, or no module data yet). */
.pmd-compare {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.pmd-col-head {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--fg-muted);
margin-bottom: 0.3rem;
}
.pmd-list {
list-style: none;
padding: 0;
margin: 0;
font-family: var(--font-mono);
font-size: 0.85rem;
}
.pmd-list li {
padding: 0.1rem 0;
}
.iface-detail-header { margin-bottom: 1.5rem; border-bottom: 1px solid var(--border); padding-bottom: 1rem; }
.iface-detail-header h2 { font-size: 1.5rem; font-weight: 600; color: var(--fg); }
.back-link { display: inline-flex; align-items: center; margin-bottom: 0.5rem; font-size: 0.9rem; }
@@ -1547,6 +1574,69 @@ details.fw-hint[open] summary::before { transform: rotate(90deg); }
}
.fw-checkbox-row input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
/* Multi-select dropdown built on <details> + checkboxes so the summary
row looks like a native <select> trigger and the body holds the
checkbox list. Native <select multiple size="1"> never renders as
a dropdown, hence the substitute. */
.cfg-multi {
position: relative;
display: inline-block;
min-width: 14rem;
}
.cfg-multi-summary {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.4rem 0.6rem;
background: var(--surface);
color: var(--fg);
border: 1px solid var(--border);
border-radius: var(--radius);
font-size: 0.875rem;
cursor: pointer;
list-style: none;
user-select: none;
}
.cfg-multi-summary::-webkit-details-marker { display: none; }
.cfg-multi-summary::after {
content: '\25BE';
margin-left: 0.5rem;
color: var(--fg-muted);
transition: transform 0.15s;
}
details[open] > .cfg-multi-summary::after { transform: rotate(180deg); }
details[open] > .cfg-multi-summary {
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}
.cfg-multi-body {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 0.5rem 0.75rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 10;
max-height: 20rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.cfg-multi-item {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.875rem;
white-space: nowrap;
cursor: pointer;
}
.cfg-multi-item input[type="checkbox"] { accent-color: var(--primary); cursor: pointer; }
.fw-result {
display: flex;
align-items: center;
+50
View File
@@ -93,8 +93,58 @@
fwUploadInit(root);
initRestoreCheckbox(root);
initYangTree(root);
initMultiDropdown(root);
}
// The <details>-based multi-select dropdown needs JS to behave like a
// real <select>: refresh the summary text when checkboxes change, and
// close when the user clicks outside the widget.
function initMultiDropdown(root) {
var scope = root || document;
scope.querySelectorAll('.cfg-multi:not([data-init])').forEach(function (det) {
det.dataset.init = 'true';
var summary = det.querySelector('.cfg-multi-summary');
var body = det.querySelector('.cfg-multi-body');
if (!summary || !body) return;
var allBox = body.querySelector('input[data-multi-all]');
var itemBoxes = body.querySelectorAll('input[type="checkbox"]:not([data-multi-all])');
function refreshSummary() {
var labels = [];
itemBoxes.forEach(function (cb) {
if (!cb.checked) return;
var lab = cb.closest('label');
labels.push(lab ? lab.textContent.trim() : cb.value);
});
summary.textContent = labels.length ? labels.join(', ') : '(All)';
}
body.addEventListener('change', function (evt) {
if (evt.target === allBox && allBox.checked) {
// (All) was just checked — clear specific selections.
itemBoxes.forEach(function (cb) { cb.checked = false; });
} else if (evt.target !== allBox && evt.target.checked && allBox) {
// A specific PMD was checked — uncheck (All).
allBox.checked = false;
} else if (evt.target !== allBox && allBox) {
// A specific PMD was unchecked — if none remain, re-check (All).
var anyChecked = false;
itemBoxes.forEach(function (cb) { if (cb.checked) anyChecked = true; });
if (!anyChecked) allBox.checked = true;
}
refreshSummary();
});
});
}
// Close any open .cfg-multi when the user clicks outside it. Single
// top-level listener — cheaper than per-widget bindings.
document.addEventListener('click', function (evt) {
document.querySelectorAll('.cfg-multi[open]').forEach(function (det) {
if (!det.contains(evt.target)) det.removeAttribute('open');
});
});
function initYangTree(scope) {
var root = scope || document;
// Stop <details> from toggling when clicking interactive elements inside <summary>.
@@ -485,6 +485,102 @@
{{end}}{{/* IsLagPort */}}
{{/* ── Ethernet ────────────────────────────────────────── */}}
{{if eq .TypeSlug "ethernet"}}
{{$ep := printf "%s/ieee802-ethernet-interface:ethernet" $ip}}
<div style="margin-top:1rem">
<h4 class="cfg-section-head">Ethernet</h4>
<form hx-post="/configure/interfaces/{{.Name}}/ethernet" hx-swap="none">
<table class="info-table">
<tr>
<th>Auto-negotiation{{template "field-info" (index $d "eth-autoneg")}}</th>
<td><label><input type="checkbox" name="autoneg" {{if .EthAutoneg}}checked{{end}}> Enabled</label></td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$ep}}/auto-negotiation/enable&redirect=/configure/interfaces"
hx-swap="none"
hx-confirm="Reset auto-negotiation to its YANG default?">{{template "icon-reset"}}</button>
</td>
</tr>
<tr>
<th>Advertised speeds{{template "field-info" (index $d "eth-advertised")}}</th>
<td>
{{if .EthSupported}}
{{$advertised := .EthAdvertised}}
<details class="cfg-multi">
<summary class="cfg-multi-summary">
{{if $advertised}}
{{range $i, $v := $advertised}}{{if $i}}, {{end}}{{shortPMD $v}}{{end}}
{{else}}(All){{end}}
</summary>
<div class="cfg-multi-body">
<label class="cfg-multi-item cfg-multi-all">
<input type="checkbox" data-multi-all {{if not $advertised}}checked{{end}}>
(All)
</label>
{{range .EthSupported}}
{{$cur := .}}
{{$selected := false}}
{{range $advertised}}{{if eq . $cur}}{{$selected = true}}{{end}}{{end}}
<label class="cfg-multi-item">
<input type="checkbox" name="advertised" value="{{.}}" {{if $selected}}checked{{end}}>
{{shortPMD .}}
</label>
{{end}}
</div>
</details>
{{else}}
<span class="text-muted">No supported-PMD data from device.</span>
{{end}}
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/interfaces/{{.Name}}/ethernet/advertised"
hx-swap="none"
hx-confirm="Reset advertised speeds to its YANG default?">{{template "icon-reset"}}</button>
</td>
</tr>
<tr>
<th>Duplex{{template "field-info" (index $d "eth-duplex")}}</th>
<td>
<label><input type="radio" name="duplex" value="" {{if eq .EthDuplex ""}}checked{{end}}> Auto</label>
<label style="margin-left:1em"><input type="radio" name="duplex" value="full" {{if eq .EthDuplex "full"}}checked{{end}}> Full</label>
<label style="margin-left:1em"><input type="radio" name="duplex" value="half" {{if eq .EthDuplex "half"}}checked{{end}}> Half</label>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$ep}}/duplex&redirect=/configure/interfaces"
hx-swap="none"
hx-confirm="Reset duplex to its YANG default?">{{template "icon-reset"}}</button>
</td>
</tr>
<tr>
<th>MDI-X{{template "field-info" (index $d "eth-mdix")}}</th>
<td>
<label><input type="radio" name="mdix" value="" {{if eq .MDIXState ""}}checked{{end}}> Auto-MDIX</label>
<label style="margin-left:1em"><input type="radio" name="mdix" value="false" {{if eq .MDIXState "false"}}checked{{end}}> Force MDI</label>
<label style="margin-left:1em"><input type="radio" name="mdix" value="true" {{if eq .MDIXState "true"}}checked{{end}}> Force MDI-X</label>
</td>
<td class="cfg-reset-col">
<button type="button" class="btn btn-secondary btn-sm" title="Reset to YANG default"
hx-delete="/configure/leaf?path={{$ep}}/infix-ethernet-interface:mdi-x&redirect=/configure/interfaces"
hx-swap="none"
hx-confirm="Reset MDI-X to its YANG default?">{{template "icon-reset"}}</button>
</td>
</tr>
</table>
<div class="cfg-card-footer">
<button class="btn btn-primary" type="submit">Save</button>
<span class="cfg-save-status"></span>
</div>
</form>
</div>
{{end}}{{/* TypeSlug ethernet */}}
{{/* ── WiFi interface editor ───────────────────────────── */}}
{{/* Radio + PSK pickers mirror the Add-Interface wizard
pattern: each picker has Edit / + New buttons that
+20 -4
View File
@@ -27,10 +27,26 @@
<td><span class="status-dot {{if .StatusUp}}status-up{{else}}status-down{{end}}"></span>{{.Status}}</td>
</tr>
{{if .PhysAddr}}<tr><th>MAC Address</th><td>{{.PhysAddr}}</td></tr>{{end}}
{{if .MTU}}<tr><th>MTU</th><td>{{.MTU}}</td></tr>{{end}}
{{if .Speed}}<tr><th>Speed</th><td>{{.Speed}}</td></tr>{{end}}
{{if .Duplex}}<tr><th>Duplex</th><td>{{.Duplex}}</td></tr>{{end}}
{{if .AutoNeg}}<tr><th>Auto-negotiation</th><td>{{.AutoNeg}}</td></tr>{{end}}
{{if .MTU}}<tr><th>MTU{{template "field-info" (index .Desc "mtu")}}</th><td>{{.MTU}}</td></tr>{{end}}
{{if .Speed}}<tr><th>Speed{{template "field-info" (index .Desc "speed")}}</th><td>{{.Speed}}</td></tr>{{end}}
{{if .Duplex}}<tr><th>Duplex{{template "field-info" (index .Desc "duplex")}}</th><td>{{.Duplex}}</td></tr>{{end}}
{{if .AutoNeg}}<tr><th>Auto-negotiation{{template "field-info" (index .Desc "autoneg")}}</th><td>{{.AutoNeg}}</td></tr>{{end}}
{{if .PhyType}}<tr><th>PHY type{{template "field-info" (index .Desc "phy-type")}}</th><td>{{.PhyType}}</td></tr>{{end}}
{{if .PMDType}}<tr><th>PMD type{{template "field-info" (index .Desc "pmd-type")}}</th><td>{{.PMDType}}</td></tr>{{end}}
{{if or .SupportedPMDs .AdvertisedPMDs}}
<tr><th>Link modes</th><td>
<div class="pmd-compare">
<div class="pmd-col">
<div class="pmd-col-head">Supported{{template "field-info" (index .Desc "supported")}}</div>
<ul class="pmd-list">{{range .SupportedPMDs}}<li>{{.}}</li>{{end}}</ul>
</div>
<div class="pmd-col">
<div class="pmd-col-head">Advertised{{template "field-info" (index .Desc "advertised")}}</div>
<ul class="pmd-list">{{range .AdvertisedPMDs}}<li>{{.}}</li>{{end}}</ul>
</div>
</div>
</td></tr>
{{end}}
{{if .WiFiMode}}<tr><th>Mode</th><td>{{.WiFiMode}}</td></tr>{{end}}
{{if .WiFiSSID}}<tr><th>SSID</th><td>{{.WiFiSSID}}</td></tr>{{end}}
{{if .WiFiSignal}}<tr><th>Signal</th><td>{{.WiFiSignal}}</td></tr>{{end}}
@@ -19,6 +19,7 @@
<th class="iface-name">Name</th>
<th>Type</th>
<th>Status</th>
<th>Speed</th>
<th>MAC</th>
<th>Data</th>
</tr>
@@ -31,6 +32,7 @@
<td class="iface-name{{if .IsMember}} iface-member-name{{end}}"><a href="/interfaces/{{.Name}}" hx-get="/interfaces/{{.Name}}" hx-target="#content" hx-push-url="true">{{.Name}}</a></td>
<td>{{.Type}}</td>
<td><span class="iface-status {{if .StatusUp}}iface-up{{else}}iface-down{{end}}"></span>{{.Status}}</td>
<td>{{.Link}}</td>
<td>{{.PhysAddr}}</td>
<td>{{range $i, $a := .Addresses}}{{if $i}}<br>{{end}}{{$a.Address}}{{if $a.Origin}} <span class="addr-origin">({{$a.Origin}})</span>{{end}}{{end}}{{if and .Addresses .Detail}}<br>{{end}}{{if .Detail}}<span class="data-detail">{{.Detail}}</span>{{end}}</td>
</tr>