mirror of
https://github.com/kernelkit/infix.git
synced 2026-07-22 01:13:00 +02:00
webui: new page, mdns neighbor table
Signed-off-by: Joachim Wiberg <troglobit@gmail.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 |
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {{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}}
|
||||
Reference in New Issue
Block a user