mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user