diff --git a/src/webui/internal/handlers/configure_interfaces.go b/src/webui/internal/handlers/configure_interfaces.go index a572a20c..7fd4ad59 100644 --- a/src/webui/internal/handlers/configure_interfaces.go +++ b/src/webui/internal/handlers/configure_interfaces.go @@ -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. 8–63 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 } diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 2390e356..e14339e5 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -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") diff --git a/src/webui/internal/restconf/errors.go b/src/webui/internal/restconf/errors.go index 5829d5f5..eb47a573 100644 --- a/src/webui/internal/restconf/errors.go +++ b/src/webui/internal/restconf/errors.go @@ -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 diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 17e053fe..e04aafc9 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -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) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index c76cd948..a7f1eb18 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -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
+ checkboxes so the summary + row looks like a native 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; diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index b1ff6671..4072b9f6 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -93,8 +93,58 @@ fwUploadInit(root); initRestoreCheckbox(root); initYangTree(root); + initMultiDropdown(root); } + // The
-based multi-select dropdown needs JS to behave like a + // real Enabled + + + + + + + Advertised speeds{{template "field-info" (index $d "eth-advertised")}} + + {{if .EthSupported}} + {{$advertised := .EthAdvertised}} +
+ + {{if $advertised}} + {{range $i, $v := $advertised}}{{if $i}}, {{end}}{{shortPMD $v}}{{end}} + {{else}}(All){{end}} + +
+ + {{range .EthSupported}} + {{$cur := .}} + {{$selected := false}} + {{range $advertised}}{{if eq . $cur}}{{$selected = true}}{{end}}{{end}} + + {{end}} +
+
+ {{else}} + No supported-PMD data from device. + {{end}} + + + + + + + + Duplex{{template "field-info" (index $d "eth-duplex")}} + + + + + + + + + + + + MDI-X{{template "field-info" (index $d "eth-mdix")}} + + + + + + + + + + + + + + {{end}}{{/* TypeSlug ethernet */}} + + {{/* ── WiFi interface editor ───────────────────────────── */}} {{/* Radio + PSK pickers mirror the Add-Interface wizard pattern: each picker has Edit / + New buttons that diff --git a/src/webui/templates/pages/iface-detail.html b/src/webui/templates/pages/iface-detail.html index 450e9bed..40b7afe8 100644 --- a/src/webui/templates/pages/iface-detail.html +++ b/src/webui/templates/pages/iface-detail.html @@ -27,10 +27,26 @@ {{.Status}} {{if .PhysAddr}}MAC Address{{.PhysAddr}}{{end}} - {{if .MTU}}MTU{{.MTU}}{{end}} - {{if .Speed}}Speed{{.Speed}}{{end}} - {{if .Duplex}}Duplex{{.Duplex}}{{end}} - {{if .AutoNeg}}Auto-negotiation{{.AutoNeg}}{{end}} + {{if .MTU}}MTU{{template "field-info" (index .Desc "mtu")}}{{.MTU}}{{end}} + {{if .Speed}}Speed{{template "field-info" (index .Desc "speed")}}{{.Speed}}{{end}} + {{if .Duplex}}Duplex{{template "field-info" (index .Desc "duplex")}}{{.Duplex}}{{end}} + {{if .AutoNeg}}Auto-negotiation{{template "field-info" (index .Desc "autoneg")}}{{.AutoNeg}}{{end}} + {{if .PhyType}}PHY type{{template "field-info" (index .Desc "phy-type")}}{{.PhyType}}{{end}} + {{if .PMDType}}PMD type{{template "field-info" (index .Desc "pmd-type")}}{{.PMDType}}{{end}} + {{if or .SupportedPMDs .AdvertisedPMDs}} + Link modes +
+
+
Supported{{template "field-info" (index .Desc "supported")}}
+
    {{range .SupportedPMDs}}
  • {{.}}
  • {{end}}
+
+
+
Advertised{{template "field-info" (index .Desc "advertised")}}
+
    {{range .AdvertisedPMDs}}
  • {{.}}
  • {{end}}
+
+
+ + {{end}} {{if .WiFiMode}}Mode{{.WiFiMode}}{{end}} {{if .WiFiSSID}}SSID{{.WiFiSSID}}{{end}} {{if .WiFiSignal}}Signal{{.WiFiSignal}}{{end}} diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html index be717cd9..7892771e 100644 --- a/src/webui/templates/pages/interfaces.html +++ b/src/webui/templates/pages/interfaces.html @@ -19,6 +19,7 @@ Name Type Status + Speed MAC Data @@ -31,6 +32,7 @@ {{.Name}} {{.Type}} {{.Status}} + {{.Link}} {{.PhysAddr}} {{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a.Address}}{{if $a.Origin}} ({{$a.Origin}}){{end}}{{end}}{{if and .Addresses .Detail}}
{{end}}{{if .Detail}}{{.Detail}}{{end}}