Client groups put devices (addresses, networks, or ACLs of them) at a blocking step: off (only your rules), normal (the default), or steps added above it, each including the lists of the steps below. Lists start at a step. In each view, a gate policy zone in front of a step's lists lets the devices below that step through (rpz-client-ip triggers with rpz-passthru), so every list is still loaded once. The IPv6 part of a gate leaves the IPv4-mapped range out: named matches IPv4 clients as ::ffff:a.b.c.d. Web UI: a Devices panel on the Blocking page and a step on each list. CLI: jarl block step, jarl block group, jarl blocklist --step. API: /blocking/steps, /blocking/groups. Database schema 8. Also: copying the state kept empty lists as empty (not nil), so reverting a changeset no longer adds restore operations for unchanged blocklists, rules and views. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
28 KiB
JavaScript
472 lines
28 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);
|
||
// 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.51 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),
|
||
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.
|
||
function splitMembers(s) {
|
||
return s.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", members, "Addresses, networks (10.10.0.0/24) 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 }));
|
||
}
|