What a quota used shows its percentage of the allowance on the Rules page (line and dialog), the dashboard and in hc quotas. Statistics' Quotas card links each entry to its rule, named, without the share of the total; per-address quotas are in it at last: their quota:rN series is the sum of their addresses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
208 lines
8.9 KiB
JavaScript
208 lines
8.9 KiB
JavaScript
// 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 {}; }
|
||
}
|