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:
1 parent
b097e10bef
commit
7be0762f48
48 files changed
+1346
-128
No files matched your search
+31
-7
@@ -405,23 +405,40 @@ names below it (docs/blocking.md).`}
|
||||
}
|
||||
|
||||
pause := &cobra.Command{
|
||||
Use: "pause [DURATION]",
|
||||
Short: "Let every name through for a while (default 10m, at most 24h)",
|
||||
Use: "pause [DURATION | indefinitely]",
|
||||
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,
|
||||
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
|
||||
at once.
|
||||
at once. "indefinitely" pauses until 'jarl block resume'.
|
||||
|
||||
jarl block pause # 10 minutes
|
||||
jarl block pause 1h
|
||||
jarl block pause indefinitely
|
||||
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 {
|
||||
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
|
||||
if len(args) == 1 {
|
||||
var err error
|
||||
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()
|
||||
@@ -465,9 +482,16 @@ func pausedNote(c interface {
|
||||
Get(context.Context, string, url.Values, any) error
|
||||
}) string {
|
||||
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 "Blocking is paused until " + st.PausedUntil.Local().Format("2006-01-02 15:04") + " ('jarl block resume' ends it).\n"
|
||||
|
||||
@@ -530,6 +530,18 @@ func report(resp *api.MutationResponse) error {
|
||||
json.Unmarshal(resp.Problems, &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 {
|
||||
fmt.Fprintln(os.Stderr, "warning:", warn)
|
||||
}
|
||||
|
||||
@@ -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
@@ -325,7 +325,61 @@ export the data.`,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -398,3 +452,26 @@ func sparkline(vals []int64) 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")
|
||||
}
|
||||
+59
-42
@@ -449,6 +449,10 @@ func jsonUnmarshal(b []byte, v any) error { return json.Unmarshal(b, v) }
|
||||
|
||||
// ---- 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 {
|
||||
cmd := &cobra.Command{Use: "record", Aliases: []string{"records", "rr"}, Short: "Manage records in primary zones"}
|
||||
var view, name, typ, search, comment, ttlArg string
|
||||
@@ -531,15 +535,17 @@ and splitting are automatic).
|
||||
if ptr {
|
||||
body["ptr"] = true
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
resp, err := c.Mutate(ctx(), "POST", path+"/records", qv, body)
|
||||
views, err := zoneViews(c, args[0], view)
|
||||
if err != nil {
|
||||
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(&comment, "comment", "", "comment")
|
||||
add.Flags().BoolVar(&ptr, "ptr", false, "also add the PTR record (A/AAAA)")
|
||||
@@ -624,26 +630,28 @@ 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) {
|
||||
return fmt.Errorf("canceled")
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
qv.Set("name", args[1])
|
||||
qv.Set("type", args[2])
|
||||
if len(args) > 3 {
|
||||
qv.Set("data", strings.Join(args[3:], " "))
|
||||
}
|
||||
if missingOK {
|
||||
qv.Set("missing_ok", "1")
|
||||
}
|
||||
if ptr {
|
||||
qv.Set("ptr", "1")
|
||||
}
|
||||
resp, err := c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
|
||||
views, err := zoneViews(c, args[0], view)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return report(resp)
|
||||
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)
|
||||
qv.Set("name", args[1])
|
||||
qv.Set("type", args[2])
|
||||
if len(args) > 3 {
|
||||
qv.Set("data", strings.Join(args[3:], " "))
|
||||
}
|
||||
if missingOK {
|
||||
qv.Set("missing_ok", "1")
|
||||
}
|
||||
if ptr {
|
||||
qv.Set("ptr", "1")
|
||||
}
|
||||
return c.Mutate(ctx(), "DELETE", path+"/records", qv, nil)
|
||||
})
|
||||
},
|
||||
}
|
||||
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().BoolVar(&missingOK, "missing-ok", false, "succeed when nothing matches")
|
||||
del.Flags().BoolVar(&ptr, "ptr", false, "also delete the matching PTR record (A/AAAA)")
|
||||
@@ -670,15 +678,17 @@ spaces.
|
||||
if comment != "" {
|
||||
body["comment"] = comment
|
||||
}
|
||||
path, qv := zonePath(args[0], view)
|
||||
resp, err := c.Mutate(ctx(), "PUT", path+"/rrsets/"+urlPathEscape(args[1])+"/"+urlPathEscape(args[2]), qv, body)
|
||||
views, err := zoneViews(c, args[0], view)
|
||||
if err != nil {
|
||||
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(&comment, "comment", "", "comment for new records")
|
||||
|
||||
@@ -728,30 +738,37 @@ with DATA), or one record by ID.
|
||||
if len(args) < 3 {
|
||||
return fmt.Errorf("give ZONE NAME TYPE [DATA], or --id")
|
||||
}
|
||||
path, zq := zonePath(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
|
||||
}
|
||||
views, err := zoneViews(c, args[0], tview)
|
||||
if err != nil {
|
||||
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().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)")
|
||||
|
||||
+1
-1
@@ -148,7 +148,7 @@ func statsOptions(cfg *config.Config) stats.Options {
|
||||
sc := cfg.Stats
|
||||
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),
|
||||
Anonymize: sc.AnonymizeClients}
|
||||
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
||||
for _, p := range sc.ExcludeClients {
|
||||
if pf, err := config.ParsePrefix(p); err == nil {
|
||||
o.Exclude = append(o.Exclude, pf.Masked())
|
||||
|
||||
Reference in new issue
Block a user