Explore the statistics: a query log to drill down by client, name, view and more; storage on the dashboard
jarld keeps every answer for a while (stats.retention.queries, default 7 days) in the statistics database: time, client, view, name, type, result and what blocked it. Every client, name, view, record type, result and blocklist on the Statistics page leads to an explore page for it: answers over time, the top values of every other dimension (each a link that narrows further) and the latest answers. GET /stats/explore. The dashboard shows the size of Jarl's databases (and the query log's rows) and, per disk Jarl uses, how full it is and the databases' share. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
fef916b7ab
commit
01ec37b9b2
21 files changed
+922
-27
No files matched your search
@@ -60,6 +60,17 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
- Comparing one name across views: `jarl zone diff adbraun.lol nas [A]`,
|
||||||
and a **Compare** button on each record.
|
and a **Compare** button on each record.
|
||||||
|
|
||||||
|
- **Exploring the statistics**: jarld keeps every answer for a while
|
||||||
|
(`stats.retention.queries`, default 7 days), and every client, name,
|
||||||
|
view, record type, result and blocklist on the Statistics page leads to
|
||||||
|
an explore page for it: answers over time, the top clients, names,
|
||||||
|
views, types, results and blocklists among them (each a link that
|
||||||
|
narrows it further), and the latest answers. "NXDOMAIN in external" →
|
||||||
|
the clients asking → everything one of them asks. `GET /stats/explore`.
|
||||||
|
- **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
|
||||||
|
is and how much of it the databases take.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
- **Compare views** and `jarl zone diff` are rebuilt around the view
|
||||||
@@ -83,6 +94,8 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
|
starts; a 0.5 jarld can't open it afterwards, so take a backup first (`sudo
|
||||||
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
|
jarld backup`). Nothing changes until you turn `auto_ptr` on for a
|
||||||
zone.
|
zone.
|
||||||
|
- The statistics database gets a new version too (the query log); 0.5
|
||||||
|
can't open it afterwards either. It starts empty and fills from then on.
|
||||||
|
|
||||||
## [0.5.2] - 2026-10-12
|
## [0.5.2] - 2026-10-12
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -151,7 +151,8 @@ func statsOptions(cfg *config.Config) stats.Options {
|
|||||||
sc := cfg.Stats
|
sc := cfg.Stats
|
||||||
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
o := stats.Options{QueryLog: sc.QueryLog, LogFile: sc.LogFile, StatisticsChannel: sc.StatisticsChannel, TopN: sc.TopN,
|
||||||
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
RetainMinute: time.Duration(sc.Retention.Minute), RetainHour: time.Duration(sc.Retention.Hour), RetainDay: time.Duration(sc.Retention.Day),
|
||||||
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
RetainQueries: time.Duration(sc.Retention.Queries),
|
||||||
|
Anonymize: sc.AnonymizeClients, MainConfig: cfg.BIND.MainConfig}
|
||||||
for _, p := range sc.ExcludeClients {
|
for _, p := range sc.ExcludeClients {
|
||||||
if pf, err := config.ParsePrefix(p); err == nil {
|
if pf, err := config.ParsePrefix(p); err == nil {
|
||||||
o.Exclude = append(o.Exclude, pf.Masked())
|
o.Exclude = append(o.Exclude, pf.Masked())
|
||||||
|
|||||||
@@ -565,6 +565,7 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
|
|||||||
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
| GET | `/stats/timeline` | `since`, `step` (auto, 1m, 5m, 15m, 1h, 6h, 1d), `key` (repeatable or comma separated; `rcode:*` = every counter of a group; default `queries`) | `{step, times: [unix...], series: [{key, values}]}` |
|
||||||
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
| GET | `/stats/top` | `kind` (name, client, nxdomain, servfail, refused, blocked, blocked_client), `since`, `limit` (default 20) | `[{key, count}]`; client, refused and blocked_client entries add `name`, the host name of Jarl's record for the address (the summary's client list too) |
|
||||||
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
| GET | `/stats/heatmap` | `since` (default 28d), `key` (default queries) | `{key, from, avg[7][24], samples[7][24]}`, Monday first, local time, `-1` = no data |
|
||||||
|
| GET | `/stats/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}]}`; 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}]` |
|
||||||
|
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ stats:
|
|||||||
minute: 7d # per-minute counters
|
minute: 7d # per-minute counters
|
||||||
hour: 90d # hourly counters and top lists
|
hour: 90d # hourly counters and top lists
|
||||||
day: 730d # daily counters and top lists
|
day: 730d # daily counters and top lists
|
||||||
|
queries: 7d # every answer, for exploring (0: none)
|
||||||
top_n: 100 # names and clients kept per hour and per day
|
top_n: 100 # names and clients kept per hour and per day
|
||||||
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48)
|
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37 (IPv6: /48)
|
||||||
exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe
|
exclude_clients: [] # e.g. [10.10.0.250] to ignore a monitoring probe
|
||||||
@@ -192,6 +193,37 @@ jarl stats summary --since 30d -o json # export (also -o yaml)
|
|||||||
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
`operator` roles include it, `dns-editor` doesn't. Statistics show client
|
||||||
addresses and what they look up, so grant it with care.
|
addresses and what they look up, so grant it with care.
|
||||||
|
|
||||||
|
## Exploring
|
||||||
|
|
||||||
|
The counters and top lists say how much, not who asked for what: "100
|
||||||
|
NXDOMAIN answers in the external view" can't tell you which clients asked.
|
||||||
|
For that, jarld also keeps every answer for a while, the **query log**: time,
|
||||||
|
client, view, name, record type, result, and the blocklist (or your rules)
|
||||||
|
that blocked it. It is in the statistics database, from the response log
|
||||||
|
named writes anyway, and answers older than `retention.queries` (default
|
||||||
|
7 days) are deleted every hour; `0` keeps none.
|
||||||
|
|
||||||
|
With it, every client, name, view, record type, result and blocklist on the
|
||||||
|
Statistics page is a link to the **explore** page (`#/stats/explore`),
|
||||||
|
which shows, for what you clicked:
|
||||||
|
|
||||||
|
* the answers over time (and how many were blocked);
|
||||||
|
* the top clients, names, views, record types, results and blocklists
|
||||||
|
among them, each a link that narrows the filter further;
|
||||||
|
* the latest 100 answers, every cell a link.
|
||||||
|
|
||||||
|
So "external, NXDOMAIN" leads to the clients asking, a client to everything
|
||||||
|
it asks, one of its names to who else asks for it. Filters combine (this
|
||||||
|
client, in this view, blocked only) and each has a ✕ to take it out. A name
|
||||||
|
can be widened to the names below it (`domain=`). The Statistics page's
|
||||||
|
view switches apply here too.
|
||||||
|
|
||||||
|
`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
|
||||||
|
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
|
||||||
|
and how many answers the query log holds.
|
||||||
|
|
||||||
## Views
|
## Views
|
||||||
|
|
||||||
With views, the Statistics page has a toggle per view (and **All** and
|
With views, the Statistics page has a toggle per view (and **All** and
|
||||||
|
|||||||
@@ -277,6 +277,35 @@ type StatusResponse struct {
|
|||||||
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
|
// EncryptedDNS is the last check of DNS over TLS and HTTPS, when it
|
||||||
// is set up (for those who may see options).
|
// is set up (for those who may see options).
|
||||||
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
|
EncryptedDNS *EncDNSSummary `json:"encrypted_dns,omitempty"`
|
||||||
|
// Storage is the size of Jarl's databases and how full the disks Jarl
|
||||||
|
// uses are (administrators only).
|
||||||
|
Storage *Storage `json:"storage,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Storage is the size of Jarl's databases and the disks Jarl uses.
|
||||||
|
type Storage struct {
|
||||||
|
Databases []Database `json:"databases"`
|
||||||
|
Total int64 `json:"total"` // the databases' bytes
|
||||||
|
Disks []Disk `json:"disks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Database is one of Jarl's databases: its file with its -wal and -shm.
|
||||||
|
type Database struct {
|
||||||
|
Label string `json:"label"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Bytes int64 `json:"bytes"`
|
||||||
|
// Rows is how many answers the query log holds (statistics only).
|
||||||
|
QueryLogRows *int64 `json:"query_log_rows,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disk is a file system Jarl uses: what of Jarl's it holds, its size,
|
||||||
|
// what is free, and how much of it the databases take.
|
||||||
|
type Disk struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
Holds []string `json:"holds"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Free int64 `json:"free"`
|
||||||
|
Databases int64 `json:"databases"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CertSummary is what the dashboard reports about certificates.
|
// CertSummary is what the dashboard reports about certificates.
|
||||||
|
|||||||
@@ -395,6 +395,9 @@ type StatsRetention struct {
|
|||||||
Minute Duration `yaml:"minute"`
|
Minute Duration `yaml:"minute"`
|
||||||
Hour Duration `yaml:"hour"`
|
Hour Duration `yaml:"hour"`
|
||||||
Day Duration `yaml:"day"`
|
Day Duration `yaml:"day"`
|
||||||
|
// Queries is how long the query log keeps every answer, for exploring
|
||||||
|
// (docs/stats.md#exploring); 0 keeps none.
|
||||||
|
Queries Duration `yaml:"queries"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
|
// Duration is a time.Duration written as "15s" or "12h" in YAML. Days
|
||||||
@@ -461,7 +464,7 @@ func Default() *Config {
|
|||||||
LogFile: "/var/log/named/jarl-responses.log",
|
LogFile: "/var/log/named/jarl-responses.log",
|
||||||
LogSize: "20m",
|
LogSize: "20m",
|
||||||
LogVersions: 2,
|
LogVersions: 2,
|
||||||
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour)},
|
Retention: StatsRetention{Minute: Duration(7 * 24 * time.Hour), Hour: Duration(90 * 24 * time.Hour), Day: Duration(730 * 24 * time.Hour), Queries: Duration(7 * 24 * time.Hour)},
|
||||||
TopN: 100,
|
TopN: 100,
|
||||||
},
|
},
|
||||||
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
ACME: ACME{HTTPReq: HTTPReq{RequireHTTPS: true}, Account: ACMEAccount{Directory: "letsencrypt"},
|
||||||
@@ -604,6 +607,9 @@ func (s *Stats) check() error {
|
|||||||
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
|
if r.Minute < Duration(time.Hour) || r.Hour < Duration(24*time.Hour) || r.Day < Duration(24*time.Hour) {
|
||||||
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
|
return fmt.Errorf("retention must keep minutes for at least 1h, hours and days for at least 1d")
|
||||||
}
|
}
|
||||||
|
if r.Queries < 0 || (r.Queries > 0 && r.Queries < Duration(time.Hour)) {
|
||||||
|
return fmt.Errorf("retention.queries must be at least 1h, or 0 for no query log")
|
||||||
|
}
|
||||||
if s.TopN < 10 || s.TopN > 10000 {
|
if s.TopN < 10 || s.TopN > 10000 {
|
||||||
return fmt.Errorf("top_n must be between 10 and 10000")
|
return fmt.Errorf("top_n must be between 10 and 10000")
|
||||||
}
|
}
|
||||||
@@ -754,6 +760,7 @@ stats:
|
|||||||
minute: 7d # per-minute counters
|
minute: 7d # per-minute counters
|
||||||
hour: 90d # hourly counters and top lists
|
hour: 90d # hourly counters and top lists
|
||||||
day: 730d # daily counters and top lists
|
day: 730d # daily counters and top lists
|
||||||
|
queries: 7d # every answer, to explore (who asked what); 0: none
|
||||||
top_n: 100 # names and clients kept per hour and per day
|
top_n: 100 # names and clients kept per hour and per day
|
||||||
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
|
anonymize_clients: false # store 10.10.0.0/24 instead of 10.10.0.37
|
||||||
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
|
exclude_clients: [] # e.g. [127.0.0.1] to ignore local monitoring
|
||||||
|
|||||||
@@ -285,6 +285,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
|
|||||||
resp.Backup = s.backupStatus(r.Context(), now)
|
resp.Backup = s.backupStatus(r.Context(), now)
|
||||||
resp.TOTP = s.totpOverview(r.Context())
|
resp.TOTP = s.totpOverview(r.Context())
|
||||||
resp.Certificates = s.certSummary(r.Context())
|
resp.Certificates = s.certSummary(r.Context())
|
||||||
|
resp.Storage = s.storage(r.Context())
|
||||||
}
|
}
|
||||||
if p.Can(auth.Action{Resource: auth.ResOptions}) {
|
if p.Can(auth.Action{Resource: auth.ResOptions}) {
|
||||||
if h := s.encryptedDNSHealth(); h != nil {
|
if h := s.encryptedDNSHealth(); h != nil {
|
||||||
|
|||||||
@@ -286,3 +286,59 @@ func (s *Server) statsServer(w http.ResponseWriter, r *http.Request, p *auth.Pri
|
|||||||
writeJSON(w, http.StatusOK, out)
|
writeJSON(w, http.StatusOK, out)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// statsExplore: GET /stats/explore?client=&name=&domain=&view=&type=&rcode=
|
||||||
|
// &blocked=&since=24h explores the query log (docs/stats.md#exploring):
|
||||||
|
// totals, a series, the top values of each dimension left open, and the
|
||||||
|
// latest answers, with client host names.
|
||||||
|
func (s *Server) statsExplore(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
|
||||||
|
q, err := s.statsQuerier(r, p)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
since, err := statsSince(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
v := r.URL.Query()
|
||||||
|
f := stats.ExploreFilter{Client: v.Get("client"), Name: v.Get("name"), Domain: v.Get("domain"), View: v.Get("view"),
|
||||||
|
Type: v.Get("type"), RCode: v.Get("rcode"), Blocked: v.Get("blocked"), Since: since}
|
||||||
|
ex, err := q.Explore(r.Context(), f, 15, 100)
|
||||||
|
if errors.Is(err, stats.ErrNoQueryLog) {
|
||||||
|
return &httpError{http.StatusNotFound, "query_log_off", err.Error()}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.nameClients(r, p, ex.By["client"]); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// The recent answers' clients, named once each.
|
||||||
|
var es []stats.Entry
|
||||||
|
seen := map[string]int{}
|
||||||
|
for _, row := range ex.Recent {
|
||||||
|
if _, ok := seen[row.Client]; !ok {
|
||||||
|
seen[row.Client] = len(es)
|
||||||
|
es = append(es, stats.Entry{Key: row.Client})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.nameClients(r, p, es); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for i := range ex.Recent {
|
||||||
|
ex.Recent[i].ClientName = es[seen[ex.Recent[i].Client]].Name
|
||||||
|
}
|
||||||
|
// The filter's client, named too.
|
||||||
|
out := struct {
|
||||||
|
*stats.Exploration
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}{Exploration: ex}
|
||||||
|
if f.Client != "" {
|
||||||
|
one := []stats.Entry{{Key: f.Client}}
|
||||||
|
if err := s.nameClients(r, p, one); err == nil {
|
||||||
|
out.ClientName = one[0].Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"jarl/internal/api"
|
||||||
|
"jarl/internal/stats"
|
||||||
|
)
|
||||||
|
|
||||||
|
// storage reports the size of Jarl's databases and how full the disks
|
||||||
|
// Jarl uses are (the dashboard's Storage card).
|
||||||
|
func (s *Server) storage(ctx context.Context) *api.Storage {
|
||||||
|
out := &api.Storage{Databases: []api.Database{}, Disks: []api.Disk{}}
|
||||||
|
dbFile := func(label, path string) *api.Database {
|
||||||
|
if path == "" || path == ":memory:" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
d := api.Database{Label: label, Path: 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.Total += d.Bytes
|
||||||
|
return &out.Databases[len(out.Databases)-1]
|
||||||
|
}
|
||||||
|
labels := []string{}
|
||||||
|
dirs := map[string]string{}
|
||||||
|
add := func(label, dir string) {
|
||||||
|
if dir != "" {
|
||||||
|
labels = append(labels, label)
|
||||||
|
dirs[label] = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const jarlDB, statsDB = "Jarl's database", "statistics database"
|
||||||
|
if p := s.store.Path(); p != "" {
|
||||||
|
dbFile(jarlDB, p)
|
||||||
|
add(jarlDB, filepath.Dir(p))
|
||||||
|
}
|
||||||
|
if s.cfg.Stats != nil {
|
||||||
|
if p := s.cfg.Stats.Store().Path(); p != "" && p != ":memory:" {
|
||||||
|
if d := dbFile(statsDB, p); d != nil && s.cfg.Stats.Options().RetainQueries > 0 {
|
||||||
|
if n, err := s.cfg.Stats.Store().QueryLogSize(ctx); err == nil {
|
||||||
|
d.QueryLogRows = &n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(statsDB, filepath.Dir(p))
|
||||||
|
}
|
||||||
|
if lf := s.cfg.Stats.Options().LogFile; lf != "" {
|
||||||
|
add("named's logs", filepath.Dir(lf))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
paths := s.eng.Config().Paths
|
||||||
|
add("zone files", paths.ZoneDir)
|
||||||
|
add("named.conf", paths.ConfigDir)
|
||||||
|
add("Jarl's cache and blocklists", paths.CacheDir)
|
||||||
|
add("backups", s.cfg.BackupDir)
|
||||||
|
for _, d := range stats.DiskUsage(labels, dirs) {
|
||||||
|
disk := api.Disk{Path: d.Path, Holds: d.Holds, Total: d.Total, Free: d.Free}
|
||||||
|
for _, db := range out.Databases {
|
||||||
|
if slices.Contains(d.Holds, db.Label) {
|
||||||
|
disk.Databases += db.Bytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.Disks = append(out.Disks, disk)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -195,6 +195,7 @@ func (s *Server) routes() {
|
|||||||
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
m.HandleFunc("GET "+p+"/stats/heatmap", s.authed(s.statsHeatmap))
|
||||||
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
|
m.HandleFunc("GET "+p+"/stats/breakdown", s.authed(s.statsBreakdown))
|
||||||
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
|
m.HandleFunc("GET "+p+"/stats/server", s.authed(s.statsServer))
|
||||||
|
m.HandleFunc("GET "+p+"/stats/explore", s.authed(s.statsExplore))
|
||||||
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
m.HandleFunc(p+"/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
writeError(w, http.StatusNotFound, "not_found", "no such API endpoint: "+r.Method+" "+r.URL.Path)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -121,3 +121,58 @@ func TestStatsAPI(t *testing.T) {
|
|||||||
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
vtok := e.do("POST", "/users/"+itoa(viewerID)+"/tokens", e.token, map[string]any{"name": "t"}, 201)["token"].(string)
|
||||||
e.do("GET", "/stats/summary", vtok, nil, 200)
|
e.do("GET", "/stats/summary", vtok, nil, 200)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStatsExploreAPI(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
ss, err := stats.OpenStore(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer ss.Close()
|
||||||
|
now := time.Now().Unix()
|
||||||
|
b := &stats.Batch{}
|
||||||
|
b.AddQueries([]stats.QueryRow{
|
||||||
|
{Time: now - 60, Client: "10.0.0.7", View: "internal", Name: "www.example.com", Type: "A", RCode: "NOERROR"},
|
||||||
|
{Time: now - 50, Client: "10.0.0.7", View: "internal", Name: "ads.tracker.test", Type: "A", RCode: "NXDOMAIN", Blocked: "hagezi"},
|
||||||
|
{Time: now - 40, Client: "198.51.100.9", View: "external", Name: "nope.example.com", Type: "A", RCode: "NXDOMAIN"},
|
||||||
|
})
|
||||||
|
if err := ss.Commit(ctx, 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}
|
||||||
|
c := stats.NewCollector(ss, opts, nil, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
e := newAPIEnv(t, c)
|
||||||
|
e.do("POST", "/zones", e.token, map[string]any{"name": "example.com", "records": []map[string]any{{"name": "ns1", "type": "A", "data": "192.0.2.1"}, {"name": "laptop", "type": "A", "data": "10.0.0.7"}}}, 201)
|
||||||
|
|
||||||
|
ex := e.do("GET", "/stats/explore?rcode=NXDOMAIN&since=1h", e.token, nil, 200)
|
||||||
|
if ex["total"].(float64) != 2 || ex["blocked"].(float64) != 1 {
|
||||||
|
t.Errorf("NXDOMAIN: %v", ex)
|
||||||
|
}
|
||||||
|
clients := ex["by"].(map[string]any)["client"].([]any)
|
||||||
|
if len(clients) != 2 {
|
||||||
|
t.Fatalf("clients %v", clients)
|
||||||
|
}
|
||||||
|
named := false
|
||||||
|
for _, c := range clients {
|
||||||
|
m := c.(map[string]any)
|
||||||
|
named = named || m["key"] == "10.0.0.7" && m["name"] == "laptop.example.com"
|
||||||
|
}
|
||||||
|
if !named {
|
||||||
|
t.Errorf("client names %v", clients)
|
||||||
|
}
|
||||||
|
ex = e.do("GET", "/stats/explore?client=10.0.0.7", e.token, nil, 200)
|
||||||
|
recent := ex["recent"].([]any)
|
||||||
|
if ex["total"].(float64) != 2 || ex["client_name"] != "laptop.example.com" || len(recent) != 2 || recent[0].(map[string]any)["client_name"] != "laptop.example.com" {
|
||||||
|
t.Errorf("laptop: %v", ex)
|
||||||
|
}
|
||||||
|
if ex := e.do("GET", "/stats/explore?client=10.0.0.7&views=external", e.token, nil, 200); ex["total"].(float64) != 0 {
|
||||||
|
t.Errorf("laptop in external only: %v", ex["total"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The dashboard reports the databases' size.
|
||||||
|
st := e.do("GET", "/status", e.token, nil, 200)
|
||||||
|
sg, ok := st["storage"].(map[string]any)
|
||||||
|
if !ok || len(sg["databases"].([]any)) == 0 {
|
||||||
|
t.Errorf("storage %v", st["storage"])
|
||||||
|
}
|
||||||
|
}
|
||||||
+42
-12
@@ -56,9 +56,12 @@ type Options struct {
|
|||||||
RetainMinute time.Duration
|
RetainMinute time.Duration
|
||||||
RetainHour time.Duration
|
RetainHour time.Duration
|
||||||
RetainDay time.Duration
|
RetainDay time.Duration
|
||||||
Anonymize bool
|
// RetainQueries is how long the query log keeps every answer (0: no
|
||||||
Exclude []netip.Prefix
|
// query log; it needs QueryLog).
|
||||||
Location *time.Location // for days and log timestamps (default: local time)
|
RetainQueries time.Duration
|
||||||
|
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
|
// MainConfig is named's main configuration file; it tells named's
|
||||||
// process apart from other named processes (for its memory and CPU).
|
// process apart from other named processes (for its memory and CPU).
|
||||||
MainConfig string
|
MainConfig string
|
||||||
@@ -87,9 +90,10 @@ type Status struct {
|
|||||||
Warnings []string `json:"warnings"`
|
Warnings []string `json:"warnings"`
|
||||||
FirstData *time.Time `json:"first_data,omitempty"`
|
FirstData *time.Time `json:"first_data,omitempty"`
|
||||||
Retention struct {
|
Retention struct {
|
||||||
Minute string `json:"minute"`
|
Minute string `json:"minute"`
|
||||||
Hour string `json:"hour"`
|
Hour string `json:"hour"`
|
||||||
Day string `json:"day"`
|
Day string `json:"day"`
|
||||||
|
Queries string `json:"queries"` // the query log ("" when off)
|
||||||
} `json:"retention"`
|
} `json:"retention"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,9 +123,11 @@ type Collector struct {
|
|||||||
recentL []int64 // response log lines per minute, last 10 minutes
|
recentL []int64 // response log lines per minute, last 10 minutes
|
||||||
linesMin int64
|
linesMin int64
|
||||||
ensureAt time.Time
|
ensureAt time.Time
|
||||||
// rewritten holds queries named blocked (client#port|name) until their
|
// rewritten holds queries named blocked (client#port|name), and what
|
||||||
// response line arrives.
|
// blocked them, until their response line arrives.
|
||||||
rewritten map[string]bool
|
rewritten map[string]string
|
||||||
|
// pending are answers for the query log, written at the next flush.
|
||||||
|
pending []QueryRow
|
||||||
// viewsSince is set once per-view counters exist (meta views_since).
|
// viewsSince is set once per-view counters exist (meta views_since).
|
||||||
viewsSince bool
|
viewsSince bool
|
||||||
viewsSinceAt int64
|
viewsSinceAt int64
|
||||||
@@ -137,6 +143,10 @@ type Collector struct {
|
|||||||
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
// maxKeys bounds the per-hour maps; rarely seen keys are dropped first.
|
||||||
const maxKeys = 200_000
|
const maxKeys = 200_000
|
||||||
|
|
||||||
|
// maxPending bounds the answers waiting for the query log between flushes
|
||||||
|
// (a minute); past it they are counted but not logged.
|
||||||
|
const maxPending = 500_000
|
||||||
|
|
||||||
// NewCollector creates a collector. Run starts it.
|
// NewCollector creates a collector. Run starts it.
|
||||||
func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector {
|
func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger) *Collector {
|
||||||
if opts.Location == nil {
|
if opts.Location == nil {
|
||||||
@@ -154,6 +164,9 @@ func NewCollector(store *Store, opts Options, zones ZoneIndex, log *slog.Logger)
|
|||||||
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
c.status.Retention.Minute = humanDuration(opts.RetainMinute)
|
||||||
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
c.status.Retention.Hour = humanDuration(opts.RetainHour)
|
||||||
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
c.status.Retention.Day = humanDuration(opts.RetainDay)
|
||||||
|
if opts.QueryLog && opts.RetainQueries > 0 {
|
||||||
|
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
||||||
|
}
|
||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,7 +375,8 @@ func (c *Collector) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
blocked := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
|
blockedBy := c.rewritten[ev.Client.String()+"#"+ev.Port+"|"+ev.Name]
|
||||||
|
blocked := blockedBy != ""
|
||||||
if blocked {
|
if blocked {
|
||||||
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
|
delete(c.rewritten, ev.Client.String()+"#"+ev.Port+"|"+ev.Name)
|
||||||
}
|
}
|
||||||
@@ -405,6 +419,9 @@ func (c *Collector) handleLine(line string) {
|
|||||||
}
|
}
|
||||||
add(KindName, ev.Name)
|
add(KindName, ev.Name)
|
||||||
add(KindClient, client)
|
add(KindClient, client)
|
||||||
|
if c.opts.RetainQueries > 0 && len(c.pending) < maxPending {
|
||||||
|
c.pending = append(c.pending, QueryRow{Time: ev.Time.Unix(), Client: client, View: view, Name: ev.Name, Type: ev.Type, RCode: ev.RCode, Blocked: blockedBy})
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
case blocked:
|
case blocked:
|
||||||
// Counted by handleRewrite; not a name that doesn't exist.
|
// Counted by handleRewrite; not a name that doesn't exist.
|
||||||
@@ -445,9 +462,9 @@ func (c *Collector) handleRewrite(rw Rewrite) {
|
|||||||
if c.rewritten == nil || len(c.rewritten) > 10_000 {
|
if c.rewritten == nil || len(c.rewritten) > 10_000 {
|
||||||
// Rewrites whose response line never came (named only logs
|
// Rewrites whose response line never came (named only logs
|
||||||
// responses while response logging is on) don't pile up.
|
// responses while response logging is on) don't pile up.
|
||||||
c.rewritten = map[string]bool{}
|
c.rewritten = map[string]string{}
|
||||||
}
|
}
|
||||||
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = true
|
c.rewritten[rw.Client.String()+"#"+rw.Port+"|"+rw.Name] = policySource(rw.Via)
|
||||||
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
minute, hour := c.minuteOf(rw.Time), c.hourOf(rw.Time)
|
||||||
view := rw.View
|
view := rw.View
|
||||||
if view == "" {
|
if view == "" {
|
||||||
@@ -639,6 +656,8 @@ func (c *Collector) flush(ctx context.Context, final bool) {
|
|||||||
pos, _ := json.Marshal(c.tail.Position())
|
pos, _ := json.Marshal(c.tail.Position())
|
||||||
b.SetMeta("tail", string(pos))
|
b.SetMeta("tail", string(pos))
|
||||||
}
|
}
|
||||||
|
b.AddQueries(c.pending)
|
||||||
|
c.pending = nil
|
||||||
c.updateWarnings()
|
c.updateWarnings()
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
@@ -737,6 +756,17 @@ func (c *Collector) prune(ctx context.Context) {
|
|||||||
c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
|
c.log.Debug("statistics: pruned", "resolution", res, "rows", n)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// The query log keeps answers for RetainQueries; with no query log,
|
||||||
|
// none at all (it may have been turned off).
|
||||||
|
keep := c.opts.RetainQueries
|
||||||
|
if !c.opts.QueryLog {
|
||||||
|
keep = 0
|
||||||
|
}
|
||||||
|
if n, err := c.store.PruneQueries(ctx, now-int64(keep.Seconds())); err != nil {
|
||||||
|
c.log.Warn("statistics: pruning the query log failed", "err", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
c.log.Debug("statistics: pruned the query log", "rows", n)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func humanDuration(d time.Duration) string {
|
func humanDuration(d time.Duration) string {
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Exploring the query log (docs/stats.md#exploring): every answer of the
|
||||||
|
// last days (stats.query_retention), filtered by any mix of client, name,
|
||||||
|
// view, type, result and blocking, broken down by each of them.
|
||||||
|
|
||||||
|
// ExploreFilter narrows the query log. Empty fields don't filter.
|
||||||
|
type ExploreFilter struct {
|
||||||
|
Client string // a client key, as the top lists show it
|
||||||
|
Name string // exactly this name
|
||||||
|
Domain string // this name or any below it
|
||||||
|
View string
|
||||||
|
Type string
|
||||||
|
RCode string
|
||||||
|
// Blocked is "yes" (blocked by anything), "no", or a blocklist's name
|
||||||
|
// ("rules" for your own rules).
|
||||||
|
Blocked string
|
||||||
|
Since time.Duration // how far back (default 24h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dimensions are what the query log breaks down by, in the order shown.
|
||||||
|
var Dimensions = []string{"client", "name", "view", "type", "rcode", "blocked"}
|
||||||
|
|
||||||
|
// Exploration is what the query log says for a filter.
|
||||||
|
type Exploration struct {
|
||||||
|
From time.Time `json:"from"`
|
||||||
|
To time.Time `json:"to"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
// Blocked counts the blocked answers among them.
|
||||||
|
Blocked int64 `json:"blocked"`
|
||||||
|
// Series are the answers (and blocked ones) per Step seconds.
|
||||||
|
Step int64 `json:"step"`
|
||||||
|
Series []ExplorePoint `json:"series"`
|
||||||
|
// By are the top values of each dimension the filter leaves open.
|
||||||
|
By map[string][]Entry `json:"by"`
|
||||||
|
// Recent are the latest answers.
|
||||||
|
Recent []QueryRow `json:"recent"`
|
||||||
|
// Oldest is the first answer the query log still has.
|
||||||
|
Oldest *time.Time `json:"oldest,omitempty"`
|
||||||
|
// Retention is how long it keeps them.
|
||||||
|
Retention string `json:"retention"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ExplorePoint is one step of the series.
|
||||||
|
type ExplorePoint struct {
|
||||||
|
Time int64 `json:"t"`
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
Blocked int64 `json:"blocked"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrNoQueryLog is returned when the query log is off.
|
||||||
|
var ErrNoQueryLog = fmt.Errorf("the query log is off: it needs stats.query_log and a stats.query_retention above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
|
||||||
|
|
||||||
|
// where builds the SQL condition and arguments for a filter.
|
||||||
|
func (q *Querier) where(f ExploreFilter, from int64) (string, []any) {
|
||||||
|
conds := []string{"ts >= ?"}
|
||||||
|
args := []any{from}
|
||||||
|
eq := func(col, v string) {
|
||||||
|
if v != "" {
|
||||||
|
conds = append(conds, col+" = ?")
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eq("client", f.Client)
|
||||||
|
eq("name", strings.ToLower(strings.TrimSuffix(f.Name, ".")))
|
||||||
|
eq("view", f.View)
|
||||||
|
eq("type", strings.ToUpper(f.Type))
|
||||||
|
eq("rcode", strings.ToUpper(f.RCode))
|
||||||
|
if d := strings.ToLower(strings.TrimSuffix(f.Domain, ".")); d != "" {
|
||||||
|
conds = append(conds, "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))")
|
||||||
|
args = append(args, d, d, d, "."+d)
|
||||||
|
}
|
||||||
|
switch f.Blocked {
|
||||||
|
case "":
|
||||||
|
case "yes":
|
||||||
|
conds = append(conds, "blocked != ''")
|
||||||
|
case "no":
|
||||||
|
conds = append(conds, "blocked = ''")
|
||||||
|
default:
|
||||||
|
eq("blocked", f.Blocked)
|
||||||
|
}
|
||||||
|
if len(q.Views) > 0 {
|
||||||
|
marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",")
|
||||||
|
conds = append(conds, "view IN ("+marks+")")
|
||||||
|
for _, v := range q.Views {
|
||||||
|
args = append(args, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return strings.Join(conds, " AND "), args
|
||||||
|
}
|
||||||
|
|
||||||
|
// Explore answers a filter: totals, a series, the top values of each open
|
||||||
|
// dimension (topN each) and the latest answers (recent of them).
|
||||||
|
func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int) (*Exploration, error) {
|
||||||
|
if !q.Opts.QueryLog || q.Opts.RetainQueries <= 0 {
|
||||||
|
return nil, ErrNoQueryLog
|
||||||
|
}
|
||||||
|
now := q.Now()
|
||||||
|
since := f.Since
|
||||||
|
if since <= 0 {
|
||||||
|
since = 24 * time.Hour
|
||||||
|
}
|
||||||
|
if since > q.Opts.RetainQueries {
|
||||||
|
since = q.Opts.RetainQueries
|
||||||
|
}
|
||||||
|
from := now.Add(-since).Unix()
|
||||||
|
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
|
||||||
|
Retention: humanDuration(q.Opts.RetainQueries)}
|
||||||
|
cond, args := q.where(f, from)
|
||||||
|
db := q.Store.db
|
||||||
|
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT count(*), coalesce(sum(blocked != ''), 0) FROM queries WHERE `+cond, args...).Scan(&out.Total, &out.Blocked); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// The series: about 100 steps over the range, in whole minutes.
|
||||||
|
step := int64(since.Seconds()) / 100
|
||||||
|
step = max(60, step-step%60)
|
||||||
|
out.Step = step
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT ts - ts % ? AS t, count(*), sum(blocked != '') FROM queries WHERE `+cond+` GROUP BY t ORDER BY t`, append([]any{step}, args...)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got := map[int64]ExplorePoint{}
|
||||||
|
for rows.Next() {
|
||||||
|
var p ExplorePoint
|
||||||
|
if err := rows.Scan(&p.Time, &p.Count, &p.Blocked); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
got[p.Time] = p
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
for t := from - from%step; t <= now.Unix(); t += step {
|
||||||
|
p := got[t]
|
||||||
|
p.Time = t
|
||||||
|
out.Series = append(out.Series, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
open := map[string]bool{"client": f.Client == "", "name": f.Name == "", "view": f.View == "", "type": f.Type == "", "rcode": f.RCode == "", "blocked": f.Blocked == "" || f.Blocked == "yes"}
|
||||||
|
for _, dim := range Dimensions {
|
||||||
|
if !open[dim] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c := cond
|
||||||
|
if dim == "blocked" {
|
||||||
|
c += " AND blocked != ''"
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, `SELECT `+dim+`, count(*) AS n FROM queries WHERE `+c+` GROUP BY `+dim+` ORDER BY n DESC, `+dim+` LIMIT ?`, append(args, topN)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
es := []Entry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e Entry
|
||||||
|
if err := rows.Scan(&e.Key, &e.Count); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
es = append(es, e)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
out.By[dim] = es
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err = db.QueryContext(ctx, `SELECT ts, client, view, name, type, rcode, blocked FROM queries WHERE `+cond+` ORDER BY ts DESC, rowid DESC LIMIT ?`, append(args, recent)...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var r QueryRow
|
||||||
|
if err := rows.Scan(&r.Time, &r.Client, &r.View, &r.Name, &r.Type, &r.RCode, &r.Blocked); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out.Recent = append(out.Recent, r)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
var oldest *int64
|
||||||
|
if err := db.QueryRowContext(ctx, `SELECT min(ts) FROM queries`).Scan(&oldest); err == nil && oldest != nil {
|
||||||
|
t := time.Unix(*oldest, 0)
|
||||||
|
out.Oldest = &t
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryLogSize returns how many answers the query log has.
|
||||||
|
func (s *Store) QueryLogSize(ctx context.Context) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM queries`).Scan(&n)
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package stats
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestExplore(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
loc := time.UTC
|
||||||
|
store, err := OpenStore(filepath.Join(t.TempDir(), "stats.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer store.Close()
|
||||||
|
clk := &clock{time.Date(2026, 10, 13, 12, 0, 30, 0, loc)}
|
||||||
|
opts := Options{QueryLog: true, LogFile: "unused", StatisticsChannel: "unused", TopN: 10, RetainMinute: time.Hour, RetainHour: time.Hour, RetainDay: time.Hour,
|
||||||
|
RetainQueries: 7 * 24 * time.Hour, Location: loc}
|
||||||
|
c := NewCollector(store, opts, func() map[string][]string { return nil }, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||||
|
c.now = clk.now
|
||||||
|
at := clk.t.Add(-10 * time.Minute)
|
||||||
|
// The phone: lookups, an NXDOMAIN, a blocked tracker.
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "www.adbraun.lol", "A", "NOERROR"))
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "nas.adbraun.lol", "AAAA", "NOERROR"))
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "typo.adbraun.lol", "A", "NXDOMAIN"))
|
||||||
|
c.HandleLine(fmt.Sprintf("%s client @0x7f0000000000 10.0.0.5#5353 (t.tracker.example): view internal: rpz QNAME NXDOMAIN rewrite t.tracker.example/A/IN via t.tracker.example.hagezi.blocklist.jarl.invalid\n", at.Format(logTimeLayout)))
|
||||||
|
c.HandleLine(logLine(at, "10.0.0.5", "internal", "t.tracker.example", "A", "NXDOMAIN"))
|
||||||
|
// A scanner in the external view, NXDOMAIN twice.
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
c.HandleLine(logLine(at, "198.51.100.7", "external", "nope.adbraun.lol", "A", "NXDOMAIN"))
|
||||||
|
}
|
||||||
|
c.flush(ctx, true)
|
||||||
|
|
||||||
|
q := &Querier{Store: store, Opts: opts, Now: clk.now}
|
||||||
|
ex, err := q.Explore(ctx, ExploreFilter{View: "external", RCode: "NXDOMAIN"}, 10, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ex.Total != 2 || len(ex.By["client"]) != 1 || ex.By["client"][0].Key != "198.51.100.7" || ex.By["view"] != nil {
|
||||||
|
t.Errorf("external NXDOMAIN: %+v", ex)
|
||||||
|
}
|
||||||
|
// What else does the phone do?
|
||||||
|
ex, err = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5"}, 10, 50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if ex.Total != 4 || ex.Blocked != 1 || len(ex.Recent) != 4 {
|
||||||
|
t.Errorf("phone: total %d blocked %d recent %d", ex.Total, ex.Blocked, len(ex.Recent))
|
||||||
|
}
|
||||||
|
if b := ex.By["blocked"]; len(b) != 1 || b[0].Key != "hagezi" || b[0].Count != 1 {
|
||||||
|
t.Errorf("blocked by %+v", b)
|
||||||
|
}
|
||||||
|
if r := ex.By["rcode"]; len(r) != 2 || r[0].Key != "NOERROR" || r[0].Count != 2 {
|
||||||
|
t.Errorf("rcode %+v", r)
|
||||||
|
}
|
||||||
|
var sum int64
|
||||||
|
for _, p := range ex.Series {
|
||||||
|
sum += p.Count
|
||||||
|
}
|
||||||
|
if sum != 4 || ex.Step != 60*int64(24*60/100) {
|
||||||
|
t.Errorf("series sum %d step %d", sum, ex.Step)
|
||||||
|
}
|
||||||
|
// A domain and what's below it.
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Domain: "adbraun.lol"}, 10, 50)
|
||||||
|
if ex.Total != 5 {
|
||||||
|
t.Errorf("domain adbraun.lol: %d", ex.Total)
|
||||||
|
}
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Name: "nas.adbraun.lol", Type: "aaaa"}, 10, 50)
|
||||||
|
if ex.Total != 1 || ex.Recent[0].Client != "10.0.0.5" {
|
||||||
|
t.Errorf("nas AAAA: %+v", ex)
|
||||||
|
}
|
||||||
|
ex, _ = q.Explore(ctx, ExploreFilter{Blocked: "no", Client: "10.0.0.5"}, 10, 50)
|
||||||
|
if ex.Total != 3 {
|
||||||
|
t.Errorf("not blocked: %d", ex.Total)
|
||||||
|
}
|
||||||
|
// Limited to a view (the Statistics page's view switches).
|
||||||
|
ex, _ = q.ForViews([]string{"internal"}).Explore(ctx, ExploreFilter{RCode: "NXDOMAIN"}, 10, 50)
|
||||||
|
if ex.Total != 2 {
|
||||||
|
t.Errorf("internal NXDOMAIN: %d", ex.Total)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pruning: past the retention they go.
|
||||||
|
clk.t = clk.t.Add(8 * 24 * time.Hour)
|
||||||
|
c.prune(ctx)
|
||||||
|
if n, _ := store.QueryLogSize(ctx); n != 0 {
|
||||||
|
t.Errorf("after pruning: %d rows", n)
|
||||||
|
}
|
||||||
|
// Off: an error that says how to turn it on.
|
||||||
|
q.Opts.RetainQueries = 0
|
||||||
|
if _, err := q.Explore(ctx, ExploreFilter{}, 10, 50); err != ErrNoQueryLog {
|
||||||
|
t.Errorf("off: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -45,6 +45,21 @@ var schema = []string{
|
|||||||
PRIMARY KEY (res, ts, kind, key)
|
PRIMARY KEY (res, ts, kind, key)
|
||||||
) WITHOUT ROWID;
|
) WITHOUT ROWID;
|
||||||
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);`,
|
||||||
|
// 2: the query log, every answer for a while (stats.query_retention),
|
||||||
|
// for drilling down: what a client asked, who asked for a name
|
||||||
|
// (docs/stats.md#exploring).
|
||||||
|
`CREATE TABLE queries (
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
client TEXT NOT NULL,
|
||||||
|
view TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
rcode TEXT NOT NULL,
|
||||||
|
blocked TEXT NOT NULL DEFAULT ''
|
||||||
|
);
|
||||||
|
CREATE INDEX queries_ts ON queries (ts);
|
||||||
|
CREATE INDEX queries_client ON queries (client, ts);
|
||||||
|
CREATE INDEX queries_name ON queries (name, ts);`,
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenStore opens (creating if needed) the statistics database. Use
|
// OpenStore opens (creating if needed) the statistics database. Use
|
||||||
@@ -123,8 +138,25 @@ type Batch struct {
|
|||||||
counters []counterRow
|
counters []counterRow
|
||||||
tops []topsWrite
|
tops []topsWrite
|
||||||
meta map[string]string
|
meta map[string]string
|
||||||
|
queries []QueryRow
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// QueryRow is one answer in the query log.
|
||||||
|
type QueryRow struct {
|
||||||
|
Time int64 `json:"ts"` // Unix seconds
|
||||||
|
Client string `json:"client"`
|
||||||
|
View string `json:"view"` // "_default" without views
|
||||||
|
Name string `json:"name"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
RCode string `json:"rcode"`
|
||||||
|
Blocked string `json:"blocked,omitempty"` // the blocklist (or "rules") that blocked it
|
||||||
|
// ClientName is the client's host name (set by the API).
|
||||||
|
ClientName string `json:"client_name,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddQueries adds answers to the query log.
|
||||||
|
func (b *Batch) AddQueries(rows []QueryRow) { b.queries = append(b.queries, rows...) }
|
||||||
|
|
||||||
type counterRow struct {
|
type counterRow struct {
|
||||||
res string
|
res string
|
||||||
ts int64
|
ts int64
|
||||||
@@ -220,9 +252,39 @@ func (s *Store) Commit(ctx context.Context, b *Batch) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if len(b.queries) > 0 {
|
||||||
|
st, err := tx.PrepareContext(ctx, `INSERT INTO queries (ts, client, view, name, type, rcode, blocked) VALUES (?, ?, ?, ?, ?, ?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, q := range b.queries {
|
||||||
|
if _, err := st.ExecContext(ctx, q.Time, q.Client, q.View, q.Name, q.Type, q.RCode, q.Blocked); err != nil {
|
||||||
|
st.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
st.Close()
|
||||||
|
}
|
||||||
return tx.Commit()
|
return tx.Commit()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PruneQueries deletes the query log's answers older than before (Unix
|
||||||
|
// time), a slice at a time so the database isn't locked for long.
|
||||||
|
func (s *Store) PruneQueries(ctx context.Context, before int64) (int64, error) {
|
||||||
|
var n int64
|
||||||
|
for {
|
||||||
|
r, err := s.db.ExecContext(ctx, `DELETE FROM queries WHERE rowid IN (SELECT rowid FROM queries WHERE ts < ? LIMIT 50000)`, before)
|
||||||
|
if err != nil {
|
||||||
|
return n, err
|
||||||
|
}
|
||||||
|
c, _ := r.RowsAffected()
|
||||||
|
n += c
|
||||||
|
if c < 50000 {
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Meta returns a stored value ("" if unset).
|
// Meta returns a stored value ("" if unset).
|
||||||
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
|
func (s *Store) Meta(ctx context.Context, key string) (string, error) {
|
||||||
var v string
|
var v string
|
||||||
|
|||||||
@@ -29,6 +29,15 @@ type Store struct {
|
|||||||
// DB exposes the underlying database (for backups and tests).
|
// DB exposes the underlying database (for backups and tests).
|
||||||
func (s *Store) DB() *sql.DB { return s.db }
|
func (s *Store) DB() *sql.DB { return s.db }
|
||||||
|
|
||||||
|
// Path returns the database file ("" in memory).
|
||||||
|
func (s *Store) Path() string {
|
||||||
|
var file string
|
||||||
|
if err := s.db.QueryRow(`SELECT file FROM pragma_database_list WHERE name = 'main'`).Scan(&file); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|
||||||
// Open opens (creating if needed) the database at path and applies
|
// Open opens (creating if needed) the database at path and applies
|
||||||
// migrations. Use ":memory:" for a throwaway in-memory database.
|
// migrations. Use ":memory:" for a throwaway in-memory database.
|
||||||
func Open(path string) (*Store, error) {
|
func Open(path string) (*Store, error) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import * as users from "./pages/users.js";
|
|||||||
import * as account from "./pages/account.js";
|
import * as account from "./pages/account.js";
|
||||||
import * as search from "./pages/search.js";
|
import * as search from "./pages/search.js";
|
||||||
import * as stats from "./pages/stats.js";
|
import * as stats from "./pages/stats.js";
|
||||||
|
import * as explore from "./pages/explore.js";
|
||||||
import * as blocking from "./pages/blocking.js";
|
import * as blocking from "./pages/blocking.js";
|
||||||
import * as exposure from "./pages/exposure.js";
|
import * as exposure from "./pages/exposure.js";
|
||||||
import * as certificates from "./pages/certificates.js";
|
import * as certificates from "./pages/certificates.js";
|
||||||
@@ -39,6 +40,7 @@ const routes = [
|
|||||||
{ pattern: /^account$/, page: account, nav: "account" },
|
{ pattern: /^account$/, page: account, nav: "account" },
|
||||||
{ pattern: /^search$/, page: search, nav: "" },
|
{ pattern: /^search$/, page: search, nav: "" },
|
||||||
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
{ pattern: /^stats$/, page: stats, nav: "stats" },
|
||||||
|
{ pattern: /^stats\/explore$/, page: explore, nav: "stats" },
|
||||||
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
{ pattern: /^blocking$/, page: blocking, nav: "blocking" },
|
||||||
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
{ pattern: /^exposure$/, page: exposure, nav: "exposure" },
|
||||||
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
|
{ pattern: /^certificates$/, page: certificates, nav: "certificates" },
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ export async function render(el) {
|
|||||||
st.backup ? backupCard(st.backup) : null,
|
st.backup ? backupCard(st.backup) : null,
|
||||||
exposure ? exposureCard(exposure) : null,
|
exposure ? exposureCard(exposure) : null,
|
||||||
st.certificates ? certCard(st.certificates) : null,
|
st.certificates ? certCard(st.certificates) : null,
|
||||||
|
st.storage ? storageCard(st.storage) : null,
|
||||||
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
st.encrypted_dns ? h("a", { class: "card link", href: "#/encrypted-dns" }, h("div", { class: "label" }, "Encrypted DNS"),
|
||||||
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
h("div", { class: "value" }, st.encrypted_dns.ok ? badge("working", "ok") : badge("not working", "err")),
|
||||||
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
h("div", { class: "sub" }, "checked ", time(st.encrypted_dns.checked))) : null,
|
||||||
@@ -100,6 +101,7 @@ export async function render(el) {
|
|||||||
h("h2", null, "Configuration checks"),
|
h("h2", null, "Configuration checks"),
|
||||||
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
|
real.length ? problemsList(real) : h("div", { class: "msg ok" }, "No problems found in the current configuration."),
|
||||||
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
|
problems.length > real.length ? h("details", null, h("summary", { class: "muted" }, plural(problems.length - real.length, "note")), problemsList(problems.filter((p) => p.severity === "info"))) : null));
|
||||||
|
if (st.storage) parts.push(storagePanel(st.storage));
|
||||||
if (s.paths) {
|
if (s.paths) {
|
||||||
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
|
parts.push(h("div", { class: "panel" }, h("h2", null, "Files"), h("dl", { class: "kv" },
|
||||||
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
|
h("dt", null, "named's main config"), h("dd", { class: "mono" }, s.main_config || "–"),
|
||||||
@@ -140,3 +142,44 @@ function certCard(c) {
|
|||||||
function card(label, value, sub) {
|
function card(label, value, sub) {
|
||||||
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), sub ? h("div", { class: "sub" }, sub) : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// storageCard: the databases' size, and the fullest disk Jarl uses.
|
||||||
|
function storageCard(sg) {
|
||||||
|
const fullest = sg.disks.reduce((a, d) => (!a || used(d) > used(a) ? d : a), null);
|
||||||
|
const value = h("span", null, bytes(sg.total));
|
||||||
|
const sub = fullest ? [h("span", { class: used(fullest) > 0.9 ? "warn-text" : "" }, Math.round(used(fullest) * 100) + "% full"), " · " + fullest.path] : "databases";
|
||||||
|
return h("a", { class: "card link", href: "#storage" , onclick: (e) => { e.preventDefault(); document.getElementById("storage")?.scrollIntoView({ behavior: "smooth" }); } },
|
||||||
|
h("div", { class: "label" }, "Databases"), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
// storagePanel shows each database's size and, per disk Jarl uses, a bar:
|
||||||
|
// the databases, everything else, and what is free.
|
||||||
|
function storagePanel(sg) {
|
||||||
|
const dbRows = sg.databases.map((d) => [h("dt", null, d.label),
|
||||||
|
h("dd", null, h("strong", null, bytes(d.bytes)), " ", h("span", { class: "mono small muted" }, d.path),
|
||||||
|
d.query_log_rows != null ? h("div", { class: "small muted" }, "the query log holds " + d.query_log_rows.toLocaleString() + " answers (stats.retention.queries)") : null)]);
|
||||||
|
return h("div", { class: "panel", id: "storage" },
|
||||||
|
h("h2", null, "Storage"),
|
||||||
|
h("dl", { class: "kv" }, dbRows, h("dt", null, "Together"), h("dd", null, h("strong", null, bytes(sg.total)))),
|
||||||
|
sg.disks.map((d) => {
|
||||||
|
const other = Math.max(0, d.total - d.free - d.databases);
|
||||||
|
const pctOf = (n) => (d.total ? (n / d.total) * 100 : 0) + "%";
|
||||||
|
const u = used(d);
|
||||||
|
return h("div", { class: "disk" },
|
||||||
|
h("div", { class: "disk-head" }, h("span", { class: "mono" }, d.path), h("span", { class: "muted small" }, " · " + d.holds.join(", ")),
|
||||||
|
h("span", { class: "grow" }), h("span", { class: u > 0.9 ? "warn-text" : "muted" }, bytes(d.free) + " free of " + bytes(d.total) + " (" + Math.round(u * 100) + "% used)")),
|
||||||
|
h("div", { class: "disk-bar", role: "img", "aria-label": Math.round(u * 100) + "% used, Jarl's databases " + bytes(d.databases) },
|
||||||
|
d.databases ? h("div", { class: "seg db", title: "Jarl's databases: " + bytes(d.databases), style: { width: pctOf(d.databases) } }) : null,
|
||||||
|
h("div", { class: "seg other" + (u > 0.9 ? " full" : ""), title: "Everything else: " + bytes(other), style: { width: pctOf(other) } })),
|
||||||
|
d.databases ? h("div", { class: "small muted" }, h("span", { class: "swatch db" }), " Jarl's databases " + bytes(d.databases), " ", h("span", { class: "swatch other" }), " everything else") : null);
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function used(d) { return d.total ? (d.total - d.free) / d.total : 0; }
|
||||||
|
|
||||||
|
function bytes(n) {
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let i = 0;
|
||||||
|
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
|
||||||
|
return (n >= 100 || i === 0 ? Math.round(n) : n.toFixed(1)) + " " + units[i];
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
// Exploring the query log (docs/stats.md#exploring): the answers of the
|
||||||
|
// last days, filtered by any mix of client, name, view, record type, result
|
||||||
|
// and blocking. Every value is a link that adds it to the filter, so a
|
||||||
|
// client leads to its names, a name to the clients asking for it, and so on.
|
||||||
|
import { h, mount, fmtTime, errorBox, badge, backLink } from "../ui.js";
|
||||||
|
import { get } from "../api.js";
|
||||||
|
import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js";
|
||||||
|
import { viewsOff } from "./stats.js";
|
||||||
|
|
||||||
|
const dims = [
|
||||||
|
["client", "Clients"], ["name", "Names"], ["view", "Views"],
|
||||||
|
["type", "Record types"], ["rcode", "Results"], ["blocked", "Blocked by"],
|
||||||
|
];
|
||||||
|
// What a filter is called, in the chips and the title.
|
||||||
|
const filterWords = { client: "client", name: "name", domain: "domain", view: "view", type: "type", rcode: "result", blocked: "blocked" };
|
||||||
|
const keys = Object.keys(filterWords);
|
||||||
|
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
|
||||||
|
|
||||||
|
// exploreHref is the explore page for these filters (and range).
|
||||||
|
export function exploreHref(filters, since) {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
for (const k of keys) if (filters[k]) q.set(k, filters[k]);
|
||||||
|
if (since && since !== "24h") q.set("since", since);
|
||||||
|
return "#/stats/explore" + (q.toString() ? "?" + q : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k);
|
||||||
|
|
||||||
|
export async function render(el, params, query) {
|
||||||
|
const f = {};
|
||||||
|
for (const k of keys) if (query.get(k)) f[k] = query.get(k);
|
||||||
|
const since = query.get("since") || "24h";
|
||||||
|
const with_ = (more) => exploreHref({ ...f, ...more }, since);
|
||||||
|
const without = (k) => { const g = { ...f }; delete g[k]; return exploreHref(g, since); };
|
||||||
|
|
||||||
|
// The Statistics page's view switches apply here too.
|
||||||
|
const off = viewsOff();
|
||||||
|
let views = "";
|
||||||
|
if (off.size) {
|
||||||
|
const all = (await get("/views").catch(() => [])).map((v) => v.name);
|
||||||
|
views = all.filter((v) => !off.has(v)).join(",");
|
||||||
|
}
|
||||||
|
let ex;
|
||||||
|
try {
|
||||||
|
ex = await get("/stats/explore", { ...f, since, views: views || undefined });
|
||||||
|
} catch (e) {
|
||||||
|
mount(el, head(f, without, since, null), e.code === "query_log_off"
|
||||||
|
? h("div", { class: "msg info" }, e.message) : errorBox(e));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parts = [head(f, without, since, ex)];
|
||||||
|
const maxSecs = retentionSecs(ex.retention);
|
||||||
|
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
|
||||||
|
h("span", { class: "muted" }, "Range"),
|
||||||
|
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
|
||||||
|
h("a", { href: exploreHref(f, k), class: k === since ? "active" : "" }, k))),
|
||||||
|
h("span", { class: "muted small" }, " · the query log keeps " + ex.retention + (ex.oldest ? ", since " + fmtTime(ex.oldest) : "")),
|
||||||
|
off.size ? h("span", { class: "muted small" }, " · views switched off on the Statistics page are left out") : null));
|
||||||
|
|
||||||
|
const nx = (ex.by.rcode || []).find((e) => e.key === "NXDOMAIN");
|
||||||
|
parts.push(h("div", { class: "cards" },
|
||||||
|
tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)),
|
||||||
|
tile("Blocked", compact(ex.blocked), ex.total ? pct(ex.blocked / ex.total) + " of them" : ""),
|
||||||
|
nx && !f.rcode ? tile("NXDOMAIN", compact(nx.count), pct(nx.count / Math.max(1, ex.total)) + " of them") : null,
|
||||||
|
!f.client && ex.by.client ? tile("Clients", (ex.by.client.length >= 15 ? "15+" : String(ex.by.client.length)), "asking") : null));
|
||||||
|
|
||||||
|
const times = ex.series.map((p) => p.t);
|
||||||
|
const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }];
|
||||||
|
if (ex.blocked) series.push({ label: "Blocked", values: ex.series.map((p) => p.blocked), slot: 2 });
|
||||||
|
parts.push(chartCard(ex.blocked ? "Answers and blocked" : "Answers", "per " + stepWord(ex.step),
|
||||||
|
ex.total ? lineChart({ series, times, step: ex.step, area: true }) : h("div", { class: "empty" }, "No answers match."),
|
||||||
|
ex.total ? timeTable(series, times, ex.step) : null, { wide: true, legend: series.length > 1 ? legend(series) : null }));
|
||||||
|
|
||||||
|
// A breakdown for each dimension the filter leaves open: click a value
|
||||||
|
// to add it.
|
||||||
|
const lists = dims.filter(([d]) => ex.by[d] && !(d === "blocked" && !ex.blocked)).map(([d, title]) => {
|
||||||
|
const href = (k) => with_(d === "blocked" ? { blocked: k } : { [d]: k });
|
||||||
|
const label = d === "blocked" ? blockedLabel : d === "view" ? (k) => (k === "_default" ? "(no views)" : k) : (k) => k;
|
||||||
|
return h("div", { class: "card chart-card" }, h("h3", null, title),
|
||||||
|
barList(ex.by[d], { total: d === "blocked" ? ex.blocked : ex.total, href, label, empty: d === "blocked" ? "Nothing blocked." : "None." }));
|
||||||
|
});
|
||||||
|
if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists));
|
||||||
|
|
||||||
|
parts.push(recentPanel(ex, with_));
|
||||||
|
mount(el, parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// head is the title and the filter chips, each with a ✕ to take it out.
|
||||||
|
function head(f, without, since, ex) {
|
||||||
|
const chips = keys.filter((k) => f[k]).map((k) => h("span", { class: "chip filter-chip" },
|
||||||
|
h("span", { class: "muted" }, filterWords[k] + " "),
|
||||||
|
h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(f[k]) : f[k]),
|
||||||
|
k === "client" && ex && ex.client_name ? h("span", { class: "muted" }, " " + ex.client_name) : null,
|
||||||
|
" ", h("a", { href: without(k), title: "Take it out", class: "chip-x", "aria-label": "Remove the " + filterWords[k] + " filter" }, "✕")));
|
||||||
|
const extra = [];
|
||||||
|
if (f.name && !f.domain) extra.push(h("a", { href: exploreHref({ ...f, name: undefined, domain: f.name }, since) }, "this name and the names below it"));
|
||||||
|
if ((f.name || f.domain) && (f.blocked === "yes" || (ex && ex.blocked))) extra.push(h("a", { href: "#/blocking?check=" + encodeURIComponent(f.name || f.domain) }, "why is it blocked?"));
|
||||||
|
if (f.name || f.domain) extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(f.name || f.domain) }, "find it in your zones"));
|
||||||
|
return h("div", { class: "page-head" },
|
||||||
|
h("div", { class: "grow" },
|
||||||
|
backLink("Statistics", "#/stats"),
|
||||||
|
h("h1", null, title(f, ex)),
|
||||||
|
h("div", { class: "muted" }, "Every answer in the query log that matches. Click a client, name, view, type or result to narrow it down; ✕ takes a filter out."),
|
||||||
|
chips.length ? h("div", { class: "chips filter-chips" }, chips,
|
||||||
|
h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null,
|
||||||
|
extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null));
|
||||||
|
}
|
||||||
|
|
||||||
|
function title(f, ex) {
|
||||||
|
const n = Object.keys(f).length;
|
||||||
|
if (!n) return "Explore the answers";
|
||||||
|
const who = f.client ? (ex && ex.client_name ? ex.client_name : f.client) : "";
|
||||||
|
if (f.client && n === 1) return "Client " + who;
|
||||||
|
const what = [f.rcode, f.type, f.blocked ? "blocked" + (f.blocked !== "yes" ? " by " + blockedLabel(f.blocked) : "") : "",
|
||||||
|
f.name || (f.domain ? f.domain + " and below" : "")].filter(Boolean).join(" ");
|
||||||
|
const parts = [what || "Answers"];
|
||||||
|
if (f.client) parts.push("for " + who);
|
||||||
|
if (f.view) parts.push("in view " + f.view);
|
||||||
|
return parts.join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
|
// recentPanel lists the latest answers, every cell a link.
|
||||||
|
function recentPanel(ex, with_) {
|
||||||
|
if (!ex.recent.length) return null;
|
||||||
|
const a = (text, more, cls) => h("a", { href: with_(more), class: cls || "" }, text);
|
||||||
|
return h("div", { class: "panel" }, h("h2", null, "Latest answers"),
|
||||||
|
h("div", { class: "table-wrap" }, h("table", { class: "recent-queries" },
|
||||||
|
h("thead", null, h("tr", null, ["Time", "Client", "View", "Name", "Type", "Result"].map((t) => h("th", null, t)))),
|
||||||
|
h("tbody", null, ex.recent.map((r) => h("tr", null,
|
||||||
|
h("td", { class: "small muted nowrap" }, fmtTime(new Date(r.ts * 1000).toISOString())),
|
||||||
|
h("td", { class: "nowrap" }, a(r.client_name || r.client, { client: r.client }, "mono"), r.client_name ? h("span", { class: "muted small" }, " " + r.client) : null),
|
||||||
|
h("td", null, a(r.view === "_default" ? "–" : r.view, { view: r.view })),
|
||||||
|
h("td", { class: "oneline-cell" }, a(r.name, { name: r.name }, "mono")),
|
||||||
|
h("td", null, a(r.type, { type: r.type })),
|
||||||
|
h("td", null, r.blocked ? a(badge("blocked: " + blockedLabel(r.blocked), "warn"), { blocked: r.blocked }) : a(r.rcode, { rcode: r.rcode }, r.rcode === "NOERROR" ? "" : "warn-text"))))))),
|
||||||
|
ex.recent.length >= 100 ? h("div", { class: "muted small" }, "The latest 100: narrow the filter to see others.") : null);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tile(label, value, sub) {
|
||||||
|
return h("div", { class: "card" }, h("div", { class: "label" }, label), h("div", { class: "value" }, value), h("div", { class: "sub" }, sub));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pct(r) { return (r * 100).toFixed(r < 0.1 ? 1 : 0) + "%"; }
|
||||||
|
|
||||||
|
function stepWord(s) {
|
||||||
|
if (s % 86400 === 0) return s === 86400 ? "day" : s / 86400 + " days";
|
||||||
|
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
|
||||||
|
return s === 60 ? "minute" : s / 60 + " minutes";
|
||||||
|
}
|
||||||
|
|
||||||
|
function retentionSecs(r) {
|
||||||
|
const m = /^(\d+)([dhm])$/.exec(r || "");
|
||||||
|
if (!m) return 7 * 86400;
|
||||||
|
return Number(m[1]) * { d: 86400, h: 3600, m: 60 }[m[2]];
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
|||||||
import { get } from "../api.js";
|
import { get } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
import { lineChart, chartCard, legend, timeTable, heatmap, heatmapTable, barList, compact, stepSeconds, endLabelWidth } from "../charts.js";
|
||||||
|
import { exploreHref } from "./explore.js";
|
||||||
|
|
||||||
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]];
|
||||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
||||||
@@ -21,7 +22,7 @@ let generation = 0;
|
|||||||
// The views shown are remembered per browser: the ones switched off, so a
|
// The views shown are remembered per browser: the ones switched off, so a
|
||||||
// new view shows up switched on.
|
// new view shows up switched on.
|
||||||
const offKey = "jarl.stats.views.off";
|
const offKey = "jarl.stats.views.off";
|
||||||
function viewsOff() {
|
export function viewsOff() {
|
||||||
try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); }
|
try { return new Set(JSON.parse(localStorage.getItem(offKey) || "[]")); } catch { return new Set(); }
|
||||||
}
|
}
|
||||||
function saveViewsOff(set) {
|
function saveViewsOff(set) {
|
||||||
@@ -114,17 +115,20 @@ export async function render(el, params, query) {
|
|||||||
parts.push(chartCard("Busiest times", "average queries per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
|
parts.push(chartCard("Busiest times", "average queries per hour by weekday and hour of day (local time), last " + ranges.find((r) => r[0] === heatSince)[1],
|
||||||
heatmap(heat, "queries"), heatmapTable(heat, "queries"), { wide: true }));
|
heatmap(heat, "queries"), heatmapTable(heat, "queries"), { wide: true }));
|
||||||
|
|
||||||
// Who asks for what.
|
// Who asks for what. With the query log, every value leads to the
|
||||||
|
// explore page: a client to what it asked, a name to who asked.
|
||||||
|
const ql = !!(status.retention || {}).queries;
|
||||||
|
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null);
|
||||||
if (status.query_log) {
|
if (status.query_log) {
|
||||||
const t = data.tops;
|
const t = data.tops;
|
||||||
parts.push(h("div", { class: "chart-grid" },
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
listCard("Most queried names", "", barList(t.name, { href: (k) => "#/search?q=" + encodeURIComponent(k) })),
|
listCard("Most queried names", ql ? "click one to see who asks for it" : "", barList(t.name, { href: ex((k) => ({ name: k })) || ((k) => "#/search?q=" + encodeURIComponent(k)) })),
|
||||||
listCard("Busiest clients", "", barList(t.client)),
|
listCard("Busiest clients", ql ? "click one to see what it asks" : "", barList(t.client, { href: ex((k) => ({ client: k })) })),
|
||||||
listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + "." })),
|
listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + ".", href: ex((k) => ({ name: k, rcode: "NXDOMAIN" })) })),
|
||||||
listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + "." })),
|
listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + ".", href: ex((k) => ({ client: k, rcode: "REFUSED" })) })),
|
||||||
t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail)) : null,
|
t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.servfail, { href: ex((k) => ({ name: k, rcode: "SERVFAIL" })) })) : null,
|
||||||
t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client)) : null,
|
t.blocked_client.length ? listCard("Clients with the most blocked queries", "often a smart TV, phone or app phoning home", barList(t.blocked_client, { href: ex((k) => ({ client: k, blocked: "yes" })) })) : null,
|
||||||
t.blocked.length ? listCard("Most blocked names", "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: (k) => "#/blocking?check=" + encodeURIComponent(k) })) : null));
|
t.blocked.length ? listCard("Most blocked names", ql ? "click a name to see who asks for it, and why it is blocked" : "answered \"doesn't exist\" by blocking; click a name to see why, or to allow it", barList(t.blocked, { href: ex((k) => ({ name: k, blocked: "yes" })) || ((k) => "#/blocking?check=" + encodeURIComponent(k)) })) : null));
|
||||||
} else {
|
} else {
|
||||||
parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl."));
|
parts.push(h("div", { class: "msg info" }, "Top names and clients need the response log: set stats.query_log: true in /etc/jarl/jarl.yaml and restart jarl."));
|
||||||
}
|
}
|
||||||
@@ -133,13 +137,13 @@ export async function render(el, params, query) {
|
|||||||
const b = data.breakdowns;
|
const b = data.breakdowns;
|
||||||
const rtt = rttOrder.map((k) => b.rtt.find((e) => e.key === k)).filter(Boolean);
|
const rtt = rttOrder.map((k) => b.rtt.find((e) => e.key === k)).filter(Boolean);
|
||||||
parts.push(h("div", { class: "chart-grid" },
|
parts.push(h("div", { class: "chart-grid" },
|
||||||
listCard("Record types", "", barList(b.qtype, { total: s.queries })),
|
listCard("Record types", "", barList(b.qtype, { total: s.queries, href: ex((k) => ({ type: k })) })),
|
||||||
listCard("Result codes", "", barList(b.rcode, { total: s.queries })),
|
listCard("Result codes", ql ? "click one to see who gets it, for what" : "", barList(b.rcode, { total: s.queries, href: ex((k) => ({ rcode: k })) })),
|
||||||
b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel })) : null,
|
b.view.length > 1 || (b.view.length === 1 && b.view[0].key !== "_default") ? listCard("Views", "", barList(b.view, { label: viewLabel, href: ex((k) => ({ view: k })) })) : null,
|
||||||
b.zone.length ? listCard("Zones", "queries per local zone; other = names in no local zone (recursion)", barList(b.zone, { label: (k) => (k === "-" ? "other" : k), href: (k) => (k === "-" ? null : "#/search?q=" + encodeURIComponent(k)) })) : null,
|
b.zone.length ? listCard("Zones", "queries per local zone; other = names in no local zone (recursion)", barList(b.zone, { label: (k) => (k === "-" ? "other" : k), href: (k) => (k === "-" ? null : "#/search?q=" + encodeURIComponent(k)) })) : null,
|
||||||
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
b.answer.length ? listCard("Answers", "authoritative (your zones) or recursive (from the cache or the internet)", barList(b.answer)) : null,
|
||||||
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
rtt.length ? listCard("Upstream response time", "how long other servers took to answer named's recursive queries", barList(rtt)) : null,
|
||||||
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: () => "#/blocking" })) : null));
|
b.blocklist.length ? listCard("Blocked by", "which of your rules and blocklists blocked queries", barList(b.blocklist, { label: (k) => (k === "rules" ? "your rules" : k), href: ex((k) => ({ blocked: k })) || (() => "#/blocking") })) : null));
|
||||||
|
|
||||||
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
|
parts.push(...serverSection(data.server, complete(data.gauges), rangeLabel));
|
||||||
el.classList.remove("refreshing");
|
el.classList.remove("refreshing");
|
||||||
@@ -302,3 +306,9 @@ function disabledPanel() {
|
|||||||
h("p", { class: "muted" }, "Jarl then makes named publish its counters on a local statistics channel and log responses to /var/log/named, and keeps aggregates " +
|
h("p", { class: "muted" }, "Jarl then makes named publish its counters on a local statistics channel and log responses to /var/log/named, and keeps aggregates " +
|
||||||
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
"(never individual queries) in a separate database, by default for 7 days per minute, 90 days per hour and 2 years per day. See docs/stats.md."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// exploreSince is the Statistics range for the explore page: the query log
|
||||||
|
// keeps days, not months.
|
||||||
|
function exploreSince(since) {
|
||||||
|
return rangeSecs[since] > 7 * 86400 ? "7d" : since;
|
||||||
|
}
|
||||||
@@ -595,3 +595,21 @@ pre.wrap { white-space: pre-wrap; word-break: break-all; }
|
|||||||
td.addr-views { width: 100%; }
|
td.addr-views { width: 100%; }
|
||||||
.view-chips { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
.view-chips { display: flex; flex-wrap: wrap; gap: 4px 10px; align-items: center; }
|
||||||
.view-chips label.chip { white-space: nowrap; font-size: 0.9em; }
|
.view-chips label.chip { white-space: nowrap; font-size: 0.9em; }
|
||||||
|
|
||||||
|
/* Explore: the filter chips with a ✕ each, and the latest answers. */
|
||||||
|
.filter-chips { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
|
||||||
|
.chip.filter-chip { display: inline-flex; gap: 2px; align-items: center; padding: 2px 6px 2px 10px; border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); font-size: 13px; }
|
||||||
|
.chip.filter-chip a.chip-x { text-decoration: none; color: var(--muted); padding: 0 4px; }
|
||||||
|
.chip.filter-chip a.chip-x:hover { color: var(--err, #c00); }
|
||||||
|
table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
/* Dashboard storage: a bar per disk. */
|
||||||
|
.disk { margin-top: 14px; }
|
||||||
|
.disk-head { display: flex; gap: 6px; align-items: baseline; flex-wrap: wrap; margin-bottom: 4px; }
|
||||||
|
.disk-head .grow { flex: 1; }
|
||||||
|
.disk-bar { display: flex; height: 10px; border-radius: 5px; background: var(--info-soft); overflow: hidden; border: 1px solid var(--border); }
|
||||||
|
.disk-bar .seg.db { background: var(--accent); }
|
||||||
|
.disk-bar .seg.other { background: var(--muted); opacity: 0.45; }
|
||||||
|
.disk-bar .seg.other.full { background: var(--warn); opacity: 0.8; }
|
||||||
|
.swatch { display: inline-block; width: 10px; height: 10px; border-radius: 2px; vertical-align: -1px; }
|
||||||
|
.swatch.db { background: var(--accent); }
|
||||||
|
.swatch.other { background: var(--muted); opacity: 0.45; }
|
||||||
Reference in new issue
Block a user