Compare commits

...
Author SHA1 Message Date
adamandClaude Opus 5.5 3ddd32f97f Release 0.6.1-rc.4
release / release (push) Successful in 4m43s
0.6.1's fourth release candidate: the Stats page's range buttons follow
the retention settings, jarl changeset show prints local time, and the CLI
no longer prints "and N more" when the server sends no PTR list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 22:32:19 +01:00
adamandClaude Opus 5.5 0976faba3f CLI: changeset show prints local time
release / release (push) Failing after 18s
Created and Applied were printed in UTC, while history and status print
local time.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 17:29:43 +01:00
adamandClaude Opus 5.5 1d535c8063 Stats: range buttons follow the retention settings
The Statistics page offers only the ranges kept (longest of the minute,
hour and day retentions), adds 1y and 2y when the daily data covers them,
caps the heatmap at the hourly retention and links to Explore within
retention.queries. Status and explore send retention_seconds. The query
log error names stats.retention.queries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:19:12 +01:00
adamandClaude Opus 5.5 7f3564c0a5 CLI: don't print "and N more" when the server sends no PTR list
A 0.6.1-rc.2 server sends ptr_other_views without ptr_other_views_list,
so zone show printed only "and 1 more" under "Other views:".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:05:50 +01:00
13 changed files with 123 additions and 36 deletions

No files matched your search

