Statistics page: the statistics database's size, and a fullness bar on the disk tiles
The server section shows the statistics database's size (with its -wal and -shm) and how many answers the query log holds, linking to the dashboard's Storage panel; the disk tiles get the same bar as the dashboard. GET /stats/server has database {path, bytes, query_log_rows}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
01ec37b9b2
commit
1b102a78f6
8 files changed
+78
-13
No files matched your search
+2
-1
@@ -69,7 +69,8 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
the clients asking → everything one of them asks. `GET /stats/explore`.
|
the clients asking → everything one of them asks. `GET /stats/explore`.
|
||||||
- **Storage** on the dashboard: the size of Jarl's databases (and how many
|
- **Storage** on the dashboard: the size of Jarl's databases (and how many
|
||||||
answers the query log holds), and per disk Jarl uses a bar of how full it
|
answers the query log holds), and per disk Jarl uses a bar of how full it
|
||||||
is and how much of it the databases take.
|
is and how much of it the databases take. The Statistics page shows the
|
||||||
|
statistics database's size too, and its disk tiles get the same bar.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -566,7 +566,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
| 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/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=` | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
|
| GET | `/stats/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=` | The query log for a filter ([stats.md](stats.md#exploring)): `total`, `blocked`, `series` (per `step` seconds: `t`, `count`, `blocked`), `by` (top 15 of each dimension the filter leaves open: client, name, view, type, rcode, blocked; clients with `name`), `recent` (latest 100: ts, client, client_name, view, name, type, rcode, blocked), `oldest`, `retention`, `client_name`. 404 `query_log_off` without a query log |
|
||||||
| 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/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}], database: {path, bytes, query_log_rows}}`; 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
|
||||||
|
|||||||
+4
-2
@@ -221,8 +221,10 @@ view switches apply here too.
|
|||||||
`anonymize_clients` and `exclude_clients` apply to the query log as to
|
`anonymize_clients` and `exclude_clients` apply to the query log as to
|
||||||
everything else. Its size: each answer takes about 150 to 200 bytes with
|
everything else. Its size: each answer takes about 150 to 200 bytes with
|
||||||
its indexes, so 50,000 answers a day for 7 days is about 60 MB, 200,000 a
|
its indexes, so 50,000 answers a day for 7 days is about 60 MB, 200,000 a
|
||||||
day about 250 MB. The dashboard's Storage panel shows the databases' size
|
day about 250 MB. The Statistics page's server section shows the
|
||||||
and how many answers the query log holds.
|
statistics database's size and how many answers the query log holds, next
|
||||||
|
to the disks (with a bar of how full each is); the dashboard's Storage
|
||||||
|
panel has all of Jarl's databases.
|
||||||
|
|
||||||
## Views
|
## Views
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,16 @@ func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
labels = append(labels, "statistics database")
|
labels = append(labels, "statistics database")
|
||||||
}
|
}
|
||||||
out["disks"] = stats.DiskUsage(labels, dirs)
|
out["disks"] = stats.DiskUsage(labels, dirs)
|
||||||
|
// The statistics database: what the statistics cost on disk.
|
||||||
|
if db := s.cfg.Stats.Store().Path(); db != "" && db != ":memory:" {
|
||||||
|
info := map[string]any{"path": db, "bytes": dbBytes(db)}
|
||||||
|
if keep := s.cfg.Stats.Options().RetainQueries; keep > 0 && s.cfg.Stats.Options().QueryLog {
|
||||||
|
if n, err := s.cfg.Stats.Store().QueryLogSize(r.Context()); err == nil {
|
||||||
|
info["query_log_rows"] = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out["database"] = info
|
||||||
|
}
|
||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ func (s *Server) storage(ctx context.Context) *api.Storage {
|
|||||||
if path == "" || path == ":memory:" {
|
if path == "" || path == ":memory:" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
d := api.Database{Label: label, Path: path}
|
d := api.Database{Label: label, Path: path, Bytes: dbBytes(path)}
|
||||||
for _, suffix := range []string{"", "-wal", "-shm"} {
|
|
||||||
if fi, err := os.Stat(path + suffix); err == nil {
|
|
||||||
d.Bytes += fi.Size()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
out.Databases = append(out.Databases, d)
|
out.Databases = append(out.Databases, d)
|
||||||
out.Total += d.Bytes
|
out.Total += d.Bytes
|
||||||
return &out.Databases[len(out.Databases)-1]
|
return &out.Databases[len(out.Databases)-1]
|
||||||
@@ -70,3 +65,14 @@ func (s *Server) storage(ctx context.Context) *api.Storage {
|
|||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbBytes is the size of a SQLite database: its file, -wal and -shm.
|
||||||
|
func dbBytes(path string) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, suffix := range []string{"", "-wal", "-shm"} {
|
||||||
|
if fi, err := os.Stat(path + suffix); err == nil {
|
||||||
|
n += fi.Size()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -176,3 +177,24 @@ func TestStatsExploreAPI(t *testing.T) {
|
|||||||
t.Errorf("storage %v", st["storage"])
|
t.Errorf("storage %v", st["storage"])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// /stats/server reports the statistics database's size and the query log.
|
||||||
|
func TestStatsServerDatabase(t *testing.T) {
|
||||||
|
ss, err := stats.OpenStore(filepath.Join(t.TempDir(), "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
b := &stats.Batch{}
|
||||||
|
b.AddQueries([]stats.QueryRow{{Time: time.Now().Unix(), Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"}})
|
||||||
|
if err := ss.Commit(context.Background(), b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
opts := stats.Options{QueryLog: true, StatisticsChannel: "127.0.0.1:1", TopN: 10, RetainMinute: time.Hour, RetainHour: 24 * time.Hour, RetainDay: 24 * time.Hour, RetainQueries: 7 * 24 * time.Hour}
|
||||||
|
e := newAPIEnv(t, stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil))))
|
||||||
|
sv := e.do("GET", "/stats/server", e.token, nil, 200)
|
||||||
|
db, ok := sv["database"].(map[string]any)
|
||||||
|
if !ok || db["bytes"].(float64) <= 0 || db["query_log_rows"].(float64) != 1 {
|
||||||
|
t.Errorf("database %v", sv["database"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -145,7 +145,7 @@ export async function render(el, params, query) {
|
|||||||
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: ex((k) => ({ blocked: k })) || (() => "#/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: ex((k) => ({ blocked: k })) || (() => "#/blocking") })) : null));
|
||||||
|
|
||||||
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
|
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel, status));
|
||||||
el.classList.remove("refreshing");
|
el.classList.remove("refreshing");
|
||||||
mount(el, parts);
|
mount(el, parts);
|
||||||
scheduleRefresh(el, params, query, gen);
|
scheduleRefresh(el, params, query, gen);
|
||||||
@@ -223,7 +223,7 @@ 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
|
// serverSection shows named's process, cache, clients, the host and the
|
||||||
// disks now, and named's memory and CPU over time.
|
// disks now, and named's memory and CPU over time.
|
||||||
function serverSection(sv, gauges, rangeLabel) {
|
function serverSection(sv, gauges, rangeLabel, status) {
|
||||||
const srv = (sv && sv.server) || {};
|
const srv = (sv && sv.server) || {};
|
||||||
const named = (sv && sv.named) || {};
|
const named = (sv && sv.named) || {};
|
||||||
const host = srv.host || {};
|
const host = srv.host || {};
|
||||||
@@ -243,7 +243,8 @@ function serverSection(sv, gauges, rangeLabel) {
|
|||||||
tc ? tile("TCP clients", tc[0], tc[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) : ""),
|
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,
|
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(", "))))));
|
sv && sv.database ? dbTile(sv.database, status) : null,
|
||||||
|
...((sv && sv.disks) || []).map(diskTile)));
|
||||||
if (sv && sv.named_error) out.push(h("div", { class: "msg info" }, "Client counts need rndc: " + sv.named_error));
|
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.
|
// 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 series = (key) => ((gauges.series || []).find((x) => x.key === key) || { values: gauges.times.map(() => -1) }).values.map((v) => (v < 0 ? null : v));
|
||||||
@@ -312,3 +313,25 @@ function disabledPanel() {
|
|||||||
function exploreSince(since) {
|
function exploreSince(since) {
|
||||||
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// dbTile is the statistics database's size: what the statistics cost on
|
||||||
|
// disk (the dashboard's Storage panel has all of Jarl's databases).
|
||||||
|
function dbTile(db, status) {
|
||||||
|
const keep = ((status || {}).retention || {}).queries;
|
||||||
|
const sub = db.query_log_rows != null
|
||||||
|
? "the query log holds " + compact(db.query_log_rows) + " answers (" + keep + ")"
|
||||||
|
: "counters and top lists; no query log";
|
||||||
|
return h("a", { class: "card link", href: "#/", title: db.path + " · all of Jarl's databases: Dashboard → Storage" },
|
||||||
|
h("div", { class: "label" }, "Statistics database"), h("div", { class: "value" }, bytes(db.bytes)), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
// diskTile is a disk's free space, with a bar of how full it is.
|
||||||
|
function diskTile(d) {
|
||||||
|
const u = d.total ? (d.total - d.free) / d.total : 0;
|
||||||
|
return h("div", { class: "card", title: d.path },
|
||||||
|
h("div", { class: "label" }, "Disk space"),
|
||||||
|
h("div", { class: "value" }, bytes(d.free) + " free"),
|
||||||
|
h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used" },
|
||||||
|
h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), style: { width: u * 100 + "%" } })),
|
||||||
|
h("div", { class: "sub" }, h("span", { class: u > 0.9 ? "warn-text" : "" }, Math.round(u * 100) + "% of " + bytes(d.total) + " used"), ", for " + d.holds.join(", ")));
|
||||||
|
}
|
||||||
@@ -613,3 +613,4 @@ table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; ove
|
|||||||
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; }
|
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; }
|
||||||
.swatch.db { background: var(--accent); }
|
.swatch.db { background: var(--accent); }
|
||||||
.swatch.other { background: var(--muted); opacity: 0.45; }
|
.swatch.other { background: var(--muted); opacity: 0.45; }
|
||||||
|
.card .disk-bar { margin: 6px 0 4px; height: 8px; }
|
||||||
Reference in new issue
Block a user