Compare commits
4
Commits
ad8eb3fc3d
...
59baccd636
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59baccd636 | ||
|
|
02c393a6bb | ||
|
|
d4756444ac | ||
|
|
67f14b030f |
No files matched your search
@@ -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
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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" : "" }),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user