mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: interfaces — tree view, collapsible bridges, forwarding flag
Display interfaces in a hierarchical tree: bridge and LAG parent rows show a ▼ toggle in the tree column that collapses/expands their member ports. The vertical connector line disappears when collapsed. Member ports within each group are sorted alphabetically; loopback interfaces always appear first, the rest alphabetically. A ⚑ forwarding-flag column indicates which interfaces have IP forwarding enabled, fetched concurrently from ietf-routing:routing. The "Address" and "Detail" columns are replaced by a single "Data" column matching the CLI layout: IP addresses, VLAN id, WiFi SSID/mode, WireGuard peer count, etc. Table rows are top-aligned so interfaces with multiple addresses don't cause odd vertical centering on other cells; the two narrow icon columns (⚑ and tree) stay middle-aligned. Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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:]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -14,23 +14,25 @@
|
||||
<table class="data-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th class="iface-fwd-col" title="IP forwarding">⚑</th>
|
||||
<th class="iface-tree-col"></th>
|
||||
<th class="iface-name">Name</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>MAC</th>
|
||||
<th>Address</th>
|
||||
<th>Detail</th>
|
||||
<th>Data</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{range .Interfaces}}
|
||||
<tr>
|
||||
<td class="iface-name">{{if .Indent}}<span class="tree-pipe">{{.Indent}}</span>{{end}}<a href="/interfaces/{{.Name}}" hx-get="/interfaces/{{.Name}}" hx-target="#content" hx-push-url="true">{{.Name}}</a></td>
|
||||
<tr{{if .IsMember}} class="iface-member" data-bridge-member="{{.GroupID}}"{{else if .HasMembers}} data-bridge-group="{{.GroupID}}"{{end}}>
|
||||
<td class="iface-fwd-col">{{if .Forwarding}}<span class="iface-fwd-flag" title="IP forwarding">⇅</span>{{end}}</td>
|
||||
<td class="iface-tree-col {{if .HasMembers}}tree-parent{{else if .IsLastMember}}tree-last{{else if .IsMember}}tree-mid{{end}}">{{if .HasMembers}}<button class="bridge-toggle" type="button" data-group="{{.GroupID}}" title="Collapse members" aria-expanded="true"><span class="bridge-toggle-arrow">▼</span></button>{{end}}</td>
|
||||
<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>{{.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}}</td>
|
||||
<td>{{.Detail}}</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>
|
||||
{{end}}
|
||||
</tbody>
|
||||
|
||||
@@ -3,111 +3,123 @@
|
||||
{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="page-content">
|
||||
<h1 class="page-title">Routing</h1>
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
|
||||
|
||||
<div class="info-card">
|
||||
<h2>Routing Table</h2>
|
||||
{{if .Routes}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Destination</th>
|
||||
<th>Next Hop</th>
|
||||
<th>Protocol</th>
|
||||
<th>Preference</th>
|
||||
<th>Active</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Routes}}
|
||||
<tr>
|
||||
<td class="num">{{.DestPrefix}}</td>
|
||||
<td>{{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}}</td>
|
||||
<td>
|
||||
{{if eq .Protocol "ospfv2"}}<span class="zone-badge" style="background:var(--blue-500);color:#fff">ospfv2</span>
|
||||
{{else if eq .Protocol "static"}}<span class="zone-badge" style="background:var(--slate-500);color:#fff">static</span>
|
||||
{{else if eq .Protocol "kernel"}}<span class="zone-badge" style="background:var(--slate-400);color:#fff">kernel</span>
|
||||
{{else if eq .Protocol "direct"}}<span class="zone-badge" style="background:var(--green-500);color:#fff">direct</span>
|
||||
{{else}}<span class="zone-badge">{{.Protocol}}</span>{{end}}
|
||||
</td>
|
||||
<td class="num">{{.Preference}}</td>
|
||||
<td>{{if .Active}}<span class="iface-status iface-up"></span>{{else}}<span class="iface-status iface-down"></span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">No routes found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .HasOSPF}}
|
||||
<div class="info-card">
|
||||
<h2>OSPF Neighbors</h2>
|
||||
{{if .OSPFNeighbors}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Router ID</th>
|
||||
<th>State</th>
|
||||
<th>Role</th>
|
||||
<th>Interface</th>
|
||||
<th>Uptime</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .OSPFNeighbors}}
|
||||
<tr>
|
||||
<td class="num">{{.RouterID}}</td>
|
||||
<td>{{.State}}</td>
|
||||
<td>{{.Role}}</td>
|
||||
<td>{{.Interface}}</td>
|
||||
<td>{{.Uptime}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">No OSPF neighbors.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<h2>OSPF Interfaces</h2>
|
||||
{{if .OSPFIfaces}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Name</th>
|
||||
<th>State</th>
|
||||
<th>Cost</th>
|
||||
<th>DR</th>
|
||||
<th>BDR</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .OSPFIfaces}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.State}}</td>
|
||||
<td class="num">{{.Cost}}</td>
|
||||
<td class="num">{{.DR}}</td>
|
||||
<td class="num">{{.BDR}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="text-muted">No OSPF interfaces.</p>
|
||||
{{end}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">Routing Table</div>
|
||||
{{if .Routes}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th class="num">Destination</th>
|
||||
<th>Next Hop</th>
|
||||
<th>Protocol</th>
|
||||
<th class="num">Preference</th>
|
||||
<th class="num">Uptime</th>
|
||||
<th>Active</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .Routes}}
|
||||
<tr>
|
||||
<td class="num">{{.DestPrefix}}</td>
|
||||
<td>{{if .NextHopAddr}}{{.NextHopAddr}}{{else}}{{.NextHopIface}}{{end}}</td>
|
||||
<td>
|
||||
{{if eq .Protocol "ospfv2"}}<span class="badge badge-info">ospfv2</span>
|
||||
{{else if eq .Protocol "ospfv3"}}<span class="badge badge-info">ospfv3</span>
|
||||
{{else if eq .Protocol "static"}}<span class="badge badge-neutral">static</span>
|
||||
{{else if eq .Protocol "kernel"}}<span class="badge badge-neutral">kernel</span>
|
||||
{{else if eq .Protocol "direct"}}<span class="badge badge-accept">direct</span>
|
||||
{{else}}<span class="badge badge-neutral">{{.Protocol}}</span>{{end}}
|
||||
</td>
|
||||
<td class="num">{{.Preference}}</td>
|
||||
<td class="num">{{.Uptime}}</td>
|
||||
<td>{{if .Active}}<span class="status-dot status-up"></span>{{else}}<span class="status-dot status-down"></span>{{end}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="info-card">
|
||||
<h2>OSPF</h2>
|
||||
<p class="text-muted">OSPF is not configured on this device.</p>
|
||||
</div>
|
||||
<p class="empty-message">No routes found.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
{{if .HasOSPF}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">OSPF Neighbors</div>
|
||||
{{if .OSPFNeighbors}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Router ID</th>
|
||||
<th class="num">Pri</th>
|
||||
<th>State</th>
|
||||
<th class="num">Uptime</th>
|
||||
<th>Address</th>
|
||||
<th>Interface</th>
|
||||
<th>Area</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .OSPFNeighbors}}
|
||||
<tr>
|
||||
<td>{{.RouterID}}</td>
|
||||
<td class="num">{{.Priority}}</td>
|
||||
<td>{{.State}}</td>
|
||||
<td class="num">{{.Uptime}}</td>
|
||||
<td>{{.Address}}</td>
|
||||
<td>{{.Interface}}</td>
|
||||
<td>{{.Area}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No OSPF neighbors.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="card-header">OSPF Interfaces</div>
|
||||
{{if .OSPFIfaces}}
|
||||
<div class="data-table-wrap">
|
||||
<table class="data-table">
|
||||
<thead><tr>
|
||||
<th>Interface</th>
|
||||
<th>Area</th>
|
||||
<th>Type</th>
|
||||
<th>State</th>
|
||||
<th class="num">Cost</th>
|
||||
<th class="num">Pri</th>
|
||||
<th>DR</th>
|
||||
<th>BDR</th>
|
||||
<th class="num">Nbrs</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{{range .OSPFIfaces}}
|
||||
<tr>
|
||||
<td>{{.Name}}</td>
|
||||
<td>{{.Area}}</td>
|
||||
<td>{{if .Type}}{{.Type}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{.State}}</td>
|
||||
<td class="num">{{.Cost}}</td>
|
||||
<td class="num">{{.Priority}}</td>
|
||||
<td>{{if .DR}}{{.DR}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td>{{if .BDR}}{{.BDR}}{{else}}<span class="text-muted">—</span>{{end}}</td>
|
||||
<td class="num">{{.NbrCount}}</td>
|
||||
</tr>
|
||||
{{end}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<p class="empty-message">No OSPF interfaces.</p>
|
||||
{{end}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="info-card">
|
||||
<div class="card-header">OSPF</div>
|
||||
<p class="empty-message">OSPF is not configured on this device.</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
Reference in New Issue
Block a user