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:
1 parent
ec16de875d
commit
b1719ce362
12 files changed
+650
-140
No files matched your search
@@ -37,6 +37,19 @@ All notable changes to Jarl are documented here. The format follows
|
||||
delegation and its glue stay.
|
||||
- Zone commands take a network for a reverse zone: `jarl zone show
|
||||
10.0.10.0/24`.
|
||||
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||
and a **Compare** button on each record.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
||||
you're on (docs/views.md#comparing-views): a summary per view, then
|
||||
what is answered differently (a view's own records in linked zones,
|
||||
drift otherwise) with **Use this everywhere**, the names only some
|
||||
views have, grouped, with **Add to VIEW**, and how many are the same.
|
||||
One line per record: the CLI cuts long TXT records to the terminal
|
||||
(`--wide` shows them whole). `--with` picks the views to compare with.
|
||||
`GET /zones/{zone}/compare` returns these sections instead of `rows`.
|
||||
|
||||
### Fixed
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestPrintCompare(t *testing.T) {
|
||||
long := `"v=spf1 ` + strings.Repeat("include:_spf.example.net ", 10) + `-all"`
|
||||
r := &compareReport{Zone: "adbraun.lol", Base: "internal", Views: []string{"internal", "external"}, Linked: true}
|
||||
r.Summary = append(r.Summary, struct {
|
||||
View string `json:"view"`
|
||||
Different int `json:"different"`
|
||||
OnlyBase int `json:"only_base"`
|
||||
OnlyOther int `json:"only_other"`
|
||||
Same int `json:"same"`
|
||||
}{View: "external", Different: 1, OnlyBase: 1, Same: 5})
|
||||
r.Different = []compareRow{{Name: "@", Type: "TXT", Views: []compareSet{
|
||||
{View: "internal", Records: []string{long, `"short"`}, IDs: []int64{7, 8}, Linked: "local"},
|
||||
{View: "external", Records: []string{`"other"`}, IDs: []int64{9}, Linked: "local"},
|
||||
}}}
|
||||
r.Only = append(r.Only, struct {
|
||||
Views []string `json:"views"`
|
||||
Rows []compareRow `json:"rows"`
|
||||
}{Views: []string{"internal"}, Rows: []compareRow{{Name: "nas", Type: "A", Views: []compareSet{{View: "internal", Records: []string{"10.0.10.20"}}, {View: "external"}}}}})
|
||||
r.Same = 5
|
||||
|
||||
var b bytes.Buffer
|
||||
printCompare(&b, r, false, 60)
|
||||
out := b.String()
|
||||
// Record lines fit (the heading and summary are prose, and may wrap).
|
||||
for _, l := range strings.Split(strings.TrimRight(out, "\n"), "\n")[3:] {
|
||||
if n := utf8.RuneCountInString(l); n > 60 {
|
||||
t.Errorf("line of %d characters: %q", n, l)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"adbraun.lol in internal, compared with external\n linked: ",
|
||||
" external: 1 answered differently, 1 only in internal, 0 only in external, 5 the same",
|
||||
"ANSWERED DIFFERENTLY (1)\n @ TXT\n internal \"v=spf1 include:",
|
||||
"…\n \"short\"\n external \"other\" [local, id 9]",
|
||||
"ONLY IN INTERNAL (1)\n nas A 10.0.10.20",
|
||||
"5 names and types are the same (--all lists them)",
|
||||
} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("output lacks %q:\n%s", want, out)
|
||||
}
|
||||
}
|
||||
// Width 0: whole.
|
||||
b.Reset()
|
||||
printCompare(&b, r, false, 0)
|
||||
if !strings.Contains(b.String(), long+" [local, id 7]") {
|
||||
t.Errorf("whole: %s", b.String())
|
||||
}
|
||||
}
|
||||
+179
-60
@@ -962,90 +962,209 @@ zone is dissolved.`
|
||||
|
||||
// zoneDiffCmd is 'zone diff': a zone's records side by side in the views
|
||||
// that have it.
|
||||
type compareSet struct {
|
||||
View string `json:"view"`
|
||||
Records []string `json:"records"`
|
||||
IDs []int64 `json:"ids"`
|
||||
Linked string `json:"linked"`
|
||||
}
|
||||
|
||||
type compareRow struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Views []compareSet `json:"views"`
|
||||
}
|
||||
|
||||
type compareReport struct {
|
||||
Zone string `json:"zone"`
|
||||
Base string `json:"base"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"`
|
||||
Summary []struct {
|
||||
View string `json:"view"`
|
||||
Different int `json:"different"`
|
||||
OnlyBase int `json:"only_base"`
|
||||
OnlyOther int `json:"only_other"`
|
||||
Same int `json:"same"`
|
||||
} `json:"summary"`
|
||||
Different []compareRow `json:"different"`
|
||||
Only []struct {
|
||||
Views []string `json:"views"`
|
||||
Rows []compareRow `json:"rows"`
|
||||
} `json:"only"`
|
||||
Same int `json:"same"`
|
||||
SameRows []compareRow `json:"same_rows"`
|
||||
}
|
||||
|
||||
func zoneDiffCmd() *cobra.Command {
|
||||
var views []string
|
||||
var all bool
|
||||
var view string
|
||||
var with []string
|
||||
var all, wide bool
|
||||
cmd := &cobra.Command{
|
||||
Use: "diff ZONE [--view V --view W] [--all]",
|
||||
Short: "Show what differs between the views that have a zone",
|
||||
Long: `Lines up a zone's records in the views that have it (or the views
|
||||
given), one row per name and type, and shows the ones that differ: a
|
||||
local override in a linked zone, a record one view lacks. With --all, the
|
||||
ones that are the same too. In a linked zone, 'jarl record update ID
|
||||
--shared' makes a view's version every view's.`,
|
||||
Use: "diff ZONE [NAME [TYPE]] [--view V] [--with W...] [--all] [--wide]",
|
||||
Short: "Compare a zone with the same zone in other views",
|
||||
Long: `Compares a zone in one view (--view, default the first view that has it)
|
||||
with the same zone in the other views (--with, default all of them): how
|
||||
many names each answers differently, the names and types only some views
|
||||
have, and how many are the same. NAME (and TYPE) narrows it to one name,
|
||||
the same records included. One line per record; long values are cut to
|
||||
fit the terminal (--wide shows them whole).
|
||||
|
||||
In a linked zone a difference is a view's own (local) records, on
|
||||
purpose; between zones that aren't linked it is drift. 'jarl record
|
||||
update ID --shared' makes a linked view's version every view's.`,
|
||||
Example: ` jarl zone diff adbraun.lol
|
||||
jarl zone diff adbraun.lol --view internal --view external --all`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
jarl zone diff adbraun.lol --view internal --with external
|
||||
jarl zone diff adbraun.lol nas
|
||||
jarl zone diff adbraun.lol @ TXT --wide`,
|
||||
Args: cobra.RangeArgs(1, 3),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
c, err := newClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path, qv := zonePath(args[0], "")
|
||||
if len(views) > 0 {
|
||||
qv.Set("views", strings.Join(views, ","))
|
||||
path, qv := zonePath(args[0], view)
|
||||
if len(with) > 0 {
|
||||
qv.Set("with", strings.Join(with, ","))
|
||||
}
|
||||
if len(args) > 1 {
|
||||
qv.Set("name", args[1])
|
||||
}
|
||||
if len(args) > 2 {
|
||||
qv.Set("type", args[2])
|
||||
}
|
||||
if all {
|
||||
qv.Set("all", "1")
|
||||
}
|
||||
var r struct {
|
||||
Zone string `json:"zone"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"`
|
||||
Same int `json:"same"`
|
||||
Rows []struct {
|
||||
Name string `json:"name"`
|
||||
Type string `json:"type"`
|
||||
Same bool `json:"same"`
|
||||
Views []struct {
|
||||
Records []string `json:"records"`
|
||||
IDs []int64 `json:"ids"`
|
||||
Linked string `json:"linked"`
|
||||
} `json:"views"`
|
||||
} `json:"rows"`
|
||||
}
|
||||
var r compareReport
|
||||
if err := c.Get(ctx(), path+"/compare", qv, &r); err != nil {
|
||||
return err
|
||||
}
|
||||
return output(r, func(w io.Writer) {
|
||||
head := []string{"NAME", "TYPE"}
|
||||
for _, v := range r.Views {
|
||||
head = append(head, strings.ToUpper(v))
|
||||
width := 0
|
||||
if !wide {
|
||||
width = terminalWidth()
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(head, "\t"))
|
||||
for _, row := range r.Rows {
|
||||
cols := []string{row.Name, row.Type}
|
||||
for _, vs := range row.Views {
|
||||
cell := "-"
|
||||
if len(vs.Records) > 0 {
|
||||
cell = strings.Join(vs.Records, ", ")
|
||||
if vs.Linked == "local" {
|
||||
cell += " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||
}
|
||||
}
|
||||
cols = append(cols, cell)
|
||||
}
|
||||
fmt.Fprintln(w, strings.Join(cols, "\t"))
|
||||
}
|
||||
link := "not linked (jarl zone link " + r.Zone + ")"
|
||||
if r.Linked {
|
||||
link = "linked"
|
||||
}
|
||||
fmt.Fprintf(w, "\n%d names and types differ, %d are the same; %s\n", len(r.Rows)-boolCount(all, r.Same), r.Same, link)
|
||||
})
|
||||
return output(r, func(w io.Writer) { printCompare(w, &r, len(args) > 1, width) })
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringArrayVar(&views, "view", nil, "a view to compare (repeat; default: every view with the zone)")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "also show what is the same everywhere")
|
||||
cmd.Flags().StringVar(&view, "view", "", "the view to compare from (default: the first that has the zone)")
|
||||
cmd.Flags().StringArrayVar(&with, "with", nil, "a view to compare with (repeat; default: every other view with the zone)")
|
||||
cmd.Flags().BoolVar(&all, "all", false, "also list what is the same")
|
||||
cmd.Flags().BoolVarP(&wide, "wide", "w", false, "show long values whole instead of cutting them to the terminal")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func boolCount(b bool, n int) int {
|
||||
if b {
|
||||
return n
|
||||
// printCompare prints a comparison as sections, one line per record,
|
||||
// each cut to width characters (0: whole).
|
||||
func printCompare(w io.Writer, r *compareReport, oneName bool, width int) {
|
||||
vw := 0 // the views column
|
||||
for _, v := range r.Views {
|
||||
vw = max(vw, len(v))
|
||||
}
|
||||
line := func(indent, label, text string) {
|
||||
s := indent + label + text
|
||||
if width > 0 && utf8.RuneCountInString(s) > width {
|
||||
keep := max(width-utf8.RuneCountInString(indent+label)-1, 8)
|
||||
text = string([]rune(text)[:min(keep, utf8.RuneCountInString(text))]) + "…"
|
||||
}
|
||||
fmt.Fprintln(w, indent+label+text)
|
||||
}
|
||||
link := "not linked, so a difference may have crept in ('jarl zone link " + r.Zone + "' keeps them in step)"
|
||||
if r.Linked {
|
||||
link = "linked: a difference is a view's own records, there on purpose"
|
||||
}
|
||||
fmt.Fprintf(w, "%s in %s, compared with %s\n %s\n", r.Zone, r.Base, strings.Join(r.Views[1:], ", "), link)
|
||||
if !oneName {
|
||||
for _, s := range r.Summary {
|
||||
fmt.Fprintf(w, " %s: %d answered differently, %d only in %s, %d only in %s, %d the same\n", s.View, s.Different, s.OnlyBase, r.Base, s.OnlyOther, s.View, s.Same)
|
||||
}
|
||||
}
|
||||
name := func(row compareRow) string { return row.Name + " " + row.Type }
|
||||
sets := func(row compareRow, local bool) {
|
||||
for _, vs := range row.Views {
|
||||
label := fmt.Sprintf(" %-*s ", vw, vs.View)
|
||||
if len(vs.Records) == 0 {
|
||||
line("", label, "-")
|
||||
continue
|
||||
}
|
||||
for i, d := range vs.Records {
|
||||
if i > 0 {
|
||||
label = strings.Repeat(" ", 4+vw+2)
|
||||
}
|
||||
suffix := ""
|
||||
if local && vs.Linked == "local" && i == 0 {
|
||||
suffix = " [local, id " + fmt.Sprint(vs.IDs[0]) + "]"
|
||||
}
|
||||
line("", label, d+suffix)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.Different) > 0 {
|
||||
fmt.Fprintf(w, "\nANSWERED DIFFERENTLY (%d)\n", len(r.Different))
|
||||
for _, row := range r.Different {
|
||||
fmt.Fprintln(w, " "+name(row))
|
||||
sets(row, true)
|
||||
}
|
||||
}
|
||||
for _, o := range r.Only {
|
||||
fmt.Fprintf(w, "\nONLY IN %s (%d)\n", strings.ToUpper(strings.Join(o.Views, ", ")), len(o.Rows))
|
||||
nw := 0
|
||||
for _, row := range o.Rows {
|
||||
nw = max(nw, len(name(row)))
|
||||
}
|
||||
for _, row := range o.Rows {
|
||||
var recs []string
|
||||
for _, vs := range row.Views {
|
||||
if len(vs.Records) > 0 {
|
||||
recs = vs.Records
|
||||
break
|
||||
}
|
||||
}
|
||||
for i, d := range recs {
|
||||
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||
if i > 0 {
|
||||
label = strings.Repeat(" ", 2+nw+2)
|
||||
}
|
||||
line("", label, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(r.SameRows) > 0 {
|
||||
fmt.Fprintf(w, "\nTHE SAME IN %s (%d)\n", strings.ToUpper(strings.Join(r.Views, ", ")), len(r.SameRows))
|
||||
nw := 0
|
||||
for _, row := range r.SameRows {
|
||||
nw = max(nw, len(name(row)))
|
||||
}
|
||||
for _, row := range r.SameRows {
|
||||
for i, d := range row.Views[0].Records {
|
||||
label := fmt.Sprintf(" %-*s ", nw, name(row))
|
||||
if i > 0 {
|
||||
label = strings.Repeat(" ", 2+nw+2)
|
||||
}
|
||||
line("", label, d)
|
||||
}
|
||||
}
|
||||
} else if r.Same > 0 && !oneName {
|
||||
fmt.Fprintf(w, "\n%d names and types are the same (--all lists them)\n", r.Same)
|
||||
}
|
||||
if oneName && len(r.Different) == 0 && len(r.Only) == 0 && len(r.SameRows) == 0 {
|
||||
fmt.Fprintln(w, "\nNo records of that name in these views.")
|
||||
}
|
||||
}
|
||||
|
||||
// terminalWidth returns the terminal's width, or 0 when standard output
|
||||
// isn't a terminal (then nothing is cut).
|
||||
func terminalWidth() int {
|
||||
fd := int(os.Stdout.Fd())
|
||||
if !term.IsTerminal(fd) {
|
||||
return 0
|
||||
}
|
||||
width, _, err := term.GetSize(fd)
|
||||
if err != nil || width <= 0 {
|
||||
return 0
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
func zoneFillPTRCmd() *cobra.Command {
|
||||
var view string
|
||||
|
||||
+1
-1
@@ -363,7 +363,7 @@ and `linked_with` (the other views).
|
||||
| POST | `/zones/{zone}/fill-ptr` | Adds the PTR records a reverse zone lacks for its view's A and AAAA records ([reverse.md](reverse.md)) |
|
||||
| GET | `/reverse-zones/suggestions` | The reverse zones the private views lack: `view_id`, `view`, `prefix`, `zone`, `group` (`yours`, or `public`: only if the address is yours), `why`, `fill` (PTR records it would get), `samples` |
|
||||
| POST | `/reverse-zones/dismiss` | `{"view": "internal", "prefix": "185.199.108.153/32"}`: don't suggest it again (`/undismiss` brings it back). A view's `reverse_suggestions: false` (PATCH `/views/{id}`) stops its suggestions |
|
||||
| GET | `/zones/{zone}/compare` | The zone across the views that have it (`?views=a,b`; `?all=1` also the same rows): `views`, `linked`, `same` (count) and `rows` (name, type, `same`, per view `records`, `ids`, `linked`). The zone may be named without a view |
|
||||
| GET | `/zones/{zone}/compare` | The zone compared with the same zone in other views, from its own view ([views.md](views.md#comparing-views)): `?with=a,b` (default: all), `?name=www&type=A` (one name, the same records included), `?all=1`. Returns `base`, `views`, `linked`, `summary` (per other view: `different`, `only_base`, `only_other`, `same`), `different` and `only` (`views`, `rows`) rows (name, `fqdn`, type, per view `records`, `data`, `ids`, `ttl`, `linked`), `same`, `same_rows`. A zone named without its view is compared from the first view that has it |
|
||||
|
||||
`POST /zones` takes `"link_with": "VIEW"` to create a zone linked with
|
||||
that view's zone of the same name.
|
||||
|
||||
+2
-1
@@ -227,7 +227,8 @@ jarl zone unlink adbraun.lol --view vps
|
||||
jarl record add adbraun.lol nas A 10.0.10.20 --view internal --local # this view only
|
||||
jarl record set adbraun.lol www A 10.0.10.5 --view internal --local # override the shared www A here
|
||||
jarl record update 1234 --local | --shared
|
||||
jarl zone diff adbraun.lol [--view V --view W] [--all] # what differs between the views
|
||||
jarl zone diff adbraun.lol [--view V] [--with W] [--all] [--wide] # compared with the same zone in other views
|
||||
jarl zone diff adbraun.lol nas [A] # just one name
|
||||
```
|
||||
|
||||
When a zone exists in several views, add `--view NAME` to commands that
|
||||
|
||||
+48
-14
@@ -209,20 +209,6 @@ each view (Jarl lists them). Share the right version afterwards. Unlinking
|
||||
keeps every record, all local; a link left with one zone is dissolved, as
|
||||
when the other zone is deleted.
|
||||
|
||||
**Comparing the views.** The zone's **Compare views** tab, or `jarl zone
|
||||
diff adbraun.lol`, lines the views up and shows each name and type that
|
||||
differs: a view's own records, or a name one view lacks. **Use this
|
||||
version** (`jarl record update ID --shared`) makes one view's version
|
||||
every view's. It works for zones that aren't linked too, to see what
|
||||
linking them would keep local.
|
||||
|
||||
```
|
||||
$ jarl zone diff adbraun.lol
|
||||
NAME TYPE INTERNAL EXTERNAL
|
||||
nas A 10.0.10.20 [local, id 812] 203.0.113.20 [local, id 790]
|
||||
5 names and types differ, 21 are the same; linked
|
||||
```
|
||||
|
||||
`--local` also works on a zone that isn't linked (its records are local
|
||||
anyway), so scripts don't need to know; `--shared` there is refused.
|
||||
|
||||
@@ -246,6 +232,54 @@ as it is: linked zones can differ per view, keep their own options, and
|
||||
don't depend on the view order (`in-view` can only use a view defined
|
||||
before it).
|
||||
|
||||
## Comparing views
|
||||
|
||||
A zone's **Compare views** tab, or `jarl zone diff`, compares it with the
|
||||
same zone in another view (or all of them), from the view you're on:
|
||||
|
||||
```
|
||||
$ jarl zone diff adbraun.lol --view internal
|
||||
adbraun.lol in internal, compared with external
|
||||
linked: a difference is a view's own records, there on purpose
|
||||
external: 1 answered differently, 24 only in internal, 2 only in external, 31 the same
|
||||
|
||||
ANSWERED DIFFERENTLY (1)
|
||||
nas A
|
||||
internal 10.0.10.20 [local, id 812]
|
||||
external 203.0.113.20 [local, id 790]
|
||||
|
||||
ONLY IN INTERNAL (24)
|
||||
printer A 10.0.10.30
|
||||
router A 10.0.10.1
|
||||
...
|
||||
|
||||
ONLY IN EXTERNAL (2)
|
||||
_acme-challenge TXT "…"
|
||||
...
|
||||
|
||||
31 names and types are the same (--all lists them)
|
||||
```
|
||||
|
||||
* **Answered differently**: names and types more than one view has, with
|
||||
different records. In linked zones that is a view's own (local)
|
||||
records, there on purpose; between zones that aren't linked, it is a
|
||||
difference that crept in. **Use this everywhere** gives every view one
|
||||
view's records (in linked zones by sharing them: `jarl record update ID
|
||||
--shared`).
|
||||
* **Only in …**: names and types some views lack. **Add to external**
|
||||
gives that view the same records, its own.
|
||||
* **The same**: how many; `--all` (or "List them") shows them.
|
||||
|
||||
One line per record: the CLI cuts long values to fit the terminal
|
||||
(`--wide` shows them whole); the web UI shows the whole value on hover.
|
||||
The apex NS records are left out, since views often list their name
|
||||
servers differently on purpose.
|
||||
|
||||
**One name.** `jarl zone diff adbraun.lol nas` (and a type: `... @ TXT`)
|
||||
shows just that name in every view, the same records included. In the
|
||||
web UI, a record's **Compare** button does the same. `--with external`
|
||||
(repeat it) picks the views to compare with.
|
||||
|
||||
## Living with views
|
||||
|
||||
* Commands that name a zone that exists in several views need `--view`
|
||||
|
||||
+133
-1
@@ -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
|
||||
}
|
||||
@@ -341,3 +341,48 @@ func TestCompareZones(t *testing.T) {
|
||||
t.Errorf("sharing the shared version: external %q", v)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareReport(t *testing.T) {
|
||||
env := linkedEnv(t)
|
||||
env.submit(env.admin, NewRequest(OpZoneCreate, 0, ZoneInput{Name: model.Ptr("example.com"), View: model.Ptr("external"), LinkWith: "internal"}))
|
||||
env.submit(env.admin,
|
||||
NewRequest(OpRRSetReplace, 0, RRSetInput{Zone: "example.com", View: "external", Name: "www", Type: "A", Data: []string{"203.0.113.10"}, Local: model.Ptr(true)}),
|
||||
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("nas"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.20"), Local: model.Ptr(true)}),
|
||||
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "internal", Name: model.Ptr("printer"), Type: model.Ptr("A"), Data: model.Ptr("10.0.0.30"), Local: model.Ptr(true)}),
|
||||
NewRequest(OpRecordCreate, 0, RecordInput{Zone: "example.com", View: "external", Name: model.Ptr("shop"), Type: model.Ptr("CNAME"), Data: model.Ptr("shops.example.net."), Local: model.Ptr(true)}))
|
||||
st := env.state()
|
||||
zones := []*model.Zone{st.FindZone("example.com.", st.ViewByName("internal").ID), st.FindZone("example.com.", st.ViewByName("external").ID)}
|
||||
|
||||
rep := Compare(st, zones, CompareFilter{})
|
||||
if rep.Base != "internal" || !rep.Linked || !slices.Equal(rep.Views, []string{"internal", "external"}) {
|
||||
t.Errorf("report %+v", rep)
|
||||
}
|
||||
if s := rep.Summary[0]; s.View != "external" || s.Different != 1 || s.OnlyBase != 2 || s.OnlyOther != 1 || s.Same != 2 {
|
||||
t.Errorf("summary %+v", s)
|
||||
}
|
||||
if len(rep.Different) != 1 || rep.Different[0].Name != "www" {
|
||||
t.Errorf("different %+v", rep.Different)
|
||||
}
|
||||
var only []string
|
||||
for _, o := range rep.Only {
|
||||
var names []string
|
||||
for _, r := range o.Rows {
|
||||
names = append(names, r.Name)
|
||||
}
|
||||
only = append(only, strings.Join(o.Views, "+")+": "+strings.Join(names, ","))
|
||||
}
|
||||
want(t, "only", only, "internal: nas,printer", "external: shop")
|
||||
if rep.Same != 2 || rep.SameRows != nil {
|
||||
t.Errorf("same %d, rows %v", rep.Same, rep.SameRows)
|
||||
}
|
||||
|
||||
// One name: just its rows, the same ones included.
|
||||
rep = Compare(st, zones, CompareFilter{Name: "www"})
|
||||
if len(rep.Different) != 1 || len(rep.Only) != 0 || len(rep.SameRows) != 0 {
|
||||
t.Errorf("www: %+v", rep)
|
||||
}
|
||||
rep = Compare(st, zones, CompareFilter{Name: "mail.example.com.", Type: "A"})
|
||||
if len(rep.SameRows) != 1 || rep.SameRows[0].Name != "mail" || len(rep.Different)+len(rep.Only) != 0 {
|
||||
t.Errorf("mail A: %+v", rep)
|
||||
}
|
||||
}
|
||||
@@ -9,15 +9,29 @@ import (
|
||||
"jarl/internal/model"
|
||||
)
|
||||
|
||||
// zoneCompare: GET /zones/{zone}/compare lines up a zone's records across
|
||||
// the views that have it (docs/views.md#linked-zones). ?views=a,b picks the
|
||||
// views (default: every view with a primary zone of that name), ?all=1
|
||||
// includes the names and types that are the same everywhere.
|
||||
// zoneCompare: GET /zones/{zone}/compare compares a zone with the same
|
||||
// zone in other views (docs/views.md#comparing-views), from the zone's own
|
||||
// view: ?with=a,b picks the other views (default: every view with a
|
||||
// primary zone of that name), ?name=www[&type=A] narrows it to one name,
|
||||
// ?all=1 includes what is the same. A zone named without its view is
|
||||
// compared from the first view that has it. (?views=a,b, the views to
|
||||
// compare with the first as the base, works too.)
|
||||
func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||
st, err := s.state(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
q := r.URL.Query()
|
||||
list := func(k string) []string {
|
||||
var out []string
|
||||
for _, v := range strings.Split(q.Get(k), ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
with, views := list("with"), list("views")
|
||||
z, err := s.zoneByPath(r, st, p)
|
||||
if err != nil {
|
||||
// A name in several views is what there is to compare.
|
||||
@@ -26,56 +40,45 @@ func (s *Server) zoneCompare(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
||||
ref = "."
|
||||
}
|
||||
name, perr := engine.ParseZoneName(ref)
|
||||
if zs := st.ZonesNamed(name); perr != nil || len(zs) < 2 {
|
||||
zs := st.ZonesNamed(name)
|
||||
if perr != nil || len(zs) < 2 {
|
||||
return err
|
||||
} else {
|
||||
}
|
||||
st.SortByView(zs)
|
||||
z = zs[0]
|
||||
if len(views) > 0 {
|
||||
for _, o := range zs {
|
||||
if strings.EqualFold(st.ViewName(o), views[0]) {
|
||||
z = o
|
||||
}
|
||||
}
|
||||
var want []string
|
||||
for _, v := range strings.Split(r.URL.Query().Get("views"), ",") {
|
||||
if v = strings.TrimSpace(v); v != "" {
|
||||
want = append(want, v)
|
||||
}
|
||||
}
|
||||
var zones []*model.Zone
|
||||
readable := func(o *model.Zone) bool {
|
||||
vn := st.ViewName(o)
|
||||
return o.Type == model.ZonePrimary && p.CanSeeZone(o.Name, vn) && p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn})
|
||||
}
|
||||
if !readable(z) {
|
||||
return badRequest("zone %s isn't a primary zone whose records you may read", engine.Display(z.Name))
|
||||
}
|
||||
zones := []*model.Zone{z}
|
||||
var others []*model.Zone
|
||||
for _, o := range st.ZonesNamed(z.Name) {
|
||||
vn := st.ViewName(o)
|
||||
if o.Type != model.ZonePrimary || !p.CanSeeZone(o.Name, vn) || !p.Can(auth.Action{Resource: auth.ResRecords, Zone: o.Name, View: vn}) {
|
||||
if o.ID == z.ID || !readable(o) {
|
||||
continue
|
||||
}
|
||||
if len(want) > 0 && !containsFold(want, vn) {
|
||||
if len(with) > 0 && !containsFold(with, vn) || len(views) > 0 && !containsFold(views, vn) {
|
||||
continue
|
||||
}
|
||||
zones = append(zones, o)
|
||||
others = append(others, o)
|
||||
}
|
||||
st.SortByView(zones)
|
||||
st.SortByView(others)
|
||||
zones = append(zones, others...)
|
||||
if len(zones) < 2 {
|
||||
return badRequest("zone %s is in only one view (that you may read): there is nothing to compare", engine.Display(z.Name))
|
||||
}
|
||||
rows := engine.CompareZones(st, zones)
|
||||
all := queryBool(r, "all")
|
||||
out := struct {
|
||||
Zone string `json:"zone"`
|
||||
Views []string `json:"views"`
|
||||
Linked bool `json:"linked"` // all of them in one link
|
||||
Same int `json:"same"` // names and types the same everywhere
|
||||
Rows []engine.CompareRow `json:"rows"`
|
||||
}{Zone: engine.Display(z.Name), Rows: []engine.CompareRow{}, Linked: true}
|
||||
for _, o := range zones {
|
||||
out.Views = append(out.Views, st.ViewName(o))
|
||||
out.Linked = out.Linked && o.LinkGroup != 0 && o.LinkGroup == zones[0].LinkGroup
|
||||
}
|
||||
for _, row := range rows {
|
||||
if row.Same {
|
||||
out.Same++
|
||||
if !all {
|
||||
continue
|
||||
}
|
||||
}
|
||||
out.Rows = append(out.Rows, row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
writeJSON(w, http.StatusOK, engine.Compare(st, zones, engine.CompareFilter{Name: q.Get("name"), Type: q.Get("type"), All: queryBool(r, "all")}))
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { h, mount, table, field, input, select, textarea, toast, errorBox, display, badge, confirmDialog, modal, checkbox, time, diffView, backLink, viewLink, lastList, plural } from "../ui.js";
|
||||
import { api, get, stream, change, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
|
||||
import { api, get, stream, change, changeMany, describeResult, rrtypes, state, draftQuery, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { optionsEditor } from "./options.js";
|
||||
import { emailOf, pendingBadge } from "./zones.js";
|
||||
@@ -70,7 +70,7 @@ export async function render(el, params, query) {
|
||||
else if (tab === "servers") await serversTab(body, zone);
|
||||
else if (tab === "dnssec") await dnssecTab(body, zone);
|
||||
else if (tab === "registrar") await registrarTab(body, zone);
|
||||
else if (tab === "compare") await compareTab(body, zone);
|
||||
else if (tab === "compare") await compareTab(body, zone, query);
|
||||
// A later click (another tab) has taken over the page.
|
||||
if (seq !== renderSeq || !holder.isConnected) return;
|
||||
holder.replaceWith(body);
|
||||
@@ -82,34 +82,110 @@ export async function render(el, params, query) {
|
||||
|
||||
// compareTab shows the names and types that differ between the views that
|
||||
// have the zone, with a button to share one view's version (linked zones).
|
||||
async function compareTab(el, zone) {
|
||||
let all = false;
|
||||
// compareTab compares the zone with the same zone in other views, from
|
||||
// this one (docs/views.md#comparing-views): a summary, what the views
|
||||
// answer differently, the names only some views have, and how much is
|
||||
// the same. ?name= (and &type=) narrows it to one name.
|
||||
async function compareTab(el, zone, query) {
|
||||
const others = zone.other_views || [];
|
||||
const withSel = select([...(others.length > 1 ? [["", "all other views"]] : []), ...others.map((v) => [v, v])], query.get("with") || others[0] || "");
|
||||
const nameIn = input({ value: query.get("name") || "", placeholder: "all names, or one: www, @", class: "mono", style: { width: "200px" } });
|
||||
const typeIn = input({ value: query.get("type") || "", placeholder: "any type", class: "mono", style: { width: "90px" } });
|
||||
let showSame = false;
|
||||
const out = h("div");
|
||||
const one = (d) => h("div", { class: "oneline mono small", title: d }, d);
|
||||
const draw = async () => {
|
||||
const r = await get("/zones/" + zone.id + "/compare", { all: all ? 1 : "" });
|
||||
const head = h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)));
|
||||
const rows = r.rows.map((row) => h("tr", { class: row.same ? "muted" : "" },
|
||||
const q = { with: withSel.value || undefined, name: nameIn.value.trim() || undefined, type: nameIn.value.trim() ? typeIn.value.trim() || undefined : undefined, all: showSame ? 1 : undefined };
|
||||
let r;
|
||||
try { r = await get("/zones/" + zone.id + "/compare", draftQuery(q)); } catch (e) { mount(out, errorBox(e)); return; }
|
||||
const oneName = !!q.name;
|
||||
const parts = [];
|
||||
parts.push(h("p", { class: "muted" }, r.linked
|
||||
? "The zones are linked: shared records are kept in step, so a difference is a view's own records, there on purpose."
|
||||
: "The zones aren't linked: nothing keeps them in step, so a difference may be one that crept in. Link them (Link… above) to keep what is the same in step."));
|
||||
if (!oneName) parts.push(h("ul", { class: "compare-summary" }, r.summary.map((s) => h("li", null,
|
||||
h("strong", null, s.view), ": ",
|
||||
[[s.different, "answered differently"], [s.only_base, "only in " + r.base], [s.only_other, "only in " + s.view], [s.same, "the same"]]
|
||||
.map(([n, what], i) => [i ? " · " : "", h("span", { class: n && i < 3 ? "" : "muted" }, n + " " + what)])))));
|
||||
|
||||
if (r.different.length) {
|
||||
parts.push(h("h3", null, "Answered differently (" + r.different.length + ")"),
|
||||
h("div", { class: "table-wrap" }, h("table", { class: "compare" },
|
||||
h("thead", null, h("tr", null, h("th", null, "Name"), h("th", null, "Type"), r.views.map((v) => h("th", null, v)))),
|
||||
h("tbody", null, r.different.map((row) => h("tr", null,
|
||||
h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||
row.views.map((vs) => h("td", { class: "compare-cell" },
|
||||
vs.records.length ? vs.records.map((d) => h("div", { class: "mono small" }, d)) : h("span", { class: "muted" }, "–"),
|
||||
vs.linked === "local" ? [badge("only " + vs.view, "accent"), " "] : null,
|
||||
r.linked && !row.same && vs.records.length ? h("button", { class: "small", title: "Make view " + vs.view + "'s version every view's: the others' records of this name and type are replaced",
|
||||
onclick: () => share(vs, row) }, "Use this version") : null))));
|
||||
mount(out,
|
||||
h("p", { class: "muted" }, r.same + " names and types are the same in " + r.views.join(", ") + (r.rows.length && !all ? "; these differ." : ".") + " ",
|
||||
r.linked ? "The zones are linked: shared records are kept in step, and a view's own records override them there." : "The zones aren't linked: a change in one doesn't reach the others. Link them (Link… above) to keep what is the same in step; linking changes no answer."),
|
||||
r.rows.length ? h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("thead", null, head), h("tbody", null, rows))) : h("div", { class: "empty" }, "Nothing differs."),
|
||||
h("label", { class: "check" }, h("input", { type: "checkbox", checked: all, onchange: (e) => { all = e.target.checked; draw(); } }), "Show what is the same too"));
|
||||
vs.records.length ? vs.records.map(one) : h("span", { class: "muted" }, "–"),
|
||||
vs.records.length ? h("div", { class: "compare-actions" },
|
||||
vs.linked === "local" ? [badge("own records", "accent"), " "] : null,
|
||||
h("button", { class: "small", title: "Every view gets " + vs.view + "'s records for " + row.name + " " + row.type, onclick: () => useEverywhere(r, row, vs) }, "Use this everywhere")) : null))))))));
|
||||
}
|
||||
for (const o of r.only) {
|
||||
const missing = r.views.filter((v) => !o.views.includes(v));
|
||||
parts.push(h("details", { class: "compare-only", open: oneName || o.rows.length <= 8 },
|
||||
h("summary", null, h("strong", null, "Only in " + o.views.join(" and ")), " (" + o.rows.length + ")",
|
||||
h("span", { class: "muted small" }, " · " + missing.join(", ") + (missing.length > 1 ? " have" : " has") + " no records of these names and types")),
|
||||
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, o.rows.map((row) => {
|
||||
const src = row.views.find((vs) => vs.records.length);
|
||||
return h("tr", null, h("td", { class: "mono" }, row.name), h("td", null, h("strong", null, row.type)),
|
||||
h("td", { class: "compare-cell" }, src.records.map(one)),
|
||||
h("td", { class: "actions-cell" }, missing.map((v) => h("button", { class: "small", title: "Give view " + v + " the same records (its own, local to it)", onclick: () => copyTo(row, src, row.views.find((x) => x.view === v)) }, "Add to " + v))));
|
||||
}))))));
|
||||
}
|
||||
if (r.same_rows && r.same_rows.length) {
|
||||
parts.push(h("h3", null, "The same in " + r.views.join(", ") + " (" + r.same_rows.length + ")"),
|
||||
h("div", { class: "table-wrap" }, h("table", { class: "compare" }, h("tbody", null, r.same_rows.map((row) => h("tr", { class: "muted" },
|
||||
h("td", { class: "mono" }, row.name), h("td", null, row.type), h("td", { class: "compare-cell" }, row.views[0].records.map(one))))))));
|
||||
} else if (!oneName && r.same) {
|
||||
parts.push(h("p", { class: "muted" }, r.same + " names and types are the same. ", h("a", { href: "#", onclick: (e) => { e.preventDefault(); showSame = true; draw(); } }, "List them")));
|
||||
}
|
||||
if (oneName && !r.different.length && !r.only.length && !(r.same_rows || []).length) parts.push(h("div", { class: "empty" }, "No records of that name in these views."));
|
||||
else if (!r.different.length && !r.only.length && !oneName) parts.push(h("div", { class: "empty" }, "Nothing differs."));
|
||||
mount(out, parts);
|
||||
};
|
||||
const share = async (vs, row) => {
|
||||
if (!await confirmDialog("Use view " + vs.view + "'s " + row.name + " " + row.type, "Every view gets " + vs.records.join(", ") + " for " + row.name + " " + row.type + ", shared from now on; the other views' records of that name and type are replaced.", "Use this version")) return;
|
||||
// useEverywhere gives every view one view's records of a name and type:
|
||||
// in a linked zone by sharing them, otherwise by setting each zone's.
|
||||
const useEverywhere = async (r, row, vs) => {
|
||||
const rest = row.views.filter((x) => x !== vs);
|
||||
const linkedAll = r.linked && zone.linked_with;
|
||||
const msg = linkedAll
|
||||
? "View " + vs.view + "'s " + row.name + " " + row.type + " records become shared: every linked view (" + [zone.view, ...zone.linked_with].join(", ") + ") answers " + vs.data.join(", ") + ", and their own records of that name and type are replaced."
|
||||
: rest.map((x) => x.view).join(" and ") + " get" + (rest.length > 1 ? "" : "s") + " " + vs.data.join(", ") + " for " + row.name + " " + row.type + ", replacing what " + (rest.length > 1 ? "they have" : "it has") + ".";
|
||||
if (!await confirmDialog("Use " + vs.view + "'s " + row.name + " " + row.type, msg, "Use this everywhere")) return;
|
||||
try {
|
||||
toast(describeResult(await change("PATCH", "/records/" + vs.ids[0], { local: false })));
|
||||
const res = linkedAll
|
||||
? await change("PATCH", "/records/" + vs.ids[0], { local: false })
|
||||
: await changeMany(rest.map((x) => ({ op: "rrset.replace", data: rrsetFrom(row, vs, x) })), "use " + vs.view + "'s " + row.name + " " + row.type);
|
||||
toast(describeResult(res));
|
||||
await draw();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const copyTo = async (row, src, dst) => {
|
||||
try {
|
||||
toast(describeResult(await changeMany([{ op: "rrset.replace", data: rrsetFrom(row, src, dst) }], "add " + row.name + " " + row.type + " to view " + dst.view)));
|
||||
await draw();
|
||||
mount(el, out);
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
};
|
||||
const go = () => draw();
|
||||
withSel.addEventListener("change", go);
|
||||
nameIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||
typeIn.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
|
||||
mount(el, h("div", { class: "panel" },
|
||||
h("div", { class: "compare-controls" },
|
||||
h("label", null, "Compare " + zone.view + " with "), withSel, " ",
|
||||
h("label", null, " Name "), nameIn, typeIn, " ",
|
||||
h("button", { class: "small", onclick: go }, "Compare"),
|
||||
nameIn.value ? [" ", h("button", { class: "small", onclick: () => { nameIn.value = ""; typeIn.value = ""; go(); } }, "All names")] : null),
|
||||
out));
|
||||
await draw();
|
||||
}
|
||||
|
||||
// rrsetFrom is the request giving zone dst a name and type's records from
|
||||
// src: its own (local) records, in a linked zone.
|
||||
function rrsetFrom(row, src, dst) {
|
||||
const d = { zone_id: dst.zone_id, name: row.fqdn, type: row.type, data: src.data, local: true };
|
||||
if (src.ttl !== undefined && src.ttl !== null) d.ttl = src.ttl;
|
||||
return d;
|
||||
}
|
||||
|
||||
// ---- linked zones (docs/views.md#linked-zones)
|
||||
@@ -610,6 +686,7 @@ async function recordsTab(el, zone, query) {
|
||||
: h("button", { class: "small reveal", title: "Make " + r.relative_name + " " + r.type + " this view's own, so it can differ here; the other views keep the shared one", onclick: () => setLocal(r, true) }, "Only here"), " "] : null,
|
||||
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
|
||||
? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
|
||||
zone.other_views && !(r.type === "NS" && r.relative_name === "@") ? [h("a", { class: "button small reveal", href: "#/zones/" + zone.id + "/compare?name=" + encodeURIComponent(r.relative_name) + "&type=" + r.type, title: "Compare " + r.relative_name + " " + r.type + " with view" + (zone.other_views.length > 1 ? "s " : " ") + zone.other_views.join(", ") }, "Compare"), " "] : null,
|
||||
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
|
||||
r.disabled
|
||||
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
|
||||
|
||||
@@ -564,8 +564,20 @@ ul.probes > li { padding: 6px 0; border-bottom: 1px solid var(--border); }
|
||||
ul.probes > li .msg { margin: 6px 0 0; }
|
||||
|
||||
/* Comparing a zone across views. */
|
||||
table.compare td.compare-cell { vertical-align: top; }
|
||||
table.compare td { vertical-align: top; }
|
||||
/* The same name and type columns in every section, so they line up. */
|
||||
table.compare { table-layout: auto; width: 100%; }
|
||||
table.compare tr > :nth-child(1) { width: 18ch; }
|
||||
table.compare tr > :nth-child(2) { width: 8ch; }
|
||||
table.compare td.compare-cell button { margin-top: 4px; }
|
||||
/* One line per record: a long TXT record is cut, with the whole of it on hover. */
|
||||
table.compare .oneline { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46ch; }
|
||||
.compare-actions { margin-top: 4px; }
|
||||
.compare-controls { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; margin-bottom: 12px; }
|
||||
.compare-controls label { color: var(--muted); }
|
||||
ul.compare-summary { margin: 0 0 12px; padding-left: 18px; }
|
||||
details.compare-only { margin: 12px 0; }
|
||||
details.compare-only summary { cursor: pointer; padding: 4px 0; }
|
||||
table.compare tr.muted td { color: var(--muted); }
|
||||
|
||||
/* Text that wraps anywhere, e.g. long statements in the Added by Jarl table. */
|
||||
|
||||
+21
-4
@@ -418,11 +418,28 @@ expect_dig "now every view's" "10.1.1.1" -k $E/external.key linked.adbraun.test
|
||||
run "--view all changes a link once" 0 $J record set adbraun.test linked A 10.1.1.2 --view all
|
||||
contains "linked with internal"
|
||||
expect_dig "both views" "10.1.1.2" -k $E/external.key linked.adbraun.test A
|
||||
run "zone diff: what differs between the views" 0 $J zone diff adbraun.test
|
||||
contains "ns1 *A *127.0.0.1 \\[local, id [0-9]*\\] *203.0.113.1 \\[local, id [0-9]*\\]"
|
||||
contains "names and types differ, [0-9]* are the same; linked"
|
||||
run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&all=1"
|
||||
run "zone diff: internal compared with external" 0 $J zone diff adbraun.test
|
||||
contains "^adbraun.test in internal, compared with external$"
|
||||
contains "^ linked: a difference is a view's own records"
|
||||
contains "^ external: [1-9][0-9]* answered differently, [0-9]* only in internal, [0-9]* only in external, [0-9]* the same"
|
||||
contains "^ANSWERED DIFFERENTLY"
|
||||
contains "^ ns1 A$"
|
||||
contains "^ internal 127.0.0.1 \\[local, id [0-9]*\\]"
|
||||
contains "^ external 203.0.113.1 \\[local, id [0-9]*\\]"
|
||||
contains "the same (--all lists them)"
|
||||
run "zone diff from external" 0 $J zone diff adbraun.test --view external
|
||||
contains "^adbraun.test in external, compared with internal"
|
||||
run "zone diff for one name" 0 $J zone diff adbraun.test ns1 A
|
||||
contains "^ ns1 A$"
|
||||
not_contains "the same (--all"
|
||||
run "a long TXT record" 0 $J record add adbraun.test longtxt TXT "$(printf 'x%.0s' $(seq 300))" --view internal --local
|
||||
run "zone diff --wide shows it whole" 0 $J zone diff adbraun.test longtxt --wide
|
||||
contains "x\\{250\\}"
|
||||
run "the compare API" 0 curl -sf -H "Authorization: Bearer $(cat $E/admin.token)" "http://127.0.0.1:$HTTP/api/v1/zones/adbraun.test/compare?view=internal&with=external&all=1"
|
||||
contains '"linked":true'
|
||||
contains '"base":"internal"'
|
||||
contains '"same_rows":\['
|
||||
run "remove the long TXT" 0 $J record delete adbraun.test longtxt TXT --view internal -y
|
||||
run "a shared delete" 0 $J record delete adbraun.test linked A --view external -y
|
||||
expect_dig "gone from internal too" "" linked.adbraun.test A
|
||||
run "zone unlink" 0 $J zone unlink adbraun.test --view external
|
||||
|
||||
Reference in new issue
Block a user