Compare views rebuilt: from the zone's view, in sections, one line per record

The Compare views tab and jarl zone diff compare a zone with the same zone in other views from the view you're on: a summary per view, what is answered differently (Use this everywhere), the names only some views have (Add to VIEW), and how many are the same. One name can be compared (jarl zone diff ZONE NAME [TYPE], a Compare button per record). The CLI prints one line per record, cut to the terminal (--wide shows it whole); the web UI cuts long values with the whole one on hover. GET /zones/{zone}/compare returns these sections, with ?with, ?name and ?type.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-13 10:55:57 +01:00
1 parent ec16de875d
commit b1719ce362
12 files changed
+656 -146

No files matched your search

+133 -1
View File
@@ -5,6 +5,7 @@ import (
"slices"
"strings"
"jarl/internal/dnsname"
"jarl/internal/model"
)
@@ -14,6 +15,8 @@ type ViewSet struct {
ZoneID int64 `json:"zone_id"`
Records []string `json:"records"` // data (with TTL and "(disabled)" when set), sorted
IDs []int64 `json:"ids"` // the records, in the same order
Data []string `json:"data"` // their data alone, in the same order
TTL *uint32 `json:"ttl,omitempty"` // the first record's own TTL
Linked string `json:"linked,omitempty"` // shared or local, in a linked zone
}
@@ -63,7 +66,7 @@ func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
}
var first string
for i, z := range zones {
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}}
vs := ViewSet{View: st.ViewName(z), ZoneID: z.ID, Records: []string{}, IDs: []int64{}, Data: []string{}}
rs := slices.Clone(sets[i][k])
slices.SortFunc(rs, func(a, b *model.Record) int { return strings.Compare(strings.ToLower(a.Data), strings.ToLower(b.Data)) })
for _, r := range rs {
@@ -76,6 +79,10 @@ func CompareZones(st *model.State, zones []*model.Zone) []CompareRow {
}
vs.Records = append(vs.Records, d)
vs.IDs = append(vs.IDs, r.ID)
vs.Data = append(vs.Data, r.Data)
if vs.TTL == nil && r.TTL != nil {
vs.TTL = model.Ptr(*r.TTL)
}
}
if z.LinkGroup != 0 && len(rs) > 0 {
vs.Linked = "shared"
@@ -102,3 +109,128 @@ func relName(name, zone string) string {
}
return strings.TrimSuffix(name, "."+zone)
}
// CompareReport is a zone in one view (the base) next to the same zone in
// other views (docs/views.md#comparing-views): what the other views answer
// differently, the names only some views have, and how much is the same.
type CompareReport struct {
Zone string `json:"zone"`
Base string `json:"base"` // the view compared from
Views []string `json:"views"` // the base first
Linked bool `json:"linked"` // all in one link
// Summary is, per other view, how it differs from the base.
Summary []CompareSummary `json:"summary"`
// Different are names and types more than one view has, with
// different records.
Different []CompareRow `json:"different"`
// Only are the names and types only some views have, by the views
// that have them.
Only []CompareOnly `json:"only"`
Same int `json:"same"`
// SameRows are the ones that are the same (when asked for).
SameRows []CompareRow `json:"same_rows,omitempty"`
}
// CompareSummary counts how a view differs from the base view.
type CompareSummary struct {
View string `json:"view"`
Different int `json:"different"` // both have it, with different records
OnlyBase int `json:"only_base"` // the base has it, this view doesn't
OnlyOther int `json:"only_other"` // this view has it, the base doesn't
Same int `json:"same"`
}
// CompareOnly are names and types that only some views have.
type CompareOnly struct {
Views []string `json:"views"`
Rows []CompareRow `json:"rows"`
}
// CompareFilter narrows a comparison to a name, and optionally a type.
type CompareFilter struct {
Name string // relative ("www", "@") or absolute
Type string
All bool // include what is the same
}
// Compare builds the report for zones (the base first). Apex NS records are
// left out: they name the servers, which views often list differently on
// purpose.
func Compare(st *model.State, zones []*model.Zone, f CompareFilter) *CompareReport {
rep := &CompareReport{Zone: dnsname.Display(zones[0].Name), Base: st.ViewName(zones[0]), Different: []CompareRow{}, Only: []CompareOnly{}, Linked: true}
for _, z := range zones {
rep.Views = append(rep.Views, st.ViewName(z))
rep.Linked = rep.Linked && z.LinkGroup != 0 && z.LinkGroup == zones[0].LinkGroup
}
for _, v := range rep.Views[1:] {
rep.Summary = append(rep.Summary, CompareSummary{View: v})
}
want := ""
if f.Name != "" {
if o, err := dnsname.Owner(f.Name, zones[0].Name); err == nil {
want = o
}
}
only := map[string]int{}
for _, row := range CompareZones(st, zones) {
if row.FQDN == zones[0].Name && row.Type == "NS" {
continue
}
if want != "" && (row.FQDN != want || f.Type != "" && !strings.EqualFold(row.Type, f.Type)) {
continue
}
base := row.Views[0]
for i, vs := range row.Views[1:] {
s := &rep.Summary[i]
switch {
case len(base.Records) > 0 && len(vs.Records) > 0 && slices.Equal(base.Records, vs.Records):
s.Same++
case len(base.Records) > 0 && len(vs.Records) > 0:
s.Different++
case len(base.Records) > 0:
s.OnlyBase++
case len(vs.Records) > 0:
s.OnlyOther++
}
}
if row.Same {
rep.Same++
if f.All || want != "" {
rep.SameRows = append(rep.SameRows, row)
}
continue
}
var having []string
versions := map[string]bool{}
for _, vs := range row.Views {
if len(vs.Records) > 0 {
having = append(having, vs.View)
versions[strings.Join(vs.Records, "\n")] = true
}
}
if len(versions) > 1 {
rep.Different = append(rep.Different, row)
continue
}
k := strings.Join(having, ",")
i, ok := only[k]
if !ok {
i = len(rep.Only)
only[k] = i
rep.Only = append(rep.Only, CompareOnly{Views: having})
}
rep.Only[i].Rows = append(rep.Only[i].Rows, row)
}
// The base's own names first, then by how many views have them.
slices.SortStableFunc(rep.Only, func(a, b CompareOnly) int {
ab, bb := slices.Contains(a.Views, rep.Base), slices.Contains(b.Views, rep.Base)
if ab != bb {
if ab {
return -1
}
return 1
}
return len(b.Views) - len(a.Views)
})
return rep
}