Files
abrown38andClaude Opus 5.5 5ec943ebff Match fields: "any of"/"any but" only with entries, never negate empty
The choice shows once the list has a row (empty is "any"), an empty
list is never sent as negated, a negated list is marked by a red bar
instead of red inputs (which read as errors), and the field's own label
names the choice for screen readers.

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

235 lines
10 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.
// Widgets shared by several pages: list editors with suggestions, the
// match editor of rules and NAT, descriptions, hit counts, durations.
import { h, input, select, field } from "./ui.js";
import { get, api } from "./api.js";
// ---- durations
// fmtSeconds writes seconds the short way: 43200 is "12h", 5400 "1h30m".
export function fmtSeconds(n) {
if (!n || n <= 0) return "";
const parts = [];
for (const [s, u] of [[86400, "d"], [3600, "h"], [60, "m"], [1, "s"]]) {
if (n >= s) { parts.push(Math.floor(n / s) + u); n %= s; }
}
return parts.join("");
}
// parseSeconds reads "12h", "30m", "1d12h" or plain seconds; null when
// it can't.
export function parseSeconds(s) {
s = String(s || "").trim();
if (s === "") return 0;
if (/^\d+$/.test(s)) return parseInt(s, 10);
const re = /(\d+)\s*([dhms])/g;
let total = 0, m, used = "";
while ((m = re.exec(s))) {
total += parseInt(m[1], 10) * { d: 86400, h: 3600, m: 60, s: 1 }[m[2]];
used += m[0];
}
return used.replace(/\s/g, "") === s.replace(/\s/g, "") && total > 0 ? total : null;
}
// ---- names for suggestions
// names loads the zones, sets and services (with the built-in ones), for
// the editors' suggestions.
export async function names() {
const [zones, sets, services] = await Promise.all([get("/zones"), get("/sets"), get("/services")]);
return { zones: zones.map((z) => z.name), sets: sets.map((s) => s.name), services: services.map((s) => s.name), zoneList: zones, setList: sets };
}
let listID = 0;
// listEditor edits a list of strings, one per row, with suggestions.
// value() returns the non-empty entries.
export function listEditor(values, opts = {}) {
const rows = h("div", { class: "list-rows" });
const id = "dl" + (++listID);
const datalist = h("datalist", { id }, (opts.suggestions || []).map((s) => h("option", { value: s })));
let items = [];
const add = (v) => {
const it = { input: input({ value: v || "", placeholder: opts.placeholder || "", list: id, class: opts.mono === false ? "" : "mono" }) };
it.row = h("div", { class: "list-row" }, it.input,
h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); items = items.filter((x) => x !== it); } }, "✕"));
items.push(it);
rows.append(it.row);
return it.input;
};
for (const v of values || []) add(v);
const box = h("div", { class: "list-editor" }, rows, datalist,
h("button", { type: "button", class: "small", onclick: () => add("").focus() }, "+ " + (opts.addLabel || "Add")));
box.value = () => items.map((it) => it.input.value.trim()).filter((v) => v !== "");
return box;
}
// ---- matches
// negatable puts "any of" / "any but" in front of a list editor, so a
// field says first whether it matches its entries or everything else;
// "any but" marks the list. The choice shows once the list has a row
// (empty is "any"), and an empty list is never negated. value() is the
// list, negated() the choice.
function negatable(list, not) {
const mode = select([["", "any of"], ["not", "any but"]], not ? "not" : "", { class: "inline" });
const box = h("div", { class: "negatable" }, mode, list);
const rows = () => list.querySelector(".list-rows").children.length;
const update = () => {
mode.classList.toggle("hidden", rows() === 0);
box.classList.toggle("negated", mode.value === "not" && rows() > 0);
};
mode.addEventListener("change", update);
// Add and remove act on their own click first; this runs after.
list.addEventListener("click", update);
update();
box.value = list.value;
box.negated = () => mode.value === "not" && list.value().length > 0;
return box;
}
// matchEditor edits what a rule, source NAT or redirect matches. It
// returns the form-grid fields; value() is the match's fields.
export function matchEditor(m, n, opts = {}) {
m = m || {};
const zones = (list) => listEditor(list, { suggestions: n.zones, placeholder: "zone", mono: false, addLabel: "Add zone" });
const addrs = (list) => listEditor(list, { suggestions: n.sets, placeholder: "a set, 192.168.1.0/24, 2001:db8:1::/64, 192.168.1.100-192.168.1.199", addLabel: "Add" });
const inZ = negatable(zones(m.in_zones), m.not_in), outZ = negatable(zones(m.out_zones), m.not_out);
const src = negatable(addrs(m.sources), m.not_sources), dst = negatable(addrs(m.destinations), m.not_destinations);
const svc = listEditor(m.services, { suggestions: n.services, placeholder: "ssh, https, tcp/8080, udp/1000-2000, icmp/echo-request", addLabel: "Add service" });
const fam = select([["", "IPv4 and IPv6"], ["ipv4", "IPv4 only"], ["ipv6", "IPv6 only"]], m.family || "");
const inField = opts.in !== false ? field("Coming in on", inZ) : null;
const outField = opts.out !== false ? field("Going out on", outZ) : null;
const fields = [
inField,
outField,
field("From", src, "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."),
field("To", dst),
field("Services", svc, "Named services (built in, or yours) or entries such as tcp/8080. Empty: any traffic."),
field("Family", fam),
];
fields.value = () => {
const v = { sources: src.value(), not_sources: src.negated(), destinations: dst.value(), not_destinations: dst.negated(),
services: svc.value(), family: fam.value };
// A hidden zone field (the chain has no such interface) matches nothing.
if (inField) Object.assign(v, inField[1].classList.contains("hidden") ? { in_zones: [], not_in: false } : { in_zones: inZ.value(), not_in: inZ.negated() });
if (outField) Object.assign(v, outField[1].classList.contains("hidden") ? { out_zones: [], not_out: false } : { out_zones: outZ.value(), not_out: outZ.negated() });
return v;
};
// zones shows the zone fields a chain can use: input and early have no
// outgoing interface, output no incoming one.
const show = (f, on) => f && f.filter(Boolean).forEach((e) => e.classList.toggle("hidden", !on));
fields.zones = (inOn, outOn) => { show(inField, inOn); show(outField, outOn); };
return fields;
}
// describe sums up a match: "in lan · from trusted · ssh, dns".
export function describe(m) {
const parts = [];
const list = (not, xs) => (not ? "any but " : "") + xs.join(", ");
// Zones are a direction, "lan → wan"; the firewall itself is the other
// end of the input and output chains.
const ins = m.in_zones || [], outs = m.out_zones || [];
if (ins.length || outs.length) {
parts.push((m.chain === "output" ? "firewall" : ins.length ? list(m.not_in, ins) : "any") + " → " +
(m.chain === "input" ? "firewall" : outs.length ? list(m.not_out, outs) : "any"));
}
if (m.sources && m.sources.length) parts.push("from " + list(m.not_sources, m.sources));
if (m.destinations && m.destinations.length) parts.push("to " + list(m.not_destinations, m.destinations));
if (m.services && m.services.length) parts.push(m.services.join(", "));
if (m.family) parts.push(m.family + " only");
return parts.length ? parts.join(" · ") : "everything";
}
// ---- hits
export function humanCount(n) {
if (n >= 1e10) return Math.floor(n / 1e9) + "G";
if (n >= 1e7) return Math.floor(n / 1e6) + "M";
if (n >= 1e4) return Math.floor(n / 1e3) + "k";
return String(n);
}
// hits shows an object's packets (marks add up), with bytes as a title.
export function hits(counters, ...marks) {
let p = 0, b = 0, found = false;
for (const m of marks) {
const c = counters && counters[m];
if (c) { p += c.packets; b += c.bytes; found = true; }
}
if (!found) return h("span", { class: "muted" }, "–");
return h("span", { class: "mono", title: p + " packets, " + b + " bytes" }, humanCount(p));
}
// counters loads the hit counters ({} without log:read).
export async function counters() {
try { return (await get("/counters")).counters; } catch { return {}; }
}
// ---- pending changes
// rowClass marks a table row: pending (new, changed, deleted, while
// changes are collected for review) and disabled.
export function rowClass(x) {
return [x.pending ? "pending-" + x.pending : "", x.disabled ? "disabled" : ""].filter(Boolean).join(" ");
}
// live drops what the pending changes delete (for picking neighbours).
export function live(list) {
return list.filter((x) => x.pending !== "deleted");
}
// ---- bytes
// fmtBytes writes bytes for people: "1.8 GB".
export function fmtBytes(n) {
if (n >= 1 << 30) return (n / (1 << 30)).toFixed(1) + " GB";
if (n >= 1 << 20) return (n / (1 << 20)).toFixed(1) + " MB";
if (n >= 1 << 10) return (n / (1 << 10)).toFixed(1) + " KB";
return n + " bytes";
}
// pct writes used as a share of total: "25%", "<1%" for a little.
export function pct(used, total) {
if (!total || used <= 0) return "0%";
const p = used / total * 100;
return p < 1 ? "<1%" : Math.floor(p) + "%";
}
// usageBar draws used out of total as a bar, red once it is all used.
export function usageBar(used, total) {
const seg = h("div", { class: "seg leased" });
seg.style.width = Math.min(100, used / total * 100) + "%";
return h("div", { class: "usage" }, h("div", { class: "usage-bar" + (used >= total ? " full" : "") }, seg));
}
// lookupNames asks for inside addresses' names (docs/configuration.md):
// {address: name}, {} when names are off or can't be had.
export async function lookupNames(addrs) {
const list = [...new Set(addrs.filter(Boolean))];
if (!list.length) return {};
try { return (await api("POST", "/names", { body: { addresses: list } })).names || {}; } catch { return {}; }
}
// named shows an address (or text with it, such as address:port) after
// its name's first label; the whole name is in the tooltip.
export function named(names, addr, text = addr) {
const n = names[addr];
return n ? h("span", { title: n + " (" + addr + ")" }, h("span", { class: "host-name" }, n.split(".")[0]), " ", h("span", { class: "muted" }, text)) : text;
}
// withNames sets each top-list entry's name from its key (an address).
export async function withNames(...lists) {
const names = await lookupNames(lists.flat().map((e) => e.key));
for (const e of lists.flat()) if (names[e.key]) e.name = names[e.key].split(".")[0];
}
// quotas loads what the rules' quotas used, by rule ID ({} without
// log:read).
export async function quotas() {
try {
const out = {};
for (const q of await get("/quotas")) out[q.rule_id] = q;
return out;
} catch { return {}; }
}