Pause blocking, blocked clients and a blocked line in the statistics, compact view picker

jarl block pause|resume (and the Blocking page) lets every name through
for a while; jarld resumes on time. The lists stay loaded: the rules zone
just passes everything. Statistics gain the clients with the most blocked
queries and blocked queries on the queries chart. The view choice in the
blocking forms is a dropdown.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-10 11:10:44 +00:00
1 parent 9fc23310bd
commit 27d1f9e538
31 files changed
+417 -45

No files matched your search

+2
View File
@@ -245,6 +245,8 @@ type StatusResponse struct {
// BlocklistErrors are blocklists whose last update failed (for those
// who may see blocking).
BlocklistErrors []BlocklistError `json:"blocklist_errors,omitempty"`
// BlockingPausedUntil is set while blocking is paused.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
}
// BlocklistError is a blocklist whose last update failed. UpdatedAt is
+7
View File
@@ -139,6 +139,13 @@ func RuleEntries(rules []Rule) map[string]string {
return e
}
// PausedZoneFile renders a rules zone that lets every name through, for
// while blocking is paused: the rules zone comes first, and its match
// stops named from looking at the lists.
func PausedZoneFile(title string) string {
return header(title+" (blocking is paused)", 1) + "*\tCNAME\trpz-passthru.\n"
}
// RulesZoneFile renders the rules zone of a view.
func RulesZoneFile(title string, rules []Rule) string {
e := RuleEntries(rules)
+52
View File
@@ -10,6 +10,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
"jarl/internal/blocking"
"jarl/internal/model"
@@ -229,3 +230,54 @@ func TestBlocklistRefresh(t *testing.T) {
t.Error("the deleted list's status is still there")
}
}
func TestBlockingPause(t *testing.T) {
env := newEnv(t)
setupZone(env)
env.submit(env.admin, blocklist("ads", "https://example.org/list"))
rulesFile := filepath.Join(env.dir, "zones/_default/rules.jarl.invalid.rpz")
if _, err := os.Stat(rulesFile); !os.IsNotExist(err) {
t.Fatal("a rules zone without rules or a pause")
}
until := env.e.Now().Add(10 * time.Minute)
res := env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
if !strings.HasPrefix(res.Ops[0].Summary, "pause blocking until ") {
t.Errorf("summary = %q", res.Ops[0].Summary)
}
f, err := os.ReadFile(rulesFile)
if err != nil || !strings.HasSuffix(string(f), "NS\tlocalhost.\n*\tCNAME\trpz-passthru.\n") {
t.Fatalf("paused rules zone: %v\n%s", err, f)
}
conf, _ := os.ReadFile(filepath.Join(env.dir, "etc/named.conf"))
if !strings.Contains(string(conf), "zone \"rules.jarl.invalid\" min-update-interval 0 log no;\n\t\tzone \"ads.") {
t.Errorf("the paused rules zone must come first:\n%s", conf)
}
// Not yet time: nothing happens.
if got := env.e.ResumeBlocking(context.Background()); !got.Equal(until) {
t.Errorf("ResumeBlocking before the end = %v", got)
}
// Time is up: resumed, and the rules zone is gone again.
env.submit(env.admin, blockRule("ads.example", "block", false))
env.e.Now = func() time.Time { return until.Add(time.Second) }
if got := env.e.ResumeBlocking(context.Background()); !got.IsZero() || env.state().Settings.BlockingPausedUntil != nil {
t.Fatalf("after the pause: %v, %v", got, env.state().Settings.BlockingPausedUntil)
}
if f, _ := os.ReadFile(rulesFile); !strings.Contains(string(f), "*.ads.example\tCNAME\t.\n") || strings.Contains(string(f), "passthru") {
t.Errorf("rules zone after the pause:\n%s", f)
}
// Reverting a pause resumes blocking.
until = env.e.Now().Add(time.Hour)
res = env.submit(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &until}))
rev, err := env.e.Revert(context.Background(), res.ChangesetID, env.admin)
if err != nil {
t.Fatal(err)
}
if _, err := env.e.Apply(context.Background(), rev.ID, env.admin, ApplyOptions{}); err != nil || env.state().Settings.BlockingPausedUntil != nil {
t.Errorf("revert of a pause: %v, %v", err, env.state().Settings.BlockingPausedUntil)
}
// At most a day.
long := env.e.Now().Add(48 * time.Hour)
if e := env.submitErr(env.admin, NewRequest(OpBlockingPause, 0, BlockingPauseInput{Until: &long})); e.Kind != KindBadRequest {
t.Errorf("a two-day pause: %+v", e)
}
}
+36
View File
@@ -1,12 +1,14 @@
package engine
import (
"context"
"encoding/json"
"fmt"
"net/url"
"regexp"
"slices"
"strings"
"time"
"jarl/internal/auth"
"jarl/internal/blocking"
@@ -270,3 +272,37 @@ func (x *expander) blockRuleCreate(req Request) ([]model.Op, error) {
return ruleLabel(r), nil
})
}
func applyBlockingPause(st *model.State, op model.Op, c *applyCtx) error {
if err := c.require(blockingAction()); err != nil {
return err
}
var in BlockingPauseInput
if err := decode(op.Data, &in); err != nil {
return err
}
if in.Until != nil && in.Until.Sub(c.now) > MaxBlockingPause+time.Minute {
return errf(KindBadRequest, "blocking can be paused for at most %s; disable lists to stop using them", MaxBlockingPause)
}
st.Settings.BlockingPausedUntil = in.Until
return nil
}
// ResumeBlocking ends a pause whose time is up, and returns when the
// current pause ends (zero when blocking isn't paused).
func (e *Engine) ResumeBlocking(ctx context.Context) time.Time {
st, err := e.store.LoadState(ctx)
if err != nil || st.Settings.BlockingPausedUntil == nil {
return time.Time{}
}
until := *st.Settings.BlockingPausedUntil
if e.Now().Before(until) {
return until
}
if _, err := e.Submit(ctx, []Request{NewRequest(OpBlockingPause, 0, BlockingPauseInput{})}, auth.System, "system", "resume blocking after the pause", ApplyOptions{}); err != nil {
e.log.Warn("resuming blocking after the pause failed; it will be tried again", "err", err)
return e.Now().Add(time.Minute)
}
e.log.Info("blocking resumed after the pause")
return time.Time{}
}
+4
View File
@@ -17,6 +17,9 @@ func DiffOps(from, to *model.State) []model.Op {
add := func(kind string, id int64, base int, data any, summary string) {
ops = append(ops, newOp(kind, id, base, data, summary))
}
if !reflect.DeepEqual(from.Settings.BlockingPausedUntil, to.Settings.BlockingPausedUntil) {
add(OpBlockingPause, 0, 0, BlockingPauseInput{Until: to.Settings.BlockingPausedUntil}, map[bool]string{true: "pause blocking", false: "resume blocking"}[to.Settings.BlockingPausedUntil != nil])
}
if !reflect.DeepEqual(stripVersion(from.Settings), stripVersion(to.Settings)) {
s := to.Settings
add(OpSettingsUpdate, 0, 0, SettingsInput{DefaultTTL: &s.DefaultTTL, Nameservers: model.Ptr(append([]string{}, s.Nameservers...)), RName: &s.RName,
@@ -184,6 +187,7 @@ func DiffOps(from, to *model.State) []model.Op {
func stripVersion(s model.Settings) model.Settings {
s.Version = 0
s.BlockingPausedUntil = nil // its own operation
return s
}
+1 -1
View File
@@ -829,7 +829,7 @@ func (e *Engine) applyOps(ctx context.Context, cs *model.Changeset, reqs []Reque
res.Applied = true
e.removeDerived(pl)
for _, op := range cs.Ops {
if strings.HasPrefix(op.Kind, "blocklist.") {
if strings.HasPrefix(op.Kind, "blocklist.") || op.Kind == OpBlockingPause {
e.KickBlocklists()
break
}
+10
View File
@@ -234,6 +234,16 @@ func (x *expander) expand(req Request) ([]model.Op, error) {
verb = "delete"
}
return []model.Op{x.versioned(req, b.Version, verb+" blocklist "+b.Name)}, nil
case OpBlockingPause:
var in BlockingPauseInput
if err := decode(req.Data, &in); err != nil {
return nil, err
}
summary := "resume blocking"
if in.Until != nil {
summary = "pause blocking until " + in.Until.Local().Format("2006-01-02 15:04")
}
return []model.Op{newOpRaw(OpBlockingPause, 0, 0, req.Data, summary)}, nil
case OpBlockRuleCreate:
return x.blockRuleCreate(req)
case OpBlockRuleUpdate, OpBlockRuleDelete:
+12 -1
View File
@@ -2,6 +2,7 @@ package engine
import (
"encoding/json"
"time"
"jarl/internal/model"
)
@@ -36,6 +37,7 @@ const (
OpBlockRuleCreate = "blockrule.create"
OpBlockRuleUpdate = "blockrule.update"
OpBlockRuleDelete = "blockrule.delete"
OpBlockingPause = "blocking.pause"
// Requests only: disabling and enabling records, by ID or by match
// (zone, name, type and optionally data), become record.update
@@ -48,7 +50,7 @@ const (
var OpKinds = []string{OpZoneCreate, OpZoneUpdate, OpZoneDelete, OpRecordCreate, OpRecordUpdate, OpRecordDelete, OpRRSetReplace,
OpViewCreate, OpViewUpdate, OpViewDelete, OpViewReorder, OpACLCreate, OpACLUpdate, OpACLDelete,
OpTSIGCreate, OpTSIGUpdate, OpTSIGDelete, OpOptionCreate, OpOptionUpdate, OpOptionDelete, OpSettingsUpdate,
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete}
OpBlocklistCreate, OpBlocklistUpdate, OpBlocklistDelete, OpBlockRuleCreate, OpBlockRuleUpdate, OpBlockRuleDelete, OpBlockingPause}
// Request is an operation as submitted by a client. Requests are expanded
// into one or more stored operations (for example an A record with "ptr"
@@ -244,3 +246,12 @@ type BlockRuleInput struct {
ViewIDs *[]int64 `json:"view_ids,omitempty"`
Comment *string `json:"comment,omitempty"`
}
// BlockingPauseInput pauses blocking until a time, or resumes it (null).
type BlockingPauseInput struct {
Until *time.Time `json:"until"`
}
// MaxBlockingPause is the longest pause: blocking shouldn't stay off by
// mistake (disable lists to stop using them).
const MaxBlockingPause = 24 * time.Hour
+2
View File
@@ -106,6 +106,8 @@ func applyOp(st *model.State, op model.Op, c *applyCtx) error {
err = applyBlockRulePut(st, op, c)
case OpBlockRuleDelete:
err = applyBlockRuleDelete(st, op, c)
case OpBlockingPause:
err = applyBlockingPause(st, op, c)
default:
err = errf(KindBadRequest, "unknown operation %q", op.Kind)
}
+4 -1
View File
@@ -208,7 +208,10 @@ type Settings struct {
Expire uint32 `json:"expire"`
NegativeTTL uint32 `json:"negative_ttl"`
SerialPolicy string `json:"serial_policy"`
Version int `json:"version"`
// BlockingPausedUntil, while set, lets every name through blocking
// (docs/blocking.md); jarld clears it when the time is up.
BlockingPausedUntil *time.Time `json:"blocking_paused_until,omitempty"`
Version int `json:"version"`
}
// DefaultSettings returns the settings of a fresh installation.
+15 -3
View File
@@ -57,17 +57,25 @@ func PolicyZones(s *model.State, p Paths) []PolicyZone {
if v := s.Views[vid]; v != nil {
view = v.Name
}
var zones []PolicyZone
hasRules := false
for _, r := range rules {
if slices.Contains(s.BlockingViews(r.ViewIDs), vid) {
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
hasRules = true
break
}
}
for _, b := range lists {
if !b.Disabled && slices.Contains(s.BlockingViews(b.ViewIDs), vid) {
out = append(out, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
zones = append(zones, PolicyZone{Name: blocking.ListZone(b.Name), ViewID: vid, View: view, File: p.BlocklistFile(b.ID), ListID: b.ID})
}
}
// While paused, every view with blocking gets a rules zone: it
// lets everything through.
if hasRules || s.Settings.BlockingPausedUntil != nil && len(zones) > 0 {
out = append(out, PolicyZone{Name: blocking.RulesZone, ViewID: vid, View: view, File: p.rulesFile(view)})
}
out = append(out, zones...)
}
return out
}
@@ -139,7 +147,11 @@ func writePolicyZones(w *confWriter, s *model.State, zones []PolicyZone, out *Ou
if z.View != "" {
title += " for view " + z.View
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))), Mode: 0o640, Kind: KindPolicy})
content := blocking.RulesZoneFile(title, ViewRules(s, z.ViewID))
if s.Settings.BlockingPausedUntil != nil {
content = blocking.PausedZoneFile(title)
}
out.Files = append(out.Files, &File{Path: z.File, Content: []byte(content), Mode: 0o640, Kind: KindPolicy})
}
}
+16
View File
@@ -81,6 +81,22 @@ func TestBlockingAPI(t *testing.T) {
t.Errorf("reader %s %s = %d", c.method, c.path, status)
}
}
// Pausing.
if _, status := e.req("POST", "/blocking/pause", e.token, map[string]any{"minutes": 0}); status != 400 {
t.Errorf("a 0-minute pause = %d", status)
}
e.do("POST", "/blocking/pause", e.token, map[string]any{"minutes": 30}, 201)
if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] == nil {
t.Errorf("status while paused = %v", st)
}
if st := e.do("GET", "/status", e.token, nil, 200); st["blocking_paused_until"] == nil {
t.Errorf("dashboard while paused = %v", st["blocking_paused_until"])
}
e.do("POST", "/blocking/resume", e.token, nil, 201)
if st := e.do("GET", "/blocking/status", e.token, nil, 200); st["paused_until"] != nil {
t.Errorf("status after resuming = %v", st)
}
// A list that can't be downloaded shows on the dashboard.
e.do("POST", "/blocking/lists", e.token, map[string]any{"name": "gone", "url": lists.URL + "/missing"}, 201)
if l := e.do("POST", "/blocking/lists/gone/refresh", e.token, nil, 200); l["state"] != "error" {
+35
View File
@@ -366,3 +366,38 @@ func (s *Server) blocklistErrors(ctx context.Context) []api.BlocklistError {
}
return out
}
// blockingStatus: GET /blocking/status says whether blocking is paused.
func (s *Server) blockingStatus(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
if err := requireBlocking(p); err != nil {
return err
}
st, err := s.store.LoadState(r.Context())
if err != nil {
return err
}
writeJSON(w, http.StatusOK, map[string]any{"paused_until": st.Settings.BlockingPausedUntil})
return nil
}
// pauseBlocking: POST /blocking/pause {"minutes": 10} lets every name
// through until then; POST /blocking/resume ends it.
func (s *Server) pauseBlocking(resume bool) func(http.ResponseWriter, *http.Request, *auth.Principal) error {
return func(w http.ResponseWriter, r *http.Request, p *auth.Principal) error {
var in engine.BlockingPauseInput
if !resume {
var body struct {
Minutes int `json:"minutes"`
}
if err := decodeJSON(r, &body); err != nil {
return err
}
if body.Minutes < 1 || time.Duration(body.Minutes)*time.Minute > engine.MaxBlockingPause {
return badRequest("pause for 1 to %d minutes", int(engine.MaxBlockingPause/time.Minute))
}
until := time.Now().Add(time.Duration(body.Minutes) * time.Minute).Truncate(time.Second)
in.Until = &until
}
return s.mutate(w, r, p, "", []engine.Request{engine.NewRequest(engine.OpBlockingPause, 0, in)}, nil)
}
}
+3
View File
@@ -281,6 +281,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request, p *auth.Principa
}
if p.Can(auth.Action{Resource: auth.ResBlocking}) {
resp.BlocklistErrors = s.blocklistErrors(r.Context())
if st, err := s.store.LoadState(r.Context()); err == nil {
resp.BlockingPausedUntil = st.Settings.BlockingPausedUntil
}
}
writeJSON(w, http.StatusOK, resp)
return nil
+1 -1
View File
@@ -82,7 +82,7 @@ func (s *Server) statsTop(w http.ResponseWriter, r *http.Request, p *auth.Princi
if err != nil {
return badRequest("%v", err)
}
if kind == stats.KindClient || kind == stats.KindRefused {
if kind == stats.KindClient || kind == stats.KindRefused || kind == stats.KindBlockedClient {
if err := s.nameClients(r, p, es); err != nil {
return err
}
+3
View File
@@ -69,6 +69,9 @@ func (s *Server) routes() {
m.HandleFunc("PATCH "+p+"/blocking/rules/{id}", s.authed(s.updateBlockRule))
m.HandleFunc("DELETE "+p+"/blocking/rules/{id}", s.authed(s.deleteBlockRule))
m.HandleFunc("GET "+p+"/blocking/check", s.authed(s.checkBlocking))
m.HandleFunc("GET "+p+"/blocking/status", s.authed(s.blockingStatus))
m.HandleFunc("POST "+p+"/blocking/pause", s.authed(s.pauseBlocking(false)))
m.HandleFunc("POST "+p+"/blocking/resume", s.authed(s.pauseBlocking(true)))
m.HandleFunc("GET "+p+"/acls", s.authed(s.listACLs))
m.HandleFunc("POST "+p+"/acls", s.authed(s.createACL))
m.HandleFunc("GET "+p+"/acls/{id}", s.authed(s.getACL))
+3
View File
@@ -93,6 +93,9 @@ func TestCollectorBlocking(t *testing.T) {
if b, _ := store.TopsAt(ctx, ResHour, m, KindBlocked); len(b) != 2 || b[0] != (Entry{Key: "ads.example", Count: 2}) {
t.Errorf("blocked names = %v", b)
}
if bc, _ := store.TopsAt(ctx, ResHour, m, KindBlockedClient); len(bc) != 2 || bc[0] != (Entry{Key: "10.0.0.5", Count: 2}) {
t.Errorf("blocked clients = %v", bc)
}
if nx, _ := store.TopsAt(ctx, ResHour, m, KindNXDomain); len(nx) != 1 || nx[0].Key != "typo.example" {
t.Errorf("NXDOMAIN names should leave out blocked ones: %v", nx)
}
+4 -1
View File
@@ -24,10 +24,12 @@ const (
KindServFail = "servfail" // names answered with SERVFAIL
KindRefused = "refused" // clients that were refused
KindBlocked = "blocked" // names blocked (docs/blocking.md)
// KindBlockedClient: clients with the most blocked queries.
KindBlockedClient = "blocked_client"
)
// Kinds lists the top-list kinds.
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked}
var Kinds = []string{KindName, KindClient, KindNXDomain, KindServFail, KindRefused, KindBlocked, KindBlockedClient}
// OtherZone is the zone key for names outside every local zone (answered
// by recursion, or refused).
@@ -380,6 +382,7 @@ func (c *Collector) handleRewrite(rw Rewrite) {
}
hours[hour] = c.hourMaps(hours, hour)
hours[hour][KindBlocked][rw.Name]++
hours[hour][KindBlockedClient][clientKey(rw.Client, c.opts.Anonymize)]++
}
// policySource names what a rewrite came from: a blocklist's name, "rules"
+48 -13
View File
@@ -22,7 +22,7 @@ let refreshTimer = null;
export async function render(el, params, query) {
clearTimeout(refreshTimer);
const [lists, rules, views] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views")]);
const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]);
const writable = can("blocking", true);
const recursive = lists.concat(rules).find((x) => x.auto_views);
const autoViews = recursive ? recursive.views : null;
@@ -39,10 +39,16 @@ export async function render(el, params, query) {
} catch (e) { mount(checkOut, errorBox(e)); }
};
const paused = status.paused_until;
mount(el,
h("div", { class: "page-head" },
h("div", { class: "grow" }, h("h1", null, "Blocking"),
h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists."))),
h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")),
writable && !paused && (lists.length || rules.length) ? pauseControls() : null),
paused ? h("div", { class: "msg warn" },
h("strong", null, "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) + ": "),
"every name is answered normally. It resumes by itself. ",
writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null,
h("div", { class: "panel" },
h("form", { class: "actions", onsubmit: runCheck },
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
@@ -51,10 +57,27 @@ export async function render(el, params, query) {
listsPanel(lists, views, autoViews, writable));
if (checkName.value) runCheck();
// Lists are downloaded in the background: show when they're done.
if (lists.some((l) => l.state === "waiting")) {
refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, 4000);
}
// Lists are downloaded in the background, and a pause ends by itself:
// show it when it happens.
let wait = 0;
if (lists.some((l) => l.state === "waiting")) wait = 4000;
if (paused) wait = Math.max(1000, Math.min(wait || Infinity, new Date(paused) - Date.now() + 3000));
if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait);
}
function pauseControls() {
const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"]], "15");
return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking"));
}
// pause pauses blocking for minutes, or resumes it (0). It applies at
// once, also with a draft open: it's for trying something now.
async function pause(minutes) {
try {
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body: { minutes } } : {});
toast(minutes ? "Blocking paused" : "Blocking resumed");
rerender();
} catch (e) { toast(e.message, "err", 8000); }
}
function checkResult(name, res, writable) {
@@ -82,17 +105,29 @@ async function addRule(name, action, exact, views, comment) {
} catch (e) { toast(e.message, "err", 8000); return false; }
}
// viewPicker returns checkboxes for the views (none ticked: automatic).
function viewPicker(views, autoViews, selected) {
// viewPicker is a compact choice of views: it shows the choice, and opens
// a list of checkboxes (none ticked: every view that offers recursion).
function viewPicker(views, autoViews) {
if (!views.length) return { el: null, value: () => [] };
const boxes = views.map((v) => checkbox(v.name, (selected || []).includes(v.name)));
const hint = autoViews && autoViews.length ? "none ticked: " + autoViews.join(", ") + " (every view that offers recursion)" : "none ticked: every view that offers recursion";
return {
el: h("div", { class: "f" }, h("label", null, "Views"), h("div", { class: "actions" }, boxes, h("span", { class: "muted small" }, hint))),
value: () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name),
const boxes = views.map((v) => checkbox(v.name, false));
const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name);
const summary = h("summary");
const show = () => {
const sel = value();
summary.textContent = sel.length ? sel.join(", ") : "automatic" + (autoViews && autoViews.length ? " (" + autoViews.join(", ") + ")" : "");
};
boxes.forEach((b) => b.input.addEventListener("change", show));
show();
const picker = h("details", { class: "view-picker" }, summary,
h("div", { class: "view-picker-list" }, h("div", { class: "muted small" }, "None ticked: every view that offers recursion"), boxes));
return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), value };
}
// An open view picker closes when you click elsewhere.
document.addEventListener("click", (ev) => {
for (const d of document.querySelectorAll("details.view-picker[open]")) if (!d.contains(ev.target)) d.open = false;
});
function viewsCell(x) {
if (!x.views.length) return h("span", { class: "muted" }, "none");
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
@@ -1,4 +1,4 @@
import { h, mount, time, relTime, problemsList, badge, plural } from "../ui.js";
import { h, mount, time, fmtTime, relTime, problemsList, badge, plural } from "../ui.js";
import { get, state } from "../api.js";
export async function render(el) {
@@ -40,6 +40,10 @@ export async function render(el) {
h("strong", null, "The last backup failed "), time(b.last_error_at), ": " + b.last_error,
h("div", { class: "small" }, "See 'journalctl -u jarl-backup' on the server.")));
}
if (st.blocking_paused_until) {
parts.push(h("div", { class: "msg info" },
h("strong", null, "Blocking is paused until " + fmtTime(st.blocking_paused_until) + "."), " It resumes by itself. ", h("a", { href: "#/blocking" }, "Blocking")));
}
for (const e of st.blocklist_errors || []) {
parts.push(h("div", { class: "msg warn" },
h("strong", null, "Blocklist " + e.name + " couldn't be updated: "), e.error + ". ",
+9 -4
View File
@@ -60,10 +60,14 @@ export async function render(el, params, query) {
s.blocked_ratio != null ? tile("Blocked", pct(s.blocked_ratio), compact(s.blocked) + " queries, by your rules and blocklists") : null));
// Volume over time: one series, so no legend box; the title names it.
// With blocking, a second line shows how many of them were blocked.
const q = timeline.series.find((x) => x.key === "queries") || { values: timeline.times.map(() => 0) };
const qSeries = [{ label: "Queries", values: q.values, slot: 1 }];
parts.push(chartCard("Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step), { wide: true }));
const bl = timeline.series.find((x) => x.key === "blocked");
if (bl && bl.values.some((v) => v > 0)) qSeries.push({ label: "Blocked", values: bl.values, slot: 2 });
parts.push(chartCard(qSeries.length > 1 ? "Queries and blocked queries" : "Queries", "per " + stepName(timeline.step) + ", " + rangeLabel,
lineChart({ series: qSeries, times: timeline.times, step, area: true }), timeTable(qSeries, timeline.times, step),
{ wide: true, legend: qSeries.length > 1 ? legend(qSeries) : null }));
// Failed answers: three fixed series, legend plus end labels.
const fSeries = failures.map((f) => ({ ...f, values: (errors.series.find((x) => x.key === f.key) || { values: errors.times.map(() => 0) }).values }));
@@ -86,6 +90,7 @@ export async function render(el, params, query) {
listCard("Names that don't exist", "NXDOMAIN answers: typos, removed hosts, misconfigured clients, malware", barList(t.nxdomain, { empty: "None " + rangeLabel + "." })),
listCard("Refused clients", "clients asking for something they may not have (often internet scanners)", barList(t.refused, { empty: "None " + rangeLabel + "." })),
t.servfail.length ? listCard("Names that failed to resolve", "SERVFAIL answers", barList(t.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.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));
} 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."));
@@ -110,14 +115,14 @@ export async function render(el, params, query) {
async function loadAll(since, heatSince, queryLog) {
const p = {
summary: get("/stats/summary", { since, top: 0 }),
timeline: get("/stats/timeline", { since, key: "queries" }),
timeline: get("/stats/timeline", { since, key: "queries,blocked" }),
errors: get("/stats/timeline", { since, key: failures.map((f) => f.key).join(",") }),
heat: get("/stats/heatmap", { since: heatSince }),
};
const groups = ["qtype", "rcode", "view", "zone", "answer", "rtt", "blocklist"];
groups.forEach((g) => { p["b_" + g] = get("/stats/breakdown", { group: g, since }); });
if (queryLog) {
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 });
for (const k of ["name", "client", "nxdomain", "refused", "servfail", "blocked", "blocked_client"]) p["t_" + k] = get("/stats/top", { kind: k, since, limit: 15 });
}
const keys = Object.keys(p);
const vals = await Promise.all(Object.values(p));
+3
View File
@@ -443,5 +443,8 @@ footer.site a:hover { color: var(--accent); text-decoration-color: currentColor;
.query-log li.pending code { opacity: 0.85; }
.err-text { color: var(--err); }
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
details.view-picker { position: relative; }
details.view-picker summary { cursor: pointer; padding: 5px 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--panel); min-width: 12em; max-width: 24em; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
details.view-picker[open] .view-picker-list { position: absolute; z-index: 20; top: calc(100% + 4px); left: 0; min-width: 14em; max-height: 260px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; padding: 8px 10px; background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
ul.plain li { padding: 3px 0; }
.query-details { margin: 8px 0; }