// 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, 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 // 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 = 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 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 {}; } }