// 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, checkbox } from "./ui.js"; import { get } 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 // 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, 10.0.10.0/24, 2001:db8::/64, 10.0.0.1-10.0.0.9", addLabel: "Add" }); const inZ = zones(m.in_zones), outZ = zones(m.out_zones); const notIn = checkbox("any zone but these", m.not_in), notOut = checkbox("any zone but these", m.not_out); const src = addrs(m.sources), dst = addrs(m.destinations); const notSrc = checkbox("any source but these", m.not_sources), notDst = checkbox("any destination but these", 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 fields = [ opts.in !== false ? field("Coming in on", h("div", null, inZ, notIn)) : null, opts.out !== false ? field("Going out on", h("div", null, outZ, notOut)) : null, field("From", h("div", null, src, notSrc), "Sets, addresses, prefixes or ranges, IPv4 and IPv6 mixed. Empty: any."), field("To", h("div", null, dst, notDst)), 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: notSrc.input.checked, destinations: dst.value(), not_destinations: notDst.input.checked, services: svc.value(), family: fam.value }; if (opts.in !== false) Object.assign(v, { in_zones: inZ.value(), not_in: notIn.input.checked }); if (opts.out !== false) Object.assign(v, { out_zones: outZ.value(), not_out: notOut.input.checked }); return v; }; return fields; } // describe sums up a match: "in lan · from trusted · ssh, dns". export function describe(m) { const parts = []; const side = (word, not, list) => { if (list && list.length) parts.push(word + (not ? " not " : " ") + list.join(", ")); }; side("in", m.not_in, m.in_zones); side("out", m.not_out, m.out_zones); side("from", m.not_sources, m.sources); side("to", 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 {}; } }