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>
This commit is contained in:
1 parent
7f3564c0a5
commit
1d535c8063
8 files changed
+81
-32
No files matched your search
@@ -95,6 +95,13 @@ type Status struct {
|
||||
Day string `json:"day"`
|
||||
Queries string `json:"queries"` // the query log ("" when off)
|
||||
} `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.
|
||||
@@ -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.Hour = humanDuration(opts.RetainHour)
|
||||
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 {
|
||||
c.status.Retention.Queries = humanDuration(opts.RetainQueries)
|
||||
c.status.RetentionSeconds.Queries = int64(opts.RetainQueries / time.Second)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// 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.
|
||||
|
||||
// ExploreFilter narrows the query log. Empty fields don't filter. Each
|
||||
@@ -47,8 +47,9 @@ type Exploration struct {
|
||||
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"`
|
||||
// Retention is how long it keeps them, also in seconds.
|
||||
Retention string `json:"retention"`
|
||||
RetentionSeconds int64 `json:"retention_seconds"`
|
||||
}
|
||||
|
||||
// ExplorePoint is one step of the series.
|
||||
@@ -59,7 +60,7 @@ type ExplorePoint struct {
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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()
|
||||
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)
|
||||
db := q.Store.db
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ var schema = []string{
|
||||
PRIMARY KEY (res, ts, kind, key)
|
||||
) WITHOUT ROWID;
|
||||
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
|
||||
// (docs/stats.md#exploring).
|
||||
`CREATE TABLE queries (
|
||||
|
||||
@@ -16,6 +16,13 @@ const filterWords = { client: "client", name: "name", domain: "domain", view: "v
|
||||
const keys = Object.keys(filterWords);
|
||||
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).
|
||||
export function exploreHref(filters, since) {
|
||||
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 maxSecs = retentionSecs(ex.retention);
|
||||
const maxSecs = ex.retention_seconds || 7 * 86400;
|
||||
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]) =>
|
||||
@@ -234,9 +241,3 @@ function stepWord(s) {
|
||||
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,10 +2,27 @@ import { h, mount, fmtTime, relTime, errorBox } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { rerender } from "../app.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"]];
|
||||
const rangeSecs = { "1h": 3600, "6h": 6 * 3600, "24h": 86400, "7d": 7 * 86400, "30d": 30 * 86400, "90d": 90 * 86400 };
|
||||
// Ranges: the since value, its words and its button. Only the ones the
|
||||
// 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
|
||||
// the result code, never its rank.
|
||||
@@ -36,8 +53,9 @@ const perViewGroups = ["qtype", "rcode", "view", "zone", "blocklist"];
|
||||
export async function render(el, params, query) {
|
||||
const gen = ++generation;
|
||||
const want = query && query.get ? query.get("since") : null;
|
||||
const since = rangeSecs[want] ? want : "24h";
|
||||
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" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Statistics"),
|
||||
h("div", { class: "muted" }, status.enabled
|
||||
@@ -50,7 +68,10 @@ export async function render(el, params, query) {
|
||||
mount(el, head, disabledPanel());
|
||||
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.
|
||||
const allViews = status.query_log ? (await get("/views").catch(() => [])).map((v) => v.name) : [];
|
||||
const off = viewsOff();
|
||||
@@ -58,14 +79,14 @@ export async function render(el, params, query) {
|
||||
const filtered = allViews.length > 0 && shown.length < allViews.length;
|
||||
const vf = filtered ? { views: shown.join(",") } : {};
|
||||
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;
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
data = await loadAll(since, heatSince, status.query_log, vf, filtered);
|
||||
} catch (e) {
|
||||
mount(el, head, filterRow(since, allViews, off), errorBox(e));
|
||||
mount(el, head, filterRow(since, kept, allViews, off), errorBox(e));
|
||||
return;
|
||||
}
|
||||
if (gen !== generation) return; // the user moved on
|
||||
@@ -74,7 +95,7 @@ export async function render(el, params, query) {
|
||||
// sudden drop in traffic.
|
||||
const timeline = complete(data.timeline), errors = complete(data.errors);
|
||||
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));
|
||||
if (filtered) {
|
||||
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
|
||||
// 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);
|
||||
const ex = (filters) => (ql ? (k) => exploreHref(filters(k), exploreSince(since, status)) : null);
|
||||
if (status.query_log) {
|
||||
const t = data.tops;
|
||||
parts.push(h("div", { class: "chart-grid" },
|
||||
@@ -201,11 +222,11 @@ function complete(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" },
|
||||
h("span", { class: "muted" }, "Range"),
|
||||
h("div", { class: "segmented" }, ranges.map(([k, label]) =>
|
||||
h("a", { href: "#/stats?since=" + k, class: k === since ? "active" : "", "aria-current": k === since ? "true" : null, title: "last " + label }, k))));
|
||||
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 }, button || k))));
|
||||
if (!views || views.length < 2) return range;
|
||||
// A toggle per view (together: linked views, say), and all or none.
|
||||
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."));
|
||||
}
|
||||
|
||||
// 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;
|
||||
// exploreSince is the Statistics range for the explore page: at most what
|
||||
// the query log keeps (days, usually, not months).
|
||||
function exploreSince(since, status) {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user