Files
housecarl/internal/web/static/assets/widgets.js
T
abrown38andClaude Opus 5.5 42e2013cb2 Names beside inside addresses
Reverse lookups (internal/names, POST /names) put a name beside each
inside address in connections, the log, quota usage, the dashboard's
quotas and the statistics' devices and blocked sources, in the web UI
and hc (--no-names to leave them out). Only inside addresses are looked
up: an inside zone's networks, and private ones outside the external
zones'. Names are cached (names.cache, 10m) and never stored;
names.resolver picks the DNS server.

Also: hc stats top devices listed each device twice; the dashboard's
quotas named the rule's ID twice.

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

196 lines
8.3 KiB
JavaScript
Raw 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, 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 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));
}
// 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 {}; }
}