Server statistics, multi-view record commands, and rc.3 fixes

Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
  sockets and client counts against their limits (statistics channel,
  rndc status), host memory and load, and free disk space. Memory and
  CPU are kept over time as gauges (sum and sample count, averaged in
  timelines). Statistics page Server section, jarl stats server,
  GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
  change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
  "Until I resume", {"indefinitely": true}).

Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
  when given without them.
- The dashboard lists named-checkconf's warnings about the running
  configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
  failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 18:03:05 +01:00
1 parent b097e10bef
commit 7be0762f48
48 files changed
+1324 -106

No files matched your search

+28
View File
@@ -8,6 +8,19 @@ All notable changes to Jarl are documented here. The format follows
### Added ### Added
- Server statistics: named's memory (resident, and its own count) and
CPU use, its cache size, sockets, and recursive and TCP clients against
their limits; the host's memory and load; free space where zone files,
logs and Jarl's data are. Memory and CPU are kept over time like the
other statistics. Statistics page → Server, `jarl stats server`, API
`/stats/server`. Read from named's statistics channel, `rndc status` and
/proc, with no extra privileges.
- CLI: the record commands that change records take several views,
`--view internal,external`, or `--view all`, and make the change in each,
as one changeset (all views or none).
- Blocking can be paused until resumed, like Pi-hole's "indefinitely":
`jarl block pause indefinitely`, **Until I resume** on the Blocking page,
`{"indefinitely": true}` in the API.
- Disabled zones: a zone can be disabled instead of deleted. named stops - Disabled zones: a zone can be disabled instead of deleted. named stops
loading it (its zone statement is left out of named.conf), while Jarl loading it (its zone statement is left out of named.conf), while Jarl
keeps it with its records, options and history, still editable; keeps it with its records, options and history, still editable;
@@ -54,6 +67,21 @@ All notable changes to Jarl are documented here. The format follows
within a group. The same option is now in the same place in every view within a group. The same option is now in the same place in every view
and zone. The first apply after upgrading reorders named.conf (a diff and zone. The first apply after upgrading reorders named.conf (a diff
that only moves lines). Top-level statements keep their order. that only moves lines). Top-level statements keep their order.
- Options that end in a list (`allow-transfer`, `forwarders`,
`listen-on`, ...) can be given without their braces, in the web UI, CLI
and API: `10.0.10.53; key xfer` becomes `{ 10.0.10.53; key xfer; }`.
Before, only some server options (those edited one element per line)
added them.
- The dashboard's configuration checks include named-checkconf's own
warnings about the running configuration (such as `both "recursion no;"
and "allow-recursion" active`), which before only showed while applying.
The CLI now prints them after an apply too.
- Statistics page: cards and tiles fill their rows instead of leaving one
alone beside an empty space, and the queries and failed answers charts
have the same margins, so their time axes line up.
- Dashboard: options have their own tile; "0 TSIG keys · 22 options" read
as if the options belonged to the keys.
- Web UI times use the 24-hour clock.
- Diffs of named.conf say which block each change is in, after the `@@` - Diffs of named.conf say which block each change is in, after the `@@`
line numbers (`@@ -50,6 +49,8 @@ view "internal" {`), like git does line numbers (`@@ -50,6 +49,8 @@ view "internal" {`), like git does
with function names. Moving an option from the server's options into a with function names. Moving an option from the server's options into a
+29 -5
View File
@@ -405,23 +405,40 @@ names below it (docs/blocking.md).`}
} }
pause := &cobra.Command{ pause := &cobra.Command{
Use: "pause [DURATION]", Use: "pause [DURATION | indefinitely]",
Short: "Let every name through for a while (default 10m, at most 24h)", Short: "Let every name through for a while (default 10m, at most 24h) or until resumed",
Long: `Pause blocking: every name is answered normally until the time is up, Long: `Pause blocking: every name is answered normally until the time is up,
then blocking resumes by itself. Useful to see whether blocking is what then blocking resumes by itself. Useful to see whether blocking is what
breaks a site. The lists stay loaded, so pausing and resuming take effect breaks a site. The lists stay loaded, so pausing and resuming take effect
at once. at once. "indefinitely" pauses until 'jarl block resume'.
jarl block pause # 10 minutes jarl block pause # 10 minutes
jarl block pause 1h jarl block pause 1h
jarl block pause indefinitely
jarl block resume`, jarl block resume`,
Args: cobra.MaximumNArgs(1), Args: cobra.MaximumNArgs(1),
ValidArgs: []string{"indefinitely", "5m", "10m", "30m", "1h", "24h"},
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 1 && args[0] == "indefinitely" {
c, err := newClient()
if err != nil {
return err
}
resp, err := c.Mutate(ctx(), "POST", "/blocking/pause", nil, map[string]any{"indefinitely": true})
if err != nil {
return err
}
if err := report(resp); err != nil || flagQuiet || !resp.Applied {
return err
}
fmt.Println("Blocking is paused until you resume it ('jarl block resume').")
return nil
}
d := 10 * time.Minute d := 10 * time.Minute
if len(args) == 1 { if len(args) == 1 {
var err error var err error
if d, err = time.ParseDuration(args[0]); err != nil || d < time.Minute { if d, err = time.ParseDuration(args[0]); err != nil || d < time.Minute {
return fmt.Errorf("invalid duration %q (like 10m or 2h)", args[0]) return fmt.Errorf("invalid duration %q (like 10m or 2h, or indefinitely)", args[0])
} }
} }
c, err := newClient() c, err := newClient()
@@ -466,8 +483,15 @@ func pausedNote(c interface {
}) string { }) string {
var st struct { var st struct {
PausedUntil *time.Time `json:"paused_until"` PausedUntil *time.Time `json:"paused_until"`
PausedIndefinitely bool `json:"paused_indefinitely"`
} }
if c.Get(ctx(), "/blocking/status", nil, &st) != nil || st.PausedUntil == nil { if c.Get(ctx(), "/blocking/status", nil, &st) != nil {
return ""
}
if st.PausedIndefinitely {
return "Blocking is paused until you resume it ('jarl block resume').\n"
}
if st.PausedUntil == nil {
return "" return ""
} }
return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n" return "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n"
+12
View File
@@ -530,6 +530,18 @@ func report(resp *api.MutationResponse) error {
json.Unmarshal(resp.Problems, &introduced) json.Unmarshal(resp.Problems, &introduced)
} }
printProblems(os.Stderr, introduced) printProblems(os.Stderr, introduced)
// named-checkconf's own warnings about the new configuration.
if r != nil && (resp.Applied || resp.DryRun) {
for _, m := range r.Check {
if !m.Error && !strings.HasPrefix(m.Text, "loaded serial") && m.Text != "OK" {
where := ""
if m.Object != "" {
where = " (" + m.Object + ")"
}
fmt.Fprintf(os.Stderr, "warning: named-checkconf: %s%s\n", m.Text, where)
}
}
}
for _, warn := range resp.Warnings { for _, warn := range resp.Warnings {
fmt.Fprintln(os.Stderr, "warning:", warn) fmt.Fprintln(os.Stderr, "warning:", warn)
} }
+101
View File
@@ -0,0 +1,101 @@
package main
import (
"fmt"
"os"
"strings"
"jarl/internal/api"
"jarl/internal/client"
)
// zoneViews expands a --view value for a record command: "" or one view
// as it is, "a,b" to those views, and "all" to every view that has the
// zone.
func zoneViews(c *client.Client, zone, view string) ([]string, error) {
if view != "all" && !strings.Contains(view, ",") {
return []string{view}, nil
}
if view != "all" {
var out []string
for _, v := range strings.Split(view, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
return out, nil
}
var zones []api.Zone
if err := c.Get(ctx(), "/zones", nil, &zones); err != nil {
return nil, err
}
want := strings.ToLower(strings.TrimSuffix(zone, "."))
var out []string
for _, z := range zones {
if strings.ToLower(strings.TrimSuffix(z.Display, ".")) == want && z.View != "" {
out = append(out, z.View)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("zone %s is in no view", zone)
}
return out, nil
}
// perView runs a record command once per view. With several views, the
// changes are staged into one changeset and applied together, so either
// every view gets the change or none does (with --changeset, they are
// staged there).
//
// do returns the server's response, or nil when there was nothing to do.
func perView(c *client.Client, views []string, description string, do func(c *client.Client, view string) (*api.MutationResponse, error)) error {
if len(views) == 1 || c.Changeset > 0 {
for _, v := range views {
resp, err := do(c, v)
if err != nil {
if len(views) > 1 {
err = fmt.Errorf("view %s: %w", v, err)
}
return err
}
if resp != nil {
if err := report(resp); err != nil {
return err
}
}
}
return nil
}
var cs api.Changeset
if err := c.Do(ctx(), "POST", "/changesets", nil, map[string]any{"description": description + " (views " + strings.Join(views, ", ") + ")"}, &cs); err != nil {
return err
}
discard := func() { c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/discard", cs.ID), nil, nil, nil) }
staging := *c
staging.Changeset, staging.DryRun = cs.ID, false
staged := 0
for _, v := range views {
resp, err := do(&staging, v)
if err != nil {
discard()
fmt.Fprintf(os.Stderr, "view %s failed, so nothing was changed in any view\n", v)
return err
}
if resp != nil {
staged++
}
}
if staged == 0 {
discard()
return nil
}
var resp api.MutationResponse
err := c.Do(ctx(), "POST", fmt.Sprintf("/changesets/%d/apply", cs.ID), nil, map[string]any{"force": c.Force, "dry_run": c.DryRun}, &resp)
if err != nil || c.DryRun {
discard()
}
if err != nil {
return err
}
return report(&resp)
}
+78 -1
View File
@@ -325,7 +325,61 @@ export the data.`,
} }
sinceFlag(breakdown, &breakdownSince, "24h", sinceHelp) sinceFlag(breakdown, &breakdownSince, "24h", sinceHelp)
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown) server := &cobra.Command{
Use: "server",
Short: "Show named's memory, CPU, cache and clients, the host's memory and load, and free disk space",
Long: `Server statistics, sampled once a minute: named's memory and CPU use,
cache size, recursive and TCP clients against their limits, the host's
memory and load, and the space left where zone files, logs and Jarl's
data are. Memory and CPU over time: 'jarl stats timeline --key
g:named:rss,g:named:cpu' (averages; CPU in tenths of a percent).`,
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
c, err := newClient()
if err != nil {
return err
}
var resp struct {
Server *stats.Server `json:"server"`
Named map[string]string `json:"named,omitempty"`
NamedError string `json:"named_error,omitempty"`
Disks []stats.Disk `json:"disks"`
}
if err := c.Get(ctx(), "/stats/server", nil, &resp); err != nil {
return err
}
return output(resp, func(w io.Writer) {
s := resp.Server
if s == nil {
fmt.Fprintln(w, "No sample yet:\tthe first is taken a minute after jarl starts")
return
}
fmt.Fprintf(w, "named:\t%s, up since %s, reconfigured %s\n", s.Version, isoLocal(s.BootTime), isoLocal(s.ConfigTime))
if s.PID > 0 {
fmt.Fprintf(w, "Memory:\t%s resident (named counts %s), %d threads, PID %d\n", sizeStr(s.RSS), sizeStr(s.MemInUse), s.Threads, s.PID)
fmt.Fprintf(w, "CPU:\t%.1f%% of one CPU over the last minute\n", s.CPUPercent)
} else {
fmt.Fprintf(w, "Memory:\tnamed counts %s (its process isn't visible to jarld)\n", sizeStr(s.MemInUse))
}
fmt.Fprintf(w, "Cache:\t%d RRsets, %s\n", s.CacheRRsets, sizeStr(s.CacheMem))
if n := resp.Named; n != nil {
fmt.Fprintf(w, "Recursive clients:\t%s (now/soft limit/limit), most at once %d\n", n["recursive_clients"], s.RecursionHighwater)
fmt.Fprintf(w, "TCP clients:\t%s (now/limit), most at once %d\n", n["tcp_clients"], s.TCPHighwater)
} else if resp.NamedError != "" {
fmt.Fprintf(w, "Clients:\tunknown: %s\n", resp.NamedError)
}
fmt.Fprintf(w, "Sockets:\t%d UDP, %d TCP\n", s.SocketsUDP, s.SocketsTCP)
if h := s.Host; h != nil && h.MemTotal > 0 {
fmt.Fprintf(w, "Host:\t%s of %s memory in use, load %v, %d CPUs, up %s\n", sizeStr(h.MemTotal-h.MemAvailable), sizeStr(h.MemTotal), h.Load, h.CPUs, (time.Duration(h.Uptime) * time.Second).String())
}
for _, d := range resp.Disks {
fmt.Fprintf(w, "Disk:\t%s free of %s for %s (%s)\n", sizeStr(d.Free), sizeStr(d.Total), strings.Join(d.Holds, ", "), d.Path)
}
})
},
}
cmd.AddCommand(status, summary, top, timeline, heatmap, breakdown, server)
return cmd return cmd
} }
@@ -398,3 +452,26 @@ func sparkline(vals []int64) string {
} }
return sb.String() return sb.String()
} }
// sizeStr formats a size in binary units.
func sizeStr(n int64) string {
units := []string{"B", "KiB", "MiB", "GiB", "TiB"}
f, i := float64(n), 0
for f >= 1024 && i < len(units)-1 {
f /= 1024
i++
}
if i == 0 || f >= 100 {
return fmt.Sprintf("%.0f %s", f, units[i])
}
return fmt.Sprintf("%.1f %s", f, units[i])
}
// isoLocal shows a time named reports (RFC 3339) in local time.
func isoLocal(s string) string {
t, err := time.Parse(time.RFC3339Nano, s)
if err != nil {
return s
}
return t.Local().Format("2006-01-02 15:04")
}
+50 -33
View File
@@ -449,6 +449,10 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
// ---- records // ---- records
// viewsFlagHelp describes --view of the record commands that change
// records.
const viewsFlagHelp = "view of the zone; several (internal,external) or \"all\" make the same change in each, together"
func recordCmd() *cobra.Command { func recordCmd() *cobra.Command {
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"} cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
var view, name, typ, search, comment, ttlArg string var view, name, typ, search, comment, ttlArg string
@@ -531,15 +535,17 @@ and splitting are automatic).
if ptr { if ptr {
body["ptr"] = true body["ptr"] = true
} }
path, qv := zonePath(args[0], view) views, err := zoneViews(c, args[0], view)
resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body)
if err != nil { if err != nil {
return err return err
} }
return report(resp) return perView(c, views, "add "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "POST", path+"/records", qv, body)
})
}, },
} }
add.Flags().StringVar(&view, "view", "", "view of the zone") add.Flags().StringVar(&view, "view", "", viewsFlagHelp)
add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)") add.Flags().StringVar(&ttlArg, "ttl", "", "TTL in seconds (default: the RRset's or the zone's)")
add.Flags().StringVar(&comment, "comment", "", "comment") add.Flags().StringVar(&comment, "comment", "", "comment")
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)") add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
@@ -624,6 +630,11 @@ or one record by ID.
if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) { if len(args) == 3 && !confirm(fmt.Sprintf("Delete all %s records of %s?", strings.ToUpper(args[2]), args[1]), yes) {
return fmt.Errorf("canceled") return fmt.Errorf("canceled")
} }
views, err := zoneViews(c, args[0], view)
if err != nil {
return err
}
return perView(c, views, "delete "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view) path, qv := zonePath(args[0], view)
qv.Set("name", args[1]) qv.Set("name", args[1])
qv.Set("type", args[2]) qv.Set("type", args[2])
@@ -636,14 +647,11 @@ or one record by ID.
if ptr { if ptr {
qv.Set("ptr", "1") qv.Set("ptr", "1")
} }
resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil) return c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
if err != nil { })
return err
}
return report(resp)
}, },
} }
del.Flags().StringVar(&view, "view", "", "view of the zone") del.Flags().StringVar(&view, "view", "", viewsFlagHelp)
del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID") del.Flags().Int64Var(&id, "id", 0, "delete the record with this ID")
del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches") del.Flags().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)") del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
@@ -670,15 +678,17 @@ spaces.
if comment != "" { if comment != "" {
body["comment"] = comment body["comment"] = comment
} }
path, qv := zonePath(args[0], view) views, err := zoneViews(c, args[0], view)
resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
if err != nil { if err != nil {
return err return err
} }
return report(resp) return perView(c, views, "set "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, qv := zonePath(args[0], view)
return c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
})
}, },
} }
set.Flags().StringVar(&view, "view", "", "view of the zone") set.Flags().StringVar(&view, "view", "", viewsFlagHelp)
set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset") set.Flags().StringVar(&ttlArg, "ttl", "", "TTL for the RRset")
set.Flags().StringVar(&comment, "comment", "", "comment for new records") set.Flags().StringVar(&comment, "comment", "", "comment for new records")
@@ -728,30 +738,37 @@ with DATA), or one record by ID.
if len(args) < 3 { if len(args) < 3 {
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id") return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
} }
path, zq := zonePath(args[0], tview) views, err := zoneViews(c, args[0], tview)
for k, v := range zq {
qv[k] = v
}
qv.Set("name", args[1])
qv.Set("type", args[2])
if len(args) > 3 {
qv.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
qv.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, qv, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil
}
if err != nil { if err != nil {
return err return err
} }
return report(resp) return perView(c, views, verb+" "+args[1]+" "+strings.ToUpper(args[2]), func(c *client.Client, view string) (*api.MutationResponse, error) {
path, zq := zonePath(args[0], view)
vq := q()
for k, v := range qv {
vq[k] = v
}
for k, v := range zq {
vq[k] = v
}
vq.Set("name", args[1])
vq.Set("type", args[2])
if len(args) > 3 {
vq.Set("data", strings.Join(args[3:], " "))
}
if tmissing {
vq.Set("missing_ok", "1")
}
resp, err := c.Mutate(ctx(), "POST", path+"/records/"+verb, vq, nil)
if err != nil && tmissing && strings.HasSuffix(err.Error(), "nothing to do") {
fmt.Println("nothing to do")
return nil, nil
}
return resp, err
})
}, },
} }
c.Flags().StringVar(&tview, "view", "", "view of the zone") c.Flags().StringVar(&tview, "view", "", viewsFlagHelp)
c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID") c.Flags().Int64Var(&tid, "id", 0, verb+" the record with this ID")
c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d") c.Flags().BoolVar(&tmissing, "missing-ok", false, "succeed when nothing matches or the records are already "+verb+"d")
c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)") c.Flags().BoolVar(&tptr, "ptr", false, "also "+verb+" the matching PTR record (A/AAAA)")
+1 -1
View File
@@ -148,7 +148,7 @@ func statsOptions(cfg *config.Config) stats.Options {
sc := cfg.Stats sc := cfg.Stats
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN, o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day), RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
Anonymize: sc.AnonymizeClients} Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
for _, p := range sc.ExcludeClients { for _, p := range sc.ExcludeClients {
if pf, err := config.ParsePrefix(p); err == nil { if pf, err := config.ParsePrefix(p); err == nil {
o.Exclude = append(o.Exclude, pf.Masked()) o.Exclude = append(o.Exclude, pf.Masked())
+5 -4
View File
@@ -427,8 +427,8 @@ Needs `blocking:read` (`blocking:write` to change). See
| PATCH / DELETE | `/blocking/rules/{id}` | | | PATCH / DELETE | `/blocking/rules/{id}` | |
| DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain | | DELETE | `/blocking/rules` | `?name=DOMAIN[&view=V][&missing_ok=1]`: the rules for a domain |
| GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` (what applies when blocking isn't paused) | | GET | `/blocking/check` | `?name=DOMAIN`: per view with blocking, `{view, result: blocked\|allowed\|none, entry, rule_id, list}` (what applies when blocking isn't paused) |
| GET | `/blocking/status` | `{paused_until}` (null when not paused) | | GET | `/blocking/status` | `{paused_until, paused_indefinitely}` (null and false when not paused) |
| POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself | | POST | `/blocking/pause` | `{"minutes": 15}` (1 to 1440): let every name through until then; jarld resumes by itself. `{"indefinitely": true}`: until resumed |
| POST | `/blocking/resume` | end a pause now | | POST | `/blocking/resume` | end a pause now |
Lists and rules are configuration like the rest: they can be staged, and Lists and rules are configuration like the rest: they can be staged, and
@@ -489,7 +489,7 @@ An **Operation** is `{"op": "...", "id": 0, "base_version": 0, "data": {...}}`:
| `settings.update` | | settings fields | | `settings.update` | | settings fields |
| `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` | | `blocklist.create` / `blocklist.update` / `blocklist.delete` | blocklist (update/delete) | list fields; views as `views` (names) or `view_ids` |
| `blockrule.create` / `blockrule.update` / `blockrule.delete` | rule (update/delete) | rule fields, likewise | | `blockrule.create` / `blockrule.update` / `blockrule.delete` | rule (update/delete) | rule fields, likewise |
| `blocking.pause` | | `{"until": "2026-10-10T15:04:05Z"}`, or `null` to resume | | `blocking.pause` | | `{"until": "2026-10-10T15:04:05Z"}`, `{"indefinitely": true}`, or `{}` to resume |
Operations later in a batch can refer to objects created earlier in it by Operations later in a batch can refer to objects created earlier in it by
name (zone and view names). One request can therefore create a view, a zone name (zone and view names). One request can therefore create a view, a zone
@@ -499,7 +499,7 @@ in it and its records.
| Method | Path | Notes | | Method | Path | Notes |
|--------|------|-------| |--------|------|-------|
| GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` (with `blocking:read`, while paused) | | GET | `/status` | named status, adoption, drift, open changesets, current problems, object counts; `expiring_tokens` (tokens that expire within 14 days or expired in the last 14: all for administrators, otherwise the caller's); `backup` (administrators: `last_at`, `last_file`, `last_size`, `last_error`, `last_error_at`, `dir`, `warn_after` in seconds, `stale`); `blocklist_errors` (with `blocking:read`: lists whose last update failed, `{name, error, updated_at}`); `blocking_paused_until` or `blocking_paused_indefinitely` (with `blocking:read`, while paused). `problems` include named-checkconf's warnings about the running configuration (`named_warning`) |
| POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) | | POST | `/system/check` | validation + named-checkconf of the current configuration (`options:read`) |
| POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) | | POST | `/system/resync` | `{"dry_run": false, "force": false}`: rewrite every file from the database and reload (admin) |
| GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` | | GET | `/audit` | `?user=&action=&outcome=&object_type=&changeset=&q=&since=&until=&limit=200&before=ID` |
@@ -516,6 +516,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| 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, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) | | GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client 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/server` | | `{server: {rss, mem_in_use, cpu_percent, threads, pid, cache_rrsets, cache_mem, sockets_udp, sockets_tcp, recursion_highwater, tcp_highwater, version, boot_time, config_time, host: {mem_total, mem_available, load, cpus, uptime}}, named: {recursive_clients, tcp_clients, zones, cpus, worker_threads}, disks: [{path, holds, total, free}]}`; gauges over time: `/stats/timeline?key=g:named:rss` (averages, -1 without a sample) |
| GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` | | GET | `/stats/breakdown` | `group` (qtype, rcode, view, zone, answer, transport, rtt, dnssec, cache, ratelimit, blocklist), `since` | `[{key, count}]` |
```sh ```sh
+6 -3
View File
@@ -168,15 +168,18 @@ most blocked names link to this check.
To see whether blocking is what breaks a site, pause it: every name is To see whether blocking is what breaks a site, pause it: every name is
answered normally until the time is up (at most a day), then blocking answered normally until the time is up (at most a day), then blocking
resumes by itself, also if jarld was restarted in between. resumes by itself, also if jarld was restarted in between. A pause can
also last until someone resumes blocking (like Pi-hole's "indefinitely").
```sh ```sh
jarl block pause # 10 minutes; or: jarl block pause 1h jarl block pause # 10 minutes; or: jarl block pause 1h
jarl block pause indefinitely
jarl block resume # end it now jarl block resume # end it now
``` ```
In the web UI, **Pause blocking** is at the top of the Blocking page, and In the web UI, **Pause blocking** is at the top of the Blocking page (with
the dashboard says while blocking is paused. The lists stay loaded (the **Until I resume** among the lengths), and the dashboard says while
blocking is paused. The lists stay loaded (the
rules zone just lets everything through), so pausing and resuming take rules zone just lets everything through), so pausing and resuming take
effect within a second. Pausing and resuming are recorded in the history effect within a second. Pausing and resuming are recorded in the history
like other changes. like other changes.
+24 -1
View File
@@ -273,8 +273,20 @@ jarl record disable --id 1234 # by ID; same for enab
# reverse zones: name records by address; the host name needs no final dot # reverse zones: name records by address; the host name needs no final dot
jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol jarl record add 0.10.10.in-addr.arpa 10.10.0.20 PTR nas.adbraun.lol
# a zone in several views (split horizon): the same change in each
jarl record add adbraun.lol mx2 A 203.0.113.5 --view internal,external
jarl record delete adbraun.lol old A --view all -y
``` ```
**Several views.** When a zone exists in more than one view, the record
commands that change records (`add`, `set`, `delete`, `disable`,
`enable`) take `--view a,b` or `--view all` (every view the zone is in).
The changes are applied together as one changeset: if one view refuses
(the zone isn't in it, a CNAME conflict, ...), no view is changed. With
`--changeset` they are staged there instead. Keep split-horizon data that
differs per view (an internal address, say) to one view at a time.
Names are relative to the zone (`@` is the apex) unless they end with a Names are relative to the zone (`@` is the apex) unless they end with a
dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl dot. Data uses zone file syntax, except that TXT/SPF take plain text: Jarl
adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address adds the quotes and splits long values into 255-byte strings. In reverse zones the name can be the address
@@ -371,12 +383,21 @@ jarl option list [--view V | --zone Z | --toplevel | --all]
jarl option set recursion yes jarl option set recursion yes
jarl option set allow-recursion '{ homelab; localhost; }' jarl option set allow-recursion '{ homelab; localhost; }'
jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }' jarl option set forwarders '{ 1.1.1.1; 9.9.9.9; }'
jarl option set allow-transfer '{ key xfer; }' --zone adbraun.lol jarl option set forwarders 1.1.1.1 9.9.9.9 # the same: braces are added to a list
jarl option set allow-transfer 'key xfer; 10.0.10.53' --zone adbraun.lol
jarl option set response-policy '{ zone "rpz.local"; }' --view internal jarl option set response-policy '{ zone "rpz.local"; }' --view internal
jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel jarl option set logging '{ channel q { file "/var/log/named/query.log" versions 3 size 20m; }; category queries { q; }; }' --toplevel
jarl option unset forwarders jarl option unset forwarders
jarl option describe # every option allowed at this scope jarl option describe # every option allowed at this scope
jarl option describe allow-query-cache # grammar and ARM description jarl option describe allow-query-cache # grammar and ARM description
```
Options that end in a list (`allow-*`, `forwarders`, `listen-on`, ...) may
be given without the braces: elements are separated by `;`, `,` or spaces,
and `key NAME` and `port N` stay with their element. Options are always
listed in one fixed order (see [concepts.md](concepts.md)).
```sh
jarl settings show jarl settings show
jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600 jarl settings set --ns ns1.adbraun.lol.,ns2.adbraun.lol. --contact hostmaster@adbraun.lol --ttl 3600
@@ -432,6 +453,7 @@ jarl stats top names | clients | nxdomain | servfail | refused | blocked | block
jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table] jarl stats timeline --since 6h [--key rcode:NXDOMAIN] [--table]
jarl stats heatmap --since 28d jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist jarl stats breakdown qtype | rcode | view | zone | answer | rtt | blocklist
jarl stats server # named's memory, CPU, cache, clients; host; disk space
``` ```
Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data. Statistics must be enabled in `jarl.yaml`, and `-o json` exports the data.
@@ -452,6 +474,7 @@ jarl block list
jarl block delete DOMAIN [--view V] | --id ID jarl block delete DOMAIN [--view V] | --id ID
jarl block check DOMAIN # blocked? by which rule or list? jarl block check DOMAIN # blocked? by which rule or list?
jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h) jarl block pause [DURATION] # let everything through for a while (default 10m, at most 24h)
jarl block pause indefinitely # ... until 'jarl block resume'
jarl block resume jarl block resume
``` ```
+25 -1
View File
@@ -104,6 +104,26 @@ lists, views, zones). named's own counters can't be filtered.
| `responses` | response log | lines read (a health check) | | `responses` | response log | lines read (a health check) |
| `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) | | `blocked`, `blocklist:NAME` | response log | queries [blocking](blocking.md) answered, and by which list (`blocklist:rules` for your own rules) |
**Server** (sampled every minute; stored as gauges, `g:KEY` the sum of
the samples and `gn:KEY` their number, so a chart shows the average):
| Gauge | Source | Meaning |
|-------|--------|---------|
| `g:named:rss` | /proc | named's resident memory (what the system sees), bytes |
| `g:named:mem` | named | the memory named counts itself as using, bytes |
| `g:named:cpu` | /proc | named's CPU use over the minute, in tenths of a percent of one CPU |
| `g:cache:mem`, `g:cache:rrsets` | named | the cache's memory and RRsets, all views |
| `g:host:mem-used`, `g:host:load` | /proc | the host's memory in use, and its 1-minute load average times 100 |
Jarl finds named's process by name (the one whose command line mentions
named's main configuration file); reading its memory and CPU time needs no
privileges. With `/proc` mounted `hidepid`, it isn't visible and those
figures are left out. The current values also include named's sockets, its
recursive and TCP clients against their limits (`rndc status`), the most
at once since it started, and the space left on the file systems holding
the zone files, Jarl's cache, named.conf, the response log and this
database.
**Top lists** (per hour and per day, the `top_n` largest): `name` (most **Top lists** (per hour and per day, the `top_n` largest): `name` (most
queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail` queried names), `client`, `nxdomain` (names answered NXDOMAIN), `servfail`
(names answered SERVFAIL), `refused` (clients that were refused), `blocked` (names answered SERVFAIL), `refused` (clients that were refused), `blocked`
@@ -131,7 +151,9 @@ prefixes and get no name.
page follows it. Charts have a hover readout (or focus them and use the page follows it. Charts have a hover readout (or focus them and use the
arrow keys) and a **Table** button showing the same data as a table. The arrow keys) and a **Table** button showing the same data as a table. The
heatmap shows the average queries per hour for each weekday and hour over at heatmap shows the average queries per hour for each weekday and hour over at
least the last 7 days. The page refreshes every minute. least the last 7 days. The page refreshes every minute. **Server**, at the
bottom, shows named's memory, CPU, cache and clients, the host and the
disks now, and named's memory and CPU over the range.
**CLI**: **CLI**:
@@ -146,6 +168,8 @@ jarl stats timeline --since 6h # sparkline; --table for every bucket
jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL jarl stats timeline --since 7d --key rcode:NXDOMAIN --key rcode:SERVFAIL
jarl stats heatmap --since 28d jarl stats heatmap --since 28d
jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit jarl stats breakdown qtype | rcode | view | zone | answer | rtt | dnssec | cache | transport | ratelimit
jarl stats server # memory, CPU, cache, clients, host, disks now
jarl stats timeline --since 7d --key g:named:rss --table # named's memory over a week
jarl stats summary --since 30d -o json # export (also -o yaml) jarl stats summary --since 30d -o json # export (also -o yaml)
``` ```
+7
View File
@@ -132,3 +132,10 @@ values Jarl accepts as raw named.conf syntax, such as `logging` channels
and `rrset-order`, are the main example. Their errors come back as and `rrset-order`, are the main example. Their errors come back as
`validation_failed` (HTTP 422) with the checker's messages in `check`, each `validation_failed` (HTTP 422) with the checker's messages in `check`, each
tied to the object it belongs to. tied to the object it belongs to.
named-checkconf's warnings don't block a change. They are shown with the
change, and the dashboard's configuration checks list them for as long as
they apply (`named_warning`; `named_error` if the running configuration no
longer passes, after a hand edit say). Jarl runs named-checkconf on the
running configuration for this, without loading zones, when the dashboard
asks and the configuration changed, or at most every 10 minutes.
+3 -1
View File
@@ -249,8 +249,10 @@ type StatusResponse struct {
// BlocklistErrors are blocklists whose last update failed (for those // BlocklistErrors are blocklists whose last update failed (for those
// who may see blocking). // who may see blocking).
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"` BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
// BlockingPausedUntil is set while blocking is paused. // BlockingPausedUntil is set while blocking is paused for a time,
// BlockingPausedIndefinitely while it is paused until resumed.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
} }
// BlocklistError is a blocklist whose last update failed. UpdatedAt is // BlocklistError is a blocklist whose last update failed. UpdatedAt is
+15
View File
@@ -340,6 +340,13 @@ type ServerStatus struct {
ConfigFile string `json:"config_file"` ConfigFile string `json:"config_file"`
Zones string `json:"zones"` Zones string `json:"zones"`
Running bool `json:"running"` Running bool `json:"running"`
// RecursiveClients is "now/soft limit/hard limit" (the soft limit is
// where named starts dropping the oldest queries), TCPClients
// "now/limit".
RecursiveClients string `json:"recursive_clients,omitempty"`
TCPClients string `json:"tcp_clients,omitempty"`
CPUs string `json:"cpus,omitempty"`
WorkerThreads string `json:"worker_threads,omitempty"`
Raw string `json:"raw"` Raw string `json:"raw"`
} }
@@ -363,6 +370,14 @@ func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
st.ConfigFile = v st.ConfigFile = v
case ok && k == "number of zones": case ok && k == "number of zones":
st.Zones = v st.Zones = v
case ok && k == "recursive clients":
st.RecursiveClients = v
case ok && k == "tcp clients":
st.TCPClients = v
case ok && k == "CPUs found":
st.CPUs = v
case ok && k == "worker threads":
st.WorkerThreads = v
case strings.Contains(line, "server is up and running"): case strings.Contains(line, "server is up and running"):
st.Running = true st.Running = true
} }
+77
View File
@@ -427,3 +427,80 @@ func classify(name, grammar string) (string, []string) {
} }
return KindRaw, nil return KindRaw, nil
} }
// listParam matches an optional parameter before a statement's list, such
// as "[ port <integer> ]" in allow-transfer's grammar.
var listParam = regexp.MustCompile(`\[ ([a-z0-9-]+) `)
// WrapList adds the braces to a list value given without them, for
// statements that end in a list ("allow-transfer [ port <integer> ] {
// <address_match_element>; ... }"): "10.0.0.53" becomes "{ 10.0.0.53; }",
// "port 5353 10.0.0.53; key xfer" becomes "port 5353 { 10.0.0.53; key
// xfer; }". Elements are separated by ";", "," or new lines, or by spaces
// when they are all addresses. Values with a brace, and values of other
// statements, are returned as they are.
func (st *Statement) WrapList(v string) string {
g, _, _ := strings.Cut(st.Grammar, "\n")
open := strings.Index(g, " {")
if strings.Contains(v, "{") || open < 0 || !strings.HasSuffix(g, "; ... };") {
return v
}
params := map[string]bool{}
for _, m := range listParam.FindAllStringSubmatch(g[:open], -1) {
params[m[1]] = true
}
fields := strings.Fields(v)
i := 0
for i+1 < len(fields) && params[strings.ToLower(fields[i])] {
i += 2
}
head := strings.Join(fields[:i], " ")
rest := strings.Join(fields[i:], " ")
var elems []string
if !strings.ContainsAny(rest, ";,\n") {
elems = spaceElements(fields[i:])
} else {
for _, e := range strings.FieldsFunc(rest, func(r rune) bool { return r == ';' || r == ',' || r == '\n' }) {
if e = strings.TrimSpace(e); e != "" {
elems = append(elems, e)
}
}
}
if len(elems) == 0 {
return v
}
list := "{ " + strings.Join(elems, "; ") + "; }"
if head != "" {
return head + " " + list
}
return list
}
// spaceElements splits list elements separated by spaces: "key NAME"
// is one element, and "port N" and "tls NAME" belong to the address
// before them (forwarders { 192.0.2.1 port 53; }).
func spaceElements(fields []string) []string {
var out []string
for i := 0; i < len(fields); i++ {
f := fields[i]
switch strings.ToLower(f) {
case "key", "!key", "!":
if i+1 < len(fields) {
f += " " + fields[i+1]
i++
}
if f[0] == '!' && strings.HasPrefix(strings.ToLower(f), "! key") && i+1 < len(fields) {
f += " " + fields[i+1]
i++
}
case "port", "tls":
if len(out) > 0 && i+1 < len(fields) {
out[len(out)-1] += " " + f + " " + fields[i+1]
i++
continue
}
}
out = append(out, f)
}
return out
}
+25
View File
@@ -77,3 +77,28 @@ func TestCatalog(t *testing.T) {
t.Errorf("zone types = %s", types) t.Errorf("zone types = %s", types)
} }
} }
func TestWrapList(t *testing.T) {
c := Default()
for _, tc := range []struct{ scope, name, in, want string }{
{ScopeOptions, "allow-transfer", "10.0.10.53", "{ 10.0.10.53; }"},
{ScopeOptions, "allow-transfer", "localhost 10.0.10.53 key xfer ! key old !10.0.0.1", "{ localhost; 10.0.10.53; key xfer; ! key old; !10.0.0.1; }"},
{ScopeOptions, "forwarders", "1.1.1.1 port 853 tls cf 9.9.9.9", "{ 1.1.1.1 port 853 tls cf; 9.9.9.9; }"},
{ScopeOptions, "allow-transfer", "localhost; 10.0.10.53", "{ localhost; 10.0.10.53; }"},
{ScopeOptions, "allow-transfer", "port 5353 key xfer, !10.0.0.1", "port 5353 { key xfer; !10.0.0.1; }"},
{ScopeOptions, "allow-transfer", "{ any; }", "{ any; }"},
{ScopeOptions, "allow-query", "10.0.0.0/8 192.168.1.0/24", "{ 10.0.0.0/8; 192.168.1.0/24; }"},
{ScopeOptions, "forwarders", "1.1.1.1 9.9.9.9", "{ 1.1.1.1; 9.9.9.9; }"},
{ScopeOptions, "listen-on", "port 53 10.0.10.5", "port 53 { 10.0.10.5; }"},
{ScopeOptions, "recursion", "yes", "yes"},
{ScopeOptions, "directory", `"/var/cache/bind"`, `"/var/cache/bind"`},
} {
st, ok := c.Lookup(tc.scope, tc.name)
if !ok {
t.Fatalf("%s not found", tc.name)
}
if got := st.WrapList(tc.in); got != tc.want {
t.Errorf("%s %q = %q, want %q", tc.name, tc.in, got, tc.want)
}
}
}
+16
View File
@@ -275,6 +275,22 @@ func TestBlockingPause(t *testing.T) {
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil { if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil) t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
} }
// Until resumed: jarld never ends it, a resume does.
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Indefinitely: true}))
if res.Ops[0].Summary != "pause blocking until resumed" || !env.state().Settings.BlockingPaused() {
t.Errorf("indefinite pause: %q", res.Ops[0].Summary)
}
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*\tCNAME\trpz-passthru.\n") {
t.Errorf("rules zone while paused indefinitely:\n%s", f)
}
env.e.Now = func() time.Time { return until.Add(1000 * time.Hour) }
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || !env.state().Settings.BlockingPausedIndefinitely {
t.Errorf("an indefinite pause ended by itself: %v", got)
}
env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{}))
if env.state().Settings.BlockingPaused() {
t.Error("resume didn't end an indefinite pause")
}
// At most a day. // At most a day.
long := env.e.Now().Add(48 * time.Hour) long := env.e.Now().Add(48 * time.Hour)
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest { if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
+5 -1
View File
@@ -282,9 +282,13 @@ func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error {
return err return err
} }
if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute { if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute {
return errf(KindBadRequest, "blocking can be paused for at most %s; disable lists to stop using them", MaxBlockingPause) return errf(KindBadRequest, "blocking can be paused for at most %s, or until it is resumed", MaxBlockingPause)
}
if in.Indefinitely && in.Until != nil {
return errf(KindBadRequest, "a pause is either until a time or until resumed, not both")
} }
st.Settings.BlockingPausedUntil = in.Until st.Settings.BlockingPausedUntil = in.Until
st.Settings.BlockingPausedIndefinitely = in.Indefinitely
return nil return nil
} }
+3 -3
View File
@@ -17,8 +17,8 @@ func DiffOps(from, to *model.State) []model.Op {
add := func(kind string, id int64, base int, data any, summary string) { add := func(kind string, id int64, base int, data any, summary string) {
ops = append(ops, newOp(kind, id, base, data, summary)) ops = append(ops, newOp(kind, id, base, data, summary))
} }
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) { if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPausedUntil != nil]) add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil, Indefinitely: to.Settings.BlockingPausedIndefinitely}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPaused()])
} }
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) { if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
s := to.Settings s := to.Settings
@@ -187,7 +187,7 @@ func DiffOps(from, to *model.State) []model.Op {
func stripVersion(s model.Settings) model.Settings { func stripVersion(s model.Settings) model.Settings {
s.Version = 0 s.Version = 0
s.BlockingPausedUntil = nil // its own operation s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
return s return s
} }
+11 -1
View File
@@ -73,6 +73,8 @@ type Engine struct {
blKick chan struct{} // a list was added or changed blKick chan struct{} // a list was added or changed
HTTP *http.Client // for downloads; nil: a default client HTTP *http.Client // for downloads; nil: a default client
Limits BlocklistLimits Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
} }
// New creates an engine. // New creates an engine.
@@ -1175,6 +1177,14 @@ type Status struct {
MainConfig string `json:"main_config"` MainConfig string `json:"main_config"`
} }
// NamedStatus asks named for its status (rndc status).
func (e *Engine) NamedStatus(ctx context.Context) (*bind.ServerStatus, error) {
if e.cfg.NoReload || e.tools == nil {
return nil, errors.New("named is not managed by this jarld (no_reload)")
}
return e.tools.Status(ctx)
}
// Status returns the current status. // Status returns the current status.
func (e *Engine) Status(ctx context.Context) (*Status, error) { func (e *Engine) Status(ctx context.Context) (*Status, error) {
st, err := e.store.LoadState(ctx) st, err := e.store.LoadState(ctx)
@@ -1218,7 +1228,7 @@ func (e *Engine) Status(ctx context.Context) (*Status, error) {
} }
} }
} }
s.Problems = nonNilProblems(e.validate(st)) s.Problems = nonNilProblems(append(e.validate(st), e.namedProblems(ctx, st, deployed)...))
s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)} s.Counts = map[string]int{"views": len(st.Views), "zones": len(st.Zones), "records": len(st.Records), "acls": len(st.ACLs), "tsig_keys": len(st.Keys), "options": len(st.Options)}
return s, nil return s, nil
} }
+2
View File
@@ -248,6 +248,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
summary := "resume blocking" summary := "resume blocking"
if in.Until != nil { if in.Until != nil {
summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04") summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04")
} else if in.Indefinitely {
summary = "pause blocking until resumed"
} }
return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil
case OpBlockRuleCreate: case OpBlockRuleCreate:
+2
View File
@@ -252,6 +252,8 @@ type BlockRuleInput struct {
// BlockingPauseInput pauses blocking until a time, or resumes it (null). // BlockingPauseInput pauses blocking until a time, or resumes it (null).
type BlockingPauseInput struct { type BlockingPauseInput struct {
Until *time.Time `json:"until"` Until *time.Time `json:"until"`
// Indefinitely pauses until someone resumes blocking (Until is nil).
Indefinitely bool `json:"indefinitely,omitempty"`
} }
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by // MaxBlockingPause is the longest pause: blocking shouldn't stay off by
+77
View File
@@ -0,0 +1,77 @@
package engine
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"jarl/internal/model"
"jarl/internal/render"
"jarl/internal/validate"
)
// namedCheckTTL is how long the result of checking named's running
// configuration is kept when nothing changed.
const namedCheckTTL = 10 * time.Minute
// namedCheck caches the warnings of named-checkconf on the configuration
// named runs, keyed by what it depends on.
type namedCheck struct {
mu sync.Mutex
key string
at time.Time
problems []validate.Problem
}
// namedProblems runs named-checkconf on the configuration named runs
// (without loading zones, which is quick) and returns what it reports as
// problems. An apply shows these too, but they are named's own checks,
// which Jarl doesn't repeat ("both recursion no and allow-recursion
// active"): the dashboard lists them with Jarl's so they don't go unseen
// after the apply. The result is reused while the deployed files and
// named's main file are unchanged.
func (e *Engine) namedProblems(ctx context.Context, st *model.State, deployed map[string]*model.DeployedFile) []validate.Problem {
if e.tools == nil || e.cfg.SkipCheck || e.cfg.MainConfig == "" || len(deployed) == 0 {
return nil
}
key := ""
if fi, err := os.Stat(e.cfg.MainConfig); err == nil {
key = fi.ModTime().String()
}
if f := deployed[e.cfg.Paths.MainConfig()]; f != nil {
key += "|" + f.SHA256
}
c := &e.namedCheck
c.mu.Lock()
defer c.mu.Unlock()
if c.key == key && e.Now().Sub(c.at) < namedCheckTTL {
return c.problems
}
res, err := e.tools.CheckConfig(ctx, e.cfg.MainConfig, false)
if err != nil {
return nil // named-checkconf couldn't run; an apply reports that
}
out := render.Render(st, e.cfg.Paths, e.cfg.Extras)
var ps []validate.Problem
for _, m := range res.Messages {
p := validate.Problem{Severity: validate.Warning, Code: "named_warning", View: m.View, Zone: m.Zone}
if m.Error {
p.Severity, p.Code = validate.Error, "named_error"
}
where := ""
if m.File != "" {
where = fmt.Sprintf(" (%s:%d)", m.File, m.Line)
}
if span, ok := out.Lookup(m.File, m.Line); ok {
p.ObjectType, p.ObjectID = span.ObjectType, span.ObjectID
where = " (" + span.Label + ", " + where[2:]
}
p.Message = "named-checkconf: " + strings.TrimSpace(m.Text) + where
ps = append(ps, p)
}
c.key, c.at, c.problems = key, e.Now(), ps
return ps
}
+2
View File
@@ -1127,6 +1127,8 @@ func NormalizeOption(st *model.State, scope string, scopeID int64, name, value s
if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") { if (stmt.Kind == catalog.KindString) && !strings.HasPrefix(v, `"`) && !strings.ContainsAny(v, " \t{};") {
v = namedconf.QuoteString(v) v = namedconf.QuoteString(v)
} }
// Lists may be given without their braces: "10.0.0.53; key xfer".
v = stmt.WrapList(v)
parsed, err := namedconf.ParseOptionValue(n, v) parsed, err := namedconf.ParseOptionValue(n, v)
if err != nil { if err != nil {
return "", "", errf(KindBadRequest, "option %s: %v", n, err) return "", "", errf(KindBadRequest, "option %s: %v", n, err)
+22
View File
@@ -0,0 +1,22 @@
package engine
import (
"testing"
"jarl/internal/model"
)
func TestNormalizeOptionWrapsLists(t *testing.T) {
st := model.NewState()
for in, want := range map[string]string{
"10.0.10.53": "{ 10.0.10.53; }",
"localhost; key xfer;": "{ localhost; key xfer; }",
"port 53 { any; }": "port 53 { any; }",
"localhost 10.0.10.53": "{ localhost; 10.0.10.53; }",
} {
_, v, err := NormalizeOption(st, model.ScopeOptions, 0, "allow-transfer", in)
if err != nil || v != want {
t.Errorf("%q: %q, %v; want %q", in, v, err, want)
}
}
}
+8
View File
@@ -214,9 +214,17 @@ type Settings struct {
// BlockingPausedUntil, while set, lets every name through blocking // BlockingPausedUntil, while set, lets every name through blocking
// (docs/blocking.md); jarld clears it when the time is up. // (docs/blocking.md); jarld clears it when the time is up.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"` BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
Version int `json:"version"` Version int `json:"version"`
} }
// BlockingPaused reports whether blocking is paused, for a time or until
// resumed.
func (s *Settings) BlockingPaused() bool {
return s.BlockingPausedUntil != nil || s.BlockingPausedIndefinitely
}
// DefaultSettings returns the settings of a fresh installation. // DefaultSettings returns the settings of a fresh installation.
func DefaultSettings() Settings { func DefaultSettings() Settings {
return Settings{ return Settings{
+2 -2
View File
@@ -72,7 +72,7 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
} }
// While paused, every view with blocking gets a rules zone: it // While paused, every view with blocking gets a rules zone: it
// lets everything through. // lets everything through.
if hasRules || s.Settings.BlockingPausedUntil != nil && len(zones) > 0 { if hasRules || s.Settings.BlockingPaused() && len(zones) > 0 {
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)}) out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
} }
out = append(out, zones...) out = append(out, zones...)
@@ -148,7 +148,7 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
title += " for view " + z.View title += " for view " + z.View
} }
content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID)) content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))
if s.Settings.BlockingPausedUntil != nil { if s.Settings.BlockingPaused() {
content = blocking.PausedZoneFile(title) content = blocking.PausedZoneFile(title)
} }
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy}) out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy})
+9 -3
View File
@@ -376,24 +376,30 @@ func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.
if err != nil { if err != nil {
return err return err
} }
writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil}) writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil, "paused_indefinitely": st.Settings.BlockingPausedIndefinitely})
return nil return nil
} }
// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name // pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name
// through until then; POST /blocking/resume ends it. // through until then, {"indefinitely": true} until resumed; POST
// /blocking/resume ends it.
func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error { func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error { return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.BlockingPauseInput var in engine.BlockingPauseInput
if !resume { if !resume {
var body struct { var body struct {
Minutes int `json:"minutes"` Minutes int `json:"minutes"`
Indefinitely bool `json:"indefinitely"`
} }
if err := decodeJSON(r, &body); err != nil { if err := decodeJSON(r, &body); err != nil {
return err return err
} }
if body.Indefinitely {
in.Indefinitely = true
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
}
if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause { if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause {
return badRequest("pause for 1 to %d minutes", int(engine.MaxBlockingPause/time.Minute)) return badRequest("pause for 1 to %d minutes, or indefinitely", int(engine.MaxBlockingPause/time.Minute))
} }
until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second) until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second)
in.Until = &until in.Until = &until
+1
View File
@@ -283,6 +283,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
resp.BlocklistErrors = s.blocklistErrors(r.Context()) resp.BlocklistErrors = s.blocklistErrors(r.Context())
if st, err := s.store.LoadState(r.Context()); err == nil { if st, err := s.store.LoadState(r.Context()); err == nil {
resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil
resp.BlockingPausedIndefinitely = st.Settings.BlockingPausedIndefinitely
} }
} }
writeJSON(w, http.StatusOK, resp) writeJSON(w, http.StatusOK, resp)
+32
View File
@@ -3,6 +3,7 @@ package server
import ( import (
"net/http" "net/http"
"net/netip" "net/netip"
"path/filepath"
"slices" "slices"
"strings" "strings"
"time" "time"
@@ -240,3 +241,34 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
} }
return ptr return ptr
} }
// statsServer: GET /stats/server is the latest server statistics (named's
// memory, CPU, cache and sockets, the host), named's client counts and
// limits (rndc status), and the space left on the file systems Jarl and
// named write to.
func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if _, err := s.statsQuerier(p); err != nil {
return err
}
out := map[string]any{"server": s.cfg.Stats.Server()}
if ns, err := s.eng.NamedStatus(r.Context()); err == nil {
out["named"] = map[string]string{"recursive_clients": ns.RecursiveClients, "tcp_clients": ns.TCPClients, "zones": ns.Zones,
"cpus": ns.CPUs, "worker_threads": ns.WorkerThreads, "last_configured": ns.LastConfigured}
} else {
out["named_error"] = err.Error()
}
paths := s.eng.Config().Paths
dirs := map[string]string{"zone files": paths.ZoneDir, "Jarl's cache and blocklists": paths.CacheDir, "named.conf": paths.ConfigDir}
labels := []string{"zone files", "Jarl's cache and blocklists", "named.conf"}
if lf := s.cfg.Stats.Options().LogFile; lf != "" {
dirs["named's logs"] = filepath.Dir(lf)
labels = append(labels, "named's logs")
}
if db := s.cfg.Stats.Store().Path(); db != "" {
dirs["statistics database"] = filepath.Dir(db)
labels = append(labels, "statistics database")
}
out["disks"] = stats.DiskUsage(labels, dirs)
writeJSON(w, http.StatusOK, out)
return nil
}
+1
View File
@@ -156,6 +156,7 @@ func (s *Server) routes() {
m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline)) m.HandleFunc("GET "+p+"/stats/timeline", s.authed(s.statsTimeline))
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap)) m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown)) m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) { m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path) writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
}) })
+16 -1
View File
@@ -47,6 +47,9 @@ type Options struct {
Anonymize bool Anonymize bool
Exclude []netip.Prefix Exclude []netip.Prefix
Location *time.Location // for days and log timestamps (default: local time) Location *time.Location // for days and log timestamps (default: local time)
// MainConfig is named's main configuration file; it tells named's
// process apart from other named processes (for its memory and CPU).
MainConfig string
// EnsureLogging turns named's response logging on (rndc responselog // EnsureLogging turns named's response logging on (rndc responselog
// on). It is called at start and when named answers queries but no // on). It is called at start and when named answers queries but no
// log lines arrive. // log lines arrive.
@@ -107,6 +110,10 @@ type Collector struct {
// rewritten holds queries named blocked (client#port|name) until their // rewritten holds queries named blocked (client#port|name) until their
// response line arrives. // response line arrives.
rewritten map[string]bool rewritten map[string]bool
// proc is the last sample of named's process, server the latest server
// statistics (server.go).
proc *namedProc
server *Server
} }
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first. // maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
@@ -235,13 +242,17 @@ func (c *Collector) start(ctx context.Context) {
} }
c.ensureLogging(ctx) c.ensureLogging(ctx)
// The first poll is the baseline for the counters. // The first poll is the baseline for the counters.
if snap, err := c.poller.Poll(ctx); err == nil { snap, err := c.poller.Poll(ctx)
if err == nil {
c.prev = snap c.prev = snap
t := c.now() t := c.now()
c.status.LastPoll = &t c.status.LastPoll = &t
} else { } else {
c.status.PollError = err.Error() c.status.PollError = err.Error()
} }
c.mu.Lock()
c.sampleServer(-1, c.now(), snap)
c.mu.Unlock()
c.prune(ctx) c.prune(ctx)
c.log.Info("statistics collector started", "query_log", c.opts.QueryLog, "statistics_channel", c.opts.StatisticsChannel) c.log.Info("statistics collector started", "query_log", c.opts.QueryLog, "statistics_channel", c.opts.StatisticsChannel)
} }
@@ -470,6 +481,10 @@ func (c *Collector) flush(ctx context.Context, final bool) {
} }
c.prev = snap c.prev = snap
} }
if !final {
// Gauges are sampled once a minute, for the minute that ended.
c.sampleServer(cur-60, now, snap)
}
b := &Batch{} b := &Batch{}
first := c.status.FirstData == nil first := c.status.FirstData == nil
for m, vals := range c.minutes { for m, vals := range c.minutes {
+6
View File
@@ -0,0 +1,6 @@
//go:build !unix
package stats
// DiskUsage is not available on this system.
func DiskUsage(labels []string, paths map[string]string) []Disk { return nil }
+39
View File
@@ -0,0 +1,39 @@
//go:build unix
package stats
import (
"fmt"
"os"
"syscall"
)
// DiskUsage returns the space on the file systems holding paths (label ->
// directory), one entry per file system with the labels of the paths on
// it, in the order of labels.
func DiskUsage(labels []string, paths map[string]string) []Disk {
var out []Disk
byDev := map[string]int{}
for _, l := range labels {
p := paths[l]
fi, err := os.Stat(p)
if err != nil {
continue
}
dev := fmt.Sprint(p)
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
dev = fmt.Sprint(st.Dev)
}
if i, ok := byDev[dev]; ok {
out[i].Holds = append(out[i].Holds, l)
continue
}
var fs syscall.Statfs_t
if err := syscall.Statfs(p, &fs); err != nil {
continue
}
byDev[dev] = len(out)
out = append(out, Disk{Path: p, Holds: []string{l}, Total: int64(fs.Blocks) * int64(fs.Bsize), Free: int64(fs.Bavail) * int64(fs.Bsize)})
}
return out
}
+30 -4
View File
@@ -15,11 +15,22 @@ import (
type Snapshot struct { type Snapshot struct {
BootTime string BootTime string
Values map[string]int64 Values map[string]int64
// Gauges are named's current values (memory, cache size, sockets),
// not counters.
Gauges map[string]int64
Version string
ConfigTime string
} }
// serverJSON is the part of /json/v1/server Jarl uses. // serverJSON is the part of /json/v1 Jarl uses.
type serverJSON struct { type serverJSON struct {
BootTime string `json:"boot-time"` BootTime string `json:"boot-time"`
ConfigTime string `json:"config-time"`
Version string `json:"version"`
SockStats map[string]int64 `json:"sockstats"`
Memory struct {
InUse int64 `json:"InUse"`
} `json:"memory"`
Opcodes map[string]int64 `json:"opcodes"` Opcodes map[string]int64 `json:"opcodes"`
RCodes map[string]int64 `json:"rcodes"` RCodes map[string]int64 `json:"rcodes"`
QTypes map[string]int64 `json:"qtypes"` QTypes map[string]int64 `json:"qtypes"`
@@ -28,6 +39,7 @@ type serverJSON struct {
Resolver struct { Resolver struct {
Stats map[string]int64 `json:"stats"` Stats map[string]int64 `json:"stats"`
CacheStats map[string]int64 `json:"cachestats"` CacheStats map[string]int64 `json:"cachestats"`
Cache map[string]int64 `json:"cache"` // RRsets in the cache by type
} `json:"resolver"` } `json:"resolver"`
} `json:"views"` } `json:"views"`
} }
@@ -77,7 +89,21 @@ func Flatten(body []byte) (*Snapshot, error) {
v[name] = n v[name] = n
} }
} }
g := map[string]int64{"named:mem": j.Memory.InUse,
"named:recursion-highwater": j.NSStats["RecursHighwater"], "named:tcp-highwater": j.NSStats["TCPConnHighWater"]}
for k, n := range j.SockStats {
switch {
case strings.HasPrefix(k, "UDP") && strings.HasSuffix(k, "Active"):
g["sockets:udp"] += n
case strings.HasPrefix(k, "TCP") && strings.HasSuffix(k, "Active"):
g["sockets:tcp"] += n
}
}
for _, view := range j.Views { for _, view := range j.Views {
g["cache:mem"] += view.Resolver.CacheStats["TreeMemInUse"] + view.Resolver.CacheStats["HeapMemInUse"]
for _, n := range view.Resolver.Cache {
g["cache:rrsets"] += n
}
for k, name := range rttKeys { for k, name := range rttKeys {
v[name] += view.Resolver.Stats[k] v[name] += view.Resolver.Stats[k]
} }
@@ -92,7 +118,7 @@ func Flatten(body []byte) (*Snapshot, error) {
delete(v, k) delete(v, k)
} }
} }
return &Snapshot{BootTime: j.BootTime, Values: v}, nil return &Snapshot{BootTime: j.BootTime, Values: v, Gauges: g, Version: j.Version, ConfigTime: j.ConfigTime}, nil
} }
// Delta returns the change from prev to cur. When named restarted (its // Delta returns the change from prev to cur. When named restarted (its
@@ -120,13 +146,13 @@ func Delta(prev, cur *Snapshot) map[string]int64 {
// Poller reads named's statistics channel. // Poller reads named's statistics channel.
type Poller struct { type Poller struct {
URL string // e.g. http://127.0.0.1:8058/json/v1/server URL string // e.g. http://127.0.0.1:8058/json/v1 (server, memory and sockets)
Client *http.Client Client *http.Client
} }
// NewPoller returns a poller for a statistics channel address. // NewPoller returns a poller for a statistics channel address.
func NewPoller(addr string) *Poller { func NewPoller(addr string) *Poller {
return &Poller{URL: "http://" + addr + "/json/v1/server", Client: &http.Client{Timeout: 10 * time.Second}} return &Poller{URL: "http://" + addr + "/json/v1", Client: &http.Client{Timeout: 10 * time.Second}}
} }
// Poll fetches a snapshot. // Poll fetches a snapshot.
+144
View File
@@ -0,0 +1,144 @@
package stats
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
"time"
)
// Server statistics from /proc: named's process and the host. They need no
// privileges: /proc/PID/stat and /proc/PID/status are readable by every
// user (unless /proc is mounted with hidepid).
// clockTicks is USER_HZ, the unit of the CPU times in /proc/PID/stat. It
// is 100 on every Linux architecture Jarl runs on.
const clockTicks = 100
// procRoot is where /proc is (tests point it elsewhere).
var procRoot = "/proc"
// namedProc is a sample of named's process.
type namedProc struct {
PID int
RSS int64 // bytes
Threads int64
CPU int64 // user + system time, in clock ticks
At time.Time
}
// findNamed returns the PID of named: the process named "named" whose
// command line mentions mainConfig, or else the oldest one (the lowest
// PID). 0 when there is none.
func findNamed(mainConfig string) int {
entries, err := os.ReadDir(procRoot)
if err != nil {
return 0
}
best := 0
for _, e := range entries {
pid, err := strconv.Atoi(e.Name())
if err != nil {
continue
}
comm, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "comm"))
if err != nil || strings.TrimSpace(string(comm)) != "named" {
continue
}
if mainConfig != "" {
if cmd, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "cmdline")); err == nil && bytes.Contains(cmd, []byte(mainConfig)) {
return pid
}
}
if best == 0 || pid < best {
best = pid
}
}
return best
}
// readNamedProc samples a process.
func readNamedProc(pid int, now time.Time) (*namedProc, bool) {
dir := filepath.Join(procRoot, strconv.Itoa(pid))
stat, err := os.ReadFile(filepath.Join(dir, "stat"))
if err != nil {
return nil, false
}
// The command name is in parentheses and may contain spaces: the
// fields that follow start after the last ')'.
i := bytes.LastIndexByte(stat, ')')
if i < 0 {
return nil, false
}
f := strings.Fields(string(stat[i+1:]))
// f[0] is field 3 (state); utime and stime are fields 14 and 15.
if len(f) < 13 {
return nil, false
}
utime, _ := strconv.ParseInt(f[11], 10, 64)
stime, _ := strconv.ParseInt(f[12], 10, 64)
p := &namedProc{PID: pid, CPU: utime + stime, At: now}
status, err := os.ReadFile(filepath.Join(dir, "status"))
if err != nil {
return nil, false
}
for _, line := range strings.Split(string(status), "\n") {
k, v, ok := strings.Cut(line, ":")
if !ok {
continue
}
switch k {
case "VmRSS":
p.RSS = kib(v)
case "Threads":
p.Threads, _ = strconv.ParseInt(strings.TrimSpace(v), 10, 64)
}
}
return p, true
}
// kib parses a "1234 kB" value from /proc into bytes.
func kib(v string) int64 {
n, _ := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(v), "kB")), 10, 64)
return n * 1024
}
// Host is the machine's memory, load and uptime.
type Host struct {
MemTotal int64 `json:"mem_total"`
MemAvailable int64 `json:"mem_available"`
Load []float64 `json:"load"` // 1, 5 and 15 minutes
CPUs int `json:"cpus"`
Uptime int64 `json:"uptime"` // seconds
}
func readHost() *Host {
h := &Host{CPUs: runtime.NumCPU()}
if b, err := os.ReadFile(filepath.Join(procRoot, "meminfo")); err == nil {
for _, line := range strings.Split(string(b), "\n") {
k, v, _ := strings.Cut(line, ":")
switch k {
case "MemTotal":
h.MemTotal = kib(v)
case "MemAvailable":
h.MemAvailable = kib(v)
}
}
}
if b, err := os.ReadFile(filepath.Join(procRoot, "loadavg")); err == nil {
for _, f := range strings.Fields(string(b))[:min(3, len(strings.Fields(string(b))))] {
x, _ := strconv.ParseFloat(f, 64)
h.Load = append(h.Load, x)
}
}
if b, err := os.ReadFile(filepath.Join(procRoot, "uptime")); err == nil {
if f := strings.Fields(string(b)); len(f) > 0 {
x, _ := strconv.ParseFloat(f[0], 64)
h.Uptime = int64(x)
}
}
return h
}
+26 -3
View File
@@ -265,7 +265,8 @@ type Timeline struct {
Series []*Series `json:"series"` Series []*Series `json:"series"`
} }
// Series is one counter over time. // Series is one counter over time. For a gauge (g:KEY) the values are
// averages, and -1 where there is no sample.
type Series struct { type Series struct {
Key string `json:"key"` Key string `json:"key"`
Values []int64 `json:"values"` Values []int64 `json:"values"`
@@ -340,8 +341,8 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
expanded = append(expanded, k) expanded = append(expanded, k)
} }
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}} tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
for _, k := range expanded { bucketed := func(key string) ([]int64, error) {
raw, err := q.Store.series(ctx, st.res, from, to, k) raw, err := q.Store.series(ctx, st.res, from, to, key)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -356,6 +357,28 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
vals[i] += v vals[i] += v
} }
} }
return vals, nil
}
for _, k := range expanded {
vals, err := bucketed(k)
if err != nil {
return nil, err
}
// A gauge (g:KEY) is the average of its samples: its sum divided
// by gn:KEY, their number. A bucket without samples is -1.
if g, ok := strings.CutPrefix(k, "g:"); ok {
n, err := bucketed("gn:" + g)
if err != nil {
return nil, err
}
for i := range vals {
if n[i] > 0 {
vals[i] /= n[i]
} else {
vals[i] = -1
}
}
}
tl.Series = append(tl.Series, &Series{Key: k, Values: vals}) tl.Series = append(tl.Series, &Series{Key: k, Values: vals})
} }
return tl, nil return tl, nil
+112
View File
@@ -0,0 +1,112 @@
package stats
import "time"
// Server is the latest server statistics: named's process, memory, cache
// and sockets, and the host's memory and load. It is sampled with the
// counters, once a minute.
type Server struct {
At time.Time `json:"at"`
Version string `json:"version,omitempty"`
BootTime string `json:"boot_time,omitempty"`
ConfigTime string `json:"config_time,omitempty"`
// From /proc (0 when named's process isn't visible).
PID int `json:"pid,omitempty"`
RSS int64 `json:"rss,omitempty"` // resident memory, bytes
Threads int64 `json:"threads,omitempty"`
CPUPercent float64 `json:"cpu_percent"` // of one CPU, over the last minute
// From the statistics channel.
MemInUse int64 `json:"mem_in_use"` // named's own count of the memory it uses
CacheRRsets int64 `json:"cache_rrsets"`
CacheMem int64 `json:"cache_mem"`
SocketsUDP int64 `json:"sockets_udp"`
SocketsTCP int64 `json:"sockets_tcp"`
RecursionHighwater int64 `json:"recursion_highwater"` // most recursive clients at once since named started
TCPHighwater int64 `json:"tcp_highwater"`
Host *Host `json:"host"`
}
// Gauge keys: each is stored as two counters, "g:KEY" (the sum of the
// minute samples) and "gn:KEY" (their number), so hours and days add up
// like counters and a bucket's value is their average (see Timeline).
const (
GaugeRSS = "named:rss"
GaugeMem = "named:mem"
GaugeCPU = "named:cpu" // tenths of a percent of one CPU
GaugeCacheMem = "cache:mem"
GaugeCacheRRs = "cache:rrsets"
GaugeHostMem = "host:mem-used"
GaugeHostLoad = "host:load" // the 1-minute load average, times 100
)
// gauge records a sample of a gauge for a minute (none when minute < 0).
func (c *Collector) gauge(minute int64, key string, v int64) {
if minute < 0 {
return
}
c.count(minute, "g:"+key, v)
c.count(minute, "gn:"+key, 1)
}
// sampleServer records the gauges for a minute and keeps the latest
// values (minute -1: only those). snap is nil when named's statistics
// channel didn't answer.
// Called with c.mu held.
func (c *Collector) sampleServer(minute int64, now time.Time, snap *Snapshot) {
srv := &Server{At: now, Host: readHost()}
if c.proc == nil || c.proc.PID == 0 {
c.proc = &namedProc{PID: findNamed(c.opts.MainConfig)}
}
p, ok := readNamedProc(c.proc.PID, now)
if !ok {
// named restarted with a new PID: look again next minute.
c.proc = nil
} else {
srv.PID, srv.RSS, srv.Threads = p.PID, p.RSS, p.Threads
if prev := c.proc; prev.At.Before(now) && !prev.At.IsZero() && p.CPU >= prev.CPU {
// Tenths of a percent of one CPU: ticks / clockTicks is CPU seconds.
permille := (p.CPU - prev.CPU) * 1000 / clockTicks / max(int64(now.Sub(prev.At)/time.Second), 1)
srv.CPUPercent = float64(permille) / 10
c.gauge(minute, GaugeCPU, permille)
}
c.proc = p
c.gauge(minute, GaugeRSS, p.RSS)
}
if h := srv.Host; h.MemTotal > 0 {
c.gauge(minute, GaugeHostMem, h.MemTotal-h.MemAvailable)
}
if h := srv.Host; len(h.Load) > 0 {
c.gauge(minute, GaugeHostLoad, int64(h.Load[0]*100))
}
if snap != nil && snap.Gauges != nil {
g := snap.Gauges
srv.Version, srv.BootTime, srv.ConfigTime = snap.Version, snap.BootTime, snap.ConfigTime
srv.MemInUse, srv.CacheRRsets, srv.CacheMem = g["named:mem"], g["cache:rrsets"], g["cache:mem"]
srv.SocketsUDP, srv.SocketsTCP = g["sockets:udp"], g["sockets:tcp"]
srv.RecursionHighwater, srv.TCPHighwater = g["named:recursion-highwater"], g["named:tcp-highwater"]
c.gauge(minute, GaugeMem, srv.MemInUse)
c.gauge(minute, GaugeCacheMem, srv.CacheMem)
c.gauge(minute, GaugeCacheRRs, srv.CacheRRsets)
}
c.server = srv
}
// Server returns the latest server statistics (nil before the first
// minute).
func (c *Collector) Server() *Server {
c.mu.Lock()
defer c.mu.Unlock()
if c.server == nil {
return nil
}
s := *c.server
return &s
}
// Disk is the space on a file system Jarl and named use.
type Disk struct {
Path string `json:"path"` // the first of the directories on it
Holds []string `json:"holds"` // what is on it: zone files, logs, ...
Total int64 `json:"total"`
Free int64 `json:"free"` // available to unprivileged users
}
+96
View File
@@ -0,0 +1,96 @@
package stats
import (
"context"
"os"
"path/filepath"
"strconv"
"testing"
"time"
)
func TestProc(t *testing.T) {
root := t.TempDir()
old := procRoot
procRoot = root
defer func() { procRoot = old }()
proc := func(pid, comm, cmdline string, utime, stime int) {
d := filepath.Join(root, pid)
os.MkdirAll(d, 0o755)
os.WriteFile(filepath.Join(d, "comm"), []byte(comm+"\n"), 0o644)
os.WriteFile(filepath.Join(d, "cmdline"), []byte(cmdline), 0o644)
// The command name may hold spaces and parentheses.
stat := pid + " (" + comm + " (x)) S 1 1 1 0 -1 4194560 100 0 0 0 " + strconv.Itoa(utime) + " " + strconv.Itoa(stime) + " 0 0 20 0 9 0 100 0 0\n"
os.WriteFile(filepath.Join(d, "stat"), []byte(stat), 0o644)
os.WriteFile(filepath.Join(d, "status"), []byte("Name:\t"+comm+"\nVmRSS:\t 51200 kB\nThreads:\t9\n"), 0o644)
}
proc("90", "named", "/usr/sbin/named\x00-c\x00/etc/other.conf", 1, 1)
proc("120", "named", "/usr/sbin/named\x00-c\x00/etc/bind/named.conf\x00-u\x00bind", 300, 100)
proc("130", "bash", "bash", 0, 0)
if pid := findNamed("/etc/bind/named.conf"); pid != 120 {
t.Errorf("findNamed = %d, want the one with the config", pid)
}
if pid := findNamed("/nowhere.conf"); pid != 90 {
t.Errorf("findNamed without a match = %d, want the oldest", pid)
}
now := time.Unix(1_000_000, 0)
p, ok := readNamedProc(120, now)
if !ok || p.RSS != 51200*1024 || p.Threads != 9 || p.CPU != 400 {
t.Fatalf("readNamedProc = %+v, %v", p, ok)
}
os.WriteFile(filepath.Join(root, "meminfo"), []byte("MemTotal: 4000000 kB\nMemFree: 100 kB\nMemAvailable: 3000000 kB\n"), 0o644)
os.WriteFile(filepath.Join(root, "loadavg"), []byte("0.52 0.40 0.31 1/300 4242\n"), 0o644)
os.WriteFile(filepath.Join(root, "uptime"), []byte("86400.12 1000.0\n"), 0o644)
h := readHost()
if h.MemTotal != 4000000*1024 || h.MemAvailable != 3000000*1024 || len(h.Load) != 3 || h.Load[0] != 0.52 || h.Uptime != 86400 {
t.Errorf("readHost = %+v", h)
}
// Two samples a minute apart: 30 seconds of CPU time is 50%.
c := &Collector{minutes: map[int64]map[string]int64{}, opts: Options{MainConfig: "/etc/bind/named.conf"}}
snap := &Snapshot{Gauges: map[string]int64{"named:mem": 1000, "cache:rrsets": 7, "cache:mem": 300, "sockets:tcp": 2}, Version: "9.20"}
c.sampleServer(-1, now, snap)
if len(c.minutes) != 0 {
t.Errorf("minute -1 recorded gauges: %v", c.minutes)
}
proc("120", "named", "/usr/sbin/named\x00-c\x00/etc/bind/named.conf", 2800, 600) // +3000 ticks = 30 s
c.sampleServer(600, now.Add(time.Minute), snap)
s := c.Server()
if s.PID != 120 || s.CPUPercent != 50 || s.RSS != 51200*1024 || s.CacheRRsets != 7 || s.SocketsTCP != 2 || s.Version != "9.20" {
t.Errorf("server = %+v", s)
}
m := c.minutes[600]
if m["g:"+GaugeCPU] != 500 || m["gn:"+GaugeCPU] != 1 || m["g:"+GaugeMem] != 1000 || m["g:"+GaugeHostLoad] != 52 {
t.Errorf("gauges = %v", m)
}
}
func TestGaugeTimeline(t *testing.T) {
store, err := OpenStore(":memory:")
if err != nil {
t.Fatal(err)
}
defer store.Close()
now := time.Unix(1_800_000_000, 0)
start := now.Unix() - now.Unix()%300 - 600 // two 5-minute buckets ago
b := &Batch{}
for i := int64(0); i < 5; i++ {
b.AddCounters(ResMinute, start+i*60, map[string]int64{"g:named:rss": 100 + i*10, "gn:named:rss": 1})
}
// The next bucket has no samples.
if err := store.Commit(context.Background(), b); err != nil {
t.Fatal(err)
}
q := &Querier{Store: store, Opts: Options{RetainMinute: 7 * 24 * time.Hour, RetainHour: 90 * 24 * time.Hour}, Now: func() time.Time { return now }}
tl, err := q.Timeline(context.Background(), 15*time.Minute, "5m", []string{"g:named:rss"})
if err != nil {
t.Fatal(err)
}
vals := map[int64]int64{}
for i, ts := range tl.Times {
vals[ts] = tl.Series[0].Values[i]
}
if vals[start] != 120 || vals[start+300] != -1 {
t.Errorf("gauge timeline = %v (times %v)", tl.Series[0].Values, tl.Times)
}
}
+10 -1
View File
@@ -24,6 +24,7 @@ const (
// Store is the statistics database. // Store is the statistics database.
type Store struct { type Store struct {
db *sql.DB db *sql.DB
path string
} }
var schema = []string{ var schema = []string{
@@ -62,7 +63,7 @@ func OpenStore(path string) (*Store, error) {
if path == ":memory:" { if path == ":memory:" {
db.SetMaxOpenConns(1) db.SetMaxOpenConns(1)
} }
s := &Store{db: db} s := &Store{db: db, path: path}
if err := s.migrate(); err != nil { if err := s.migrate(); err != nil {
db.Close() db.Close()
return nil, fmt.Errorf("statistics database %s: %w", path, err) return nil, fmt.Errorf("statistics database %s: %w", path, err)
@@ -70,6 +71,14 @@ func OpenStore(path string) (*Store, error) {
return s, nil return s, nil
} }
// Path returns the database file ("" in memory).
func (s *Store) Path() string {
if s.path == ":memory:" {
return ""
}
return s.path
}
// Close closes the database. // Close closes the database.
func (s *Store) Close() error { return s.db.Close() } func (s *Store) Close() error { return s.db.Close() }
+30 -16
View File
@@ -125,10 +125,19 @@ export function legend(series) {
h("li", null, h("span", { class: "key line s" + s.slot }), s.label))); h("li", null, h("span", { class: "key line s" + s.slot }), s.label)));
} }
// endLabelWidth is the room lineChart needs for the end labels of series.
export function endLabelWidth(series) {
return series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8;
}
// lineChart draws series over time. series: [{label, values, slot}] (slot // lineChart draws series over time. series: [{label, values, slot}] (slot
// = categorical color 1..3); times: bucket starts (Unix seconds); step in // = categorical color 1..3); times: bucket starts (Unix seconds); step in
// seconds. With area, the first series gets a 10% wash under its line. // seconds. With area, the first series gets a 10% wash under its line.
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true }) { // gutter {left, right} sets the least room for the axis labels and the end
// labels: charts shown one above the other pass the same gutter, so their
// time axes line up. format formats values (axis and readout); a null
// value is a gap in the line (no data for that time).
export function lineChart({ series, times, step, area = false, height = 220, endLabels = true, gutter = {}, format = compact }) {
const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" }); const root = h("div", { class: "chart", tabindex: "0", "aria-label": series.map((s) => s.label).join(", ") + " over time" });
const tip = tooltip(); const tip = tooltip();
root.append(tip); root.append(tip);
@@ -139,12 +148,12 @@ export function lineChart({ series, times, step, area = false, height = 220, end
if (!w || w === width) return; if (!w || w === width) return;
width = w; width = w;
root.querySelector("svg")?.remove(); root.querySelector("svg")?.remove();
const max = Math.max(1, ...series.flatMap((s) => s.values)); const max = Math.max(1, ...series.flatMap((s) => s.values).filter((v) => v != null));
const yTicks = niceTicks(max, 4); const yTicks = niceTicks(max, 4);
const yMax = yTicks[yTicks.length - 1]; const yMax = yTicks[yTicks.length - 1];
const labelW = Math.max(...yTicks.map((t) => compact(t).length)) * 7 + 10; const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
const endW = endLabels && series.length > 1 && series.length <= 4 ? Math.max(...series.map((s) => s.label.length)) * 7 + 12 : 8; const endW = endLabels ? endLabelWidth(series) : 8;
const m = { top: 10, right: endW, bottom: 24, left: labelW }; const m = { top: 10, right: Math.max(endW, gutter.right || 0), bottom: 24, left: Math.max(labelW, gutter.left || 0) };
const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom; const pw = Math.max(40, w - m.left - m.right), ph = height - m.top - m.bottom;
const t0 = times[0], t1 = times[times.length - 1] + step; const t0 = times[0], t1 = times[times.length - 1] + step;
const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw; const x = (t) => m.left + ((t - t0) / Math.max(1, t1 - t0)) * pw;
@@ -154,7 +163,7 @@ export function lineChart({ series, times, step, area = false, height = 220, end
// Grid and axes. // Grid and axes.
for (const t of yTicks) { for (const t of yTicks) {
s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) })); s.append(svg("line", { class: t === 0 ? "axis" : "grid", x1: m.left, x2: m.left + pw, y1: y(t), y2: y(t) }));
s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, compact(t))); s.append(svg("text", { class: "tick", x: m.left - 6, y: y(t) + 4, "text-anchor": "end" }, format(t)));
} }
const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90))); const { ticks, step: tstep } = timeTicks(t0, t1, Math.max(2, Math.floor(pw / 90)));
for (const t of ticks) { for (const t of ticks) {
@@ -162,17 +171,21 @@ export function lineChart({ series, times, step, area = false, height = 220, end
} }
// Series. // Series.
series.forEach((sr, k) => { series.forEach((sr, k) => {
const pts = sr.values.map((v, i) => [cx(i), y(v)]); // A gap (null) starts a new piece of line.
const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join(""); const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
if (area && k === 0 && pts.length) { const d = pts.map((p, i) => (p ? (i && pts[i - 1] ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1) : "")).join("");
if (area && k === 0 && pts.length && pts.every(Boolean)) {
s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` })); s.append(svg("path", { class: "area s" + sr.slot, d: d + `L${pts[pts.length - 1][0].toFixed(1)} ${y(0)}L${pts[0][0].toFixed(1)} ${y(0)}Z` }));
} }
s.append(svg("path", { class: "line s" + sr.slot, d })); s.append(svg("path", { class: "line s" + sr.slot, d }));
if (pts.length === 1) s.append(svg("circle", { class: "dot s" + sr.slot, cx: pts[0][0], cy: pts[0][1], r: 4 })); // A point without neighbours has no line: show it as a dot.
pts.forEach((p, i) => {
if (p && !pts[i - 1] && !pts[i + 1]) s.append(svg("circle", { class: "dot s" + sr.slot, cx: p[0], cy: p[1], r: 3 }));
});
}); });
// Direct end labels when there is room and they don't collide. // Direct end labels when there is room and they don't collide.
if (m.right > 8) { if (m.right > 8) {
const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values[sr.values.length - 1] || 0) })).sort((a, b) => a.y - b.y); const ends = series.map((sr) => ({ label: sr.label, y: y(sr.values.findLast((v) => v != null) || 0) })).sort((a, b) => a.y - b.y);
if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) { if (ends.every((e, i) => i === 0 || e.y - ends[i - 1].y >= 13)) {
for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label)); for (const e of ends) s.append(svg("text", { class: "end-label", x: m.left + pw + 6, y: e.y + 4 }, e.label));
} }
@@ -205,13 +218,13 @@ export function lineChart({ series, times, step, area = false, height = 220, end
geom.cross.setAttribute("visibility", "visible"); geom.cross.setAttribute("visibility", "visible");
series.forEach((sr, k) => { series.forEach((sr, k) => {
geom.dots[k].setAttribute("cx", xpos); geom.dots[k].setAttribute("cx", xpos);
geom.dots[k].setAttribute("cy", geom.y(sr.values[i])); geom.dots[k].setAttribute("cy", geom.y(sr.values[i] ?? 0));
geom.dots[k].setAttribute("visibility", "visible"); geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "visible");
}); });
tip.replaceChildren( tip.replaceChildren(
h("div", { class: "tip-head" }, bucketLabel(times[i], step)), h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
...series.map((sr) => h("div", { class: "tip-row" }, ...series.map((sr) => h("div", { class: "tip-row" },
h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i].toLocaleString()), " ", h("span", { class: "muted" }, sr.label)))); h("span", { class: "key line s" + sr.slot }), h("strong", null, sr.values[i] == null ? "–" : format === compact ? sr.values[i].toLocaleString() : format(sr.values[i])), " ", h("span", { class: "muted" }, sr.label))));
tip.hidden = false; tip.hidden = false;
// Keep the readout inside the chart, on the far side of the pointer. // Keep the readout inside the chart, on the far side of the pointer.
const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12; const left = xpos > root.clientWidth / 2 ? xpos - tip.offsetWidth - 12 : xpos + 12;
@@ -242,12 +255,13 @@ export function lineChart({ series, times, step, area = false, height = 220, end
// timeTable is the table view of a line chart (busiest buckets first is // timeTable is the table view of a line chart (busiest buckets first is
// not useful here: keep time order, newest first). // not useful here: keep time order, newest first).
export function timeTable(series, times, step) { export function timeTable(series, times, step, format) {
const cell = (v) => (v == null ? "–" : format ? format(v) : v.toLocaleString());
const rows = times.map((t, i) => ({ t, i })).reverse(); const rows = times.map((t, i) => ({ t, i })).reverse();
return h("div", { class: "table-wrap scroll" }, h("table", null, return h("div", { class: "table-wrap scroll" }, h("table", null,
h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))), h("thead", null, h("tr", null, h("th", null, "Time"), series.map((s) => h("th", { class: "num" }, s.label)))),
h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)), h("tbody", null, rows.map((r) => h("tr", null, h("td", null, bucketLabel(r.t, step)),
series.map((s) => h("td", { class: "num" }, s.values[r.i].toLocaleString()))))))); series.map((s) => h("td", { class: "num" }, cell(s.values[r.i]))))))));
} }
// heatmap draws average activity by weekday (rows, Monday first) and hour // heatmap draws average activity by weekday (rows, Monday first) and hour
+10 -8
View File
@@ -39,15 +39,15 @@ export async function render(el, params, query) {
} catch (e) { mount(checkOut, errorBox(e)); } } catch (e) { mount(checkOut, errorBox(e)); }
}; };
const paused = status.paused_until; const paused = status.paused_until || status.paused_indefinitely;
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Blocking"), h("div", { class: "grow" }, h("h1", null, "Blocking"),
h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")), h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")),
writable && !paused && (lists.length || rules.length) ? pauseControls() : null), writable && !paused && (lists.length || rules.length) ? pauseControls() : null),
paused ? h("div", { class: "msg warn" }, paused ? h("div", { class: "msg warn" },
h("strong", null, "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ": "), h("strong", null, status.paused_indefinitely ? "Blocking is paused: " : "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) + ": "),
"every name is answered normally. It resumes by itself. ", status.paused_indefinitely ? "every name is answered normally until you resume it. " : "every name is answered normally. It resumes by itself. ",
writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null, writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null,
h("div", { class: "panel" }, h("div", { class: "panel" },
h("form", { class: "actions", onsubmit: runCheck }, h("form", { class: "actions", onsubmit: runCheck },
@@ -61,20 +61,22 @@ export async function render(el, params, query) {
// show it when it happens. // show it when it happens.
let wait = 0; let wait = 0;
if (lists.some((l) => l.state === "waiting")) wait = 4000; if (lists.some((l) => l.state === "waiting")) wait = 4000;
if (paused) wait = Math.max(1000, Math.min(wait || Infinity, new Date(paused) - Date.now() + 3000)); if (status.paused_until) wait = Math.max(1000, Math.min(wait || Infinity, new Date(status.paused_until) - Date.now() + 3000));
if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait); if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait);
} }
function pauseControls() { function pauseControls() {
const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"]], "15"); const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"], ["-1", "Until I resume"]], "15");
return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking")); return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking"));
} }
// pause pauses blocking for minutes, or resumes it (0). It applies at // pause pauses blocking for minutes (-1: until resumed), or resumes it
// once, also with a draft open: it's for trying something now. // (0). It applies at once, also with a draft open: it's for trying
// something now.
async function pause(minutes) { async function pause(minutes) {
try { try {
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body: { minutes } } : {}); const body = minutes < 0 ? { indefinitely: true } : { minutes };
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body } : {});
toast(minutes ? "Blocking paused" : "Blocking resumed"); toast(minutes ? "Blocking paused" : "Blocking resumed");
rerender(); rerender();
} catch (e) { toast(e.message, "err", 8000); } } catch (e) { toast(e.message, "err", 8000); }
@@ -13,7 +13,8 @@ export async function render(el) {
card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"), card("Jarl", s.adopted ? badge("active", "ok") : badge("not adopted", "warn"),
s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"), s.adopted ? "named loads Jarl's configuration" : "named does not load Jarl's configuration yet — run 'jarld adopt' on the server"),
card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")), card("Zones", (s.counts || {}).zones ?? "–", plural((s.counts || {}).records || 0, "record") + " · " + plural((s.counts || {}).views || 0, "view")),
card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key") + " · " + plural((s.counts || {}).options || 0, "option")), card("Access", plural((s.counts || {}).acls || 0, "ACL"), plural((s.counts || {}).tsig_keys || 0, "TSIG key")),
card("Options", (s.counts || {}).options ?? "–", "server, view, zone and top-level"),
st.backup ? backupCard(st.backup) : null, st.backup ? backupCard(st.backup) : null,
); );
const last = s.last_applied; const last = s.last_applied;
@@ -44,6 +45,10 @@ export async function render(el) {
parts.push(h("div", { class: "msg info" }, parts.push(h("div", { class: "msg info" },
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking"))); h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
} }
if (st.blocking_paused_indefinitely) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Blocking is paused until someone resumes it."), " Every name is answered normally. ", h("a", { href: "#/blocking" }, "Blocking")));
}
for (const e of st.blocklist_errors || []) { for (const e of st.blocklist_errors || []) {
parts.push(h("div", { class: "msg warn" }, parts.push(h("div", { class: "msg warn" },
h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ", h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ",
+3 -2
View File
@@ -32,8 +32,9 @@ function valueWidget(stmt, value, ctx) {
// Statements such as allow-transfer and listen-on take an address // Statements such as allow-transfer and listen-on take an address
// match list after optional parameters: suggest names at the cursor. // match list after optional parameters: suggest names at the cursor.
const takesList = stmt && /address_match_element/.test(stmt.grammar || ""); const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
if (takesList && !value) t.placeholder = "{ homelab; key xfer-key; }"; if (takesList && !value) t.placeholder = "homelab; key xfer-key";
return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim() }; return { el: h("div", null, t, takesList && ctx ? suggestions(t, ctx, { mode: "inline" }) : null), get: () => t.value.trim(),
hint: takesList ? "The { } around the list are optional: 10.0.0.53; key xfer-key becomes { 10.0.0.53; key xfer-key; }." : null };
} }
const i = input({ value: value || "", class: "mono" }); const i = input({ value: value || "", class: "mono" });
return { el: i, get: () => i.value.trim() }; return { el: i, get: () => i.value.trim() };
+71 -7
View File
@@ -1,6 +1,6 @@
import { h, mount, fmtTime, errorBox } from "../ui.js"; import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
import { get } from "../api.js"; import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds } from "../charts.js"; import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]]; const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 }; const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
@@ -65,15 +65,18 @@ export async function render(el, params, query) {
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }]; const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
const bl = timeline.series.find((x) => x.key === "blocked"); const bl = timeline.series.find((x) => x.key === "blocked");
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 }); if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step),
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
// Failed answers: three fixed series, legend plus end labels. // Failed answers: three fixed series, legend plus end labels.
const fSeries = failures.map((f) => ({ ...f, values: (errors.series.find((x) => x.key === f.key) || { values: errors.times.map(() => 0) }).values })); const fSeries = failures.map((f) => ({ ...f, values: (errors.series.find((x) => x.key === f.key) || { values: errors.times.map(() => 0) }).values }));
const anyFailures = fSeries.some((f) => f.values.some((v) => v > 0)); const anyFailures = fSeries.some((f) => f.values.some((v) => v > 0));
// The two charts are one above the other: the same room for labels on
// both sides keeps their time axes in line.
const gutter = { left: 52, right: Math.max(endLabelWidth(qSeries), anyFailures ? endLabelWidth(fSeries) : 0) };
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true, gutter }), timeTable(qSeries, timeline.times, step),
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
parts.push(chartCard("Failed answers", "NXDOMAIN: the name doesn't exist · SERVFAIL: resolution failed · REFUSED: not allowed", parts.push(chartCard("Failed answers", "NXDOMAIN: the name doesn't exist · SERVFAIL: resolution failed · REFUSED: not allowed",
anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step] }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."), anyFailures ? lineChart({ series: fSeries, times: errors.times, step: stepSeconds[errors.step], gutter }) : h("div", { class: "empty" }, "No failed answers " + rangeLabel + "."),
anyFailures ? timeTable(fSeries, errors.times, stepSeconds[errors.step]) : null, anyFailures ? timeTable(fSeries, errors.times, stepSeconds[errors.step]) : null,
{ wide: true, legend: anyFailures ? legend(fSeries) : null })); { wide: true, legend: anyFailures ? legend(fSeries) : null }));
@@ -107,6 +110,8 @@ export async function render(el, params, query) {
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null, b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null, rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null)); b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
el.classList.remove("refreshing"); el.classList.remove("refreshing");
mount(el, parts); mount(el, parts);
scheduleRefresh(el, params, query, gen); scheduleRefresh(el, params, query, gen);
@@ -118,6 +123,8 @@ async function loadAll(since, heatSince, queryLog) {
timeline: get("/stats/timeline", { since, key: "queries,blocked" }), timeline: get("/stats/timeline", { since, key: "queries,blocked" }),
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }), errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
heat: get("/stats/heatmap", { since: heatSince }), heat: get("/stats/heatmap", { since: heatSince }),
server: get("/stats/server").catch(() => null),
gauges: get("/stats/timeline", { since, key: gaugeKeys.join(",") }),
}; };
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"]; const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); }); groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
@@ -162,6 +169,63 @@ function filterRow(since) {
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k)))); h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
} }
const gaugeKeys = ["g:named:rss", "g:named:mem", "g:cache:mem", "g:named:cpu"];
// serverSection shows named's process, cache, clients, the host and the
// disks now, and named's memory and CPU over time.
function serverSection(sv, gauges, rangeLabel) {
const srv = (sv && sv.server) || {};
const named = (sv && sv.named) || {};
const host = srv.host || {};
const out = [h("h2", { class: "section" }, "Server")];
const clients = (v, what) => {
// "now/soft/hard" or "now/limit".
const [now, ...limits] = (v || "").split("/");
return v ? [now, "of " + limits[limits.length - 1] + " allowed" + what] : null;
};
const rc = clients(named.recursive_clients, srv.recursion_highwater ? " · most at once: " + srv.recursion_highwater : "");
const tc = clients(named.tcp_clients, srv.tcp_highwater ? " · most at once: " + srv.tcp_highwater : "");
out.push(h("div", { class: "cards" },
tile("named memory", srv.rss ? bytes(srv.rss) : "–", srv.rss ? "resident · named counts " + bytes(srv.mem_in_use) + " · " + srv.threads + " threads" : "named's process isn't visible to jarld"),
tile("named CPU", srv.pid ? srv.cpu_percent.toFixed(1) + "%" : "–", "of one CPU, last minute" + (named.cpus ? " · " + named.cpus + " CPUs" : "")),
tile("Cache", compact(srv.cache_rrsets || 0) + " RRsets", bytes(srv.cache_mem || 0) + " of memory, all views"),
rc ? tile("Recursive clients", rc[0], rc[1]) : null,
tc ? tile("TCP clients", tc[0], tc[1]) : null,
tile("Up since", srv.boot_time ? relTime(srv.boot_time).replace(" ago", "") : "–", srv.config_time ? "named started " + fmtTime(srv.boot_time) + " · reconfigured " + relTime(srv.config_time) : ""),
host.mem_total ? tile("Host memory", pct((host.mem_total - host.mem_available) / host.mem_total), "of " + bytes(host.mem_total) + " in use · load " + (host.load || []).map((x) => x.toFixed(2)).join(" ")) : null,
...((sv && sv.disks) || []).map((d) => tile("Disk space", bytes(d.free) + " free", h("span", { title: d.path }, "of " + bytes(d.total) + ", for " + d.holds.join(", "))))));
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
// Gauges over time: averages, -1 where there was no sample.
const series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
const gStep = stepSeconds[gauges.step];
const memSeries = [
{ label: "Resident", values: series("g:named:rss"), slot: 1 },
{ label: "named's count", values: series("g:named:mem"), slot: 2 },
{ label: "Cache", values: series("g:cache:mem"), slot: 3 },
];
const cpuSeries = [{ label: "CPU", values: series("g:named:cpu").map((v) => (v == null ? null : v / 10)), slot: 1 }];
const any = (ss) => ss.some((x) => x.values.some((v) => v != null));
const gutter = { left: 64, right: endLabelWidth(memSeries) };
out.push(chartCard("named memory", "average per " + stepName(gauges.step) + ", " + rangeLabel + " · resident is what the system sees",
any(memSeries) ? lineChart({ series: memSeries, times: gauges.times, step: gStep, gutter, format: bytes }) : h("div", { class: "empty" }, "No samples yet: they are taken once a minute."),
any(memSeries) ? timeTable(memSeries, gauges.times, gStep, bytes) : null, { wide: true, legend: any(memSeries) ? legend(memSeries) : null }));
out.push(chartCard("named CPU", "percent of one CPU, average per " + stepName(gauges.step),
any(cpuSeries) ? lineChart({ series: cpuSeries, times: gauges.times, step: gStep, gutter, format: (v) => (Math.round(v * 10) / 10) + "%" }) : h("div", { class: "empty" }, "No samples yet."),
any(cpuSeries) ? timeTable(cpuSeries, gauges.times, gStep, (v) => v.toFixed(1) + "%") : null, { wide: true }));
return out;
}
// bytes formats a size in binary units.
function bytes(n) {
const units = ["B", "KiB", "MiB", "GiB", "TiB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) {
n /= 1024;
i++;
}
return (i === 0 || n >= 100 ? Math.round(n) : n.toFixed(1).replace(/\.0$/, "")) + " " + units[i];
}
function tile(label, value, sub) { function tile(label, value, sub) {
return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub)); return h("div", { class: "card stat" }, h("div", { class: "label" }, label), h("div", { class: "value big" }, value), h("div", { class: "sub" }, sub));
} }
+15 -2
View File
@@ -130,7 +130,10 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
/* Panels and cards */ /* Panels and cards */
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; } .panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; } .panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; margin-bottom: 14px; } /* Tiles share a row and grow to fill it, so a wrapped tile never sits
alone beside an empty space. */
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
.cards > * { flex: 1 1 170px; min-width: 0; }
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); } .card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; } .card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; } .card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
@@ -317,6 +320,7 @@ td.break { word-break: break-all; }
} }
.refreshing { opacity: 0.6; transition: opacity 0.2s; } .refreshing { opacity: 0.6; transition: opacity 0.2s; }
h2.section { margin: 24px 0 10px; font-size: 18px; }
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); } .segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; } .segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
@@ -329,7 +333,16 @@ td.break { word-break: break-all; }
.chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; } .chart-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
.chart-head .grow { flex: 1; min-width: 200px; } .chart-head .grow { flex: 1; min-width: 200px; }
.chart-head h2 { margin: 0; font-size: 16px; } .chart-head h2 { margin: 0; font-size: 16px; }
.chart-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 0 14px; } /* Two or three columns; the last cards stretch to fill their row, so no
card is left alone beside an empty space. */
.chart-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 14px; }
.chart-grid > :last-child:nth-child(odd) { grid-column: 1 / -1; }
@media (min-width: 1100px) {
.chart-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.chart-grid > :last-child:nth-child(odd) { grid-column: auto; }
.chart-grid > :last-child:nth-child(3n+1) { grid-column: 1 / -1; }
.chart-grid > :last-child:nth-child(3n+2) { grid-column: span 2; }
}
.chart-grid > .panel h2 { margin: 0; font-size: 16px; } .chart-grid > .panel h2 { margin: 0; font-size: 16px; }
.list-sub { margin: 2px 0 8px; } .list-sub { margin: 2px 0 8px; }
+1 -1
View File
@@ -44,7 +44,7 @@ export function fmtTime(iso) {
if (!iso) return ""; if (!iso) return "";
const d = new Date(iso); const d = new Date(iso);
if (isNaN(d)) return iso; if (isNaN(d)) return iso;
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit" }); return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
} }
export function relTime(iso) { export function relTime(iso) {
+30
View File
@@ -139,6 +139,7 @@ expect_dig() { # DESC WANT DIG-ARGS...
if [ "$got" = "$want" ]; then ok "dig $desc = '$want'"; else bad "dig $desc: got '$got' want '$want'"; fi if [ "$got" = "$want" ]; then ok "dig $desc = '$want'"; else bad "dig $desc: got '$got' want '$want'"; fi
} }
contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; } contains() { if echo "$OUT" | grep -q -- "$1"; then ok "output has '$1'"; else bad "output lacks '$1'"; echo "$OUT" | sed 's/^/ /'; fi; }
not_contains() { if echo "$OUT" | grep -q -- "$1"; then bad "output has '$1'"; echo "$OUT" | sed 's/^/ /'; else ok "output lacks '$1'"; fi; }
# With nothing configured, the CLI uses jarld's local socket, where # With nothing configured, the CLI uses jarld's local socket, where
# jarld's own user (this script's) is an administrator without a token. # jarld's own user (this script's) is an administrator without a token.
@@ -296,10 +297,34 @@ contains "token.revoke"
run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones run "view create (adopts zones)" 0 $J view create internal --match-clients homelab --adopt-zones
run "view create external" 0 $J view create external --match-clients any run "view create external" 0 $J view create external --match-clients any
run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1" run "zone create in external" 0 $J zone create adbraun.test --view external --ns ns1.adbraun.test. --record "ns1 A 203.0.113.1"
# named-checkconf's own warnings stay on the dashboard after the apply.
run "option set recursion no (external)" 0 $J option set recursion no --view external
run "allow-recursion without its braces" 0 $J option set allow-recursion 192.0.2.1 --view external
contains "named-checkconf"
run "status keeps named's warning" 0 $J status
contains "named-checkconf: .*recursion"
run "option list: braces added" 0 $J option list --view external
contains "{ 192.0.2.1; }"
run "unset allow-recursion" 0 $J option unset allow-recursion --view external
run "unset recursion" 0 $J option unset recursion --view external
run "status: the warning is gone" 0 $J status
not_contains "named-checkconf"
run "zone list shows both views" 0 $J zone list run "zone list shows both views" 0 $J zone list
contains "external" contains "external"
expect_dig "internal view nas" "192.168.1.20" nas.adbraun.test A expect_dig "internal view nas" "192.168.1.20" nas.adbraun.test A
run "record add needs a view now" 6 $J record add adbraun.test x A 192.168.1.90 run "record add needs a view now" 6 $J record add adbraun.test x A 192.168.1.90
run "record add --view all" 0 $J record add adbraun.test both A 192.0.2.77 --view all
contains "applied"
expect_dig "both views: internal has it" "192.0.2.77" both.adbraun.test A
run "record list external has it too" 0 $J record list adbraun.test --view external
contains "192.0.2.77"
run "several views are all-or-nothing (exit 4)" 4 $J record add adbraun.test half A 192.0.2.78 --view internal,nosuch
contains "nothing was changed in any view"
expect_dig "so internal didn't get it" "" half.adbraun.test A
run "record delete --view internal,external" 0 $J record delete adbraun.test both A --view internal,external -y
expect_dig "deleted in both" "" both.adbraun.test A
run "and gone from external" 0 $J record list adbraun.test --view external
not_contains "192.0.2.77"
run "acme set goes into both views" 0 $J acme set adbraun.test both-views run "acme set goes into both views" 0 $J acme set adbraun.test both-views
expect_dig "acme TXT (internal, from 127.0.0.1)" '"both-views"' _acme-challenge.adbraun.test TXT expect_dig "acme TXT (internal, from 127.0.0.1)" '"both-views"' _acme-challenge.adbraun.test TXT
run "acme set --view external only" 0 $J acme unset adbraun.test --view internal run "acme set --view external only" 0 $J acme unset adbraun.test --view internal
@@ -466,6 +491,11 @@ expect_rpz "paused: so is a blocked rule's name" blocked.adbraun.test none
run "block list says it's paused" 0 $J block list run "block list says it's paused" 0 $J block list
contains "Blocking is paused until" contains "Blocking is paused until"
run "block resume" 0 $J block resume run "block resume" 0 $J block resume
run "block pause indefinitely" 0 $J block pause indefinitely
expect_rpz "paused until resumed: a listed name is answered" x.tracker.adbraun.test none
run "block list says until resumed" 0 $J block list
contains "paused until you resume it"
run "block resume after an indefinite pause" 0 $J block resume
expect_rpz "resumed: the list blocks again" x.tracker.adbraun.test test.blocklist.jarl.invalid. expect_rpz "resumed: the list blocks again" x.tracker.adbraun.test test.blocklist.jarl.invalid.
expect_rpz "resumed: the rule blocks again" blocked.adbraun.test rules.jarl.invalid. expect_rpz "resumed: the rule blocks again" blocked.adbraun.test rules.jarl.invalid.
# The test server dates the file to the second, and the download above # The test server dates the file to the second, and the download above