webui: new page, mdns neighbor table

Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
Joachim Wiberg
2026-06-14 22:07:33 +02:00
parent fc328eb79b
commit e36aac736e
7 changed files with 454 additions and 0 deletions
+287
View File
@@ -0,0 +1,287 @@
// SPDX-License-Identifier: MIT
package handlers
import (
"fmt"
"html/template"
"log"
"net/http"
"net/url"
"sort"
"strings"
"github.com/kernelkit/webui/internal/restconf"
)
// ─── RESTCONF JSON types ──────────────────────────────────────────────────────
type mdnsWrapper struct {
MDNS mdnsJSON `json:"infix-services:mdns"`
}
type mdnsJSON struct {
Enabled *yangBool `json:"enabled"`
Domain string `json:"domain"`
Hostname string `json:"hostname"`
Interfaces mdnsIfacesJSON `json:"interfaces"`
Reflector mdnsReflJSON `json:"reflector"`
Neighbors mdnsNeighborsJSON `json:"neighbors"`
}
type mdnsIfacesJSON struct {
Allow []string `json:"allow"`
Deny []string `json:"deny"`
}
type mdnsReflJSON struct {
Enabled *yangBool `json:"enabled"`
ServiceFilter []string `json:"service-filter"`
}
type mdnsNeighborsJSON struct {
Neighbor []mdnsNeighborJSON `json:"neighbor"`
}
type mdnsNeighborJSON struct {
Hostname string `json:"hostname"`
Address []string `json:"address"`
LastSeen string `json:"last-seen"`
Service []mdnsServiceJSON `json:"service"`
}
type mdnsServiceJSON struct {
Name string `json:"name"`
Type string `json:"type"`
Port uint16 `json:"port"`
Txt []string `json:"txt"`
}
// ─── Template data ────────────────────────────────────────────────────────────
type mdnsPageData struct {
PageData
Enabled bool
EnabledText string
Domain string
Allow string
Deny string
Reflector bool
SvcFilter string
Neighbors []mdnsNeighborEntry
Error string
}
type mdnsNeighborEntry struct {
Hostname string
PrimaryAddr string
ExtraAddrs []string // additional addresses shown in fold-out
LastSeen string // HH:MM:SS
Services []mdnsSvcEntry
}
type mdnsSvcEntry struct {
Label string // "https", "ssh", etc.
Port uint16
URL template.URL // non-empty for http/https — safe to use in href
}
// ─── Handler ─────────────────────────────────────────────────────────────────
// MDNSHandler serves the mDNS overview page.
type MDNSHandler struct {
Template *template.Template
RC *restconf.Client
}
// Overview renders the mDNS page (GET /mdns).
func (h *MDNSHandler) Overview(w http.ResponseWriter, r *http.Request) {
data := mdnsPageData{
PageData: newPageData(r, "mdns", "mDNS"),
}
var raw mdnsWrapper
if err := h.RC.Get(r.Context(), "/data/infix-services:mdns", &raw); err != nil {
log.Printf("restconf mdns: %v", err)
data.Error = "Could not fetch mDNS data"
} else {
m := raw.MDNS
data.Enabled = m.Enabled == nil || bool(*m.Enabled)
if data.Enabled {
data.EnabledText = "Active"
} else {
data.EnabledText = "Inactive"
}
data.Domain = m.Domain
if data.Domain == "" {
data.Domain = "local"
}
data.Allow = strings.Join(m.Interfaces.Allow, ", ")
data.Deny = strings.Join(m.Interfaces.Deny, ", ")
data.Reflector = m.Reflector.Enabled != nil && bool(*m.Reflector.Enabled)
data.SvcFilter = strings.Join(m.Reflector.ServiceFilter, ", ")
for _, n := range m.Neighbors.Neighbor {
data.Neighbors = append(data.Neighbors, buildMDNSNeighbor(n))
}
sort.Slice(data.Neighbors, func(i, j int) bool {
return data.Neighbors[i].Hostname < data.Neighbors[j].Hostname
})
}
tmplName := "mdns.html"
if r.Header.Get("HX-Request") == "true" {
tmplName = "content"
}
if err := h.Template.ExecuteTemplate(w, tmplName, data); err != nil {
log.Printf("template error: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
func buildMDNSNeighbor(n mdnsNeighborJSON) mdnsNeighborEntry {
// mdnsSortAddrs puts IPv4 first, then global IPv6, then link-local — so
// addrs[0] is already the best address for URL construction.
addrs := mdnsSortAddrs(n.Address)
primary := ""
if len(addrs) > 0 {
primary = addrs[0]
}
entry := mdnsNeighborEntry{
Hostname: n.Hostname,
PrimaryAddr: primary,
LastSeen: mdnsLastSeen(n.LastSeen),
}
if len(addrs) > 1 {
entry.ExtraAddrs = addrs[1:]
}
for _, svc := range n.Service {
entry.Services = append(entry.Services, buildMDNSSvc(svc, primary, n.Hostname))
}
return entry
}
func buildMDNSSvc(svc mdnsServiceJSON, addr, host string) mdnsSvcEntry {
meta := mdnsParseTxt(svc.Txt)
var rawURL string
switch svc.Type {
case "_http._tcp":
if meta.adminurl != "" {
rawURL = rebaseURLHost(meta.adminurl, host)
} else {
rawURL = fmt.Sprintf("http://%s:%d%s", addr, svc.Port, meta.path)
}
case "_https._tcp":
if meta.adminurl != "" {
rawURL = rebaseURLHost(meta.adminurl, host)
} else {
rawURL = fmt.Sprintf("https://%s:%d%s", addr, svc.Port, meta.path)
}
}
// Use the DNS-SD instance name for clickable services so that two https
// entries on different ports ("Infix NOS" vs "ttyd") are distinguishable.
label := mdnsSvcLabel(svc.Type)
if rawURL != "" && svc.Name != "" {
label = svc.Name
}
return mdnsSvcEntry{
Label: label,
Port: svc.Port,
URL: template.URL(rawURL), // #nosec G203 — URL built from trusted operational data
}
}
// rebaseURLHost rewrites the host of raw to host, keeping scheme, port, and
// path. It corrects stale adminurl TXT records: avahi resolves hostname
// conflicts at runtime by appending -2, -3, … but the advertiser writes the
// adminurl with its configured hostname and never updates it, so a device
// renamed infix-2.local still advertises adminurl=http://infix.local — which
// resolves to the *other* device. The mDNS-resolved hostname is authoritative.
// A no-op when raw is unparseable, hostless, or already points at host.
func rebaseURLHost(raw, host string) string {
if host == "" {
return raw
}
u, err := url.Parse(raw)
if err != nil || u.Host == "" {
return raw
}
if port := u.Port(); port != "" {
u.Host = host + ":" + port
} else {
u.Host = host
}
return u.String()
}
// "_https._tcp" → "https", "_netconf-ssh._tcp" → "netconf-ssh"
func mdnsSvcLabel(stype string) string {
s := strings.TrimPrefix(stype, "_")
if idx := strings.Index(s, "._"); idx >= 0 {
s = s[:idx]
}
return s
}
func mdnsSortAddrs(addrs []string) []string {
out := make([]string, len(addrs))
copy(out, addrs)
sort.SliceStable(out, func(i, j int) bool {
return mdnsAddrPrio(out[i]) < mdnsAddrPrio(out[j])
})
return out
}
func mdnsAddrPrio(a string) int {
if !strings.Contains(a, ":") {
return 0 // IPv4
}
if strings.HasPrefix(strings.ToLower(a), "fe80:") {
return 2 // link-local IPv6
}
return 1 // global IPv6
}
func mdnsLastSeen(ts string) string {
if ts == "" {
return ""
}
parts := strings.SplitN(ts, "T", 2)
if len(parts) < 2 {
return ts
}
t := parts[1]
if len(t) >= 8 {
return t[:8]
}
return t
}
type mdnsTxtMeta struct {
path string
adminurl string
}
func mdnsParseTxt(records []string) mdnsTxtMeta {
var m mdnsTxtMeta
for _, r := range records {
switch {
case m.path == "" && strings.HasPrefix(r, "path="):
m.path = r[5:]
case m.adminurl == "" && strings.HasPrefix(r, "adminurl="):
m.adminurl = r[9:]
}
if m.path != "" && m.adminurl != "" {
break
}
}
return m
}
+6
View File
@@ -77,6 +77,10 @@ func New(
if err != nil {
return nil, err
}
mdnsTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/mdns.html")
if err != nil {
return nil, err
}
containersTmpl, err := template.ParseFS(templateFS, "layouts/*.html", "pages/containers.html")
if err != nil {
return nil, err
@@ -121,6 +125,7 @@ func New(
dhcp := &handlers.DHCPHandler{Template: dhcpTmpl, RC: rc}
ntp := &handlers.NTPHandler{Template: ntpTmpl, RC: rc}
lldp := &handlers.LLDPHandler{Template: lldpTmpl, RC: rc}
mdns := &handlers.MDNSHandler{Template: mdnsTmpl, RC: rc}
containers := &handlers.ContainersHandler{Template: containersTmpl, RC: rc}
mux := http.NewServeMux()
@@ -153,6 +158,7 @@ func New(
mux.HandleFunc("GET /dhcp", dhcp.Overview)
mux.HandleFunc("GET /ntp", ntp.Overview)
mux.HandleFunc("GET /lldp", lldp.Overview)
mux.HandleFunc("GET /mdns", mdns.Overview)
mux.HandleFunc("GET /containers", containers.Overview)
handler := authMiddleware(store, mux)
+53
View File
@@ -1083,6 +1083,59 @@ details[open].nav-group-top > summary.nav-group-summary-top::after {
.light .badge-info { background: #dbeafe; color: #1e40af; }
.light .badge-neutral { background: var(--slate-100); color: var(--slate-600); }
/* mDNS service chips — self-contained, no .badge inheritance */
.mdns-svc {
display: inline-block;
font-size: 0.75rem;
font-weight: 500;
padding: 0.15rem 0.45rem;
border-radius: var(--radius-sm);
white-space: nowrap;
background: var(--slate-100);
color: var(--slate-600);
}
.mdns-svc-link {
background: #2563eb;
color: #ffffff;
text-decoration: none;
}
.mdns-svc-link:hover { background: #1d4ed8; color: #ffffff; }
@media (prefers-color-scheme: dark) {
.mdns-svc { background: var(--slate-800); color: var(--slate-400); }
.mdns-svc-link { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
.mdns-svc-link:hover { background: rgba(59, 130, 246, 0.35); color: #93c5fd; }
}
.dark .mdns-svc { background: var(--slate-800); color: var(--slate-400); }
.dark .mdns-svc-link { background: rgba(59, 130, 246, 0.2); color: #93c5fd; }
.dark .mdns-svc-link:hover { background: rgba(59, 130, 246, 0.35); color: #93c5fd; }
.light .mdns-svc { background: var(--slate-100); color: var(--slate-600); }
.light .mdns-svc-link { background: #2563eb; color: #ffffff; }
.light .mdns-svc-link:hover { background: #1d4ed8; color: #ffffff; }
/* mDNS extra-address rows — hidden until toggle */
.mdns-extra-row { display: none; }
.mdns-extra-addr { color: var(--fg-muted); padding-top: 0.1rem; padding-bottom: 0.1rem; }
/* mDNS toggle column and chevron button */
.mdns-toggle-col { width: 1.5rem; min-width: 1.5rem; padding: 0 0.25rem !important; text-align: center; }
.data-table td.mdns-toggle-col,
.data-table th.mdns-toggle-col { vertical-align: middle; }
.mdns-addr-toggle {
background: none;
border: none;
cursor: pointer;
padding: 0;
color: var(--fg-muted);
font-size: 0.75rem;
line-height: 1;
}
.mdns-addr-toggle[aria-expanded="true"] .mdns-addr-arrow {
display: inline-block;
transform: rotate(90deg);
}
.mdns-lastseen { white-space: nowrap; }
/* Sub-heading within a card body (e.g. "Active Leases" in DHCP) */
.section-subtitle {
font-size: 0.75rem;
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="3"></circle>
<path d="M6.3 6.3a8 8 0 0 0 0 11.4"></path>
<path d="M17.7 6.3a8 8 0 0 1 0 11.4"></path>
<path d="M3.5 3.5a14 14 0 0 0 0 17"></path>
<path d="M20.5 3.5a14 14 0 0 1 0 17"></path>
</svg>

After

Width:  |  Height:  |  Size: 394 B

+14
View File
@@ -467,6 +467,20 @@
});
})();
// mDNS extra-address row toggle
(function() {
document.addEventListener('click', function(e) {
var btn = e.target.closest('.mdns-addr-toggle');
if (!btn) return;
var group = btn.getAttribute('data-group');
var expanded = btn.getAttribute('aria-expanded') === 'true';
btn.setAttribute('aria-expanded', expanded ? 'false' : 'true');
document.querySelectorAll('[data-mdns-extra="' + group + '"]').forEach(function(row) {
row.style.display = expanded ? '' : 'table-row';
});
});
})();
// Bridge/LAG member row collapse toggle
(function() {
document.addEventListener('click', function(e) {
+10
View File
@@ -108,6 +108,16 @@
LLDP
</a>
</li>
<li>
<a href="/mdns"
hx-get="/mdns"
hx-target="#content"
hx-push-url="true"
class="nav-link {{if eq .ActivePage "mdns"}}active{{end}}">
<span class="nav-icon" style="--icon: url('/assets/img/icons/mdns.svg')"></span>
mDNS
</a>
</li>
</ul>
<div class="nav-section-label">Services</div>
+77
View File
@@ -0,0 +1,77 @@
{{define "mdns.html"}}
{{template "base" .}}
{{end}}
{{define "content"}}
{{if .Error}}
<div class="alert alert-error">{{.Error}}</div>
{{end}}
<section class="info-grid">
<div class="info-card">
<div class="card-header">mDNS Status</div>
<table class="info-table">
<tr>
<th>mDNS</th>
<td>
<span class="iface-status {{if .Enabled}}iface-up{{else}}iface-down{{end}}"></span>
{{.EnabledText}}
</td>
</tr>
<tr><th>Domain</th><td>{{.Domain}}</td></tr>
{{if .Allow}}<tr><th>Allow</th><td>{{.Allow}}</td></tr>{{end}}
{{if .Deny}}<tr><th>Deny</th><td>{{.Deny}}</td></tr>{{end}}
{{if .Reflector}}
<tr>
<th>Reflector</th>
<td><span class="iface-status iface-up"></span> Active{{if .SvcFilter}} — filter: {{.SvcFilter}}{{end}}</td>
</tr>
{{end}}
</table>
</div>
{{if .Neighbors}}
<div class="info-card info-grid-span-2">
<div class="card-header">Neighbors</div>
<div class="data-table-wrap">
<table class="data-table">
<thead>
<tr>
<th>Hostname</th>
<th>Address</th>
<th>Last Seen</th>
<th>Services</th>
</tr>
</thead>
<tbody>
{{range .Neighbors}}
{{$host := .Hostname}}
<tr>
<td class="mdns-hostname">{{if .ExtraAddrs}}<button class="mdns-addr-toggle" data-group="{{$host}}" aria-expanded="false" title="Show more addresses"><span class="mdns-addr-arrow"></span></button>&nbsp;{{end}}{{.Hostname}}</td>
<td class="num">{{.PrimaryAddr}}</td>
<td class="mdns-lastseen">{{.LastSeen}}</td>
<td>{{range $i, $svc := .Services}}{{if $i}} {{end}}{{if $svc.URL}}<a href="{{$svc.URL}}" target="_blank" rel="noopener" class="mdns-svc mdns-svc-link">{{$svc.Label}}</a>{{else}}<span class="mdns-svc">{{$svc.Label}}({{$svc.Port}})</span>{{end}}{{end}}</td>
</tr>
{{range .ExtraAddrs}}
<tr class="mdns-extra-row" data-mdns-extra="{{$host}}">
<td></td>
<td class="num mdns-extra-addr">{{.}}</td>
<td></td>
<td></td>
</tr>
{{end}}
{{end}}
</tbody>
</table>
</div>
</div>
{{else if not .Error}}
<div class="info-card info-grid-span-2">
<div class="card-header">Neighbors</div>
<p class="empty-message">No mDNS neighbors discovered.</p>
</div>
{{end}}
</section>
{{end}}