Compare commits

...
4 Commits
Author SHA1 Message Date
adamandClaude Opus 5.5 59baccd636 Release 0.4.0-rc.1
release / release (push) Successful in 1m53s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:15 +00:00
adamandClaude Opus 5.5 02c393a6bb Apply pending changes from the banner
An Apply now button on the pending-changes banner applies the draft and
stays on the page; if the changes are refused, a dialog says why.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:56:10 +00:00
adamandClaude Opus 5.5 d4756444ac Edit blocklists and rules in the web UI
An Edit button opens a dialog for a list's name, URL, views and comment,
or a rule's action, scope, views and comment. The download-now button is
called Refresh. Checkboxes in dialogs are no longer stretched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:50:41 +00:00
adamandClaude Opus 5.5 67f14b030f Make disabled records obvious, and offer the right fix when checking a blocked name
Disabled records are hatched and badged next to their name. Checking a
name blocked by your own rule for that name offers to remove the rule,
instead of an allow rule that would be refused as a duplicate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 12:45:10 +00:00
7 changed files with 148 additions and 29 deletions

No files matched your search

+13
View File
@@ -46,9 +46,22 @@ 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.
- The pending-changes banner has an Apply now button: it applies your
draft without going to the Changes page, and stays on the page you're
on. If the changes are refused, a dialog says why and links to Changes.
- Blocking page: blocklists and rules have an Edit button (name, URL,
views, comment; a rule's action and scope). The list button that
downloads now is called Refresh, like `jarl blocklist refresh`.
- 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
+5 -3
View File
@@ -171,9 +171,11 @@ review step.
## Status
Version 0.3.0 is the current release. It adds disabled records,
script-friendly CLI output and client names in the statistics, on top of
0.2.0's DNSSEC signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
Version 0.3.0 is the current release, and 0.4.0 is in testing
(0.4.0-rc.1): it adds blocking (blocklists and your own rules, like
Pi-hole, done by named). 0.3.0 added disabled records, script-friendly
CLI output and client names in the statistics, on top of 0.2.0's DNSSEC
signing, Porkbun support, two-factor login and view colours. Jarl is feature-complete for one server's configuration.
Managing several servers from one jarld and dynamic updates are not
handled yet ([concepts.md](docs/concepts.md#what-jarl-deliberately-doesnt-do-yet)).
+1 -1
View File
@@ -1 +1 @@
0.3.0
0.4.0-rc.1
+24 -3
View File
@@ -1,7 +1,7 @@
// Jarl web UI: router, layout and login.
import { h, mount, toast, errorBox, plural, lastList } from "./ui.js";
import { h, mount, toast, errorBox, plural, lastList, modal } from "./ui.js";
import { wordmark, logo } from "./brand.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt } from "./api.js";
import { api, get, state, loadDraft, onChange, setUnauthorizedHandler, setImmediate, setCodePrompt, useDraft } from "./api.js";
import * as totp from "./totp.js";
import * as dashboard from "./pages/dashboard.js";
import * as zones from "./pages/zones.js";
@@ -207,7 +207,28 @@ function renderBar() {
mount(barEl, n > 0 ? h("div", { class: "pending-bar" },
h("strong", null, plural(n, "pending change")),
h("span", { class: "muted" }, "Your edits are collected in changeset #" + d.id + " and are not live yet."),
h("a", { class: "button primary small", href: "#/changes" }, "Review & apply")) : null);
h("a", { class: "button small", href: "#/changes" }, "Review"),
h("button", { class: "primary small apply-now", title: "Apply the pending changes now, without reviewing them first", onclick: (ev) => applyDraft(ev.target, d) }, "Apply now")) : null);
}
// applyDraft applies the draft changeset from the banner and stays on the
// page. If it is refused, a dialog says why and links to Changes.
async function applyDraft(btn, d) {
btn.disabled = true;
btn.textContent = "Applying…";
try {
const r = await api("POST", "/changesets/" + d.id + "/apply", { body: {} });
const res = r.result || {};
toast("Applied changeset #" + d.id + (res.reloaded && res.reloaded.length ? " — named reloaded" : ""));
(r.warnings || []).forEach((w) => toast(w, "warn", 10000));
useDraft(null);
rerender();
} catch (e) {
btn.disabled = false;
btn.textContent = "Apply now";
const review = await modal("The changes were not applied", errorBox(e), [{ label: "Close", value: false }, { label: "Review changes", kind: "primary", value: true }], { wide: true });
if (review) location.hash = "#/changes";
}
}
async function logout() {
+94 -19
View File
@@ -1,5 +1,5 @@
// Blocking: your block/allow rules and blocklists (docs/blocking.md).
import { h, mount, table, input, select, checkbox, toast, errorBox, badge, confirmDialog, relTime, time } from "../ui.js";
import { h, mount, table, field, input, select, checkbox, toast, errorBox, badge, confirmDialog, modal, relTime, time } from "../ui.js";
import { get, change, describeResult, draftQuery, can, api } from "../api.js";
import { rerender } from "../app.js";
import { pendingBadge } from "./zones.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 {
@@ -107,9 +134,9 @@ async function addRule(name, action, exact, views, comment) {
// 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, false));
function viewPicker(views, autoViews, selected = []) {
if (!views.length) return { el: null, picker: null, value: () => [] };
const boxes = views.map((v) => checkbox(v.name, selected.includes(v.name)));
const value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name);
const summary = h("summary");
const show = () => {
@@ -120,7 +147,52 @@ function viewPicker(views, autoViews) {
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 };
return { el: h("div", { class: "f" }, h("label", null, "Views"), picker), picker, value };
}
// The views a list or rule names itself ([] when automatic).
const ownViews = (x) => (x.auto_views ? [] : x.views);
// editDialog changes a list or rule: fields are [label, control, help]
// rows, and data() the PATCH body.
async function editDialog(title, path, rows, data) {
const err = h("div");
await modal(title, h("div", null, h("div", { class: "form-grid" }, rows.filter(Boolean).map(([l, c, help]) => field(l, c, help))), err), [
{ label: "Cancel", value: null },
{ label: "Save", kind: "primary", onclick: async () => {
try {
const r = await change("PATCH", path, data());
toast(describeResult(r));
rerender();
return true;
} catch (e) { mount(err, errorBox(e)); return false; }
} },
], { sticky: true });
}
function editList(l, views, autoViews) {
const name = input({ value: l.name, class: "mono" });
const url = input({ value: l.url, class: "mono", style: { width: "100%" } });
const comment = input({ value: l.comment || "" });
const vp = viewPicker(views, autoViews, ownViews(l));
editDialog("Edit blocklist " + l.name, "/blocking/lists/" + l.id, [
["Name", name], ["URL", url, "A new URL is downloaded right after saving."],
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value }));
}
function editRule(r, views, autoViews) {
const name = input({ value: r.name, class: "mono" });
const action = select([["block", "Block"], ["allow", "Allow"]], r.action);
const exact = checkbox("only this exact name", !!r.exact);
const comment = input({ value: r.comment || "" });
const vp = viewPicker(views, autoViews, ownViews(r));
editDialog("Edit the rule for " + r.name, "/blocking/rules/" + r.id, [
["Action", action], ["Domain", name], ["", exact],
vp.picker ? ["Views", vp.picker, "None ticked: every view that offers recursion."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value }));
}
// An open view picker closes when you click elsewhere.
@@ -163,7 +235,9 @@ function rulesPanel(rules, views, autoViews, writable) {
{ label: "Domain", render: (r) => [h("span", { class: "mono" }, r.name), r.exact ? null : h("span", { class: "muted small" }, " and below")] },
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") },
writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete"), tdClass: "actions-cell" } : null,
writable ? { label: "", render: (r) => r.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editRule(r, views, autoViews) }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/rules/" + r.id, "the rule for " + r.name) }, "Delete")), tdClass: "actions-cell" } : null,
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
}
@@ -202,7 +276,8 @@ function listsPanel(lists, views, autoViews, writable) {
{ 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: "URL", render: (l) => h("a", { href: l.url, target: "_blank", rel: "noopener", class: "small mono", title: l.url }, shortURL(l.url)) },
writable ? { label: "", render: (l) => l.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download now", onclick: () => refresh(l) }, "Update"), " ",
h("button", { class: "small", onclick: () => editList(l, views, autoViews) }, "Edit"), " ",
h("button", { class: "small", disabled: l.state === "disabled" || !!l.pending, title: "Download it now (it is checked daily anyway)", onclick: () => refresh(l) }, "Refresh"), " ",
h("button", { class: "small", onclick: () => toggle(l) }, l.disabled ? "Enable" : "Disable"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/lists/" + l.id, "blocklist " + l.name) }, "Delete")), tdClass: "actions-cell" } : null,
].filter(Boolean), lists, { empty: "No blocklists yet.", rowClass: (l) => l.pending ? "pending-" + l.pending : l.disabled ? "disabled" : "" }),
+3 -2
View File
@@ -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
+8 -1
View File
@@ -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;
@@ -114,6 +116,7 @@ main { padding: 16px; max-width: 1400px; margin: 0 auto; }
padding: 8px 16px;
}
.pending-bar strong { color: var(--warn); }
.pending-bar .apply-now { margin-left: auto; }
/* The review/live switch in the header. */
.mode-toggle { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; border-radius: 999px; font-size: 12px; font-weight: 500; background: transparent; color: var(--muted); border: 1px solid var(--border); }
.mode-toggle:hover { color: var(--text); border-color: var(--accent); }
@@ -150,6 +153,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; }
@@ -166,6 +172,7 @@ textarea { width: 100%; min-height: 80px; resize: vertical; }
.form-grid > label { padding-top: 6px; color: var(--muted); }
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
.form-grid input, .form-grid select { width: 100%; max-width: 560px; }
.form-grid input[type=checkbox] { width: auto; }
.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
.inline-form .f label:not(.check) { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }