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:
Joachim Wiberg
2026-06-14 22:07:30 +02:00
parent 1dda8075cb
commit 0ac5630be0
6 changed files with 445 additions and 173 deletions
+110 -39
View File
@@ -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)
+104 -24
View File
@@ -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:]
+93 -1
View File
@@ -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 {
+15
View File
@@ -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;
+9 -7
View File
@@ -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>
+114 -102
View File
@@ -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}}