diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c50c6a..a118d4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. - 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 - 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 CLI: grouped by topic (files, listening, access control, recursion and forwarding, DNSSEC, transfers and notify, then the rest), alphabetical diff --git a/docs/blocking.md b/docs/blocking.md index 40a5171..73ccd95 100644 --- a/docs/blocking.md +++ b/docs/blocking.md @@ -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 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. ## Memory and performance diff --git a/internal/web/static/assets/pages/blocking.js b/internal/web/static/assets/pages/blocking.js index d39656b..05f8255 100644 --- a/internal/web/static/assets/pages/blocking.js +++ b/internal/web/static/assets/pages/blocking.js @@ -22,7 +22,11 @@ let refreshTimer = null; export async function render(el, params, query) { 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 recursive = lists.concat(rules).find((x) => x.auto_views); const autoViews = recursive ? recursive.views : null; @@ -53,8 +57,8 @@ export async function render(el, params, query) { h("form", { class: "actions", onsubmit: runCheck }, h("label", { class: "muted" }, "Check a name"), checkName, h("button", { type: "submit" }, "Check")), checkOut), - rulesPanel(rules, views, autoViews, writable), - listsPanel(lists, views, autoViews, writable)); + rulesPanel(rules, views, autoViews, writable, hits), + listsPanel(lists, views, autoViews, writable, hits)); if (checkName.value) runCheck(); // 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(", "); } -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 action = select([["block", "Block"], ["allow", "Allow"]], "block"); 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"))); return h("div", { class: "panel" }, 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."), writable ? [form, err] : null, 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 : "" })); } -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 url = input({ placeholder: "https://…", class: "mono" }); 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: "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" }, + hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : 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: "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" }, diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index f6aa0fc..3716c88 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -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: "", render: (r) => r.pending === "delete" ? "" : h("span", null, !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"), " ", r.disabled ? h("button", { class: "small toggle", title: "Put the record back in the zone", onclick: () => toggle(r) }, "Enable") diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index 432573b..9647856 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -320,6 +320,12 @@ td.break { word-break: break-all; } } .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; } .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); }