Files
adamandClaude Opus 5.5 e2e5007f94 Address ranges in address match lists (IPv4 and IPv6)
ACLs, views' match lists, options with address match lists and blocking
client groups take ranges such as 10.0.10.101-10.0.10.199 or
2001:db8::100-2001:db8::1ff (negated, or with spaces around the dash, too).
Jarl stores the range as written and writes the fewest prefixes that cover
exactly that range to named.conf, with the range as a comment. New package
internal/iprange; namedconf gets an AMRange element (a range used to parse
as an ACL name), ExpandRanges and statement comments. Mistakes are refused
when entered, validation sees what ranges cover, and IPv6 ranges in the
IPv4-mapped space get a warning. The web UI shows what a range becomes
while typing (GET /catalog/ranges).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-11 23:17:14 +01:00

474 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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";
import { rangeHints } from "../aml.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);
// 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, steps, groups] = 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,
get("/blocking/steps", draftQuery()), get("/blocking/groups", draftQuery())]);
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;
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, hits),
listsPanel(lists, views, autoViews, writable, hits, steps),
devicesPanel(groups, steps, 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 && m.step ? ["for devices at step ", h("strong", null, m.step), " and above, by blocklist ", h("strong", null, m.list), " (entry ", h("code", null, m.entry), "); other devices aren't 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, steps) {
const step = stepSelect(steps, l.step, false);
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,
steps.length ? ["Step", step, "Devices at this step or above get the list. normal: every device except those at off."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), url: url.value.trim(), views: vp.value(), comment: comment.value, step: step.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(", ");
}
// 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);
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"),
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([
{ 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 : "" }));
}
// stepSelect picks a step: off (only for groups), normal and the steps
// above it.
function stepSelect(steps, value, withOff) {
const opts = [];
if (withOff) opts.push(["off", "off (only your rules)"]);
opts.push(["normal", "normal" + (withOff ? " (like devices in no group)" : " (every device)")]);
steps.filter((s) => s.pending !== "delete").forEach((s) => opts.push([s.name, s.name]));
return select(opts, value || "normal");
}
function listsPanel(lists, views, autoViews, writable, hits, steps) {
const name = input({ placeholder: "hagezi-multi", class: "mono", style: { width: "11em" } });
const url = input({ placeholder: "https://…", class: "mono" });
const vp = viewPicker(views, autoViews);
const listStep = stepSelect(steps, "normal", false);
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(), step: listStep.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),
steps.length ? h("div", { class: "f" }, h("label", null, "Step"), listStep) : null,
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" },
hits ? { label: "Blocked (24h)", render: (l) => hitsLink(hits[l.name]), class: "right", tdClass: "right" } : null,
steps.length ? { label: "Step", render: (l) => l.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(l.step, "accent") } : 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" },
{ 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, steps) }, "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); }
}
// ---- devices: blocking steps and client groups (docs/blocking.md)
// devicesPanel shows the steps (each includes the lists of the steps
// below it) and the client groups that put devices at a step.
function devicesPanel(groups, steps, views, autoViews, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const name = input({ placeholder: "kids", class: "mono", style: { width: "9em" } });
const members = input({ placeholder: "10.10.0.50 10.10.0.20-10.10.0.29 or an ACL", class: "mono" });
const step = stepSelect(steps, live.length ? live[0].name : "off", true);
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/groups", { name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value() });
toast(describeResult(r));
rerender();
} catch (e) { mount(err, errorBox(e)); }
} },
h("div", { class: "f" }, h("label", null, "Group"), name),
h("div", { class: "f grow" }, h("label", null, "Devices"), members, rangeHints(members)),
h("div", { class: "f" }, h("label", null, "Step"), step),
vp.el,
h("div", { class: "f" }, h("label", null, " "), h("button", { class: "primary", type: "submit" }, "Add group")));
return h("div", { class: "panel" },
h("h2", null, "Devices"),
h("p", { class: "muted small" }, "Give some devices less or more blocking. Each step gets the lists of the steps below it plus its own; devices in no group are at normal. Your rules apply at every step. named must see the devices' own addresses (not a router's), so give them fixed ones."),
stepsLine(steps, writable),
writable ? [form, err] : null,
table([
{ label: "Group", render: (g) => [h("strong", { class: "mono" }, g.name), g.pending ? [" ", pendingBadge(g.pending)] : null] },
{ label: "Step", render: (g) => g.step === "off" ? badge("off", "") : g.step === "normal" ? h("span", { class: "muted" }, "normal") : badge(g.step, "accent") },
{ label: "Devices", render: (g) => g.members.length ? h("span", { class: "mono small" }, g.members.join(", ")) : h("span", { class: "muted" }, "none") },
views.length ? { label: "Views", render: viewsCell } : null,
{ label: "Comment", render: (g) => h("span", { class: "muted" }, g.comment || "") },
writable ? { label: "", render: (g) => g.pending === "delete" ? "" : h("span", null,
h("button", { class: "small", onclick: () => editGroup(g, steps, views, autoViews) }, "Edit"), " ",
h("button", { class: "small danger", onclick: () => del("/blocking/groups/" + g.id, "client group " + g.name) }, "Delete")), tdClass: "actions-cell" } : null,
].filter(Boolean), groups, { empty: "No groups: every device is at normal.", rowClass: (g) => g.pending ? "pending-" + g.pending : "" }));
}
// splitMembers turns "a, b c" into a list; a range may have spaces
// around its dash.
function splitMembers(s) {
return s.replace(/\s*-\s*/g, "-").split(/[\s,;]+/).map((x) => x.trim()).filter(Boolean);
}
// stepsLine shows the steps in order, with controls for the ones above
// normal.
function stepsLine(steps, writable) {
const live = steps.filter((s) => s.pending !== "delete");
const order = async (ids) => {
try {
const r = await change("POST", "/blocking/steps/order", { steps: ids.map(String) });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const move = (i, d) => {
const ids = live.map((s) => s.id);
[ids[i], ids[i + d]] = [ids[i + d], ids[i]];
order(ids);
};
const add = async () => {
const n = await promptName("Add a step", "Name of the new step (above the others), e.g. strict", "");
if (!n) return;
try {
const r = await change("POST", "/blocking/steps", { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const rename = async (s) => {
const n = await promptName("Rename step " + s.name, "New name", s.name);
if (!n || n === s.name) return;
try {
const r = await change("PATCH", "/blocking/steps/" + s.id, { name: n });
toast(describeResult(r));
rerender();
} catch (e) { toast(e.message, "err", 8000); }
};
const chip = (label, title) => h("span", { class: "step-chip", title }, label);
const items = [chip("off", "only your rules"), chip("normal", "your rules and the normal lists; devices in no group")];
live.forEach((s, i) => {
const used = s.lists.length || s.groups.length;
items.push(h("span", { class: "step-chip accent", title: "lists: " + (s.lists.join(", ") || "none") + "; groups: " + (s.groups.join(", ") || "none") },
s.name, s.pending ? [" ", pendingBadge(s.pending)] : null,
writable ? h("span", { class: "step-tools" },
h("button", { class: "small", disabled: i === 0, title: "Move down: fewer lists", onclick: () => move(i, -1) }, "←"),
h("button", { class: "small", disabled: i === live.length - 1, title: "Move up: more lists", onclick: () => move(i, 1) }, "→"),
h("button", { class: "small", title: "Rename", onclick: () => rename(s) }, "✎"),
h("button", { class: "small danger", disabled: !!used, title: used ? "In use by lists or groups" : "Delete", onclick: () => del("/blocking/steps/" + s.id, "step " + s.name) }, "×")) : null));
});
return h("div", { class: "steps-line" }, h("span", { class: "muted small" }, "Steps: "),
items.map((x, i) => [i ? h("span", { class: "muted" }, " → ") : null, x]),
writable ? [" ", h("button", { class: "small", onclick: add }, "Add step")] : null);
}
// promptName asks for a short name in a dialog.
async function promptName(title, label, value) {
const i = input({ value, class: "mono" });
const v = await modal(title, h("div", { class: "form-grid" }, field(label, i)), [
{ label: "Cancel", value: null },
{ label: "OK", kind: "primary", value: true, onclick: () => !!i.value.trim() },
]);
return v === true ? i.value.trim() : null;
}
function editGroup(g, steps, views, autoViews) {
const name = input({ value: g.name, class: "mono" });
const members = input({ value: g.members.join(" "), class: "mono", style: { width: "100%" } });
const step = stepSelect(steps, g.step, true);
const comment = input({ value: g.comment || "" });
const vp = viewPicker(views, autoViews, ownViews(g));
editDialog("Edit client group " + g.name, "/blocking/groups/" + g.id, [
["Name", name],
["Devices", h("div", null, members, rangeHints(members)), "Addresses, networks (10.10.0.0/24), ranges (10.10.0.20-10.10.0.49) or ACLs, separated by spaces. A device can be in one group; a more specific address in another group wins."],
["Step", step],
vp.picker ? ["Views", vp.picker, "None ticked: every view with blocking."] : null,
["Comment", comment],
], () => ({ name: name.value.trim(), members: splitMembers(members.value), step: step.value, views: vp.value(), comment: comment.value }));
}