check / check (push) Successful in 1m6s
Each row of the Hostnames table listed a nameserver's record types in the order Go happens to walk the record map, which changed from row to row and on every page load, so two nameservers with the same records looked different. formatRecords now sorts the types by their place in the README's list (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS); any other type comes after them in alphabetical order. Values within a type were already sorted by the resolver. Model: opus-5-5
176 lines
3.6 KiB
Go
176 lines
3.6 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.
|
|
type dashboardData struct {
|
|
Snapshot state.Snapshot
|
|
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()
|
|
|
|
data := dashboardData{
|
|
Snapshot: 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)
|
|
}
|