check / check (push) Canceled after 0s
A port entry in the state saves the apex domains that resolve to its address with its hostnames. The dashboard's Ports table now has a Domains column next to Hostnames, and a port entry in /api/v1/status has a `domains` list, with `hostnames` no longer holding a domain. A name is taken as a domain when it has a domain entry, as the dashboard and API already tell a domain's own records from a hostname's. Both read the split from one function, buildPorts. The state file is unchanged; README says its port `hostnames` include domains. Model: opus-5-5
187 lines
4.2 KiB
Go
187 lines
4.2 KiB
Go
package handlers
|
|
|
|
import (
|
|
"cmp"
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"maps"
|
|
"math"
|
|
"net/http"
|
|
"slices"
|
|
"strings"
|
|
"time"
|
|
|
|
"sneak.berlin/go/dnswatcher/internal/notify"
|
|
"sneak.berlin/go/dnswatcher/internal/state"
|
|
)
|
|
|
|
//go:embed templates/dashboard.html
|
|
var dashboardFS embed.FS
|
|
|
|
// Time unit constants for relative time calculations.
|
|
const (
|
|
secondsPerMinute = 60
|
|
minutesPerHour = 60
|
|
hoursPerDay = 24
|
|
)
|
|
|
|
// newDashboardTemplate parses the embedded dashboard HTML
|
|
// template with helper functions.
|
|
func newDashboardTemplate() *template.Template {
|
|
funcs := template.FuncMap{
|
|
"relTime": relTime,
|
|
"joinStrings": joinStrings,
|
|
"formatRecords": formatRecords,
|
|
"expiryDays": expiryDays,
|
|
}
|
|
|
|
return template.Must(
|
|
template.New("dashboard.html").
|
|
Funcs(funcs).
|
|
ParseFS(dashboardFS, "templates/dashboard.html"),
|
|
)
|
|
}
|
|
|
|
// dashboardData is the data passed to the dashboard template. Hostnames
|
|
// and DomainRecords split the records in Snapshot.Hostnames, which also
|
|
// holds the apex domains' own (see splitHostnames). Ports holds
|
|
// Snapshot.Ports with each port's names split into domains and
|
|
// hostnames, as /api/v1/status gives them (see buildPorts).
|
|
type dashboardData struct {
|
|
Snapshot state.Snapshot
|
|
Hostnames map[string]*state.HostnameState
|
|
DomainRecords map[string]*state.HostnameState
|
|
Ports map[string]*statusPortInfo
|
|
Alerts []notify.AlertEntry
|
|
StateAge string
|
|
GeneratedAt string
|
|
}
|
|
|
|
// HandleDashboard returns the dashboard page handler.
|
|
func (h *Handlers) HandleDashboard() http.HandlerFunc {
|
|
tmpl := newDashboardTemplate()
|
|
|
|
return func(
|
|
writer http.ResponseWriter,
|
|
_ *http.Request,
|
|
) {
|
|
snap := h.state.GetSnapshot()
|
|
alerts := h.notifyHistory.Recent()
|
|
hostnames, domainRecords := splitHostnames(snap)
|
|
|
|
data := dashboardData{
|
|
Snapshot: snap,
|
|
Hostnames: hostnames,
|
|
DomainRecords: domainRecords,
|
|
Ports: buildPorts(snap),
|
|
Alerts: alerts,
|
|
StateAge: relTime(snap.LastUpdated),
|
|
GeneratedAt: time.Now().UTC().Format("2006-01-02 15:04:05"),
|
|
}
|
|
|
|
writer.Header().Set(
|
|
"Content-Type", "text/html; charset=utf-8",
|
|
)
|
|
|
|
err := tmpl.Execute(writer, data)
|
|
if err != nil {
|
|
h.log.Error(
|
|
"dashboard template error",
|
|
"error", err,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// relTime returns a human-readable relative time string such
|
|
// as "2 minutes ago" or "never" for zero times.
|
|
func relTime(t time.Time) string {
|
|
if t.IsZero() {
|
|
return "never"
|
|
}
|
|
|
|
d := time.Since(t)
|
|
if d < 0 {
|
|
return "just now"
|
|
}
|
|
|
|
seconds := int(math.Round(d.Seconds()))
|
|
if seconds < secondsPerMinute {
|
|
return fmt.Sprintf("%ds ago", seconds)
|
|
}
|
|
|
|
minutes := seconds / secondsPerMinute
|
|
if minutes < minutesPerHour {
|
|
return fmt.Sprintf("%dm ago", minutes)
|
|
}
|
|
|
|
hours := minutes / minutesPerHour
|
|
if hours < hoursPerDay {
|
|
return fmt.Sprintf(
|
|
"%dh %dm ago", hours, minutes%minutesPerHour,
|
|
)
|
|
}
|
|
|
|
days := hours / hoursPerDay
|
|
|
|
return fmt.Sprintf(
|
|
"%dd %dh ago", days, hours%hoursPerDay,
|
|
)
|
|
}
|
|
|
|
// joinStrings joins a string slice with a separator.
|
|
func joinStrings(items []string, sep string) string {
|
|
return strings.Join(items, sep)
|
|
}
|
|
|
|
// formatRecords formats a map of record type → values into a
|
|
// compact display string. Record types are listed in the order the
|
|
// README lists them, any other type after them in alphabetical order,
|
|
// so rows of nameservers with the same records read the same.
|
|
func formatRecords(records map[string][]string) string {
|
|
if len(records) == 0 {
|
|
return "-"
|
|
}
|
|
|
|
order := []string{"A", "AAAA", "CNAME", "MX", "TXT", "SRV", "CAA", "NS"}
|
|
|
|
position := func(rtype string) int {
|
|
i := slices.Index(order, rtype)
|
|
if i < 0 {
|
|
return len(order)
|
|
}
|
|
|
|
return i
|
|
}
|
|
|
|
rtypes := slices.Collect(maps.Keys(records))
|
|
slices.SortFunc(rtypes, func(a, b string) int {
|
|
return cmp.Or(
|
|
cmp.Compare(position(a), position(b)),
|
|
strings.Compare(a, b),
|
|
)
|
|
})
|
|
|
|
var parts []string
|
|
|
|
for _, rtype := range rtypes {
|
|
for _, v := range records[rtype] {
|
|
parts = append(parts, rtype+": "+v)
|
|
}
|
|
}
|
|
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
// expiryDays returns the number of days until the given time,
|
|
// rounded down. Returns 0 if already expired.
|
|
func expiryDays(t time.Time) int {
|
|
d := time.Until(t).Hours() / hoursPerDay
|
|
if d < 0 {
|
|
return 0
|
|
}
|
|
|
|
return int(d)
|
|
}
|