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>
145 lines
3.8 KiB
Go
145 lines
3.8 KiB
Go
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
|
|
}
|