Added:
- Server statistics: named's memory and CPU (from /proc), cache size,
sockets and client counts against their limits (statistics channel,
rndc status), host memory and load, and free disk space. Memory and
CPU are kept over time as gauges (sum and sample count, averaged in
timelines). Statistics page Server section, jarl stats server,
GET /stats/server.
- jarl record add|set|delete|disable|enable --view a,b | all: the same
change in each view, applied as one changeset.
- Blocking can be paused until resumed (jarl block pause indefinitely,
"Until I resume", {"indefinitely": true}).
Changed:
- List options (allow-transfer, forwarders, ...) get their braces added
when given without them.
- The dashboard lists named-checkconf's warnings about the running
configuration; the CLI prints them after an apply.
- Stats page: tile and card rows fill their width, and the queries and
failed answers charts share margins so their time axes line up.
- Dashboard: options have their own tile.
- 24-hour times in the web UI.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
321 lines
18 KiB
JavaScript
321 lines
18 KiB
JavaScript
// Blocking: your block/allow rules and blocklists (docs/blocking.md).
|
||
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";
|
||
|
||
// Well-known lists in Adblock format (they say which entries cover
|
||
// subdomains). Sizes change; these are rough guides.
|
||
const suggestions = [
|
||
["hagezi-light", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/light.txt", "Hagezi Light: small, very few false positives"],
|
||
["hagezi-multi", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/multi.txt", "Hagezi Multi Normal: ads, tracking, malware; a good default"],
|
||
["hagezi-pro", "https://raw.githubusercontent.com/hagezi/dns-blocklists/main/adblock/pro.txt", "Hagezi Pro: more thorough, occasionally blocks something wanted"],
|
||
["oisd-small", "https://small.oisd.nl/", "OISD Small: ads and tracking, very few false positives"],
|
||
];
|
||
|
||
const stateBadge = {
|
||
ok: ["in use", "ok"], waiting: ["downloading", "accent"], error: ["update failed", "err"],
|
||
disabled: ["disabled", ""], idle: ["no effect", "warn"],
|
||
};
|
||
|
||
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")]);
|
||
const writable = can("blocking", true);
|
||
const recursive = lists.concat(rules).find((x) => x.auto_views);
|
||
const autoViews = recursive ? recursive.views : null;
|
||
|
||
const checkOut = h("div");
|
||
const checkName = input({ type: "search", placeholder: "ads.example.com", class: "mono", value: (query && query.get("check")) || "" });
|
||
const runCheck = async (ev) => {
|
||
if (ev) ev.preventDefault();
|
||
const name = checkName.value.trim();
|
||
if (!name) return;
|
||
try {
|
||
const res = await get("/blocking/check", { name });
|
||
mount(checkOut, checkResult(name, res, rules, writable));
|
||
} catch (e) { mount(checkOut, errorBox(e)); }
|
||
};
|
||
|
||
const paused = status.paused_until || status.paused_indefinitely;
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Blocking"),
|
||
h("div", { class: "muted" }, "Answer \"doesn't exist\" for ads, trackers and malware, for the devices that use this server to resolve names. Your rules come first, then the blocklists.")),
|
||
writable && !paused && (lists.length || rules.length) ? pauseControls() : null),
|
||
paused ? h("div", { class: "msg warn" },
|
||
h("strong", null, status.paused_indefinitely ? "Blocking is paused: " : "Blocking is paused until " + new Date(paused).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }) + ": "),
|
||
status.paused_indefinitely ? "every name is answered normally until you resume it. " : "every name is answered normally. It resumes by itself. ",
|
||
writable ? h("button", { class: "small", onclick: () => pause(0) }, "Resume now") : null) : null,
|
||
h("div", { class: "panel" },
|
||
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));
|
||
if (checkName.value) runCheck();
|
||
|
||
// Lists are downloaded in the background, and a pause ends by itself:
|
||
// show it when it happens.
|
||
let wait = 0;
|
||
if (lists.some((l) => l.state === "waiting")) wait = 4000;
|
||
if (status.paused_until) wait = Math.max(1000, Math.min(wait || Infinity, new Date(status.paused_until) - Date.now() + 3000));
|
||
if (wait) refreshTimer = setTimeout(() => { if (location.hash.startsWith("#/blocking") && el.isConnected) rerender(); }, wait);
|
||
}
|
||
|
||
function pauseControls() {
|
||
const length = select([["5", "5 minutes"], ["15", "15 minutes"], ["60", "1 hour"], ["240", "4 hours"], ["-1", "Until I resume"]], "15");
|
||
return h("div", { class: "actions" }, length, h("button", { title: "Answer every name normally for a while, to see whether blocking is what breaks something", onclick: () => pause(Number(length.value)) }, "Pause blocking"));
|
||
}
|
||
|
||
// pause pauses blocking for minutes (-1: until resumed), or resumes it
|
||
// (0). It applies at once, also with a draft open: it's for trying
|
||
// something now.
|
||
async function pause(minutes) {
|
||
try {
|
||
const body = minutes < 0 ? { indefinitely: true } : { minutes };
|
||
await api("POST", minutes ? "/blocking/pause" : "/blocking/resume", minutes ? { body } : {});
|
||
toast(minutes ? "Blocking paused" : "Blocking resumed");
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
// 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 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) => {
|
||
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);
|
||
}
|
||
|
||
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 {
|
||
const r = await change("POST", "/blocking/rules", { name, action, exact, views, comment: comment || "" });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
return true;
|
||
} catch (e) { toast(e.message, "err", 8000); return false; }
|
||
}
|
||
|
||
// 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, 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 = () => {
|
||
const sel = value();
|
||
summary.textContent = sel.length ? sel.join(", ") : "automatic" + (autoViews && autoViews.length ? " (" + autoViews.join(", ") + ")" : "");
|
||
};
|
||
boxes.forEach((b) => b.input.addEventListener("change", show));
|
||
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), 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.
|
||
document.addEventListener("click", (ev) => {
|
||
for (const d of document.querySelectorAll("details.view-picker[open]")) if (!d.contains(ev.target)) d.open = false;
|
||
});
|
||
|
||
function viewsCell(x) {
|
||
if (!x.views.length) return h("span", { class: "muted" }, "none");
|
||
return x.auto_views ? [x.views.join(", "), h("span", { class: "muted small" }, " (auto)")] : x.views.join(", ");
|
||
}
|
||
|
||
function rulesPanel(rules, views, autoViews, writable) {
|
||
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);
|
||
const comment = input({ placeholder: "optional" });
|
||
const vp = viewPicker(views, autoViews);
|
||
const err = h("div");
|
||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||
ev.preventDefault();
|
||
try {
|
||
const r = await change("POST", "/blocking/rules", { name: name.value.trim(), action: action.value, exact: exact.input.checked, views: vp.value(), comment: comment.value });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { mount(err, errorBox(e)); }
|
||
} },
|
||
h("div", { class: "f" }, h("label", null, "Action"), action),
|
||
h("div", { class: "f grow" }, h("label", null, "Domain"), name),
|
||
h("div", { class: "f" }, h("label", null, " "), exact),
|
||
h("div", { class: "f" }, h("label", null, "Comment"), comment),
|
||
vp.el,
|
||
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"),
|
||
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([
|
||
{ label: "Action", render: (r) => [r.action === "block" ? badge("block", "err") : badge("allow", "ok"), r.pending ? [" ", pendingBadge(r.pending)] : null] },
|
||
{ 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("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 : "" }));
|
||
}
|
||
|
||
function listsPanel(lists, views, autoViews, writable) {
|
||
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
|
||
const url = input({ placeholder: "https://…", class: "mono" });
|
||
const vp = viewPicker(views, autoViews);
|
||
const err = h("div");
|
||
const form = h("form", { class: "inline-form", onsubmit: async (ev) => {
|
||
ev.preventDefault();
|
||
try {
|
||
const r = await change("POST", "/blocking/lists", { name: name.value.trim(), url: url.value.trim(), views: vp.value() });
|
||
toast(describeResult(r) + (r.applied ? " — jarld downloads it now" : ""));
|
||
rerender();
|
||
} catch (e) { mount(err, errorBox(e)); }
|
||
} },
|
||
h("div", { class: "f" }, h("label", null, "Name"), name),
|
||
h("div", { class: "f grow" }, h("label", null, "URL"), url),
|
||
vp.el,
|
||
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add list")));
|
||
const used = new Set(lists.map((l) => l.url));
|
||
const picks = suggestions.filter(([, u]) => !used.has(u));
|
||
const totalMB = lists.reduce((a, l) => a + (l.memory_mb || 0), 0);
|
||
return h("div", { class: "panel" },
|
||
h("h2", null, "Blocklists"),
|
||
h("p", { class: "muted small" }, "jarld downloads each list, checks it for changes daily, and keeps using the previous version if an update fails. Hosts, plain domain and Adblock (||domain^) lists work; Adblock entries also cover the names below them."),
|
||
writable ? [form, err] : null,
|
||
writable && picks.length ? h("p", { class: "small" }, h("span", { class: "muted" }, "Well-known lists: "),
|
||
picks.map(([n, u, d], i) => [i ? " · " : "", h("a", { href: "#", title: d + "\n" + u, onclick: (ev) => { ev.preventDefault(); name.value = n; url.value = u; name.focus(); } }, n)])) : null,
|
||
table([
|
||
{ 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" },
|
||
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" },
|
||
{ 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", 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" : "" }),
|
||
totalMB ? h("p", { class: "muted small" }, "named needs about " + totalMB + " MB of memory for these lists (an estimate). A list costs memory in every view it applies to, so pick views if some don't need it.") : null);
|
||
}
|
||
|
||
function shortURL(u) {
|
||
const s = u.replace(/^https?:\/\//, "");
|
||
return s.length > 48 ? s.slice(0, 22) + "…" + s.slice(-24) : s;
|
||
}
|
||
|
||
async function refresh(l) {
|
||
toast("Downloading " + l.name + "…", "ok", 2000);
|
||
try {
|
||
const r = await api("POST", "/blocking/lists/" + l.id + "/refresh");
|
||
if (r.status && r.status.error) toast(l.name + ": " + r.status.error, "err", 10000);
|
||
else toast(l.name + ": " + r.status.entries.toLocaleString() + " domains");
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
async function toggle(l) {
|
||
try {
|
||
const r = await change("PATCH", "/blocking/lists/" + l.id, { disabled: !l.disabled });
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
async function del(path, what) {
|
||
if (!(await confirmDialog("Delete", "Delete " + what + "?", "Delete", "danger"))) return;
|
||
try {
|
||
const r = await change("DELETE", path);
|
||
toast(describeResult(r));
|
||
rerender();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|