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
+1346 -128

No files matched your search

+4 -2
View File
@@ -249,8 +249,10 @@ type StatusResponse struct {
// BlocklistErrors are blocklists whose last update failed (for those
// who may see blocking).
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
// BlockingPausedUntil is set while blocking is paused.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
// 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"`
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
}
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
+16 -1
View File
@@ -340,7 +340,14 @@ type ServerStatus struct {
ConfigFile string `json:"config_file"`
Zones string `json:"zones"`
Running bool `json:"running"`
Raw string `json:"raw"`
// 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"`
}
// Status returns named's status.
@@ -363,6 +370,14 @@ func (t *Tools) Status(ctx context.Context) (*ServerStatus, error) {
st.ConfigFile = v
case ok && k == "number of zones":
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"):
st.Running = true
}
+77
View File
@@ -427,3 +427,80 @@ func classify(name, grammar string) (string, []string) {
}
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)
}
}
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 {
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.
long := env.e.Now().Add(48 * time.Hour)
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
}
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.BlockingPausedIndefinitely = in.Indefinitely
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) {
ops = append(ops, newOp(kind, id, base, data, summary))
}
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) {
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPausedUntil != nil])
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) || from.Settings.BlockingPausedIndefinitely != to.Settings.BlockingPausedIndefinitely {
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)) {
s := to.Settings
@@ -187,7 +187,7 @@ func DiffOps(from, to *model.State) []model.Op {
func stripVersion(s model.Settings) model.Settings {
s.Version = 0
s.BlockingPausedUntil = nil // its own operation
s.BlockingPausedUntil, s.BlockingPausedIndefinitely = nil, false // their own operation
return s
}
+11 -1
View File
@@ -73,6 +73,8 @@ type Engine struct {
blKick chan struct{} // a list was added or changed
HTTP *http.Client // for downloads; nil: a default client
Limits BlocklistLimits
namedCheck namedCheck // named-checkconf on the running configuration (namedwarn.go)
}
// New creates an engine.
@@ -1175,6 +1177,14 @@ type Status struct {
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.
func (e *Engine) Status(ctx context.Context) (*Status, error) {
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)}
return s, nil
}
+2
View File
@@ -248,6 +248,8 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
summary := "resume blocking"
if in.Until != nil {
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
case OpBlockRuleCreate:
+2
View File
@@ -252,6 +252,8 @@ type BlockRuleInput struct {
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
type BlockingPauseInput struct {
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
+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{};") {
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)
if err != nil {
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)
}
}
}
+9 -1
View File
@@ -214,7 +214,15 @@ type Settings struct {
// BlockingPausedUntil, while set, lets every name through blocking
// (docs/blocking.md); jarld clears it when the time is up.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
Version int `json:"version"`
// BlockingPausedIndefinitely pauses blocking until someone resumes it.
BlockingPausedIndefinitely bool `json:"blocking_paused_indefinitely,omitempty"`
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.
+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
// 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, zones...)
@@ -148,7 +148,7 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
title += " for view " + z.View
}
content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))
if s.Settings.BlockingPausedUntil != nil {
if s.Settings.BlockingPaused() {
content = blocking.PausedZoneFile(title)
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy})
+10 -4
View File
@@ -376,24 +376,30 @@ func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.
if err != nil {
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
}
// 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 {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.BlockingPauseInput
if !resume {
var body struct {
Minutes int `json:"minutes"`
Minutes int `json:"minutes"`
Indefinitely bool `json:"indefinitely"`
}
if err := decodeJSON(r, &body); err != nil {
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 {
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)
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())
if st, err := s.store.LoadState(r.Context()); err == nil {
resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil
resp.BlockingPausedIndefinitely = st.Settings.BlockingPausedIndefinitely
}
}
writeJSON(w, http.StatusOK, resp)
+32
View File
@@ -3,6 +3,7 @@ package server
import (
"net/http"
"net/netip"
"path/filepath"
"slices"
"strings"
"time"
@@ -240,3 +241,34 @@ func clientNames(st *model.State, want map[netip.Addr]bool, readable func(*model
}
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/heatmap", s.authed(s.statsHeatmap))
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) {
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
Exclude []netip.Prefix
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
// on). It is called at start and when named answers queries but no
// log lines arrive.
@@ -107,6 +110,10 @@ type Collector struct {
// rewritten holds queries named blocked (client#port|name) until their
// response line arrives.
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.
@@ -235,13 +242,17 @@ func (c *Collector) start(ctx context.Context) {
}
c.ensureLogging(ctx)
// 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
t := c.now()
c.status.LastPoll = &t
} else {
c.status.PollError = err.Error()
}
c.mu.Lock()
c.sampleServer(-1, c.now(), snap)
c.mu.Unlock()
c.prune(ctx)
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
}
if !final {
// Gauges are sampled once a minute, for the minute that ended.
c.sampleServer(cur-60, now, snap)
}
b := &Batch{}
first := c.status.FirstData == nil
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
}
+36 -10
View File
@@ -15,19 +15,31 @@ import (
type Snapshot struct {
BootTime string
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 {
BootTime string `json:"boot-time"`
Opcodes map[string]int64 `json:"opcodes"`
RCodes map[string]int64 `json:"rcodes"`
QTypes map[string]int64 `json:"qtypes"`
NSStats map[string]int64 `json:"nsstats"`
Views map[string]struct {
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"`
RCodes map[string]int64 `json:"rcodes"`
QTypes map[string]int64 `json:"qtypes"`
NSStats map[string]int64 `json:"nsstats"`
Views map[string]struct {
Resolver struct {
Stats map[string]int64 `json:"stats"`
CacheStats map[string]int64 `json:"cachestats"`
Cache map[string]int64 `json:"cache"` // RRsets in the cache by type
} `json:"resolver"`
} `json:"views"`
}
@@ -77,7 +89,21 @@ func Flatten(body []byte) (*Snapshot, error) {
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 {
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 {
v[name] += view.Resolver.Stats[k]
}
@@ -92,7 +118,7 @@ func Flatten(body []byte) (*Snapshot, error) {
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
@@ -120,13 +146,13 @@ func Delta(prev, cur *Snapshot) map[string]int64 {
// Poller reads named's statistics channel.
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
}
// NewPoller returns a poller for a statistics channel address.
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.
+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 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 {
Key string `json:"key"`
Values []int64 `json:"values"`
@@ -340,8 +341,8 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
expanded = append(expanded, k)
}
tl := &Timeline{Step: st.name, Times: starts, Series: []*Series{}}
for _, k := range expanded {
raw, err := q.Store.series(ctx, st.res, from, to, k)
bucketed := func(key string) ([]int64, error) {
raw, err := q.Store.series(ctx, st.res, from, to, key)
if err != nil {
return nil, err
}
@@ -356,6 +357,28 @@ func (q *Querier) Timeline(ctx context.Context, since time.Duration, step string
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})
}
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)
}
}
+11 -2
View File
@@ -23,7 +23,8 @@ const (
// Store is the statistics database.
type Store struct {
db *sql.DB
db *sql.DB
path string
}
var schema = []string{
@@ -62,7 +63,7 @@ func OpenStore(path string) (*Store, error) {
if path == ":memory:" {
db.SetMaxOpenConns(1)
}
s := &Store{db: db}
s := &Store{db: db, path: path}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("statistics database %s: %w", path, err)
@@ -70,6 +71,14 @@ func OpenStore(path string) (*Store, error) {
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.
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)));
}
// 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
// = categorical color 1..3); times: bucket starts (Unix seconds); step in
// 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 tip = tooltip();
root.append(tip);
@@ -139,12 +148,12 @@ export function lineChart({ series, times, step, area = false, height = 220, end
if (!w || w === width) return;
width = w;
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 yMax = yTicks[yTicks.length - 1];
const labelW = Math.max(...yTicks.map((t) => compact(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 m = { top: 10, right: endW, bottom: 24, left: labelW };
const labelW = Math.max(...yTicks.map((t) => format(t).length)) * 7 + 10;
const endW = endLabels ? endLabelWidth(series) : 8;
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 t0 = times[0], t1 = times[times.length - 1] + step;
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.
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("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)));
for (const t of ticks) {
@@ -162,17 +171,21 @@ export function lineChart({ series, times, step, area = false, height = 220, end
}
// Series.
series.forEach((sr, k) => {
const pts = sr.values.map((v, i) => [cx(i), y(v)]);
const d = pts.map((p, i) => (i ? "L" : "M") + p[0].toFixed(1) + " " + p[1].toFixed(1)).join("");
if (area && k === 0 && pts.length) {
// A gap (null) starts a new piece of line.
const pts = sr.values.map((v, i) => (v == null ? null : [cx(i), y(v)]));
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: "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.
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)) {
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");
series.forEach((sr, k) => {
geom.dots[k].setAttribute("cx", xpos);
geom.dots[k].setAttribute("cy", geom.y(sr.values[i]));
geom.dots[k].setAttribute("visibility", "visible");
geom.dots[k].setAttribute("cy", geom.y(sr.values[i] ?? 0));
geom.dots[k].setAttribute("visibility", sr.values[i] == null ? "hidden" : "visible");
});
tip.replaceChildren(
h("div", { class: "tip-head" }, bucketLabel(times[i], step)),
...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;
// 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;
@@ -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
// 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();
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("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
+10 -8
View File
@@ -39,15 +39,15 @@ export async function render(el, params, query) {
} catch (e) { mount(checkOut, errorBox(e)); }
};
const paused = status.paused_until;
const paused = status.paused_until || status.paused_indefinitely;
mount(el,
h("div", { class: "page-head" },
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.")),
writable && !paused && (lists.length || rules.length) ? pauseControls() : null),
paused ? h("div", { class: "msg warn" },
h("strong", null, "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ": "),
"every name is answered normally. It resumes by itself. ",
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" }) + ": "),
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,
h("div", { class: "panel" },
h("form", { class: "actions", onsubmit: runCheck },
@@ -61,20 +61,22 @@ export async function render(el, params, query) {
// show it when it happens.
let wait = 0;
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);
}
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"));
}
// pause pauses blocking for minutes, or resumes it (0). It applies at
// once, also with a draft open: it's for trying something now.
// pause pauses blocking for minutes (-1: until resumed), or resumes it
// (0). It applies at once, also with a draft open: it's for trying
// something now.
async function pause(minutes) {
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");
rerender();
} 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"),
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("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,
);
const last = s.last_applied;
@@ -44,6 +45,10 @@ export async function render(el) {
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")));
}
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 || []) {
parts.push(h("div", { class: "msg warn" },
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
// match list after optional parameters: suggest names at the cursor.
const takesList = stmt && /address_match_element/.test(stmt.grammar || "");
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() };
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(),
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" });
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 { 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 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 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 });
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.
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));
// 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",
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,
{ 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,
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));
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
el.classList.remove("refreshing");
mount(el, parts);
scheduleRefresh(el, params, query, gen);
@@ -118,6 +123,8 @@ async function loadAll(since, heatSince, queryLog) {
timeline: get("/stats/timeline", { since, key: "queries,blocked" }),
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
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"];
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))));
}
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) {
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 */
.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; }
.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 .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; }
@@ -317,6 +320,7 @@ td.break { word-break: break-all; }
}
.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; }
.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; }
@@ -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 .grow { flex: 1; min-width: 200px; }
.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; }
.list-sub { margin: 2px 0 8px; }
+1 -1
View File
@@ -44,7 +44,7 @@ export function fmtTime(iso) {
if (!iso) return "";
const d = new Date(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) {