diff --git a/src/webui/internal/handlers/dashboard.go b/src/webui/internal/handlers/dashboard.go index 6af9d739..5c1c0e08 100644 --- a/src/webui/internal/handlers/dashboard.go +++ b/src/webui/internal/handlers/dashboard.go @@ -9,7 +9,9 @@ import ( "html/template" "log" "math" + "net" "net/http" + "os/exec" "strconv" "strings" "sync" @@ -95,6 +97,32 @@ type systemState struct { Clock clock `json:"clock"` Software software `json:"infix-system:software"` Resource resourceUsage `json:"infix-system:resource-usage"` + DNS *dnsResolver `json:"infix-system:dns-resolver"` + NTP *ntpSources `json:"infix-system:ntp"` +} + +// dnsResolver mirrors the effective resolver state — servers carry an +// origin (static vs dhcp) and, for dhcp, the interface they arrived on. +type dnsResolver struct { + Server []dnsServer `json:"server"` + Search []string `json:"search"` +} + +type dnsServer struct { + Address string `json:"address"` + Origin string `json:"origin"` + Interface string `json:"interface"` +} + +type ntpSources struct { + Sources struct { + Source []ntpSource `json:"source"` + } `json:"sources"` +} + +type ntpSource struct { + Address string `json:"address"` + State string `json:"state"` } type platform struct { @@ -218,7 +246,28 @@ type dashboardData struct { Disks []diskEntry Board boardInfo KeyVitals []sensorEntry // Overview's at-a-glance subset: CPU/SoC + wifi-radio temperatures and fan RPMs. Status > Hardware has the full inventory. - Error string + // Connectivity card. + Gateways []gatewayEntry + InternetProbe string // address pinged for the Internet reachability row + DNSServers []dnsServer + DNSSearch []string + NTPSync string // "" / the selected NTP source address + // Addresses card. + Addresses []ifaceAddrEntry + Error string +} + +// gatewayEntry is a default route's next-hop. +type gatewayEntry struct { + Addr string + Iface string +} + +// ifaceAddrEntry is one L3 interface's addresses for the Addresses card. +type ifaceAddrEntry struct { + Name string + Addrs []string + Up bool } type boardInfo struct { @@ -252,6 +301,10 @@ type diskEntry struct { ReadOnly bool } +// internetProbe is the address the Connectivity card pings for its Internet +// reachability row — a well-known, stable anycast resolver. +const internetProbe = "1.1.1.1" + // DashboardHandler serves the main dashboard page. type DashboardHandler struct { Template *template.Template @@ -278,11 +331,13 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { Location string `json:"location"` } `json:"ietf-system:system"` } + ifaces interfacesWrapper + routes ribWrapper stateErr, hwErr, confErr error wg sync.WaitGroup ) - wg.Add(3) + wg.Add(5) go func() { defer wg.Done() stateErr = h.RC.Get(ctx, "/data/ietf-system:system-state", &state) @@ -295,6 +350,20 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { defer wg.Done() confErr = h.RC.Get(ctx, "/data/ietf-system:system", &sysConf) }() + // Connectivity/Addresses cards are best-effort: a failure here logs but + // doesn't fault the whole dashboard, so the card simply renders empty. + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-interfaces:interfaces", &ifaces); err != nil { + log.Printf("restconf interfaces: %v", err) + } + }() + go func() { + defer wg.Done() + if err := h.RC.Get(ctx, "/data/ietf-routing:routing", &routes); err != nil { + log.Printf("restconf routing: %v", err) + } + }() wg.Wait() if stateErr != nil { @@ -413,6 +482,25 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { data.Location = sysConf.System.Location } + // Connectivity & Addresses cards (best-effort, independent of the above). + data.Gateways = defaultGateways(routes) + data.InternetProbe = internetProbe + if stateErr == nil { + if dns := state.SystemState.DNS; dns != nil { + data.DNSServers = dns.Server + data.DNSSearch = dns.Search + } + if ntp := state.SystemState.NTP; ntp != nil { + for _, s := range ntp.Sources.Source { + if s.State == "selected" { + data.NTPSync = s.Address + break + } + } + } + } + data.Addresses = ifaceAddresses(ifaces) + tmplName := "dashboard.html" if r.Header.Get("HX-Request") == "true" { tmplName = "content" @@ -423,6 +511,104 @@ func (h *DashboardHandler) Index(w http.ResponseWriter, r *http.Request) { } } +// defaultGateways extracts the installed default-route next-hops (v4 and v6). +func defaultGateways(rw ribWrapper) []gatewayEntry { + var ribs []ribJSON + if rw.Routing != nil { + ribs = rw.Routing.Ribs.Rib + } else if rw.Ribs != nil { + ribs = rw.Ribs.Rib + } + var gws []gatewayEntry + for _, rib := range ribs { + for _, rt := range rib.Routes.Route { + if rt.Active == nil { + continue // only installed routes + } + switch rt.destinationPrefix() { + case "0.0.0.0/0", "::/0": + default: + continue + } + iface, addr := rt.NextHop.resolve() + if addr == "" { + continue + } + gws = append(gws, gatewayEntry{Addr: addr, Iface: iface}) + } + } + return gws +} + +// ifaceAddresses lists every interface carrying an IP address, loopback last. +func ifaceAddresses(iw interfacesWrapper) []ifaceAddrEntry { + var l3, lo []ifaceAddrEntry + for _, ifc := range iw.Interfaces.Interface { + var addrs []string + for _, ip := range []*ipCfg{ifc.IPv4, ifc.IPv6} { + if ip == nil { + continue + } + for _, a := range ip.Address { + addrs = append(addrs, fmt.Sprintf("%s/%d", a.IP, int(a.PrefixLength))) + } + } + if len(addrs) == 0 { + continue + } + e := ifaceAddrEntry{Name: ifc.Name, Addrs: addrs, Up: ifc.OperStatus == "up"} + if prettyIfType(ifc.Type) == ifTypeLoopback { + lo = append(lo, e) + } else { + l3 = append(l3, e) + } + } + return append(l3, lo...) +} + +// Reachability pings an address and returns a tiny indicator — a pulsing green +// dot when it replies, a red ✗ otherwise. Connectivity-card slots load it +// async (hx-trigger="load") so the dashboard render isn't blocked on a probe. +// The target is validated as a literal IP so the ping argument can never become +// an arbitrary host; a link-local IPv6 is scoped with its egress interface. +func (h *DashboardHandler) Reachability(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + ip := r.URL.Query().Get("ip") + addr := net.ParseIP(ip) + if addr == nil { + fmt.Fprint(w, ``) + return + } + target := ip + if iface := r.URL.Query().Get("iface"); iface != "" && addr.IsLinkLocalUnicast() && validZone(iface) { + target = ip + "%" + iface + } + + ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second) + defer cancel() + if err := exec.CommandContext(ctx, "ping", "-c", "1", "-W", "1", target).Run(); err != nil { + fmt.Fprintf(w, `✗`, + template.HTMLEscapeString(ip)) + return + } + fmt.Fprintf(w, ``, + template.HTMLEscapeString(ip)) +} + +// validZone guards the IPv6 zone (interface name) passed to ping: a plain +// interface name, no shell metacharacters (exec args aren't shell-parsed, but +// keep it tight). +func validZone(s string) bool { + for _, c := range s { + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || + c >= '0' && c <= '9' || c == '.' || c == '_' || c == '-' + if !ok { + return false + } + } + return s != "" +} + // softwareVersion returns the version string for the booted software slot. func softwareVersion(sw software) string { for _, slot := range sw.Slot { diff --git a/src/webui/internal/server/server.go b/src/webui/internal/server/server.go index 63e54b0e..0db03db6 100644 --- a/src/webui/internal/server/server.go +++ b/src/webui/internal/server/server.go @@ -305,6 +305,7 @@ func New( // Authenticated routes. mux.HandleFunc("GET /{$}", dash.Index) + mux.HandleFunc("GET /dashboard/reachability", dash.Reachability) mux.HandleFunc("GET /interfaces", iface.Overview) mux.HandleFunc("GET /interfaces/{name}", iface.Detail) mux.HandleFunc("GET /interfaces/{name}/counters", iface.Counters) diff --git a/src/webui/static/css/style.css b/src/webui/static/css/style.css index 880bb59a..e63cb44e 100644 --- a/src/webui/static/css/style.css +++ b/src/webui/static/css/style.css @@ -2085,6 +2085,31 @@ details[open] > .cfg-multi-summary { /* Keystore */ .empty-message { color: var(--fg-muted); font-style: italic; padding: 1rem; text-align: center; } +/* Overview Connectivity card. Reach indicators reuse .status-dot for shape; + these add the neutral "pending" colour, a reply pulse, and a colour-blind-safe + ✗ (shape, not just colour). */ +.dns-origin { color: var(--fg-muted); font-size: 0.8rem; } +.reach-pending { background: var(--slate-300); } +.dark .reach-pending { background: var(--slate-600); } +.reach-pulse { animation: reach-pulse 1.6s ease-in-out infinite; } +@keyframes reach-pulse { + 0% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0.5); } + 70% { box-shadow: 0 0 0 5px rgba(34, 197, 94, 0); } + 100% { box-shadow: 0 0 0 0 rgba(34, 197, 94, 0); } +} +.reach-x { + display: inline-block; + width: 8px; + margin-right: 0.5rem; + text-align: center; + vertical-align: middle; + color: var(--danger); + font-weight: 700; +} +@media (prefers-reduced-motion: reduce) { + .reach-pulse { animation: none; } +} + .key-row-toggle { background: none; border: none; diff --git a/src/webui/templates/pages/dashboard.html b/src/webui/templates/pages/dashboard.html index 505a76d9..7f70c8b7 100644 --- a/src/webui/templates/pages/dashboard.html +++ b/src/webui/templates/pages/dashboard.html @@ -101,5 +101,57 @@ {{end}} {{end}} + +
| Gateway | +
+ {{range .Gateways}} {{.Addr}}{{if .Iface}} via {{.Iface}}{{end}} {{else}}none{{end}}
+ |
+
|---|---|
| Internet | +{{.InternetProbe}} | +
| DNS | +
+ {{range .DNSServers}} {{.Address}} {{if eq .Origin "dhcp"}}DHCP{{if .Interface}} · {{.Interface}}{{end}}{{else}}{{.Origin}}{{end}} {{else}}none{{end}}
+ |
+
| Search | {{range $i, $s := .DNSSearch}}{{if $i}}, {{end}}{{$s}}{{end}} |
| Time | synced · {{.NTPSync}} |
| {{.Name}} | +{{range $i, $a := .Addrs}}{{if $i}} {{end}}{{$a}}{{end}} |
+
|---|