Edit blocklists and rules in the web UI
An Edit button opens a dialog for a list's name, URL, views and comment, or a rule's action, scope, views and comment. The download-now button is called Refresh. Checkboxes in dialogs are no longer stretched. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
67f14b030f
commit
d4756444ac
3 files changed
+59
-7
No files matched your search
@@ -46,6 +46,9 @@ All notable changes to Jarl are documented here. The format follows
|
|||||||
indents and Enter keeps the indentation (Esc, then Tab, moves on). With
|
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
|
a draft open, the tables show the draft's values, so a staged edit stays
|
||||||
visible after Save.
|
visible after Save.
|
||||||
|
- 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
|
- 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
|
next to their name, instead of only a muted colour and a badge in the
|
||||||
comment column.
|
comment column.
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Blocking: your block/allow rules and blocklists (docs/blocking.md).
|
// 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 { get, change, describeResult, draftQuery, can, api } from "../api.js";
|
||||||
import { rerender } from "../app.js";
|
import { rerender } from "../app.js";
|
||||||
import { pendingBadge } from "./zones.js";
|
import { pendingBadge } from "./zones.js";
|
||||||
@@ -134,9 +134,9 @@ async function addRule(name, action, exact, views, comment) {
|
|||||||
|
|
||||||
// viewPicker is a compact choice of views: it shows the choice, and opens
|
// viewPicker is a compact choice of views: it shows the choice, and opens
|
||||||
// a list of checkboxes (none ticked: every view that offers recursion).
|
// a list of checkboxes (none ticked: every view that offers recursion).
|
||||||
function viewPicker(views, autoViews) {
|
function viewPicker(views, autoViews, selected = []) {
|
||||||
if (!views.length) return { el: null, value: () => [] };
|
if (!views.length) return { el: null, picker: null, value: () => [] };
|
||||||
const boxes = views.map((v) => checkbox(v.name, false));
|
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 value = () => views.filter((v, i) => boxes[i].input.checked).map((v) => v.name);
|
||||||
const summary = h("summary");
|
const summary = h("summary");
|
||||||
const show = () => {
|
const show = () => {
|
||||||
@@ -147,7 +147,52 @@ function viewPicker(views, autoViews) {
|
|||||||
show();
|
show();
|
||||||
const picker = h("details", { class: "view-picker" }, summary,
|
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));
|
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.
|
// An open view picker closes when you click elsewhere.
|
||||||
@@ -190,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")] },
|
{ 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,
|
views.length ? { label: "Views", render: viewsCell } : null,
|
||||||
{ label: "Comment", render: (r) => h("span", { class: "muted" }, r.comment || "") },
|
{ 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 : "" }));
|
].filter(Boolean), rules, { empty: "No rules yet.", rowClass: (r) => r.pending ? "pending-" + r.pending : "" }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,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: "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)) },
|
{ 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,
|
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", 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,
|
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" : "" }),
|
].filter(Boolean), lists, { empty: "No blocklists yet.", rowClass: (l) => l.pending ? "pending-" + l.pending : l.disabled ? "disabled" : "" }),
|
||||||
|
|||||||
@@ -171,6 +171,7 @@ textarea { width: 100%; min-height: 80px; resize: vertical; }
|
|||||||
.form-grid > label { padding-top: 6px; color: var(--muted); }
|
.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 .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, .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 { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
|
||||||
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
|
.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; }
|
.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