Show blocked counts on the Blocking page, and reveal + CNAME on hover

The Blocking page shows how many queries each blocklist, and the rules
together, blocked in the last 24 hours (with statistics on). The + CNAME
button on A/AAAA rows shows when the row is hovered or focused, and
always on touch screens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
adamandClaude Opus 5.5 committed 2026-10-11 20:26:12 +01:00
1 parent 8f0a0cc233
commit 5b8f681e2a
5 files changed
+32 -7

No files matched your search

+5 -1
View File
@@ -92,7 +92,11 @@ All notable changes to Jarl are documented here. The format follows
reorder them; view links everywhere open the view's page. reorder them; view links everywhere open the view's page.
- Web UI: A and AAAA records have a **+ CNAME** button that fills in the - Web UI: A and AAAA records have a **+ CNAME** button that fills in the
add form with a CNAME pointing to the record's name, leaving only the add form with a CNAME pointing to the record's name, leaving only the
alias's name to type. alias's name to type. It shows when the row is hovered or focused
(always on touch screens).
- Blocking page: how many queries each blocklist blocked in the last 24
hours, and your rules together, with statistics on (linking to the
Statistics page).
- Options are kept in one fixed order, in named.conf, the web UI and the - Options are kept in one fixed order, in named.conf, the web UI and the
CLI: grouped by topic (files, listening, access control, recursion and CLI: grouped by topic (files, listening, access control, recursion and
forwarding, DNSSEC, transfers and notify, then the rest), alphabetical forwarding, DNSSEC, transfers and notify, then the rest), alphabetical
+4
View File
@@ -194,6 +194,10 @@ names, and the clients with the most blocked queries (`jarl stats summary`,
breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which breakdown blocklist`). Blocked answers are left out of the NXDOMAIN figures, which
are about names that really don't exist. are about names that really don't exist.
The Blocking page shows the last 24 hours too: a **Blocked (24h)** column
for each list, and one count for your rules together (named logs which
policy zone matched, not which rule in it).
Without statistics named doesn't log what it blocks at all. Without statistics named doesn't log what it blocks at all.
## Memory and performance ## Memory and performance
+16 -5
View File
@@ -22,7 +22,11 @@ let refreshTimer = null;
export async function render(el, params, query) { export async function render(el, params, query) {
clearTimeout(refreshTimer); clearTimeout(refreshTimer);
const [lists, rules, views, status] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status")]); // Queries blocked in the last day, per list and for the rules together
// (null without query statistics, or without permission to see them).
const [lists, rules, views, status, hitList] = await Promise.all([get("/blocking/lists", draftQuery()), get("/blocking/rules", draftQuery()), get("/views"), get("/blocking/status"),
can("stats") ? get("/stats/breakdown", { group: "blocklist", since: "24h" }).catch(() => null) : null]);
const hits = hitList ? Object.fromEntries(hitList.map((e) => [e.key, e.count])) : null;
const writable = can("blocking", true); const writable = can("blocking", true);
const recursive = lists.concat(rules).find((x) => x.auto_views); const recursive = lists.concat(rules).find((x) => x.auto_views);
const autoViews = recursive ? recursive.views : null; const autoViews = recursive ? recursive.views : null;
@@ -53,8 +57,8 @@ export async function render(el, params, query) {
h("form", { class: "actions", onsubmit: runCheck }, h("form", { class: "actions", onsubmit: runCheck },
h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")), h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")),
checkOut), checkOut),
rulesPanel(rules, views, autoViews, writable), rulesPanel(rules, views, autoViews, writable, hits),
listsPanel(lists, views, autoViews, writable)); listsPanel(lists, views, autoViews, writable, hits));
if (checkName.value) runCheck(); if (checkName.value) runCheck();
// Lists are downloaded in the background, and a pause ends by itself: // Lists are downloaded in the background, and a pause ends by itself:
@@ -207,7 +211,12 @@ function viewsCell(x) {
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", "); return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
} }
function rulesPanel(rules, views, autoViews, writable) { // hitsLink shows a count of blocked queries, linking to the statistics.
function hitsLink(n) {
return h("a", { href: "#/stats?since=24h", title: "queries blocked in the last 24 hours; more on the Statistics page" }, (n || 0).toLocaleString());
}
function rulesPanel(rules, views, autoViews, writable, hits) {
const name = input({ placeholder: "ads.example.com", class: "mono" }); const name = input({ placeholder: "ads.example.com", class: "mono" });
const action = select([["block", "Block"], ["allow", "Allow"]], "block"); const action = select([["block", "Block"], ["allow", "Allow"]], "block");
const exact = checkbox("only this exact name", false); const exact = checkbox("only this exact name", false);
@@ -230,6 +239,7 @@ function rulesPanel(rules, views, autoViews, writable) {
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule"))); h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add rule")));
return h("div", { class: "panel" }, return h("div", { class: "panel" },
h("h2", null, "Your rules"), h("h2", null, "Your rules"),
hits && rules.some((r) => r.action === "block") ? h("p", { class: "small" }, "Blocked ", hitsLink(hits.rules), " queries in the last 24 hours (all rules together: named doesn't say which rule matched).") : null,
h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."), h("p", { class: "muted small" }, "A rule covers the domain and every name below it, unless it's for the exact name only. Rules beat every blocklist, and the most specific rule wins: allowing cdn.example.com overrides blocking example.com for cdn.example.com and the names below it."),
writable ? [form, err] : null, writable ? [form, err] : null,
table([ table([
@@ -243,7 +253,7 @@ function rulesPanel(rules, views, autoViews, writable) {
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" })); ].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
} }
function listsPanel(lists, views, autoViews, writable) { function listsPanel(lists, views, autoViews, writable, hits) {
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } }); const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
const url = input({ placeholder: "https://…", class: "mono" }); const url = input({ placeholder: "https://…", class: "mono" });
const vp = viewPicker(views, autoViews); const vp = viewPicker(views, autoViews);
@@ -273,6 +283,7 @@ function listsPanel(lists, views, autoViews, writable) {
{ label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] }, { label: "Name", render: (l) => [h("strong", { class: "mono" }, l.name), l.pending ? [" ", pendingBadge(l.pending)] : null] },
{ label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } }, { label: "State", render: (l) => { const [t, k] = stateBadge[l.state] || [l.state, ""]; return [badge(t, k), l.status && l.status.error ? h("div", { class: "small err-text" }, l.status.error) : null]; } },
{ label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" }, { label: "Domains", render: (l) => l.status && l.status.updated_at ? l.status.entries.toLocaleString() : "–", tdClass: "right" },
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
views.length ? { label: "Views", render: viewsCell } : null, views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" }, { label: "Updated", render: (l) => l.status && l.status.updated_at ? h("span", { title: "checked " + time(l.status.checked_at) }, relTime(l.status.updated_at)) : "–", tdClass: "small" },
{ label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" }, { label: "Memory", render: (l) => l.memory_mb ? h("span", { title: "named's memory for this list, estimated (all its views)" }, "~" + l.memory_mb + " MB") : "–", tdClass: "small right" },
+1 -1
View File
@@ -482,7 +482,7 @@ async function recordsTab(el, zone, query) {
{ label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] }, { label: "Comment", render: (r) => [r.pending ? [pendingBadge(r.pending), " "] : null, h("span", { class: "muted" }, r.comment || "")] },
{ label: "", render: (r) => r.pending === "delete" ? "" : h("span", null, { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
!reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled !reverse && (r.type === "A" || r.type === "AAAA") && !r.disabled
? [h("button", { class: "small", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null, ? [h("button", { class: "small reveal", title: "Add a CNAME record that points to " + r.name.replace(/\.$/, ""), onclick: () => aliasTo(r) }, "+ CNAME"), " "] : null,
h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ",
r.disabled r.disabled
? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable") ? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable")
+6
View File
@@ -320,6 +320,12 @@ td.break { word-break: break-all; }
} }
.refreshing { opacity: 0.6; transition: opacity 0.2s; } .refreshing { opacity: 0.6; transition: opacity 0.2s; }
/* Secondary row actions show when the row is hovered or focused; touch
screens, which can't hover, always show them. Opacity keeps them in the
layout (no shifting) and reachable with Tab. */
tr .reveal { opacity: 0; transition: opacity 0.1s; }
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
@media (hover: none) { tr .reveal { opacity: 1; } }
h2.section { margin: 24px 0 10px; font-size: 18px; } h2.section { margin: 24px 0 10px; font-size: 18px; }
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; } .filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); } .segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }