Compare commits
3
Commits
v0.3.0-rc.1
...
v0.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
436cd79ec9 | ||
|
|
c645dae14f | ||
|
|
403f85486d |
No files matched your search
@@ -6,6 +6,8 @@ All notable changes to Jarl are documented here. The format follows
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.0] - 2026-10-09
|
||||
|
||||
### Added
|
||||
|
||||
- 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
|
||||
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
|
||||
|
||||
- The web UI and API can listen on ports below 1024, such as 443:
|
||||
|
||||
@@ -166,10 +166,9 @@ review step.
|
||||
|
||||
## Status
|
||||
|
||||
Version 0.2.0 is the current release, and 0.3.0 is in testing
|
||||
(0.3.0-rc.1). 0.2.0 added DNSSEC signing, Porkbun support, two-factor
|
||||
login and view colours; 0.3.0 adds disabled records and scripting output
|
||||
for the CLI. Jarl is feature-complete for one server's configuration.
|
||||
Version 0.3.0 is the current release. It adds disabled records,
|
||||
script-friendly CLI output and client names in the statistics, on top of
|
||||
0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
|
||||
Managing several servers from one jarld and dynamic updates are not
|
||||
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
|
||||
|
||||
|
||||
+17
-3
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"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])
|
||||
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,9 +157,18 @@ export the data.`,
|
||||
if head == "" {
|
||||
head = "NAME"
|
||||
}
|
||||
fmt.Fprintf(w, "#\t%s\tQUERIES\t\n", head)
|
||||
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)
|
||||
}
|
||||
for i, e := range es {
|
||||
fmt.Fprintf(w, "%d\t%s\t%d\t%s\n", i+1, e.Key, e.Count, bar(e.Count, es[0].Count, 20))
|
||||
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))
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
+1
-1
@@ -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/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/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/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit), `since` | `[{key, count}]` |
|
||||
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
**Web UI**: Statistics. Pick a range (1 hour to 90 days); everything on the
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,14 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"jarl/internal/auth"
|
||||
"jarl/internal/dnsname"
|
||||
"jarl/internal/model"
|
||||
"jarl/internal/stats"
|
||||
)
|
||||
|
||||
@@ -53,6 +57,9 @@ func (s *Server) statsSummary(w http.ResponseWriter, r *http.Request, p *auth.Pr
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.nameClients(r, p, sum.Tops[stats.KindClient]); err != nil {
|
||||
return err
|
||||
}
|
||||
writeJSON(w, http.StatusOK, sum)
|
||||
return nil
|
||||
}
|
||||
@@ -75,6 +82,11 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
|
||||
if err != nil {
|
||||
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)
|
||||
return nil
|
||||
}
|
||||
@@ -145,3 +157,86 @@ func (s *Server) statsBreakdown(w http.ResponseWriter, r *http.Request, p *auth.
|
||||
writeJSON(w, http.StatusOK, es)
|
||||
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
|
||||
}
|
||||
@@ -58,6 +58,17 @@ func TestStatsAPI(t *testing.T) {
|
||||
if len(tops) != 2 || tops[0]["key"] != "www.example.com" {
|
||||
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" {
|
||||
t.Errorf("zones = %v", b)
|
||||
}
|
||||
|
||||
@@ -528,7 +528,7 @@ func (c *Collector) updateWarnings() {
|
||||
func top(m map[string]int64, n int) []Entry {
|
||||
out := make([]Entry, 0, len(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 {
|
||||
if a.Count != b.Count {
|
||||
|
||||
@@ -118,7 +118,7 @@ func TestCollector(t *testing.T) {
|
||||
}
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
c.flush(ctx, false)
|
||||
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)
|
||||
}
|
||||
if v, _ := store.sums(ctx, ResDay, day, day+1, "queries"); v["queries"] != 7 {
|
||||
@@ -149,14 +149,14 @@ func TestCollector(t *testing.T) {
|
||||
c2.readLog()
|
||||
clk.t = time.Date(2026, 10, 5, 11, 3, 2, 0, loc)
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,7 +128,7 @@ func sorted(m map[string]int64, prefix string) []Entry {
|
||||
out := []Entry{}
|
||||
for k, v := range m {
|
||||
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 {
|
||||
|
||||
@@ -135,6 +135,9 @@ type topsWrite struct {
|
||||
type Entry struct {
|
||||
Key string `json:"key"`
|
||||
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.
|
||||
|
||||
@@ -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 name = label(e.key);
|
||||
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" },
|
||||
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("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,7 @@ export async function render(el, params, query) {
|
||||
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));
|
||||
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,
|
||||
{ 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" },
|
||||
@@ -236,6 +236,24 @@ export function emailOf(rname) {
|
||||
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) {
|
||||
return badge({ add: "pending: new", change: "pending: changed", delete: "pending: delete", disable: "pending: disable", enable: "pending: enable" }[kind] || kind, kind === "delete" ? "err" : "warn");
|
||||
}
|
||||
@@ -141,6 +141,8 @@ th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: upp
|
||||
tr:hover td { background: var(--info-soft); }
|
||||
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; }
|
||||
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); }
|
||||
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
||||
tr.editing td { background: var(--accent-soft); }
|
||||
|
||||
Reference in new issue
Block a user