A Quotas panel with each quota's allowance, use (or, per address, the count and the busiest), a usage bar and the next reset. The usage bar moves to widgets.js and the Rules page's usage dialog shares it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
7.3 KiB
JavaScript
175 lines
7.3 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 } 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 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 {}; }
|
||
}
|
||
|
||
// ---- 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";
|
||
}
|
||
|
||
// 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));
|
||
}
|
||
|
||
// 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 {}; }
|
||
}
|