Compare commits

...
7 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 48f0082d8b Release 0.6.0
release / release (push) Successful in 2m14s
Reverse zones made easy, comparing views rebuilt, exploring the statistics through a query log (filters, several values, exclusions), storage on the dashboard, encrypted DNS with several host names and views per listen address, option set matching named statements, forwarding over TLS documented. Upgrade from 0.5.2 tested in Debian 13.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:44:10 +01:00
adamandClaude Opus 5.5 d4399cf630 Release 0.6.0-rc.3
release / release (push) Successful in 2m11s
Since rc.2: the Encrypted DNS page and docs test with BIND's dig +tls / +https instead of kdig and curl's JSON API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:38:27 +01:00
adamandClaude Opus 5.5 d2a25d654d Encrypted DNS: test with BIND's dig, not kdig or curl's JSON API
The client hints and docs tested DoT with kdig (Knot's, often not installed) and DoH with curl's application/dns-json, which named's DoH doesn't speak (RFC 8484 wire format only). They use dig +tls / dig +https with +tls-ca and +tls-hostname now, with the port when it isn't the default.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:38:25 +01:00
adamandClaude Opus 5.5 87368c4aaf Release 0.6.0-rc.2
release / release (push) Successful in 2m13s
Since rc.1: the explore page has a filter bar, several values per filter, !value to leave one out and *.domain, a hide link on list entries, and refreshes with its own filters (the Statistics page's refresh drew over it).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:19:04 +01:00
adamandClaude Opus 5.5 7d93c373d1 Explore: refresh with its own filters; the Statistics refresh stays on its page
The Statistics page's once-a-minute refresh also ran on #/stats/explore and drew the Statistics page over it, losing the filters. It now runs on the Statistics page only, and the explore page refreshes itself every minute with the same filters (not while the filter bar is being edited).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:16:10 +01:00
adamandClaude Opus 5.5 e94d3634b6 Explore: several values per filter, !value to leave one out, *.domain
Each explore filter takes several values, comma separated (any of them), !value to leave one out, and *.example.com for a name and the names below it. Every value is a chip with its own ✕; clicking a value in the lists adds it, and hide leaves it out. The filter bar's fields take lists. domain= still works.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:10:00 +01:00
adamandClaude Opus 5.5 a8c7760fc8 Explore: a filter bar, and Explore the answers on the Statistics page
The explore page has a filter bar to type filters in (client, name or it and below, view, type, result, blocked), starting from the current filters and suggesting values from what is shown, so taking every filter out no longer means going back. The Statistics page opens it with Explore the answers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 18:58:45 +01:00
13 changed files with 316 additions and 83 deletions

No files matched your search

+10 -1
View File
@@ -6,6 +6,8 @@ All notable changes to Jarl are documented here. The format follows
## [Unreleased] ## [Unreleased]
## [0.6.0] - 2026-10-13
### Added ### Added
- Reverse zones made easy (docs/reverse.md): - Reverse zones made easy (docs/reverse.md):
@@ -68,7 +70,11 @@ All notable changes to Jarl are documented here. The format follows
an explore page for it: answers over time, the top clients, names, an explore page for it: answers over time, the top clients, names,
views, types, results and blocklists among them (each a link that views, types, results and blocklists among them (each a link that
narrows it further), and the latest answers. "NXDOMAIN in external" → narrows it further), and the latest answers. "NXDOMAIN in external" →
the clients asking → everything one of them asks. `GET /stats/explore`. the clients asking → everything one of them asks. A filter bar types
filters in, several values each (any of them), `!value` to leave one
out and `*.example.com` for a domain and below; **hide** on a list entry
leaves it out. **Explore the answers** opens the page from the
Statistics page. `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. The Statistics page shows the is and how much of it the databases take. The Statistics page shows the
@@ -91,6 +97,9 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### Fixed
- Encrypted DNS: the client hints tested with kdig (Knot's, often not
installed) and with curl's JSON API, which named's DoH doesn't speak;
they use BIND's `dig +tls` / `dig +https` now.
- Server options → Added by Jarl: long statements didn't wrap, and the - Server options → Added by Jarl: long statements didn't wrap, and the
browser console reported a Content Security Policy violation (an inline browser console reported a Content Security Policy violation (an inline
style the page's policy blocks). style the page's policy blocks).
+1 -2
View File
@@ -202,8 +202,7 @@ review step.
## Status ## Status
Version 0.5.2 is the current release, and 0.6.0 is in testing Version 0.6.0 is the current release. It makes reverse zones easy (suggested from your records,
(0.6.0-rc.1): it makes reverse zones easy (suggested from your records,
created in the right view and filled, PTR records kept in step), rebuilds created in the right view and filled, PTR records kept in step), rebuilds
comparing a zone's views, lets you explore the statistics down to each comparing a zone's views, lets you explore the statistics down to each
client and name (a query log), shows the databases' size, and gives client and name (a query log), shows the databases' size, and gives
+1 -1
View File
@@ -1 +1 @@
0.6.0-rc.1 0.6.0
+1 -1
View File
@@ -565,7 +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/explore` | `?client=&name=&domain=&view=&type=&rcode=&blocked=yes\|no\|LIST&since=24h&views=`; each takes several values, comma separated (any of them), `!value` to leave one out, and names `*.example.com` for it and below | 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}], database: {path, bytes, query_log_rows}}`; 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}]` |
+11 -4
View File
@@ -111,14 +111,21 @@ For DoH clients: https://dns.adbraun.lol/dns-query
| Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL | | Chrome, Edge | Settings → Privacy and security → Security → Use secure DNS → custom: the DoH URL |
| Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL | | Windows 11 | Settings → Network → your connection → DNS server assignment → Edit: the server's address, DNS over HTTPS on (manual template), the DoH URL |
Test from a machine on your network: Test from a machine on your network, with BIND's own `dig` (9.18 or
later; `bind9-dnsutils` on Debian):
```sh ```sh
kdig +tls-ca +tls-host=dns.adbraun.lol @10.0.10.5 example.com dig +tls +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoT, port 853
dig +https @10.0.10.5 example.com dig +https +tls-ca +tls-hostname=dns.adbraun.lol @10.0.10.5 example.com # DoH, port 443, /dns-query
curl -s -H 'accept: application/dns-json' 'https://dns.adbraun.lol/dns-query?name=example.com'
``` ```
`+tls-ca` checks the certificate against the system's CAs and
`+tls-hostname` against the host name, as clients do; add `-p PORT` for
another port. Without `+tls-ca` the query is still encrypted, but the
certificate isn't checked. named's DoH speaks the standard wire format
(RFC 8484), not the JSON API some public resolvers also offer, so
`curl ... accept: application/dns-json` doesn't work against it.
**Android away from home.** Private DNS in hostname mode applies on every **Android away from home.** Private DNS in hostname mode applies on every
network. Away from home, your server can't be reached, and Android then network. Away from home, your server can't be reached, and Android then
resolves nothing at all ("Private DNS server cannot be accessed"). Use a resolves nothing at all ("Private DNS server cannot be accessed"). Use a
+12 -1
View File
@@ -213,7 +213,18 @@ which shows, for what you clicked:
* the latest 100 answers, every cell a link. * the latest 100 answers, every cell a link.
So "external, NXDOMAIN" leads to the clients asking, a client to everything 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 it asks, one of its names to who else asks for it. The page also opens
from **Explore the answers** at the top of the Statistics page, and has a
filter bar to type filters in: clients, names, views, record types,
results, blocked or not; it suggests values from what is shown.
Each kind of filter takes several values, comma separated, and matches
any of them: `10.0.0.5, 10.0.0.7` is either client. `!value` leaves one
out (`!10.0.0.250` hides a monitoring probe), and `*.example.com` is the
name and every name below it, so `!*.apple.com` drops a chatty device's
noise. Kinds combine: this client, in this view, NXDOMAIN, not that name.
In the lists, clicking a value adds it and **hide** leaves it out; every
value has a chip with a ✕ to take it out again. Filters combine (this
client, in this view, blocked only) and each has a ✕ to take it out. A name 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 can be widened to the names below it (`domain=`). The Statistics page's
view switches apply here too. view switches apply here too.
+97 -25
View File
@@ -11,7 +11,10 @@ import (
// last days (stats.query_retention), filtered by any mix of client, name, // last days (stats.query_retention), filtered by any mix of client, name,
// view, type, result and blocking, broken down by each of them. // view, type, result and blocking, broken down by each of them.
// ExploreFilter narrows the query log. Empty fields don't filter. // ExploreFilter narrows the query log. Empty fields don't filter. Each
// field may hold several values, comma separated: any of them; a value
// written !VALUE excludes it. A name *.example.com is it and the names
// below it.
type ExploreFilter struct { type ExploreFilter struct {
Client string // a client key, as the top lists show it Client string // a client key, as the top lists show it
Name string // exactly this name Name string // exactly this name
@@ -58,33 +61,96 @@ type ExplorePoint struct {
// ErrNoQueryLog is returned when the query log is off. // 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)") 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. // values splits a filter field into the values it must be one of and the
// ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
func values(field string, norm func(string) string) (in, out []string) {
for _, v := range strings.Split(field, ",") {
v = strings.TrimSpace(v)
not := strings.HasPrefix(v, "!")
v = norm(strings.TrimSpace(strings.TrimPrefix(v, "!")))
switch {
case v == "":
case not:
out = append(out, v)
default:
in = append(in, v)
}
}
return in, out
}
// nameValues are the name filter's values: Name's, and Domain's as
// "*.DOMAIN" (the name and the names below it).
func (f ExploreFilter) nameValues() (in, out []string) {
norm := func(v string) string { return strings.ToLower(strings.TrimSuffix(v, ".")) }
in, out = values(f.Name, norm)
din, dout := values(f.Domain, norm)
for _, d := range din {
in = append(in, "*."+strings.TrimPrefix(d, "*."))
}
for _, d := range dout {
out = append(out, "*."+strings.TrimPrefix(d, "*."))
}
return in, out
}
// fieldValues returns a dimension's filter values.
func (f ExploreFilter) fieldValues(dim string) (in, out []string) {
same := func(v string) string { return v }
upper := strings.ToUpper
switch dim {
case "client":
return values(f.Client, same)
case "name":
return f.nameValues()
case "view":
return values(f.View, same)
case "type":
return values(f.Type, upper)
case "rcode":
return values(f.RCode, upper)
case "blocked":
return values(f.Blocked, same)
}
return nil, nil
}
// cond is the SQL condition for one value of a dimension.
func cond(dim, v string) (string, []any) {
switch {
case dim == "name" && strings.HasPrefix(v, "*."):
d := strings.TrimPrefix(v, "*.")
return "(name = ? OR (length(name) > length(?) AND substr(name, length(name) - length(?)) = ?))", []any{d, d, d, "." + d}
case dim == "blocked" && v == "yes":
return "blocked != ''", nil
case dim == "blocked" && v == "no":
return "blocked = ''", nil
}
return dim + " = ?", []any{v}
}
// where builds the SQL condition and arguments for a filter: within a
// dimension any of its values, and none of its !values; the dimensions
// together.
func (q *Querier) where(f ExploreFilter, from int64) (string, []any) { func (q *Querier) where(f ExploreFilter, from int64) (string, []any) {
conds := []string{"ts >= ?"} conds := []string{"ts >= ?"}
args := []any{from} args := []any{from}
eq := func(col, v string) { for _, dim := range Dimensions {
if v != "" { in, out := f.fieldValues(dim)
conds = append(conds, col+" = ?") if len(in) > 0 {
args = append(args, v) var or []string
for _, v := range in {
c, a := cond(dim, v)
or = append(or, c)
args = append(args, a...)
}
conds = append(conds, "("+strings.Join(or, " OR ")+")")
}
for _, v := range out {
c, a := cond(dim, v)
conds = append(conds, "NOT "+c)
args = append(args, a...)
} }
}
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 { if len(q.Views) > 0 {
marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",") marks := strings.TrimSuffix(strings.Repeat("?,", len(q.Views)), ",")
@@ -143,7 +209,13 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
out.Series = append(out.Series, p) 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"} // A dimension is broken down unless the filter pins it to one value
// (several: how they compare; !values leave the rest to see).
open := map[string]bool{}
for _, dim := range Dimensions {
in, _ := f.fieldValues(dim)
open[dim] = len(in) != 1 || (dim == "blocked" && in[0] == "yes") || (dim == "name" && strings.HasPrefix(in[0], "*."))
}
for _, dim := range Dimensions { for _, dim := range Dimensions {
if !open[dim] { if !open[dim] {
continue continue
+33
View File
@@ -84,6 +84,39 @@ func TestExplore(t *testing.T) {
t.Errorf("internal NXDOMAIN: %d", ex.Total) t.Errorf("internal NXDOMAIN: %d", ex.Total)
} }
// Several values: any of them; !values: none of them.
total := func(f ExploreFilter) int64 {
t.Helper()
ex, err := q.Explore(ctx, f, 10, 50)
if err != nil {
t.Fatal(err)
}
return ex.Total
}
for _, c := range []struct {
f ExploreFilter
want int64
}{
{ExploreFilter{Client: "10.0.0.5, 198.51.100.7"}, 6},
{ExploreFilter{Client: "!10.0.0.5"}, 2},
{ExploreFilter{RCode: "NXDOMAIN,noerror"}, 6},
{ExploreFilter{Name: "*.adbraun.lol"}, 5},
{ExploreFilter{Name: "!*.adbraun.lol"}, 1},
{ExploreFilter{Name: "www.adbraun.lol,nas.adbraun.lol"}, 2},
{ExploreFilter{Name: "*.adbraun.lol, !typo.adbraun.lol", Client: "!198.51.100.7"}, 2},
{ExploreFilter{Blocked: "!hagezi"}, 5},
{ExploreFilter{Domain: "adbraun.lol", Name: "!nope.adbraun.lol"}, 3},
} {
if got := total(c.f); got != c.want {
t.Errorf("%+v: %d answers, want %d", c.f, got, c.want)
}
}
// Two clients: their breakdown shows them side by side.
ex, _ = q.Explore(ctx, ExploreFilter{Client: "10.0.0.5,198.51.100.7"}, 10, 50)
if len(ex.By["client"]) != 2 {
t.Errorf("two clients' breakdown %+v", ex.By["client"])
}
// Pruning: past the retention they go. // Pruning: past the retention they go.
clk.t = clk.t.Add(8 * 24 * time.Hour) clk.t = clk.t.Add(8 * 24 * time.Hour)
c.prune(ctx) c.prune(ctx)
+4 -2
View File
@@ -329,7 +329,9 @@ export function heatmapTable(hm, unit) {
// barList shows ranked entries as labelled horizontal bars in one hue // barList shows ranked entries as labelled horizontal bars in one hue
// (one series: no legend, the title names it). Values are always printed, // (one series: no legend, the title names it). Values are always printed,
// so the list is its own table view. // so the list is its own table view.
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href } = {}) { // extra(key), when given, adds something after an entry's label (e.g. a
// link that excludes it).
export function barList(entries, { total, label = (k) => k, empty = "No data yet.", href, extra } = {}) {
if (!entries.length) return h("div", { class: "empty" }, empty); if (!entries.length) return h("div", { class: "empty" }, empty);
// Entries may be in a natural order (e.g. latency buckets), not by size. // Entries may be in a natural order (e.g. latency buckets), not by size.
const peak = Math.max(1, ...entries.map((e) => e.count)); const peak = Math.max(1, ...entries.map((e) => e.count));
@@ -343,7 +345,7 @@ export function barList(entries, { total, label = (k) => k, empty = "No data yet
return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" }, return h("li", { title: (e.name ? e.name + " (" + name + ")" : name) + ": " + e.count.toLocaleString() + " (" + share.toFixed(1) + "%)" },
h("div", { class: "bar-text" }, h("div", { class: "bar-text" },
link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown), link ? h("a", { href: link, class: "bar-label" }, shown) : h("span", { class: "bar-label" }, shown),
h("span", { class: "bar-value" }, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))), h("span", { class: "bar-value" }, extra ? [extra(e.key), " "] : null, compact(e.count), h("span", { class: "muted" }, " · " + share.toFixed(share < 10 ? 1 : 0) + "%"))),
h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } }))); h("div", { class: "bar-track" }, h("div", { class: "bar-fill", style: { width: Math.max(0.5, (e.count / peak) * 100) + "%" } })));
})); }));
} }
+4 -2
View File
@@ -35,13 +35,15 @@ function statusPanel(s) {
]; ];
const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS"; const addr = (s.dot[0] || s.doh[0] || {}).address || "ADDRESS";
const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : ""; const dotPortArg = s.dot[0] && s.dot[0].port !== 853 ? " -p " + s.dot[0].port : "";
const dohPortArg = s.doh[0] && s.doh[0].port !== 443 ? " -p " + s.doh[0].port : "";
const clients = []; const clients = [];
if (s.dot_host) { if (s.dot_host) {
clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host), clients.push(h("h3", null, "DNS over TLS"), copyBox(s.dot_host),
h("ul", { class: "small" }, h("ul", { class: "small" },
h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."), h("li", null, h("strong", null, "Android: "), "Settings → Network → Private DNS → hostname: ", h("code", null, s.hostname), ". Away from home Android can't reach this server and then resolves nothing, so use a VPN home (WireGuard) or switch Private DNS to Automatic when you leave."),
h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."), h("li", null, h("strong", null, "Linux (systemd-resolved): "), h("code", null, "DNS=" + addr + "#" + s.hostname), " and ", h("code", null, "DNSOverTLS=yes"), " in /etc/systemd/resolved.conf (", h("code", null, "opportunistic"), " falls back to plain DNS away from home)."),
h("li", null, h("strong", null, "Test: "), h("code", null, "kdig +tls-ca +tls-host=" + s.hostname + dotPortArg + " @" + addr + " example.com")))); h("li", null, h("strong", null, "Test: "), h("code", null, "dig +tls +tls-ca +tls-hostname=" + s.hostname + dotPortArg + " @" + addr + " example.com"),
" (BIND's dig; +tls-ca checks the certificate against the system's CAs)")));
} }
if (s.doh_url) { if (s.doh_url) {
clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url), clients.push(h("h3", null, "DNS over HTTPS"), copyBox(s.doh_url),
@@ -49,7 +51,7 @@ function statusPanel(s) {
h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."), h("li", null, h("strong", null, "Firefox: "), "Settings → Privacy & Security → DNS over HTTPS → Increased protection (falls back to normal DNS) → Custom: the URL above."),
h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."), h("li", null, h("strong", null, "Chrome, Edge: "), "Settings → Privacy and security → Security → Use secure DNS → custom: the URL above."),
h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."), h("li", null, h("strong", null, "Windows 11: "), "Settings → Network → your connection → DNS server assignment → Edit: " + addr + ", DNS over HTTPS on (manual template), the URL above."),
h("li", null, h("strong", null, "Test: "), h("code", null, "curl -s -H 'accept: application/dns-json' '" + s.doh_url + "?name=example.com'"), " (or ", h("code", null, "dig +https @" + addr + " example.com"), ")"))); h("li", null, h("strong", null, "Test: "), h("code", null, "dig +https +tls-ca +tls-hostname=" + s.hostname + dohPortArg + " @" + addr + " example.com"))));
} }
return [ return [
h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)), h("div", { class: "panel" }, h("h2", null, "Set up ", badge("on", "ok")), h("dl", { class: "kv" }, rows)),
+128 -42
View File
@@ -2,7 +2,7 @@
// last days, filtered by any mix of client, name, view, record type, result // 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 // 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. // 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 { h, mount, fmtTime, errorBox, badge, backLink, input } from "../ui.js";
import { get } from "../api.js"; import { get } from "../api.js";
import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js"; import { lineChart, chartCard, legend, timeTable, barList, compact } from "../charts.js";
import { viewsOff } from "./stats.js"; import { viewsOff } from "./stats.js";
@@ -19,37 +19,61 @@ const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400
// exploreHref is the explore page for these filters (and range). // exploreHref is the explore page for these filters (and range).
export function exploreHref(filters, since) { export function exploreHref(filters, since) {
const q = new URLSearchParams(); const q = new URLSearchParams();
for (const k of keys) if (filters[k]) q.set(k, filters[k]); for (const k of keys) {
const v = Array.isArray(filters[k]) ? filters[k].join(",") : filters[k];
if (v) q.set(k, v);
}
if (since && since !== "24h") q.set("since", since); if (since && since !== "24h") q.set("since", since);
return "#/stats/explore" + (q.toString() ? "?" + q : ""); return "#/stats/explore" + (q.toString() ? "?" + q : "");
} }
const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k); const blockedLabel = (k) => (k === "rules" ? "your rules" : k === "yes" ? "anything" : k === "no" ? "nothing" : k);
export async function render(el, params, query) { // A filter is, per kind, a list of values: any of them, and !VALUE for
// "not this". Names: *.example.com is it and the names below it.
const kinds = ["client", "name", "view", "type", "rcode", "blocked"];
const split = (v) => (Array.isArray(v) ? v : String(v || "").split(",")).map((x) => x.trim()).filter(Boolean);
function parse(query) {
const f = {}; const f = {};
for (const k of keys) if (query.get(k)) f[k] = query.get(k); for (const k of kinds) f[k] = split(query.get(k));
for (const d of split(query.get("domain"))) f.name.push(d.startsWith("!") ? "!*." + d.slice(1).replace(/^\*\./, "") : "*." + d.replace(/^\*\./, ""));
return f;
}
const plain = (v) => v.replace(/^!/, "");
const isNot = (v) => v.startsWith("!");
function addTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== "!" + v && x !== v).concat(v); return g; }
function notTo(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v && x !== "!" + v).concat("!" + v); return g; }
function dropFrom(f, k, v) { const g = copy(f); g[k] = g[k].filter((x) => x !== v); return g; }
function copy(f) { const g = {}; for (const k of kinds) g[k] = [...(f[k] || [])]; return g; }
const includes = (f, k) => (f[k] || []).filter((v) => !isNot(v));
const anyFilter = (f) => kinds.some((k) => (f[k] || []).length);
let generation = 0;
export async function render(el, params, query) {
const gen = ++generation;
const hash = location.hash;
const f = parse(query);
const since = query.get("since") || "24h"; const since = query.get("since") || "24h";
const with_ = (more) => exploreHref({ ...f, ...more }, since); const go = (g) => exploreHref(g, since);
const without = (k) => { const g = { ...f }; delete g[k]; return exploreHref(g, since); }; const q = {};
for (const k of kinds) if (f[k].length) q[k] = f[k].join(",");
// The Statistics page's view switches apply here too. // The Statistics page's view switches apply here too.
const off = viewsOff(); const off = viewsOff();
let views = ""; const allViews = (await get("/views").catch(() => [])).map((v) => v.name);
if (off.size) { const views = off.size ? allViews.filter((v) => !off.has(v)).join(",") : "";
const all = (await get("/views").catch(() => [])).map((v) => v.name);
views = all.filter((v) => !off.has(v)).join(",");
}
let ex; let ex;
try { try {
ex = await get("/stats/explore", { ...f, since, views: views || undefined }); ex = await get("/stats/explore", { ...q, since, views: views || undefined });
} catch (e) { } catch (e) {
mount(el, head(f, without, since, null), e.code === "query_log_off" mount(el, head(f, go, since, null), filterBar(f, since, null, allViews), e.code === "query_log_off"
? h("div", { class: "msg info" }, e.message) : errorBox(e)); ? h("div", { class: "msg info" }, e.message) : errorBox(e));
return; return;
} }
const parts = [head(f, without, since, ex)]; const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
const maxSecs = retentionSecs(ex.retention); const maxSecs = retentionSecs(ex.retention);
parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" }, parts.push(h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"), h("span", { class: "muted" }, "Range"),
@@ -62,8 +86,8 @@ export async function render(el, params, query) {
parts.push(h("div", { class: "cards" }, parts.push(h("div", { class: "cards" },
tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)), tile("Answers", compact(ex.total), "from " + fmtTime(ex.from)),
tile("Blocked", compact(ex.blocked), ex.total ? pct(ex.blocked / ex.total) + " of them" : ""), 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, nx && !includes(f, "rcode").length ? 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)); includes(f, "client").length !== 1 && 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 times = ex.series.map((p) => p.t);
const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }]; const series = [{ label: "Answers", values: ex.series.map((p) => p.count), slot: 1 }];
@@ -72,58 +96,120 @@ export async function render(el, params, query) {
ex.total ? lineChart({ series, times, step: ex.step, area: true }) : h("div", { class: "empty" }, "No answers match."), 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 })); 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 // A breakdown for each kind the filter leaves open: click a value to add
// to add it. // it, "hide" to leave it out.
const lists = dims.filter(([d]) => ex.by[d] && !(d === "blocked" && !ex.blocked)).map(([d, title]) => { 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; const label = d === "blocked" ? blockedLabel : d === "view" ? (k) => (k === "_default" ? "(no views)" : k) : (k) => k;
const pinned = (k) => includes(f, d).includes(k);
return h("div", { class: "card chart-card" }, h("h3", null, title), 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." })); barList(ex.by[d], { total: d === "blocked" ? ex.blocked : ex.total, label, empty: d === "blocked" ? "Nothing blocked." : "None.",
href: (k) => (pinned(k) ? null : go(addTo(f, d, k))),
extra: (k) => h("a", { class: "bar-hide", href: go(notTo(f, d, k)), title: "Leave " + label(k) + " out" }, "hide") }));
}); });
if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists)); if (lists.length) parts.push(h("div", { class: "chart-grid" }, lists));
parts.push(recentPanel(ex, with_)); parts.push(recentPanel(ex, (k, v) => go(addTo(f, k, v))));
if (gen !== generation) return; // the user moved on
el.classList.remove("refreshing");
mount(el, parts); mount(el, parts);
scheduleRefresh(el, params, query, gen, hash);
} }
// head is the title and the filter chips, each with a ✕ to take it out. // scheduleRefresh redraws the page a minute later with the same filters,
function head(f, without, since, ex) { // so the graphs move along; not while a filter is being typed in.
const chips = keys.filter((k) => f[k]).map((k) => h("span", { class: "chip filter-chip" }, function scheduleRefresh(el, params, query, gen, hash) {
h("span", { class: "muted" }, filterWords[k] + " "), setTimeout(() => {
h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(f[k]) : f[k]), if (gen !== generation || location.hash !== hash || !el.isConnected) return;
k === "client" && ex && ex.client_name ? h("span", { class: "muted" }, " " + ex.client_name) : null, const form = el.querySelector("form.explore-filters");
" ", h("a", { href: without(k), title: "Take it out", class: "chip-x", "aria-label": "Remove the " + filterWords[k] + " filter" }, "✕"))); if (form && (form.contains(document.activeElement) || form.dataset.dirty)) {
scheduleRefresh(el, params, query, gen, hash); // try again later
return;
}
el.classList.add("refreshing");
render(el, params, query).catch(() => el.classList.remove("refreshing"));
}, 60_000);
}
// filterBar types filters in. Each field takes several values, comma
// separated (any of them), !value to leave one out, and names *.domain for
// it and the names below it. It starts from the current filters and
// suggests values from what is shown.
function filterBar(f, since, ex, views) {
const sugg = (id, entries) => h("datalist", { id }, (entries || []).map((e) => h("option", { value: e.key }, e.name || null)));
const field = (k, placeholder, width, list) => input({ value: (f[k] || []).join(", "), placeholder, class: "mono", list, style: { width } });
const client = field("client", "any client", "190px", "ex-clients");
const name = field("name", "nas.adbraun.lol, *.apple.com", "250px", "ex-names");
const view = field("view", "any view", "130px", "ex-views");
const type = field("type", "any type", "110px", "ex-types");
const rcode = field("rcode", "any result", "130px", "ex-rcodes");
const blocked = field("blocked", "yes, no or a list", "140px", "ex-blocked");
const apply = () => {
const g = { client: split(client.value), name: split(name.value).map((v) => v.toLowerCase().replace(/\.$/, "")), view: split(view.value),
type: split(type.value).map((v) => v.toUpperCase()), rcode: split(rcode.value).map((v) => v.toUpperCase()), blocked: split(blocked.value) };
location.hash = exploreHref(g, since);
};
const form = h("form", { class: "explore-filters", onsubmit: (e) => { e.preventDefault(); apply(); }, oninput: () => { form.dataset.dirty = "1"; } },
h("label", null, "Client ", client), h("label", null, "Name ", name), h("label", null, "View ", view),
h("label", null, "Type ", type), h("label", null, "Result ", rcode), h("label", null, "Blocked ", blocked),
h("button", { type: "submit", class: "primary small" }, "Show"),
sugg("ex-clients", ex && ex.by.client), sugg("ex-names", ex && ex.by.name), sugg("ex-views", views.map((v) => ({ key: v }))),
sugg("ex-types", (ex && ex.by.type) || ["A", "AAAA", "PTR", "MX", "TXT", "HTTPS", "SRV"].map((k) => ({ key: k }))),
sugg("ex-rcodes", ["NOERROR", "NXDOMAIN", "SERVFAIL", "REFUSED"].map((k) => ({ key: k }))),
sugg("ex-blocked", [{ key: "yes" }, { key: "no" }, ...((ex && ex.by.blocked) || [])]));
return h("div", { class: "panel explore-filter-panel" }, form,
h("div", { class: "muted small" }, "Several values: comma separated, any of them. !value leaves one out (!10.0.0.250, !*.apple.com). *.example.com is it and the names below it."));
}
// head is the title and a chip per filter value, each with a ✕.
function head(f, go, since, ex) {
const chips = [];
for (const k of kinds) for (const v of f[k] || []) {
const val = plain(v);
chips.push(h("span", { class: "chip filter-chip" + (isNot(v) ? " not" : "") },
h("span", { class: "muted" }, (isNot(v) ? "not " : "") + filterWords[k] + " "),
h("strong", { class: "mono" }, k === "blocked" ? blockedLabel(val) : val),
k === "client" && !isNot(v) && ex && ex.client_name && includes(f, "client").length === 1 ? h("span", { class: "muted" }, " " + ex.client_name) : null,
" ", h("a", { href: go(dropFrom(f, k, v)), title: "Take it out", class: "chip-x", "aria-label": "Remove " + filterWords[k] + " " + val }, "✕")));
}
const extra = []; 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")); const names = includes(f, "name");
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 (names.length === 1) {
if (f.name || f.domain) extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(f.name || f.domain) }, "find it in your zones")); const n = names[0], bare = n.replace(/^\*\./, "");
if (!n.startsWith("*.")) extra.push(h("a", { href: go(addTo(dropFrom(f, "name", n), "name", "*." + n)) }, "this name and the names below it"));
if (includes(f, "blocked").length || (ex && ex.blocked)) extra.push(h("a", { href: "#/blocking?check=" + encodeURIComponent(bare) }, "why is it blocked?"));
extra.push(h("a", { href: "#/search?q=" + encodeURIComponent(bare) }, "find it in your zones"));
}
return h("div", { class: "page-head" }, return h("div", { class: "page-head" },
h("div", { class: "grow" }, h("div", { class: "grow" },
backLink("Statistics", "#/stats"), backLink("Statistics", "#/stats"),
h("h1", null, title(f, ex)), 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."), h("div", { class: "muted" }, "Every answer in the query log that matches. Click a value to add it, \"hide\" to leave it out; ✕ takes it out of the filter."),
chips.length ? h("div", { class: "chips filter-chips" }, chips, chips.length ? h("div", { class: "chips filter-chips" }, chips,
h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null, h("a", { href: exploreHref({}, since), class: "small" }, "Clear all")) : null,
extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null)); extra.length ? h("div", { class: "small" }, extra.map((a, i) => [i ? " · " : "", a])) : null));
} }
function title(f, ex) { function title(f, ex) {
const n = Object.keys(f).length; if (!anyFilter(f)) return "Explore the answers";
if (!n) return "Explore the answers"; const inc = (k) => includes(f, k);
const who = f.client ? (ex && ex.client_name ? ex.client_name : f.client) : ""; const or = (vs, fmt = (x) => x) => vs.map(fmt).join(" or ");
if (f.client && n === 1) return "Client " + who; const clients = inc("client");
const what = [f.rcode, f.type, f.blocked ? "blocked" + (f.blocked !== "yes" ? " by " + blockedLabel(f.blocked) : "") : "", const who = clients.length === 1 && ex && ex.client_name ? ex.client_name : or(clients);
f.name || (f.domain ? f.domain + " and below" : "")].filter(Boolean).join(" "); const onlyClient = clients.length && kinds.every((k) => k === "client" || !(f[k] || []).length);
if (onlyClient && clients.length === 1) return "Client " + who;
const what = [or(inc("rcode")), or(inc("type")),
inc("blocked").length ? "blocked" + (inc("blocked")[0] !== "yes" ? " by " + or(inc("blocked"), blockedLabel) : "") : "",
or(inc("name"), (n) => (n.startsWith("*.") ? n.slice(2) + " and below" : n))].filter(Boolean).join(" ");
const parts = [what || "Answers"]; const parts = [what || "Answers"];
if (f.client) parts.push("for " + who); if (clients.length) parts.push("for " + who);
if (f.view) parts.push("in view " + f.view); if (inc("view").length) parts.push("in view " + or(inc("view")));
return parts.join(" "); return parts.join(" ");
} }
// recentPanel lists the latest answers, every cell a link. // recentPanel lists the latest answers, every cell a link.
function recentPanel(ex, with_) { function recentPanel(ex, add) {
if (!ex.recent.length) return null; if (!ex.recent.length) return null;
const a = (text, more, cls) => h("a", { href: with_(more), class: cls || "" }, text); const a = (text, more, cls) => { const [k, v] = Object.entries(more)[0]; return h("a", { href: add(k, v), class: cls || "" }, text); };
return h("div", { class: "panel" }, h("h2", null, "Latest answers"), return h("div", { class: "panel" }, h("h2", null, "Latest answers"),
h("div", { class: "table-wrap" }, h("table", { class: "recent-queries" }, 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("thead", null, h("tr", null, ["Time", "Client", "View", "Name", "Type", "Result"].map((t) => h("th", null, t)))),
+6 -2
View File
@@ -43,7 +43,9 @@ export async function render(el, params, query) {
h("div", { class: "muted" }, status.enabled h("div", { class: "muted" }, status.enabled
? "From named's counters" + (status.query_log ? " and response log" : "") + " · data since " + (status.first_data ? fmtTime(status.first_data) : "–") + ? "From named's counters" + (status.query_log ? " and response log" : "") + " · data since " + (status.first_data ? fmtTime(status.first_data) : "–") +
" · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day " · kept: minutes " + status.retention.minute + ", hours " + status.retention.hour + ", days " + status.retention.day
: "Query statistics are off"))); : "Query statistics are off")),
status.enabled && status.retention && status.retention.queries
? h("div", { class: "actions" }, h("a", { class: "button", href: "#/stats/explore", title: "Every answer of the last " + status.retention.queries + ", by client, name, view, type and result" }, "Explore the answers")) : null);
if (!status.enabled) { if (!status.enabled) {
mount(el, head, disabledPanel()); mount(el, head, disabledPanel());
return; return;
@@ -181,7 +183,9 @@ async function loadAll(since, heatSince, queryLog, vf, filtered) {
// keeping the current render (dimmed) until the new one is ready. // keeping the current render (dimmed) until the new one is ready.
function scheduleRefresh(el, params, query, gen) { function scheduleRefresh(el, params, query, gen) {
setTimeout(() => { setTimeout(() => {
if (gen !== generation || !location.hash.startsWith("#/stats") || !el.isConnected) return; // Only on the Statistics page itself: not #/stats/explore.
const h = location.hash;
if (gen !== generation || !(h === "#/stats" || h.startsWith("#/stats?")) || !el.isConnected) return;
el.classList.add("refreshing"); el.classList.add("refreshing");
render(el, params, query).catch(() => el.classList.remove("refreshing")); render(el, params, query).catch(() => el.classList.remove("refreshing"));
}, 60_000); }, 60_000);
+8
View File
@@ -614,3 +614,11 @@ table.recent-queries td.oneline-cell { max-width: 40ch; white-space: nowrap; ove
.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; } .card .disk-bar { margin: 6px 0 4px; height: 8px; }
/* Explore: the filter bar. */
form.explore-filters { display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center; }
form.explore-filters label { color: var(--muted); display: inline-flex; gap: 6px; align-items: center; }
.explore-filter-panel { padding-top: 10px; padding-bottom: 10px; }
.chip.filter-chip.not strong { text-decoration: line-through; }
.bar-list a.bar-hide { font-size: 11px; color: var(--muted); margin-right: 4px; text-decoration: none; opacity: 0.6; }
.bar-list li:hover a.bar-hide, .bar-list a.bar-hide:focus { opacity: 1; text-decoration: underline; }