Compare commits

...
3 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 436cd79ec9 Release 0.3.0
release / release (push) Successful in 1m49s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:39:15 +00:00
adamandClaude Opus 5.5 c645dae14f Release 0.3.0-rc.2
release / release (push) Successful in 1m50s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:27:56 +00:00
adamandClaude Opus 5.5 403f85486d Mark signed zones with a padlock, and name clients in statistics
The zones list shows a padlock after signed zones, linking to their
DNSSEC tab. Client lists in the statistics show the host name Jarl's
records have for each address (PTR, else A/AAAA); no DNS queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 23:27:53 +00:00
16 changed files with 220 additions and 18 deletions

No files matched your search

+10
View File
@@ -6,6 +6,8 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [0.3.0] - 2026-10-09
### Added ### Added
- Disabled records: a record can be disabled instead of deleted. It stays - Disabled records: a record can be disabled instead of deleted. It stays
@@ -35,6 +37,14 @@ All notable changes to Jarl are documented here. The format follows
and `list` prints the IDs too. With a change (`jarl record add ... -o and `list` prints the IDs too. With a change (`jarl record add ... -o
id`) they apply to the changed object. id`) they apply to the changed object.
- Statistics: client lists (busiest and refused clients, in the web UI,
`jarl stats top clients|refused` and `jarl stats summary`) show the host
name Jarl's records have for each address: the PTR record's target, or
else the name of an A/AAAA record. Looked up in Jarl's own data, so no
DNS queries; an address without a record shows as before.
- Zones list: a padlock after each signed zone's name, linking to its
DNSSEC tab (open and amber while signing is being turned off).
### Changed ### Changed
- The web UI and API can listen on ports below 1024, such as 443: - The web UI and API can listen on ports below 1024, such as 443:
+3 -4
View File
@@ -166,10 +166,9 @@ review step.
## Status ## Status
Version 0.2.0 is the current release, and 0.3.0 is in testing Version 0.3.0 is the current release. It adds disabled records,
(0.3.0-rc.1). 0.2.0 added DNSSEC signing, Porkbun support, two-factor script-friendly CLI output and client names in the statistics, on top of
login and view colours; 0.3.0 adds disabled records and scripting output 0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
for the CLI. Jarl is feature-complete for one server's configuration.
Managing several servers from one jarld and dynamic updates are not Managing several servers from one jarld and dynamic updates are not
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)). handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
+1 -1
View File
@@ -1 +1 @@
0.3.0-rc.1 0.3.0
+15 -1
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"io" "io"
"math" "math"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -109,7 +110,11 @@ export the data.`,
} }
fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k]) fmt.Fprintf(w, "\n%s\t\n", map[string]string{stats.KindName: "Top names", stats.KindClient: "Top clients", stats.KindNXDomain: "Top NXDOMAIN names"}[k])
for _, e := range es { for _, e := range es {
fmt.Fprintf(w, " %s\t%d\n", e.Key, e.Count) key := e.Key
if e.Name != "" {
key += " (" + e.Name + ")"
}
fmt.Fprintf(w, " %s\t%d\n", key, e.Count)
} }
} }
}) })
@@ -152,10 +157,19 @@ export the data.`,
if head == "" { if head == "" {
head = "NAME" head = "NAME"
} }
named := slices.ContainsFunc(es, func(e stats.Entry) bool { return e.Name != "" })
if named {
fmt.Fprintf(w, "#\t%s\tHOST\tQUERIES\t\n", head)
} else {
fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head) fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head)
}
for i, e := range es { for i, e := range es {
if named {
fmt.Fprintf(w, "%d\t%s\t%s\t%d\t%s\n", i+1, e.Key, dash(e.Name), e.Count, bar(e.Count, es[0].Count, 20))
} else {
fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20)) fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20))
} }
}
}) })
}, },
} }
+1 -1
View File
@@ -486,7 +486,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` | | GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio`, `tops` | | GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio`, `tops` |
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` | | GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]` | | GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused), `since`, `limit` (default 20) | `[{key, count}]`; client and refused entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data | | GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` | | GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
+10
View File
@@ -111,6 +111,16 @@ Nothing else is stored. In particular there's no per-query record, so you
can't look up "who asked for X at 14:02". That is deliberate, both for size can't look up "who asked for X at 14:02". That is deliberate, both for size
and privacy. and privacy.
**Client names.** Where the client lists (busiest and refused clients) show
an address Jarl has a record for, they show its host name too: the PTR
record's target, or else the name of an A/AAAA record with that address
(disabled records don't count, and only records you may read). This is
looked up in Jarl's own data when the list is shown, not stored and not
asked of DNS, so it is instant and adds no queries of its own. The name is
what the records say now: with DHCP, an address seen last week may have
been another machine. Anonymized clients (`anonymize_clients`) are
prefixes and get no name.
## Using it ## Using it
**Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the **Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the
+38
View File
@@ -0,0 +1,38 @@
package server
import (
"net/netip"
"testing"
"jarl/internal/model"
)
func TestClientNames(t *testing.T) {
st := model.NewState()
add := func(id int64, name, typ, data string, disabled bool) {
st.Records[id] = &model.Record{ID: id, ZoneID: 1, Name: name, Type: typ, Data: data, Disabled: disabled}
}
add(1, "nas.adbraun.lol.", "A", "192.168.1.20", false)
add(2, "20.1.168.192.in-addr.arpa.", "PTR", "storage.adbraun.lol.", false) // PTR wins over A
add(3, "web.adbraun.lol.", "A", "192.168.1.30", false) // no PTR: the A record's name
add(4, "alias.adbraun.lol.", "A", "192.168.1.30", false) // first in name order wins
add(5, "old.adbraun.lol.", "A", "192.168.1.40", true) // disabled: ignored
add(6, "secret.adbraun.lol.", "A", "192.168.1.50", false) // not readable
add(7, "v6.adbraun.lol.", "AAAA", "2001:db8::7", false)
add(8, "printer.adbraun.lol.", "A", "192.168.1.60", false) // not asked for
want := map[netip.Addr]bool{}
for _, a := range []string{"192.168.1.20", "192.168.1.30", "192.168.1.40", "192.168.1.50", "2001:db8::7", "10.0.0.1"} {
want[netip.MustParseAddr(a)] = true
}
got := clientNames(st, want, func(r *model.Record) bool { return r.ID != 6 })
exp := map[string]string{"192.168.1.20": "storage.adbraun.lol", "192.168.1.30": "alias.adbraun.lol", "2001:db8::7": "v6.adbraun.lol"}
if len(got) != len(exp) {
t.Errorf("got %v", got)
}
for a, n := range exp {
if got[netip.MustParseAddr(a)] != n {
t.Errorf("%s = %q, want %q", a, got[netip.MustParseAddr(a)], n)
}
}
}
+95
View File
@@ -2,10 +2,14 @@ package server
import ( import (
"net/http" "net/http"
"net/netip"
"slices"
"strings" "strings"
"time" "time"
"jarl/internal/auth" "jarl/internal/auth"
"jarl/internal/dnsname"
"jarl/internal/model"
"jarl/internal/stats" "jarl/internal/stats"
) )
@@ -53,6 +57,9 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
if err != nil { if err != nil {
return err return err
} }
if err := s.nameClients(r, p, sum.Tops[stats.KindClient]); err != nil {
return err
}
writeJSON(w, http.StatusOK, sum) writeJSON(w, http.StatusOK, sum)
return nil return nil
} }
@@ -75,6 +82,11 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
if err != nil { if err != nil {
return badRequest("%v", err) return badRequest("%v", err)
} }
if kind == stats.KindClient || kind == stats.KindRefused {
if err := s.nameClients(r, p, es); err != nil {
return err
}
}
writeJSON(w, http.StatusOK, es) writeJSON(w, http.StatusOK, es)
return nil return nil
} }
@@ -145,3 +157,86 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
writeJSON(w, http.StatusOK, es) writeJSON(w, http.StatusOK, es)
return nil return nil
} }
// nameClients gives client entries the host name Jarl's records have for
// the address: the PTR record's target, or else the name of an A/AAAA
// record with the address. It sends no DNS queries, and uses only records
// the user may read. Anonymized clients (prefixes) get no name.
func (s *Server) nameClients(r *http.Request, p *auth.Principal, es []stats.Entry) error {
if len(es) == 0 {
return nil
}
want := map[netip.Addr]bool{}
for _, e := range es {
if a, err := netip.ParseAddr(e.Key); err == nil {
want[a.Unmap()] = true
}
}
if len(want) == 0 {
return nil
}
st, err := s.state(r)
if err != nil {
return err
}
names := clientNames(st, want, func(rec *model.Record) bool { return canReadRecord(p, st, rec) })
for i, e := range es {
if a, err := netip.ParseAddr(e.Key); err == nil {
es[i].Name = names[a.Unmap()]
}
}
return nil
}
// clientNames finds names for the addresses in want (see nameClients).
// Where several records match, the first in name order wins.
func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model.Record) bool) map[netip.Addr]string {
ptr, host := map[netip.Addr]string{}, map[netip.Addr]string{}
var recs []*model.Record
for _, rec := range st.Records {
if !rec.Disabled && (rec.Type == "PTR" || rec.Type == "A" || rec.Type == "AAAA") {
recs = append(recs, rec)
}
}
slices.SortFunc(recs, func(a, b *model.Record) int {
if a.Name != b.Name {
if model.CanonicalLess(a.Name, b.Name) {
return -1
}
return 1
}
return strings.Compare(a.Data, b.Data)
})
for _, rec := range recs {
var a netip.Addr
var name string
if rec.Type == "PTR" {
var ok bool
if a, ok = dnsname.AddrFromReverse(rec.Name); !ok {
continue
}
name = rec.Data
} else {
var err error
if a, err = netip.ParseAddr(rec.Data); err != nil {
continue
}
name = rec.Name
}
a = a.Unmap()
m := host
if rec.Type == "PTR" {
m = ptr
}
if !want[a] || m[a] != "" || !readable(rec) {
continue
}
m[a] = dnsname.Display(name)
}
for a, n := range host {
if ptr[a] == "" {
ptr[a] = n
}
}
return ptr
}
+11
View File
@@ -58,6 +58,17 @@ func TestStatsAPI(t *testing.T) {
if len(tops) != 2 || tops[0]["key"] != "www.example.com" { if len(tops) != 2 || tops[0]["key"] != "www.example.com" {
t.Errorf("top names = %v", tops) t.Errorf("top names = %v", tops)
} }
// Clients get the host name of Jarl's record for their address.
if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != nil {
t.Errorf("unnamed client = %v", c)
}
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
if c := e.list("/stats/top?kind=client&since=2h", e.token); len(c) != 1 || c[0]["name"] != "laptop.example.com" {
t.Errorf("named client = %v", c)
}
if sum := e.do("GET", "/stats/summary?since=2h", e.token, nil, 200); sum["tops"].(map[string]any)["client"].([]any)[0].(map[string]any)["name"] != "laptop.example.com" {
t.Errorf("summary clients = %v", sum["tops"])
}
if b := e.list("/stats/breakdown?group=zone&since=1h", e.token); len(b) != 2 || b[0]["key"] != "example.com" { if b := e.list("/stats/breakdown?group=zone&since=1h", e.token); len(b) != 2 || b[0]["key"] != "example.com" {
t.Errorf("zones = %v", b) t.Errorf("zones = %v", b)
} }
+1 -1
View File
@@ -528,7 +528,7 @@ func (c *Collector) updateWarnings() {
func top(m map[string]int64, n int) []Entry { func top(m map[string]int64, n int) []Entry {
out := make([]Entry, 0, len(m)) out := make([]Entry, 0, len(m))
for k, v := range m { for k, v := range m {
out = append(out, Entry{k, v}) out = append(out, Entry{Key: k, Count: v})
} }
slices.SortFunc(out, func(a, b Entry) int { slices.SortFunc(out, func(a, b Entry) int {
if a.Count != b.Count { if a.Count != b.Count {
+6 -6
View File
@@ -118,7 +118,7 @@ func TestCollector(t *testing.T) {
} }
} }
names, _ := store.TopsAt(ctx, ResHour, m, KindName) names, _ := store.TopsAt(ctx, ResHour, m, KindName)
if len(names) != 3 || names[0] != (Entry{"nas.adbraun.lol", 2}) { if len(names) != 3 || names[0] != (Entry{Key: "nas.adbraun.lol", Count: 2}) {
t.Errorf("hour names = %v", names) t.Errorf("hour names = %v", names)
} }
if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "nope.adbraun.lol" { if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "nope.adbraun.lol" {
@@ -133,7 +133,7 @@ func TestCollector(t *testing.T) {
clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc) clk.t = time.Date(2026, 10, 5, 11, 0, 2, 0, loc)
c.flush(ctx, false) c.flush(ctx, false)
day := time.Date(2026, 10, 5, 0, 0, 0, 0, loc).Unix() day := time.Date(2026, 10, 5, 0, 0, 0, 0, loc).Unix()
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{"nas.adbraun.lol", 3}) { if d, _ := store.TopsAt(ctx, ResDay, day, KindName); len(d) == 0 || d[0] != (Entry{Key: "nas.adbraun.lol", Count: 3}) {
t.Errorf("day names after the hour = %v", d) t.Errorf("day names after the hour = %v", d)
} }
if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 { if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 {
@@ -149,14 +149,14 @@ func TestCollector(t *testing.T) {
c2.readLog() c2.readLog()
clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc) clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc)
c2.flush(ctx, false) c2.flush(ctx, false)
if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{"nas.adbraun.lol", 4}) { if h, _ := store.TopsAt(ctx, ResHour, m, KindName); len(h) == 0 || h[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) {
t.Errorf("hour 10 after late line = %v", h) t.Errorf("hour 10 after late line = %v", h)
} }
if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{"nas.adbraun.lol", 4}) { if d, _ := store.TopsAt(ctx, ResDay, day, KindName); d[0] != (Entry{Key: "nas.adbraun.lol", Count: 4}) {
t.Errorf("day after late line = %v", d) t.Errorf("day after late line = %v", d)
} }
h11 := time.Date(2026, 10, 5, 11, 0, 0, 0, loc).Unix() h11 := time.Date(2026, 10, 5, 11, 0, 0, 0, loc).Unix()
if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{"printer.adbraun.lol", 2}) { if h, _ := store.TopsAt(ctx, ResHour, h11, KindName); len(h) != 1 || h[0] != (Entry{Key: "printer.adbraun.lol", Count: 2}) {
t.Errorf("hour 11 continued after restart = %v", h) t.Errorf("hour 11 continued after restart = %v", h)
} }
@@ -198,7 +198,7 @@ func TestCollector(t *testing.T) {
t.Errorf("heatmap Monday 10h=%v 11h=%v Thu 3h=%v", hm.Avg[0][10], hm.Avg[0][11], hm.Avg[3][3]) t.Errorf("heatmap Monday 10h=%v 11h=%v Thu 3h=%v", hm.Avg[0][10], hm.Avg[0][11], hm.Avg[3][3])
} }
b, _ := q.Breakdown(ctx, "zone", 24*time.Hour) b, _ := q.Breakdown(ctx, "zone", 24*time.Hour)
if len(b) != 2 || b[0] != (Entry{"adbraun.lol", 7}) || b[1] != (Entry{OtherZone, 1}) { if len(b) != 2 || b[0] != (Entry{Key: "adbraun.lol", Count: 7}) || b[1] != (Entry{Key: OtherZone, Count: 1}) {
t.Errorf("zone breakdown = %v", b) t.Errorf("zone breakdown = %v", b)
} }
+1 -1
View File
@@ -128,7 +128,7 @@ func sorted(m map[string]int64, prefix string) []Entry {
out := []Entry{} out := []Entry{}
for k, v := range m { for k, v := range m {
if strings.HasPrefix(k, prefix) && v != 0 { if strings.HasPrefix(k, prefix) && v != 0 {
out = append(out, Entry{strings.TrimPrefix(k, prefix), v}) out = append(out, Entry{Key: strings.TrimPrefix(k, prefix), Count: v})
} }
} }
slices.SortFunc(out, func(a, b Entry) int { slices.SortFunc(out, func(a, b Entry) int {
+3
View File
@@ -135,6 +135,9 @@ type topsWrite struct {
type Entry struct { type Entry struct {
Key string `json:"key"` Key string `json:"key"`
Count int64 `json:"count"` Count int64 `json:"count"`
// Name is a client's host name, from Jarl's own records (set by the
// API for client lists).
Name string `json:"name,omitempty"`
} }
// AddCounters adds values to the counters of a bucket. // AddCounters adds values to the counters of a bucket.
+4 -2
View File
@@ -324,9 +324,11 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
const share = sum ? (e.count / sum) * 100 : 0; const share = sum ? (e.count / sum) * 100 : 0;
const name = label(e.key); const name = label(e.key);
const link = href ? href(e.key) : null; const link = href ? href(e.key) : null;
return h("li", { title: name + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, // Clients Jarl has a host name for: the name, then the address.
const shown = e.name ? [e.name, h("span", { class: "muted" }, " " + name)] : name;
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" }, h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, name) : h("span", { class: "bar-label" }, name), link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))), h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
})); }));
+19 -1
View File
@@ -23,7 +23,7 @@ export async function render(el, params, query) {
lastList.zones = hash; lastList.zones = hash;
const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value)); const rows = zones.filter((z) => (!q || z.name.includes(q)) && (!viewSel.value || z.view === viewSel.value) && (!typeSel.value || z.type === typeSel.value));
mount(list, table([ mount(list, table([
{ label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" }, { label: "Zone", render: (z) => [z.pending === "delete" ? h("span", null, z.display_name) : h("a", { href: "#/zones/" + z.id }, z.display_name), dnssecLock(z), z.pending ? [" ", pendingBadge(z.pending)] : null], tdClass: "name" },
views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null, views.length ? { label: "View", render: (z) => viewLink(viewsById.get(z.view_id), z.view, hash) } : null,
{ label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") }, { label: "Type", render: (z) => badge(z.type, z.type === "primary" ? "accent" : "") },
{ label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" }, { label: "Records", render: (z) => z.type === "primary" ? [String(z.record_count), z.disabled_count ? h("span", { class: "muted small", title: z.disabled_count + " disabled (left out of the zone)" }, " (" + z.disabled_count + " off)") : null] : "", class: "right", tdClass: "right" },
@@ -236,6 +236,24 @@ export function emailOf(rname) {
return m[1].replace(/\\\./g, ".") + "@" + m[2]; return m[1].replace(/\\\./g, ".") + "@" + m[2];
} }
// dnssecLock marks a signed zone with a padlock linking to its DNSSEC tab:
// closed while signed, open while signing is being turned off (BIND's
// insecure policy).
function dnssecLock(z) {
if (!z.dnssec_policy) return null;
const off = z.dnssec_policy === "insecure";
const a = h("a", {
class: "dnssec-lock" + (off ? " off" : ""),
href: "#/zones/" + z.id + "/dnssec",
title: off ? "DNSSEC: signing is being turned off" : "DNSSEC: signed (policy " + z.dnssec_policy + ")",
"aria-label": off ? "DNSSEC being turned off" : "DNSSEC signed",
});
a.innerHTML = '<svg viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="' +
(off ? "M5 7V5a3 3 0 0 1 5.8-1.1" : "M5 7V5a3 3 0 0 1 6 0v2") +
'" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/><rect x="3" y="7" width="10" height="7.5" rx="1.5" fill="currentColor"/></svg>';
return a;
}
export function pendingBadge(kind) { export function pendingBadge(kind) {
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn"); return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
} }
+2
View File
@@ -141,6 +141,8 @@ th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: upp
tr:hover td { background: var(--info-soft); } tr:hover td { background: var(--info-soft); }
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; } td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; } td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
a.dnssec-lock { display: inline-flex; vertical-align: -1px; margin-left: 6px; color: var(--ok); text-decoration: none; }
a.dnssec-lock.off { color: var(--warn); }
tr.same-name-next td { border-bottom-color: color-mix(in srgb, var(--border) 40%, transparent); } tr.same-name-next td { border-bottom-color: color-mix(in srgb, var(--border) 40%, transparent); }
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; } td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
tr.editing td { background: var(--accent-soft); } tr.editing td { background: var(--accent-soft); }