diff --git a/CHANGELOG.md b/CHANGELOG.md index 22e90a4..8ed87be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,9 +46,16 @@ All notable changes to Jarl are documented here. The format follows indents and Enter keeps the indentation (Esc, then Tab, moves on). With a draft open, the tables show the draft's values, so a staged edit stays visible after Save. +- Records table: disabled records are hatched and have a "disabled" badge + next to their name, instead of only a muted colour and a badge in the + comment column. ### Fixed +- Blocking page, checking a name: when your own block rule is for that very + name, it offers to remove the rule instead of an allow rule, which was + refused as a second rule for the same name. It also names the rule that + matched rather than the policy zone entry. - docs/validation.md lacked the `disabled_conflict` warning added in 0.3.0. ## [0.3.0] - 2026-10-09 diff --git a/internal/web/static/assets/pages/blocking.js b/internal/web/static/assets/pages/blocking.js index 4693c2c..f3330c5 100644 --- a/internal/web/static/assets/pages/blocking.js +++ b/internal/web/static/assets/pages/blocking.js @@ -35,7 +35,7 @@ export async function render(el, params, query) { if (!name) return; try { const res = await get("/blocking/check", { name }); - mount(checkOut, checkResult(name, res, writable)); + mount(checkOut, checkResult(name, res, rules, writable)); } catch (e) { mount(checkOut, errorBox(e)); } }; @@ -80,21 +80,48 @@ async function pause(minutes) { } catch (e) { toast(e.message, "err", 8000); } } -function checkResult(name, res, writable) { +// checkResult shows what blocking does with a name, and offers the way +// to stop it: an allow rule (it beats every list, and a rule for a parent +// domain), or, when your own block rule is for this very name, removing +// that rule (a second rule for the same name isn't allowed). +function checkResult(name, res, rules, writable) { if (!res.length) return h("p", { class: "muted" }, "Blocking isn't used in any view yet: add a rule or a blocklist below."); - const blocked = res.some((m) => m.result === "blocked"); + const n = name.trim().toLowerCase().replace(/\.$/, ""); + const ruleById = new Map(rules.map((r) => [r.id, r])); + const blocked = res.filter((m) => m.result === "blocked"); + const ownBlocks = [...new Set(blocked.map((m) => ruleById.get(m.rule_id)).filter((r) => r && r.name === n))]; + const sameName = rules.filter((r) => r.name === n && !ownBlocks.includes(r)); + let action = null; + if (writable && ownBlocks.length) { + action = h("p", null, h("button", { class: "small", onclick: () => removeRules(ownBlocks) }, "Remove your block rule for " + n), + h("span", { class: "muted small" }, ownBlocks.some((r) => !r.exact) ? " (it also covers the names below it)" : "")); + } else if (writable && blocked.length && !sameName.length) { + action = h("p", null, h("button", { class: "small", onclick: () => addRule(n, "allow", false, []) }, "Allow " + n), + h("span", { class: "muted small" }, " adds an allow rule for it and the names below it, which beats every list and any rule for a parent domain.")); + } else if (writable && blocked.length) { + action = h("p", { class: "muted small" }, "There already is a rule for " + n + " (in other views): change it under Your rules."); + } return h("div", null, - h("ul", { class: "plain" }, res.map((m) => h("li", null, - m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null, - m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""), - " ", - m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"] - : m.result !== "none" ? ["by your rule for ", h("code", null, ruleName(m.entry)), m.entry.startsWith("*.") ? " (names below it)" : ""] : null))), - blocked && writable ? h("p", null, h("button", { class: "small", onclick: () => addRule(name, "allow", false, []) }, "Allow " + name), - h("span", { class: "muted small" }, " adds an allow rule for it and the names below it, which beats every list.")) : null); + h("ul", { class: "plain" }, res.map((m) => { + const rule = ruleById.get(m.rule_id); + return h("li", null, + m.view ? h("span", { class: "muted" }, "view " + m.view + ": ") : null, + m.result === "blocked" ? badge("blocked", "err") : m.result === "allowed" ? badge("allowed", "ok") : badge("not blocked", ""), + " ", + m.list ? ["by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), ")"] + : m.result !== "none" ? ["by your rule to ", rule ? rule.action : "", " ", h("code", null, rule ? rule.name : m.entry.replace(/^\*\./, "")), rule && !rule.exact ? " and below" : ""] : null); + })), + action); } -const ruleName = (entry) => entry.replace(/^\*\./, ""); +async function removeRules(list) { + try { + let r; + for (const rule of list) r = await change("DELETE", "/blocking/rules/" + rule.id); + toast(describeResult(r)); + rerender(); + } catch (e) { toast(e.message, "err", 8000); } +} async function addRule(name, action, exact, views, comment) { try { diff --git a/internal/web/static/assets/pages/zone.js b/internal/web/static/assets/pages/zone.js index 7910b6d..b341846 100644 --- a/internal/web/static/assets/pages/zone.js +++ b/internal/web/static/assets/pages/zone.js @@ -453,11 +453,12 @@ async function recordsTab(el, zone, query) { const rows = current.filter((r) => (!q || r.name.includes(q) || r.data.toLowerCase().includes(q) || (r.comment || "").toLowerCase().includes(q)) && (!typeFilter.value || r.type === typeFilter.value) && (!stateFilter.value || !!r.disabled === (stateFilter.value === "off"))); mount(list, table([ - { label: "Name", render: (r) => reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, tdClass: "name" }, + { label: "Name", render: (r) => [reverse && addressOf(r.name) ? [r.relative_name, " ", h("span", { class: "muted small" }, addressOf(r.name))] : r.relative_name, + r.disabled && r.pending !== "disable" ? [" ", badge("disabled", "off")] : null], tdClass: "name" }, { label: "TTL", render: (r) => r.ttl !== undefined && r.ttl !== null ? String(r.ttl) : h("span", { class: "muted", title: "zone default" }, String(r.effective_ttl)), tdClass: "mono" }, { label: "Type", render: (r) => h("strong", null, r.type) }, { label: "Data", render: (r) => r.data, tdClass: "data" }, - { label: "Comment", render: (r) => [r.disabled && r.pending !== "disable" ? [badge("disabled"), " "] : null, 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, h("button", { class: "small", title: "Edit", onclick: () => edit(r) }, "Edit"), " ", r.disabled diff --git a/internal/web/static/assets/style.css b/internal/web/static/assets/style.css index b94a42d..c5accd4 100644 --- a/internal/web/static/assets/style.css +++ b/internal/web/static/assets/style.css @@ -4,6 +4,7 @@ --panel: #ffffff; --text: #1d2330; --muted: #5f6b7d; + --hatch: rgba(95, 107, 125, 0.13); --border: #d9dee6; --accent: #2f5fb3; --accent-text: #ffffff; @@ -34,6 +35,7 @@ --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; + --hatch: rgba(154, 165, 181, 0.12); --border: #2f3540; --accent: #6d9be8; --accent-text: #0f1520; @@ -58,7 +60,7 @@ } } :root[data-theme="dark"] { - --bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; + --bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; --hatch: rgba(154, 165, 181, 0.12); --accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326; --warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833; --code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a; @@ -150,6 +152,9 @@ tr.pending-add td { background: var(--add); } tr.pending-change td, tr.pending-disable td, tr.pending-enable td { background: var(--warn-soft); } tr.pending-delete td { background: var(--del); text-decoration: line-through; color: var(--muted); } tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); } +/* Disabled records: hatched, so they stand out from live and pending rows. */ +tr.disabled td { background-image: repeating-linear-gradient(135deg, transparent 0 7px, var(--hatch) 7px 9px); } +.badge.off { background: var(--muted); color: var(--panel); } button.toggle { min-width: 5.2em; } .empty { color: var(--muted); padding: 18px; text-align: center; }