check / check (push) Canceled after 0s
An apex domain's own records are still saved with the hostnames' records, under the domain's name, so the port and TLS checks find its addresses. Notifications about them now start `Domain:`, decided by the configured domains. The dashboard and /api/v1/status, which read only the saved state, take a hostname entry whose name also has a domain entry as that domain's own records: the dashboard shows them in a second table under Domains, the API in the domain's `recordsByNameserver`, and neither lists or counts them as hostnames. The startup notification counts domains and hostnames from the configuration. README says which of a domain's own records are watched and how their changes are notified. Model: opus-5-5
159 lines
3.4 KiB
Go
159 lines
3.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"embed"
|
|
"fmt"
|
|
"html/template"
|
|
"math"
|
|
"net/http"
|
|
"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).
|
|
type dashboardData struct {
|
|
Snapshot state.Snapshot
|
|
Hostnames map[string]*state.HostnameState
|
|
DomainRecords map[string]*state.HostnameState
|
|
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,
|
|
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.
|
|
func formatRecords(records map[string][]string) string {
|
|
if len(records) == 0 {
|
|
return "-"
|
|
}
|
|
|
|
var parts []string
|
|
|
|
for rtype, values := range records {
|
|
for _, v := range values {
|
|
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)
|
|
}
|