diff --git a/src/webui/internal/handlers/interfaces.go b/src/webui/internal/handlers/interfaces.go index 0bc28180..224bd132 100644 --- a/src/webui/internal/handlers/interfaces.go +++ b/src/webui/internal/handlers/interfaces.go @@ -10,6 +10,7 @@ import ( "net/http" "sort" "strings" + "sync" "github.com/kernelkit/webui/internal/restconf" ) @@ -33,10 +34,16 @@ type ifaceJSON struct { Statistics *ifaceStats `json:"statistics"` Ethernet *ethernetJSON `json:"ieee802-ethernet-interface:ethernet"` BridgePort *bridgePortJSON `json:"infix-interfaces:bridge-port"` + Vlan *vlanJSON `json:"infix-interfaces:vlan"` WiFi *wifiJSON `json:"infix-interfaces:wifi"` WireGuard *wireGuardJSON `json:"infix-interfaces:wireguard"` } +type vlanJSON struct { + ID int `json:"id"` + LowerLayerIf string `json:"lower-layer-if"` +} + type bridgePortJSON struct { Bridge string `json:"bridge"` STP *struct { @@ -190,16 +197,20 @@ type interfacesData struct { } type ifaceEntry struct { - Indent string // tree prefix for bridge/LAG members - Name string - Type string - Status string - StatusUp bool - PhysAddr string - Addresses []addrEntry - Detail string // extra info: wifi AP, wireguard peers, etc. - RxBytes string - TxBytes string + HasMembers bool // is a bridge/LAG master with child ports + IsMember bool // is a bridge port or LAG member + IsLastMember bool // is the last child in its group + Forwarding bool // IP forwarding enabled (⇅ flag) + GroupID string // bridge/LAG name — set on parent and all its members + Name string + Type string + Status string + StatusUp bool + PhysAddr string + Addresses []addrEntry + Detail string // extra info: wifi AP, wireguard peers, etc. + RxBytes string + TxBytes string } type addrEntry struct { @@ -221,12 +232,41 @@ func (h *InterfacesHandler) Overview(w http.ResponseWriter, r *http.Request) { PageData: newPageData(r, "interfaces", "Interfaces"), } - var ifaces interfacesWrapper - if err := h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &ifaces); err != nil { - log.Printf("restconf interfaces: %v", err) + var ( + ifaces interfacesWrapper + ri struct { + Routing struct { + Interfaces struct { + Interface []string `json:"interface"` + } `json:"interfaces"` + } `json:"ietf-routing:routing"` + } + ifaceErr error + wg sync.WaitGroup + ) + wg.Add(2) + go func() { + defer wg.Done() + ifaceErr = h.RC.Get(r.Context(), "/data/ietf-interfaces:interfaces", &ifaces) + }() + go func() { + defer wg.Done() + // Best-effort: ignore errors (routing may not be configured). + // Fetch the full routing object — the /interfaces sub-path returns empty + // on Infix even when the data is present in the parent resource. + h.RC.Get(r.Context(), "/data/ietf-routing:routing", &ri) //nolint:errcheck + }() + wg.Wait() + + if ifaceErr != nil { + log.Printf("restconf interfaces: %v", ifaceErr) data.Error = "Could not fetch interface information" } else { - data.Interfaces = buildIfaceList(ifaces.Interfaces.Interface) + fwdSet := make(map[string]bool, len(ri.Routing.Interfaces.Interface)) + for _, name := range ri.Routing.Interfaces.Interface { + fwdSet[name] = true + } + data.Interfaces = buildIfaceList(ifaces.Interfaces.Interface, fwdSet) } tmplName := "interfaces.html" @@ -274,9 +314,10 @@ func prettyIfType(full string) string { } // buildIfaceList converts raw RESTCONF interface data into a flat, -// hierarchically ordered display list matching the cli_pretty style. -// Bridge members are grouped under their parent with tree indicators. -func buildIfaceList(raw []ifaceJSON) []ifaceEntry { +// hierarchically ordered display list. Bridge/LAG members are grouped +// under their parent. fwdSet contains the names of interfaces with IP +// forwarding enabled (the ⇅ flag). +func buildIfaceList(raw []ifaceJSON, fwdSet map[string]bool) []ifaceEntry { byName := map[string]*ifaceJSON{} children := map[string][]string{} childSet := map[string]bool{} @@ -291,46 +332,68 @@ func buildIfaceList(raw []ifaceJSON) []ifaceEntry { } } + // Collect top-level interfaces (not bridge/LAG members) and sort them: + // loopback first, then alphabetically. + var topLevel []ifaceJSON + for _, iface := range raw { + if !childSet[iface.Name] { + topLevel = append(topLevel, iface) + } + } + sort.Slice(topLevel, func(i, j int) bool { + li := prettyIfType(topLevel[i].Type) == "loopback" + lj := prettyIfType(topLevel[j].Type) == "loopback" + if li != lj { + return li + } + return topLevel[i].Name < topLevel[j].Name + }) + + // Sort children of each bridge/LAG alphabetically. + for parent := range children { + sort.Strings(children[parent]) + } + var result []ifaceEntry - for _, iface := range raw { - if childSet[iface.Name] { - continue - } - - result = append(result, makeIfaceEntry(iface, "")) - + for _, iface := range topLevel { + e := makeIfaceEntry(iface, fwdSet) members := children[iface.Name] + e.HasMembers = len(members) > 0 + if e.HasMembers { + e.GroupID = iface.Name + } + result = append(result, e) + for i, childName := range members { child, ok := byName[childName] if !ok { continue } - prefix := "\u251c\u00a0" // ├ - if i == len(members)-1 { - prefix = "\u2514\u00a0" // └ - } - e := makeIfaceEntry(*child, prefix) + me := makeIfaceEntry(*child, fwdSet) + me.IsMember = true + me.IsLastMember = i == len(members)-1 + me.GroupID = iface.Name if child.BridgePort != nil && child.BridgePort.STP != nil && child.BridgePort.STP.CIST != nil && child.BridgePort.STP.CIST.State != "" { - e.Status = child.BridgePort.STP.CIST.State - e.StatusUp = e.Status == "forwarding" + me.Status = child.BridgePort.STP.CIST.State + me.StatusUp = me.Status == "forwarding" } - result = append(result, e) + result = append(result, me) } } return result } -func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry { +func makeIfaceEntry(iface ifaceJSON, fwdSet map[string]bool) ifaceEntry { e := ifaceEntry{ - Indent: indent, - Name: iface.Name, - Type: prettyIfType(iface.Type), - Status: iface.OperStatus, - StatusUp: iface.OperStatus == "up", - PhysAddr: iface.PhysAddress, + Forwarding: fwdSet[iface.Name], + Name: iface.Name, + Type: prettyIfType(iface.Type), + Status: iface.OperStatus, + StatusUp: iface.OperStatus == "up", + PhysAddr: iface.PhysAddress, } if iface.Statistics != nil { @@ -355,6 +418,14 @@ func makeIfaceEntry(iface ifaceJSON, indent string) ifaceEntry { } } + if v := iface.Vlan; v != nil { + if v.LowerLayerIf != "" { + e.Detail = fmt.Sprintf("vid %d (%s)", v.ID, v.LowerLayerIf) + } else { + e.Detail = fmt.Sprintf("vid %d", v.ID) + } + } + if iface.WiFi != nil { if ap := iface.WiFi.AccessPoint; ap != nil { n := len(ap.Stations.Station) diff --git a/src/webui/internal/handlers/routing.go b/src/webui/internal/handlers/routing.go index c8464cc7..fb3dbe51 100644 --- a/src/webui/internal/handlers/routing.go +++ b/src/webui/internal/handlers/routing.go @@ -11,6 +11,7 @@ import ( "net/http" "strings" "sync" + "time" "github.com/kernelkit/webui/internal/restconf" ) @@ -20,24 +21,31 @@ type RouteEntry struct { NextHopIface string NextHopAddr string Protocol string - Preference int + Preference string // formatted as "distance/metric" + Uptime string Active bool } type OSPFNeighbor struct { RouterID string - State string - Role string - Interface string + Priority int + State string // includes role suffix, e.g. "Full/DR" Uptime string + Address string + Interface string + Area string } type OSPFIface struct { - Name string - State string - Cost int - DR string - BDR string + Name string + Area string + Type string + State string + Cost int + Priority int + DR string + BDR string + NbrCount int } type routingData struct { @@ -82,6 +90,8 @@ type ribRouteJSON struct { SourceProtocol string `json:"source-protocol"` Active *json.RawMessage `json:"active"` RoutePreference int `json:"route-preference"` + OspfMetric int `json:"ietf-ospf:metric"` + LastUpdated string `json:"last-updated"` } func (r ribRouteJSON) destinationPrefix() string { @@ -157,18 +167,22 @@ type ospfAreaJSON struct { } type ospfIfaceJSON struct { - Name string `json:"name"` - State string `json:"state"` - Cost int `json:"cost"` - DRRouterID string `json:"dr-router-id"` - BDRRouterID string `json:"bdr-router-id"` - Neighbors struct { + Name string `json:"name"` + State string `json:"state"` + Cost int `json:"cost"` + Priority int `json:"priority"` + InterfaceType string `json:"interface-type"` + DRRouterID string `json:"dr-router-id"` + BDRRouterID string `json:"bdr-router-id"` + Neighbors struct { Neighbor []ospfNeighborJSON `json:"neighbor"` } `json:"neighbors"` } type ospfNeighborJSON struct { NeighborRouterID string `json:"neighbor-router-id"` + Address string `json:"address"` + Priority int `json:"priority"` State string `json:"state"` Role string `json:"infix-routing:role"` InterfaceName string `json:"infix-routing:interface-name"` @@ -211,7 +225,8 @@ func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { NextHopIface: iface, NextHopAddr: addr, Protocol: shortProto(route.SourceProtocol), - Preference: route.RoutePreference, + Preference: fmt.Sprintf("%d/%d", route.RoutePreference, route.OspfMetric), + Uptime: uptimeFromTimestamp(route.LastUpdated), Active: route.Active != nil, }) } @@ -235,19 +250,33 @@ func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { for _, area := range proto.OSPF.Areas.Area { for _, iface := range area.Interfaces.Interface { data.OSPFIfaces = append(data.OSPFIfaces, OSPFIface{ - Name: iface.Name, - State: iface.State, - Cost: iface.Cost, - DR: iface.DRRouterID, - BDR: iface.BDRRouterID, + Name: iface.Name, + Area: area.AreaID, + Type: ospfIfaceType(iface.InterfaceType), + State: ospfIfaceState(iface.State), + Cost: iface.Cost, + Priority: iface.Priority, + DR: iface.DRRouterID, + BDR: iface.BDRRouterID, + NbrCount: len(iface.Neighbors.Neighbor), }) for _, nbr := range iface.Neighbors.Neighbor { + stateStr := capitalize(nbr.State) + if nbr.Role != "" && nbr.State == "full" { + stateStr = "Full/" + nbr.Role + } + ifaceName := nbr.InterfaceName + if ifaceName == "" { + ifaceName = iface.Name + } data.OSPFNeighbors = append(data.OSPFNeighbors, OSPFNeighbor{ RouterID: nbr.NeighborRouterID, - State: nbr.State, - Role: nbr.Role, - Interface: iface.Name, + Priority: nbr.Priority, + State: stateStr, Uptime: formatUptime(nbr.Uptime), + Address: nbr.Address, + Interface: ifaceName, + Area: area.AreaID, }) } } @@ -267,6 +296,57 @@ func (h *RoutingHandler) Overview(w http.ResponseWriter, r *http.Request) { } } +func uptimeFromTimestamp(s string) string { + if s == "" { + return "" + } + t, err := time.Parse(time.RFC3339, s) + if err != nil { + return "" + } + return formatUptime(uint32(time.Since(t).Seconds())) +} + +func ospfIfaceType(t string) string { + switch t { + case "broadcast": + return "Broadcast" + case "point-to-point": + return "P2P" + case "point-to-multipoint": + return "P2MP" + case "non-broadcast": + return "NBMA" + case "virtual-link": + return "VLink" + default: + if t != "" { + return capitalize(t) + } + return "" + } +} + +func ospfIfaceState(s string) string { + switch s { + case "dr": + return "DR" + case "bdr": + return "BDR" + case "dr-other": + return "DROther" + default: + return capitalize(s) + } +} + +func capitalize(s string) string { + if len(s) == 0 { + return s + } + return strings.ToUpper(s[:1]) + s[1:] +} + func shortProto(proto string) string { if i := strings.LastIndex(proto, ":"); i >= 0 { return proto[i+1:] diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index fc1d6885..b9b5637d 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -606,6 +606,7 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { padding: 0.75rem 1rem; border-bottom: 1px solid var(--border); text-align: left; + vertical-align: top; } .data-table th { @@ -1079,7 +1080,98 @@ details[open].nav-group-top > summary.nav-group-summary-top::after { .iface-name a { color: var(--primary); text-decoration: none; } .iface-name a:hover { text-decoration: underline; } -.tree-pipe { color: var(--slate-300); font-family: var(--font-mono); margin-right: 0.5rem; } +/* Forwarding flag column */ +.iface-fwd-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; } +.iface-fwd-flag { font-size: 0.8rem; color: var(--primary); user-select: none; } + +/* Tree connector column — pseudo-elements draw the continuous vertical/horizontal lines. + Wide enough to be a comfortable click target for the collapse button on parent rows. */ +.iface-tree-col { + width: 1.75rem; + min-width: 1.75rem; + padding: 0 !important; + position: relative; + overflow: visible; + text-align: center; +} +/* These two narrow icon columns must stay middle-aligned even though the rest of the + table uses vertical-align: top. Use td.class for higher specificity (0-1-1 beats 0-1-0). */ +.data-table td.iface-tree-col, +.data-table td.iface-fwd-col, +.data-table th.iface-fwd-col { vertical-align: middle; } + +/* Parent row: draw a vertical line from row center down to the bottom edge */ +.iface-tree-col.tree-parent::after { + content: ''; + position: absolute; + left: 50%; top: 50%; bottom: 0; + width: 2px; margin-left: -1px; + background: var(--border); +} + +/* Middle member: full-height vertical line + horizontal arm right */ +.iface-tree-col.tree-mid::before { + content: ''; + position: absolute; + left: 50%; top: 0; bottom: 0; + width: 2px; margin-left: -1px; + background: var(--border); +} +.iface-tree-col.tree-mid::after { + content: ''; + position: absolute; + left: 50%; top: 50%; right: 0; + height: 2px; margin-top: -1px; + background: var(--border); +} + +/* Last member: vertical line top-to-center + horizontal arm right */ +.iface-tree-col.tree-last::before { + content: ''; + position: absolute; + left: 50%; top: 0; bottom: 50%; + width: 2px; margin-left: -1px; + background: var(--border); +} +.iface-tree-col.tree-last::after { + content: ''; + position: absolute; + left: 50%; top: 50%; right: 0; + height: 2px; margin-top: -1px; + background: var(--border); +} + +/* Member rows: subtle background tint; name indented to clear the tree arm */ +.iface-member { background: var(--surface-alt); } +/* Tighter left padding on the name cell so connector arms read as attached to the text */ +.iface-name { padding-left: 0.4rem !important; } +.iface-member-name { padding-left: 1rem !important; } + +/* Bridge/LAG collapse toggle — sits inside .iface-tree-col, centred over the connector line. + z-index: 1 ensures it is clickable above the ::after pseudo-element. */ +.bridge-toggle { + background: none; + border: none; + cursor: pointer; + padding: 0; + color: var(--fg-muted); + font-size: 0.65rem; + line-height: 1; + display: block; + width: 100%; + position: relative; + z-index: 1; +} +.bridge-toggle-arrow { + display: inline-block; + transition: transform 0.15s ease; +} +/* When the parent row is collapsed: rotate arrow and hide the connector line below */ +.bridge-collapsed .bridge-toggle-arrow { transform: rotate(-90deg); } +.bridge-collapsed .iface-tree-col.tree-parent::after { display: none; } + +.data-detail { color: var(--fg-muted); font-size: 0.85em; } + .addr-origin { color: var(--fg-muted); font-size: 0.75rem; margin-left: 0.5rem; } .wg-peer { diff --git a/src/webui/static/js/app.js b/src/webui/static/js/app.js index 87e3a3d6..e244897f 100644 --- a/src/webui/static/js/app.js +++ b/src/webui/static/js/app.js @@ -445,6 +445,21 @@ }); })(); +// Bridge/LAG member row collapse toggle +(function() { + document.addEventListener('click', function(e) { + var btn = e.target.closest('.bridge-toggle'); + if (!btn) return; + var group = btn.getAttribute('data-group'); + var parentRow = btn.closest('tr'); + var collapsed = parentRow.classList.toggle('bridge-collapsed'); + btn.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + document.querySelectorAll('[data-bridge-member="' + group + '"]').forEach(function(row) { + row.style.display = collapsed ? 'none' : ''; + }); + }); +})(); + // Sidebar toggle (mobile) (function() { var MOBILE_BP = 1024; diff --git a/src/webui/templates/pages/interfaces.html b/src/webui/templates/pages/interfaces.html index 6d611397..be717cd9 100644 --- a/src/webui/templates/pages/interfaces.html +++ b/src/webui/templates/pages/interfaces.html @@ -14,23 +14,25 @@ - + + + - - + {{range .Interfaces}} - - + + + + - - + {{end}} diff --git a/src/webui/templates/pages/routing.html b/src/webui/templates/pages/routing.html index 9d9df7a5..5c570d80 100644 --- a/src/webui/templates/pages/routing.html +++ b/src/webui/templates/pages/routing.html @@ -3,111 +3,123 @@ {{end}} {{define "content"}} -
-

Routing

- {{if .Error}}
{{.Error}}
{{end}} +{{if .Error}}
{{.Error}}
{{end}} -
-

Routing Table

- {{if .Routes}} -
-
NameName Type Status MACAddressDetailData
{{if .Indent}}{{.Indent}}{{end}}{{.Name}}{{if .Forwarding}}{{end}}{{if .HasMembers}}{{end}}{{.Name}} {{.Type}} {{.Status}} {{.PhysAddr}}{{range $i, $a := .Addresses}}{{if $i}}
{{end}}{{$a.Address}}{{if $a.Origin}} ({{$a.Origin}}){{end}}{{end}}
{{.Detail}}{{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}}
- - - - - - - - - {{range .Routes}} - - - - - - - - {{end}} - -
DestinationNext HopProtocolPreferenceActive
{{.DestPrefix}}{{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}} - {{if eq .Protocol "ospfv2"}}ospfv2 - {{else if eq .Protocol "static"}}static - {{else if eq .Protocol "kernel"}}kernel - {{else if eq .Protocol "direct"}}direct - {{else}}{{.Protocol}}{{end}} - {{.Preference}}{{if .Active}}{{else}}{{end}}
- - {{else}} -

No routes found.

- {{end}} - - - {{if .HasOSPF}} -
-

OSPF Neighbors

- {{if .OSPFNeighbors}} -
- - - - - - - - - - {{range .OSPFNeighbors}} - - - - - - - - {{end}} - -
Router IDStateRoleInterfaceUptime
{{.RouterID}}{{.State}}{{.Role}}{{.Interface}}{{.Uptime}}
-
- {{else}} -

No OSPF neighbors.

- {{end}} -
- -
-

OSPF Interfaces

- {{if .OSPFIfaces}} -
- - - - - - - - - - {{range .OSPFIfaces}} - - - - - - - - {{end}} - -
NameStateCostDRBDR
{{.Name}}{{.State}}{{.Cost}}{{.DR}}{{.BDR}}
-
- {{else}} -

No OSPF interfaces.

- {{end}} +
+
Routing Table
+ {{if .Routes}} +
+ + + + + + + + + + + {{range .Routes}} + + + + + + + + + {{end}} + +
DestinationNext HopProtocolPreferenceUptimeActive
{{.DestPrefix}}{{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}} + {{if eq .Protocol "ospfv2"}}ospfv2 + {{else if eq .Protocol "ospfv3"}}ospfv3 + {{else if eq .Protocol "static"}}static + {{else if eq .Protocol "kernel"}}kernel + {{else if eq .Protocol "direct"}}direct + {{else}}{{.Protocol}}{{end}} + {{.Preference}}{{.Uptime}}{{if .Active}}{{else}}{{end}}
{{else}} -
-

OSPF

-

OSPF is not configured on this device.

-
+

No routes found.

{{end}}
+ +{{if .HasOSPF}} +
+
OSPF Neighbors
+ {{if .OSPFNeighbors}} +
+ + + + + + + + + + + + {{range .OSPFNeighbors}} + + + + + + + + + + {{end}} + +
Router IDPriStateUptimeAddressInterfaceArea
{{.RouterID}}{{.Priority}}{{.State}}{{.Uptime}}{{.Address}}{{.Interface}}{{.Area}}
+
+ {{else}} +

No OSPF neighbors.

+ {{end}} +
+ +
+
OSPF Interfaces
+ {{if .OSPFIfaces}} +
+ + + + + + + + + + + + + + {{range .OSPFIfaces}} + + + + + + + + + + + + {{end}} + +
InterfaceAreaTypeStateCostPriDRBDRNbrs
{{.Name}}{{.Area}}{{if .Type}}{{.Type}}{{else}}{{end}}{{.State}}{{.Cost}}{{.Priority}}{{if .DR}}{{.DR}}{{else}}{{end}}{{if .BDR}}{{.BDR}}{{else}}{{end}}{{.NbrCount}}
+
+ {{else}} +

No OSPF interfaces.

+ {{end}} +
+{{else}} +
+
OSPF
+

OSPF is not configured on this device.

+
+{{end}} {{end}}