diff --git a/TODO.md b/TODO.md index f71bbcf..2b8c2eb 100644 --- a/TODO.md +++ b/TODO.md @@ -19,6 +19,8 @@ trial run of the finished image: https://git.eeqj.de/sneak/dnswatcher/issues/149 # Completed Steps +- 2026-10-02: the dashboard lists each nameserver's record types in one fixed + order, the README's, then any other type, not a random one (closes #226). - 2026-10-02: the dashboard and `/api/v1/status` show why a nameserver query or a certificate check failed, which only the state file showed (closes #225). - 2026-10-02: a name's CNAME is stored once per nameserver, not once per record diff --git a/internal/handlers/dashboard.go b/internal/handlers/dashboard.go index 6d4ac57..6b65e51 100644 --- a/internal/handlers/dashboard.go +++ b/internal/handlers/dashboard.go @@ -1,11 +1,14 @@ package handlers import ( + "cmp" "embed" "fmt" "html/template" + "maps" "math" "net/http" + "slices" "strings" "time" @@ -122,16 +125,37 @@ func joinStrings(items []string, sep string) string { } // formatRecords formats a map of record type → values into a -// compact display string. +// 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, values := range records { - for _, v := range values { + for _, rtype := range rtypes { + for _, v := range records[rtype] { parts = append(parts, rtype+": "+v) } } diff --git a/internal/handlers/dashboard_test.go b/internal/handlers/dashboard_test.go index d60a36e..01e8d3f 100644 --- a/internal/handlers/dashboard_test.go +++ b/internal/handlers/dashboard_test.go @@ -80,6 +80,45 @@ func TestFormatRecords(t *testing.T) { } } +// TestFormatRecordsTypeOrder checks that record types are listed in +// the README's order (A, AAAA, CNAME, MX, TXT, SRV, CAA, NS), with +// any other type after them in alphabetical order. +func TestFormatRecordsTypeOrder(t *testing.T) { + t.Parallel() + + got := handlers.FormatRecords(map[string][]string{ + "SOA": {"ns1.example.com. hostmaster.example.com. 1 2 3 4 5"}, + "NS": {"ns1.example.com.", "ns2.example.com."}, + "CAA": {`0 issue "letsencrypt.org"`}, + "DNAME": {"example.net."}, + "TXT": {"v=spf1 -all"}, + "SRV": {"10 5 443 www.example.com."}, + "MX": {"10 mail.example.com."}, + "CNAME": {"www.example.com."}, + "AAAA": {"2001:db8::1"}, + "A": {"192.0.2.1"}, + }) + + want := strings.Join([]string{ + "A: 192.0.2.1", + "AAAA: 2001:db8::1", + "CNAME: www.example.com.", + "MX: 10 mail.example.com.", + "TXT: v=spf1 -all", + "SRV: 10 5 443 www.example.com.", + `CAA: 0 issue "letsencrypt.org"`, + "NS: ns1.example.com.", + "NS: ns2.example.com.", + "DNAME: example.net.", + "SOA: ns1.example.com. hostmaster.example.com. 1 2 3 4 5", + }, ", ") + + if got != want { + t.Errorf("FormatRecords lists types out of order:\n got %q\nwant %q", + got, want) + } +} + // dashboardRow returns the table row of page that contains name. func dashboardRow(t *testing.T, page string, name string) string { t.Helper()