Stats heatmap: empty hours same height; cname_chain only for long chains

The "Busiest times" grid's hours without data used the class "empty",
which picked up the padding of empty messages. One CNAME to another no
longer warns; three or more get an info note showing the chain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-18 16:58:40 +01:00
1 parent 9db76a113c
commit 12013b12d5
6 files changed
+41 -10

No files matched your search

+9
View File
@@ -42,6 +42,11 @@ All notable changes to Jarl are documented here. The format follows
### Changed ### Changed
- `cname_chain` no longer warns about one CNAME pointing at another (a
service name at a host alias is common, and named answers the chain at
once). A chain of three or more CNAMEs gets an info note that shows
it.
- A certificate's private key is only exported over HTTPS, the local - A certificate's private key is only exported over HTTPS, the local
socket, or to the server itself (plain HTTP from another host is socket, or to the server itself (plain HTTP from another host is
refused with `https_required`). refused with `https_required`).
@@ -70,6 +75,10 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### Fixed
- The statistics' "Busiest times" grid drew hours without data taller
than the others (they picked up the page's padding for empty
messages).
- A token for one view of a linked zone (such as an ACME token with - A token for one view of a linked zone (such as an ACME token with
`views=external`) couldn't add records: a new record is shared, and `views=external`) couldn't add records: a new record is shared, and
that reached the other view. A new name and type is now local to the that reached the other view. A new name and type is now local to the
+1 -1
View File
@@ -34,7 +34,7 @@ showing them.
| `cname_multiple` | error | A name can have only one CNAME | | `cname_multiple` | error | A name can have only one CNAME |
| `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) | | `cname_apex` | error | No CNAME at the zone apex (it needs SOA and NS) |
| `cname_loop` | error | CNAMEs that point back to themselves | | `cname_loop` | error | CNAMEs that point back to themselves |
| `cname_chain` | warning | CNAME to a CNAME: works, but slows resolution | | `cname_chain` | info | a chain of three or more CNAMEs (one CNAME to another says nothing): works, but each is another lookup for some resolvers |
| `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone | | `cname_dangling` | warning | CNAME to a name that doesn't exist in the zone |
| `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) | | `dname_multiple`, `below_dname` | error | One DNAME per name, and nothing below a DNAME (RFC 6672) |
| `apex_ns_missing` | error | A zone needs NS records at its apex | | `apex_ns_missing` | error | A zone needs NS records at its apex |
+12 -4
View File
@@ -385,8 +385,16 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if !ix.inZoneTarget(target) { if !ix.inZoneTarget(target) {
return return
} }
// Follow the chain inside the zone to detect loops. // Follow the chain inside the zone to detect loops. One CNAME to
// another is common and costs nothing (named answers the chain in
// one response); a longer chain gets a note.
seen := map[string]bool{r.Name: true} seen := map[string]bool{r.Name: true}
chain := []string{disp(r.Name), disp(target)}
note := func() {
if len(chain) > 3 {
c.recProblem(z, r, Info, "cname_chain", "%s reaches its address through %d CNAMEs (%s); fine, but each one is another lookup for resolvers that don't get the chain at once", disp(r.Name), len(chain)-1, strings.Join(chain, " → "))
}
}
cur := target cur := target
for hops := 0; hops < 20; hops++ { for hops := 0; hops < 20; hops++ {
if seen[cur] { if seen[cur] {
@@ -398,18 +406,18 @@ func (c *checker) checkCNAMETarget(z *model.Zone, ix *zoneIndex, r *model.Record
if len(next) == 0 { if len(next) == 0 {
break break
} }
if hops == 0 {
c.recProblem(z, r, Warning, "cname_chain", "%s is a CNAME to %s, which is itself a CNAME; chains slow down resolution", disp(r.Name), disp(target))
}
p := ix.parsed[next[0].ID] p := ix.parsed[next[0].ID]
if p == nil { if p == nil {
break break
} }
cur = p.RR.(*dns.CNAME).Target cur = p.RR.(*dns.CNAME).Target
chain = append(chain, disp(cur))
if !ix.inZoneTarget(cur) { if !ix.inZoneTarget(cur) {
note()
return return
} }
} }
note()
if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) { if len(ix.byName[target]) == 0 && !ix.hasAddress(target) && !hasDescendant(ix, target) {
c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target)) c.recProblem(z, r, Warning, "cname_dangling", "%s is a CNAME to %s, which does not exist in the zone", disp(r.Name), disp(target))
} }
+16 -2
View File
@@ -161,8 +161,9 @@ func TestRecordConflicts(t *testing.T) {
}, "cname_loop", Error}, }, "cname_loop", Error},
{"cname chain", func(b *builder, z *model.Zone) { {"cname chain", func(b *builder, z *model.Zone) {
b.rec(z, "a", "CNAME", "b") b.rec(z, "a", "CNAME", "b")
b.rec(z, "b", "CNAME", "ns1") b.rec(z, "b", "CNAME", "c")
}, "cname_chain", Warning}, b.rec(z, "c", "CNAME", "ns1")
}, "cname_chain", Info},
{"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning}, {"dangling cname", func(b *builder, z *model.Zone) { b.rec(z, "a", "CNAME", "nothing") }, "cname_dangling", Warning},
{"null mx not alone", func(b *builder, z *model.Zone) { {"null mx not alone", func(b *builder, z *model.Zone) {
b.rec(z, "@", "MX", "0 .") b.rec(z, "@", "MX", "0 .")
@@ -380,3 +381,16 @@ func TestProblemStringDisplaysZone(t *testing.T) {
t.Errorf("String() = %q, want %q", got, want) t.Errorf("String() = %q, want %q", got, want)
} }
} }
// One CNAME to another is common and says nothing.
func TestShortCNAMEChain(t *testing.T) {
b := newBuilder()
z := b.primary("example.com", nil)
b.rec(z, "songshare", "CNAME", "navidrome")
b.rec(z, "navidrome", "CNAME", "ns1")
for _, p := range State(b.s, Options{}) {
if p.Code == "cname_chain" {
t.Errorf("one extra CNAME: %s %s", p.Severity, p.Message)
}
}
}
+2 -2
View File
@@ -283,7 +283,7 @@ export function heatmap(hm, unit) {
const n = hm.samples[d][hr]; const n = hm.samples[d][hr];
const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" : const text = v < 0 ? day + " " + pad(hr) + ":00 – no data" :
day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)"); day + " " + pad(hr) + ":00 – " + Math.round(v).toLocaleString() + " " + unit + " per hour (average of " + n + (n === 1 ? " hour)" : " hours)");
const cell = h("div", { class: "hm-cell " + (v < 0 ? "empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text }); const cell = h("div", { class: "hm-cell " + (v < 0 ? "hm-empty" : "heat-" + level(v)), tabindex: "-1", "aria-label": text });
const showTip = () => { const showTip = () => {
tip.textContent = text; tip.textContent = text;
tip.hidden = false; tip.hidden = false;
@@ -313,7 +313,7 @@ export function heatmap(hm, unit) {
h("span", { class: "muted small" }, "0"), h("span", { class: "muted small" }, "0"),
Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })), Array.from({ length: steps }, (_, i) => h("span", { class: "hm-swatch heat-" + i })),
h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"), h("span", { class: "muted small" }, compact(peak) + " " + unit + "/hour"),
h("span", { class: "hm-swatch empty" }), h("span", { class: "muted small" }, "no data")); h("span", { class: "hm-swatch hm-empty" }), h("span", { class: "muted small" }, "no data"));
return h("div", { class: "heatmap-wrap" }, grid, tip, scale); return h("div", { class: "heatmap-wrap" }, grid, tip, scale);
} }
+1 -1
View File
@@ -398,7 +398,7 @@ td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.hm-row { display: flex; align-items: center; } .hm-row { display: flex; align-items: center; }
.hm-cell { height: 22px; border-radius: 3px; } .hm-cell { height: 22px; border-radius: 3px; }
.hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; } .hm-cell:hover, .hm-cell:focus { outline: 2px solid var(--text); outline-offset: -1px; }
.hm-cell.empty, .hm-swatch.empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); } .hm-empty { background: transparent; box-shadow: inset 0 0 0 1px var(--chart-grid); }
.heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); } .heat-0 { background: var(--heat-0); } .heat-1 { background: var(--heat-1); } .heat-2 { background: var(--heat-2); }
.heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); } .heat-3 { background: var(--heat-3); } .heat-4 { background: var(--heat-4); } .heat-5 { background: var(--heat-5); }
.heat-6 { background: var(--heat-6); } .heat-6 { background: var(--heat-6); }