+18
View File
@@ -42,6 +42,14 @@ All notable changes to Jarl are documented here. The format follows
### Changed ### Changed
- The Statistics page's range buttons follow `stats.retention`: ranges
longer than any data kept are left out, and **1y** and **2y** appear
when `retention.day` (or `retention.hour`) covers them (with the
default 730d, both do). The busiest-times heatmap says how far back it
really goes (at most `retention.hour`), and links to the Explore page
keep within `retention.queries` instead of assuming 7 days.
`GET /stats/status` and `GET /stats/explore` add `retention_seconds`.
- A reverse zone's missing PTR records count only its own view's A and - A reverse zone's missing PTR records count only its own view's A and
AAAA records. An address only another view's records use (an internal AAAA records. An address only another view's records use (an internal
host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no host, seen from the vps view's copy of `10.0.10.in-addr.arpa`) is no
@@ -97,6 +105,16 @@ All notable changes to Jarl are documented here. The format follows
### Fixed ### Fixed
- The "query log is off" error named a setting that doesn't exist,
`stats.query_retention`; it's `stats.retention.queries`.
- `jarl zone show` for a reverse zone printed only "and 1 more" under
"Other views:" when the server was older than the CLI (0.6.1-rc.2
sends the count but not the addresses). It now prints just the count
line then.
- `jarl changeset show` printed its Created and Applied times in UTC,
while `jarl history` and `jarl status` print local time. It uses local
time now too.
- The statistics' "Busiest times" grid drew hours without data taller - The statistics' "Busiest times" grid drew hours without data taller
than the others (they picked up the page's padding for empty than the others (they picked up the page's padding for empty
messages). messages).
+1 -1
View File
@@ -203,7 +203,7 @@ review step.
## Status ## Status
Version 0.6.0 is the current release, and 0.6.1 is in testing Version 0.6.0 is the current release, and 0.6.1 is in testing
(0.6.1-rc.3): it gets certificates onto your other hosts (`jarl cert (0.6.1-rc.4): it gets certificates onto your other hosts (`jarl cert
get`: Jarl obtains and renews them, a host downloads them and runs a get`: Jarl obtains and renews them, a host downloads them and runs a
hook, a daily timer keeps them current), exports certificates, fixes hook, a daily timer keeps them current), exports certificates, fixes
PTR records and overrides in linked zones, and makes one-view tokens PTR records and overrides in linked zones, and makes one-view tokens
+1 -1
View File
@@ -1 +1 @@
0.6.1-rc.3 0.6.1-rc.4
+2 -2
View File
@@ -272,9 +272,9 @@ func changesetCmd() *cobra.Command {
return err return err
} }
return output(cs, func(w io.Writer) { return output(cs, func(w io.Writer) {
fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Format("2006-01-02 15:04"), cs.Username, cs.Via) fmt.Fprintf(w, "Changeset:\t%d (%s)\nDescription:\t%s\nCreated:\t%s by %s via %s\n", cs.ID, cs.Status, cs.Description, cs.CreatedAt.Local().Format("2006-01-02 15:04"), cs.Username, cs.Via)
if cs.AppliedAt != nil { if cs.AppliedAt != nil {
fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Format("2006-01-02 15:04"), cs.AppliedBy) fmt.Fprintf(w, "Applied:\t%s by %s\n", cs.AppliedAt.Local().Format("2006-01-02 15:04"), cs.AppliedBy)
} }
fmt.Fprintln(w, "\n#\tOPERATION") fmt.Fprintln(w, "\n#\tOPERATION")
for _, op := range cs.Ops { for _, op := range cs.Ops {
+25
View File
@@ -0,0 +1,25 @@
package main
import (
"bytes"
"strings"
"testing"
"jarl/internal/api"
)
func TestPrintPTRFills(t *testing.T) {
var b bytes.Buffer
printPTRFills(&b, []api.PTRFill{{Address: "10.0.10.20", Target: "nextcloud.adbraun.lol.", From: "internal"}}, 3)
out := b.String()
if !strings.Contains(out, "10.0.10.20 -> nextcloud.adbraun.lol (view internal)") || !strings.Contains(out, "and 2 more") {
t.Errorf("listed fills:\n%s", out)
}
// An older server sends the count without the list.
b.Reset()
printPTRFills(&b, nil, 1)
if b.Len() != 0 {
t.Errorf("no list from the server printed %q, want nothing", b.String())
}
}
+6 -1
View File
@@ -1183,8 +1183,13 @@ func terminalWidth() int {
} }
// printPTRFills lists PTR records a reverse zone lacks under zone show's // printPTRFills lists PTR records a reverse zone lacks under zone show's
// line about them: address -> name, and the view the name is from. // line about them: address -> name, and the view the name is from. A server
// older than the CLI may send the count without the list; then nothing is
// listed, rather than "and N more" under no addresses.
func printPTRFills(w io.Writer, fills []api.PTRFill, total int) { func printPTRFills(w io.Writer, fills []api.PTRFill, total int) {
if len(fills) == 0 {
return
}
for _, f := range fills { for _, f := range fills {
from := "" from := ""
if f.From != "" { if f.From != "" {
+2 -2
View File
@@ -579,12 +579,12 @@ Needs `stats:read`. When statistics are disabled, every endpoint but
| Method | Path | Query | Returns | | Method | Path | Query | Returns |
|--------|------|-------|---------| |--------|------|-------|---------|
| GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, warnings}` | | GET | `/stats/status` | | `{enabled, query_log, statistics_channel, log_file, last_poll, last_log_line, log_lines_read, first_data, retention, retention_seconds, warnings}`: `retention` has `minute`, `hour`, `day`, `queries` as `"7d"`, `retention_seconds` the same in seconds (`queries` 0 without a query log) |
| GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` | | GET | `/stats/summary` | `since` (90m, 24h, 7d, 2w; default 24h), `top` (default 10) | totals, `avg_qps`, `peak`, `rcodes`, `qtypes`, `views`, `zones`, `answers`, `transport`, `cache_hit_ratio`, `nxdomain_ratio` (without blocked answers), `blocked`, `blocked_ratio`, `blocklists` (blocked per list; `rules` for your rules), `tops` |
| 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=`; 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/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` (`"7d"`) and `retention_seconds`, `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}]` |
+5 -1
View File
@@ -263,7 +263,11 @@ database, roughly in proportion to the number of views.
lists for `retention.hour`, daily ones for `retention.day`. Older data is lists for `retention.hour`, daily ones for `retention.day`. Older data is
deleted every hour. deleted every hour.
- Charts use the finest resolution that covers the range: minutes up to 48 - Charts use the finest resolution that covers the range: minutes up to 48
hours, hours up to 90 days, days beyond. hours (and `retention.minute`), hours up to `retention.hour`, days beyond.
- The Statistics page offers only the ranges the data is kept for: up to 90
days with the defaults, plus a year and 2 years when `retention.day` (or
`retention.hour`) is that long. The Explore page offers the ranges up to
`retention.queries`.
- Top lists are exact within an hour (up to 200,000 distinct names or - Top lists are exact within an hour (up to 200,000 distinct names or
clients per hour, beyond which the rarest are dropped). Daily lists are clients per hour, beyond which the rarest are dropped). Daily lists are
built from the hourly top lists, so for very long tails they are built from the hourly top lists, so for very long tails they are
+11
View File
@@ -95,6 +95,13 @@ type Status struct {
Day string `json:"day"` Day string `json:"day"`
Queries string `json:"queries"` // the query log ("" when off) Queries string `json:"queries"` // the query log ("" when off)
} `json:"retention"` } `json:"retention"`
// RetentionSeconds is Retention in seconds, for the web UI's ranges.
RetentionSeconds struct {
Minute int64 `json:"minute"`
Hour int64 `json:"hour"`
Day int64 `json:"day"`
Queries int64 `json:"queries"` // 0 when the query log is off
} `json:"retention_seconds"`
} }
// Collector gathers statistics into a Store. // Collector gathers statistics into a Store.
@@ -164,8 +171,12 @@ 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)
c.status.RetentionSeconds.Minute = int64(opts.RetainMinute / time.Second)
c.status.RetentionSeconds.Hour = int64(opts.RetainHour / time.Second)
c.status.RetentionSeconds.Day = int64(opts.RetainDay / time.Second)
if opts.QueryLog && opts.RetainQueries > 0 { if opts.QueryLog && opts.RetainQueries > 0 {
c.status.Retention.Queries = humanDuration(opts.RetainQueries) c.status.Retention.Queries = humanDuration(opts.RetainQueries)
c.status.RetentionSeconds.Queries = int64(opts.RetainQueries / time.Second)
} }
return c return c
} }
+5 -4
View File
@@ -8,7 +8,7 @@ import (
) )
// Exploring the query log (docs/stats.md#exploring): every answer of the // Exploring the query log (docs/stats.md#exploring): every answer of the
// last days (stats.query_retention), filtered by any mix of client, name, // last days (stats.retention.queries), 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. Each // ExploreFilter narrows the query log. Empty fields don't filter. Each
@@ -47,8 +47,9 @@ type Exploration struct {
Recent []QueryRow `json:"recent"` Recent []QueryRow `json:"recent"`
// Oldest is the first answer the query log still has. // Oldest is the first answer the query log still has.
Oldest *time.Time `json:"oldest,omitempty"` Oldest *time.Time `json:"oldest,omitempty"`
// Retention is how long it keeps them. // Retention is how long it keeps them, also in seconds.
Retention string `json:"retention"` Retention string `json:"retention"`
RetentionSeconds int64 `json:"retention_seconds"`
} }
// ExplorePoint is one step of the series. // ExplorePoint is one step of the series.
@@ -59,7 +60,7 @@ 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.retention.queries above 0 in /etc/jarl/jarl.yaml (docs/stats.md#exploring)")
// values splits a filter field into the values it must be one of and the // 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". // ones (written !VALUE) it must not be: "10.0.0.5, !10.0.0.9".
@@ -178,7 +179,7 @@ func (q *Querier) Explore(ctx context.Context, f ExploreFilter, topN, recent int
} }
from := now.Add(-since).Unix() from := now.Add(-since).Unix()
out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{}, out := &Exploration{From: time.Unix(from, 0), To: now, By: map[string][]Entry{}, Series: []ExplorePoint{}, Recent: []QueryRow{},
Retention: humanDuration(q.Opts.RetainQueries)} Retention: humanDuration(q.Opts.RetainQueries), RetentionSeconds: int64(q.Opts.RetainQueries / time.Second)}
cond, args := q.where(f, from) cond, args := q.where(f, from)
db := q.Store.db db := q.Store.db
+1 -1
View File
@@ -45,7 +45,7 @@ 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), // 2: the query log, every answer for a while (stats.retention.queries),
// for drilling down: what a client asked, who asked for a name // for drilling down: what a client asked, who asked for a name
// (docs/stats.md#exploring). // (docs/stats.md#exploring).
`CREATE TABLE queries ( `CREATE TABLE queries (
+8 -7
View File
@@ -16,6 +16,13 @@ const filterWords = { client: "client", name: "name", domain: "domain", view: "v
const keys = Object.keys(filterWords); const keys = Object.keys(filterWords);
const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]]; const ranges = [["1h", 3600], ["6h", 6 * 3600], ["24h", 86400], ["3d", 3 * 86400], ["7d", 7 * 86400], ["30d", 30 * 86400]];
// exploreRange is the longest range here of at most secs ("1h" at least).
export function exploreRange(secs) {
let out = "1h";
for (const [k, s] of ranges) if (s <= secs) out = k;
return out;
}
// 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();
@@ -74,7 +81,7 @@ export async function render(el, params, query) {
} }
const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)]; const parts = [head(f, go, since, ex), filterBar(f, since, ex, allViews)];
const maxSecs = retentionSecs(ex.retention); const maxSecs = ex.retention_seconds || 7 * 86400;
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"),
h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) => h("div", { class: "segmented" }, ranges.filter(([, s]) => s <= maxSecs).map(([k]) =>
@@ -234,9 +241,3 @@ function stepWord(s) {
if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours"; if (s % 3600 === 0) return s === 3600 ? "hour" : s / 3600 + " hours";
return s === 60 ? "minute" : s / 60 + " minutes"; 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]];
}
+38 -16
View File
@@ -2,10 +2,27 @@ 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"; import { exploreHref, exploreRange } from "./explore.js";
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"]]; // Ranges: the since value, its words and its button. Only the ones the
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 }; // statistics are kept for are offered (keptSecs).
const ranges = [["1h", "1 hour"], ["6h", "6 hours"], ["24h", "24 hours"], ["7d", "7 days"], ["30d", "30 days"], ["90d", "90 days"],
["365d", "year", "1y"], ["730d", "2 years", "2y"]];
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400, "365d": 365 * 86400, "730d": 730 * 86400 };
// keptSecs is how far back the statistics go: the longest of the minute,
// hour and day retentions (a server older than retention_seconds: 2 years).
function keptSecs(status) {
const r = status.retention_seconds;
return r ? Math.max(r.minute, r.hour, r.day) : 730 * 86400;
}
// longestWithin is the longest range of at most secs ("1h" at least).
function longestWithin(secs) {
let out = "1h";
for (const [k] of ranges) if (rangeSecs[k] <= secs) out = k;
return out;
}
// Failed answers keep the same color whatever the range: color follows // Failed answers keep the same color whatever the range: color follows
// the result code, never its rank. // the result code, never its rank.
@@ -36,8 +53,9 @@ const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
export async function render(el, params, query) { export async function render(el, params, query) {
const gen = ++generation; const gen = ++generation;
const want = query && query.get ? query.get("since") : null; const want = query && query.get ? query.get("since") : null;
const since = rangeSecs[want] ? want : "24h";
const status = await get("/stats/status"); const status = await get("/stats/status");
const kept = keptSecs(status);
const since = rangeSecs[want] && rangeSecs[want] <= kept ? want : longestWithin(Math.min(86400, kept));
const head = h("div", { class: "page-head" }, const head = h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Statistics"), h("div", { class: "grow" }, h("h1", null, "Statistics"),
h("div", { class: "muted" }, status.enabled h("div", { class: "muted" }, status.enabled
@@ -50,7 +68,10 @@ export async function render(el, params, query) {
mount(el, head, disabledPanel()); mount(el, head, disabledPanel());
return; return;
} }
const heatSince = rangeSecs[since] < 7 * 86400 ? "7d" : since; // The heatmap needs hourly counters: at least a week of them, at most
// what is kept.
const hourSecs = status.retention_seconds ? status.retention_seconds.hour : 90 * 86400;
const heatSince = longestWithin(Math.min(Math.max(rangeSecs[since], 7 * 86400), hourSecs));
// Which views: all of them unless some are switched off. // Which views: all of them unless some are switched off.
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : []; const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
const off = viewsOff(); const off = viewsOff();
@@ -58,14 +79,14 @@ export async function render(el, params, query) {
const filtered = allViews.length > 0 && shown.length < allViews.length; const filtered = allViews.length > 0 && shown.length < allViews.length;
const vf = filtered ? { views: shown.join(",") } : {}; const vf = filtered ? { views: shown.join(",") } : {};
if (filtered && !shown.length) { if (filtered && !shown.length) {
mount(el, head, filterRow(since, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above.")); mount(el, head, filterRow(since, kept, allViews, off), h("div", { class: "empty" }, "No views chosen: switch one on above."));
return; return;
} }
let data; let data;
try { try {
data = await loadAll(since, heatSince, status.query_log, vf, filtered); data = await loadAll(since, heatSince, status.query_log, vf, filtered);
} catch (e) { } catch (e) {
mount(el, head, filterRow(since, allViews, off), errorBox(e)); mount(el, head, filterRow(since, kept, allViews, off), errorBox(e));
return; return;
} }
if (gen !== generation) return; // the user moved on if (gen !== generation) return; // the user moved on
@@ -74,7 +95,7 @@ export async function render(el, params, query) {
// sudden drop in traffic. // sudden drop in traffic.
const timeline = complete(data.timeline), errors = complete(data.errors); const timeline = complete(data.timeline), errors = complete(data.errors);
const step = stepSeconds[timeline.step]; const step = stepSeconds[timeline.step];
const parts = [head, filterRow(since, allViews, off)]; const parts = [head, filterRow(since, kept, allViews, off)];
for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w)); for (const w of status.warnings || []) parts.push(h("div", { class: "msg warn" }, w));
if (filtered) { if (filtered) {
parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" + parts.push(h("div", { class: "msg info small" }, "Showing view" + (shown.length > 1 ? "s " : " ") + shown.join(", ") + ": answers from named's response log" +
@@ -120,7 +141,7 @@ export async function render(el, params, query) {
// Who asks for what. With the query log, every value leads to the // 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. // explore page: a client to what it asked, a name to who asked.
const ql = !!(status.retention || {}).queries; const ql = !!(status.retention || {}).queries;
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since)) : null); const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since, status)) : 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" },
@@ -201,11 +222,11 @@ function complete(tl) {
return tl; return tl;
} }
function filterRow(since, views, off) { function filterRow(since, kept, views, off) {
const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" }, const range = h("div", { class: "filter-row", role: "group", "aria-label": "Time range" },
h("span", { class: "muted" }, "Range"), h("span", { class: "muted" }, "Range"),
h("div", { class: "segmented" }, ranges.map(([k, label]) => h("div", { class: "segmented" }, ranges.filter(([k]) => rangeSecs[k] <= kept).map(([k, label, button]) =>
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k)))); h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, button || k))));
if (!views || views.length < 2) return range; if (!views || views.length < 2) return range;
// A toggle per view (together: linked views, say), and all or none. // A toggle per view (together: linked views, say), and all or none.
const set = (next) => { saveViewsOff(next); rerender(); }; const set = (next) => { saveViewsOff(next); rerender(); };
@@ -312,10 +333,11 @@ function disabledPanel() {
"(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 // exploreSince is the Statistics range for the explore page: at most what
// keeps days, not months. // the query log keeps (days, usually, not months).
function exploreSince(since) { function exploreSince(since, status) {
return rangeSecs[since] > 7 * 86400 ? "7d" : since; const q = status.retention_seconds ? status.retention_seconds.queries : 7 * 86400;
return exploreRange(Math.min(rangeSecs[since], q));
} }
// dbTile is the statistics database's size: what the statistics cost on // dbTile is the statistics database's size: what the statistics cost on