Compare commits
2
Commits
b24c730b97
...
b1719ce362
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1719ce362 | ||
|
|
ec16de875d |
No files matched your search
@@ -37,6 +37,25 @@ 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
|
||||
|
||||
- Server options → Added by Jarl: long statements didn't wrap, and the
|
||||
browser console reported a Content Security Policy violation (an inline
|
||||
style the page's policy blocks).
|
||||
|
||||
### Upgrading
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
+181
-62
@@ -962,89 +962,208 @@ 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))
|
||||
}
|
||||
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)
|
||||
})
|
||||
width := 0
|
||||
if !wide {
|
||||
width = terminalWidth()
|
||||
}
|
||||
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))
|
||||
}
|
||||
return 0
|
||||
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 {
|
||||
|
||||
+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 {
|
||||
z = zs[0]
|
||||
}
|
||||
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)
|
||||
}
|
||||
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})
|
||||
}
|
||||
var zones []*model.Zone
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -182,7 +182,7 @@ function generatedPanel(el, list) {
|
||||
h("thead", null, h("tr", null, h("th", null, "Where"), h("th", null, "Statement"), h("th", null, "Why"), h("th", null, "Controlled by"))),
|
||||
h("tbody", null, list.map((g) => h("tr", null,
|
||||
h("td", { class: "small" }, g.where),
|
||||
h("td", null, h("pre", { class: "code optval", style: "white-space: pre-wrap; word-break: break-all" }, g.text)),
|
||||
h("td", null, h("pre", { class: "code optval wrap" }, g.text)),
|
||||
h("td", { class: "small" }, g.why),
|
||||
h("td", { class: "small mono" }, g.setting))))))));
|
||||
}
|
||||
|
||||
@@ -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" : "" },
|
||||
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"));
|
||||
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(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();
|
||||
} 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();
|
||||
mount(el, out);
|
||||
}
|
||||
|
||||
// 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,6 +564,21 @@ 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. */
|
||||
pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
||||
+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