Files
steward/internal/web/static/assets/widgets.js
T
abrown38andClaude Opus 5.5 8ee430276c Address order: sequential, reverse or random, per subnet and pool
- Which never-used pool address a new client gets: sequential (the
  lowest, as before), reverse or random, in the settings, a subnet or
  a single pool (FIRST-LAST:ORDER). When no never-used address is left,
  the one free the longest, whatever the order; returning clients keep
  theirs.
- A subnet's pools are used in the order listed, or one at random
  weighted by its never-used addresses. Pools now keep the order they
  are given in (no more sorting by address).
- CLI --address-order and --pool-order; --add-pool of an existing range
  changes its order in place. Web UI: per-pool order and a move-up
  button, pool and address order on the subnet and settings pages.
- Kea's allocator: random is imported. Schema migration 5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:13:25 +01:00

249 lines
13 KiB
JavaScript

// Widgets shared by several pages: usage bars, the options and pools
// editors, the subnet form, durations.
import { h, mount, input, select, field, checkbox } from "./ui.js";
import { catalog, 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;
}
// ---- usage
// usageBar shows how much of a subnet's pools is leased.
export function usageBar(u) {
if (!u || !u.pool_size) return h("span", { class: "muted small" }, u && u.reservations ? "reservations only" : "no pools");
const pct = (n) => Math.min(100, (n * 100) / u.pool_size);
const full = u.leased + u.declined >= u.pool_size;
const leased = h("div", { class: "seg leased" }), declined = h("div", { class: "seg declined" });
leased.style.width = pct(u.leased) + "%";
declined.style.width = pct(u.declined) + "%";
return h("div", { class: "usage", title: u.leased + " leased, " + u.declined + " held back, " + (u.pool_size - u.leased - u.declined) + " free of " + u.pool_size },
h("div", { class: "usage-text" }, u.leased + " / " + u.pool_size + " (" + Math.round(pct(u.leased)) + "%)", u.declined ? " · " + u.declined + " held back" : ""),
h("div", { class: "usage-bar" + (full ? " full" : "") }, leased, declined));
}
// ---- options
// optionsEditor edits a list of options. inherited are the options this
// level gets from above (shown, and overridden by an option of the same
// code here). Its value() is the list for the API ({option, value, type}).
export async function optionsEditor(options, inherited = []) {
const cat = await catalog();
const rows = h("div", { class: "opt-rows" });
const box = h("div", null, rows);
const byCode = new Map(cat.options.map((o) => [o.code, o]));
const choices = [
["", "choose an option…"],
...cat.options.filter((o) => o.common).map((o) => [String(o.code), o.name + " (" + o.code + ")"]),
...cat.options.filter((o) => !o.common).map((o) => [String(o.code), o.name + " (" + o.code + ")"]),
["custom", "another option, by number…"],
];
const list = [];
const addRow = (opt) => {
const known = opt && byCode.has(opt.code);
const sel = select(choices, opt ? (known ? String(opt.code) : "custom") : "");
const code = input({ type: "number", min: 1, max: 254, placeholder: "code", value: opt && !known ? opt.code : "" });
code.style.width = "6em";
const typ = select(cat.types, (opt && opt.type) || "hex", { class: "type" });
const val = input({ class: "value", value: opt ? opt.value : "", placeholder: "value" });
const help = h("div", { class: "help" });
const row = h("div", { class: "opt-row" });
const sync = () => {
const custom = sel.value === "custom";
code.classList.toggle("hidden", !custom);
typ.classList.toggle("hidden", !custom);
const d = byCode.get(parseInt(sel.value, 10));
mount(help, d ? d.type + ": " + d.help : custom ? "Steward doesn't know this option: give its number and how its value is written" : "");
if (d) val.placeholder = examples[d.type] || "value";
};
sel.addEventListener("change", sync);
const entry = {
row,
value: () => {
if (sel.value === "") return null;
if (sel.value === "custom") return { option: String(code.value), value: val.value, type: typ.value };
return { option: sel.value, value: val.value };
},
};
const del = h("button", { type: "button", class: "small", title: "Remove this option", onclick: () => { row.remove(); list.splice(list.indexOf(entry), 1); } }, "✕");
mount(row, sel, code, typ, val, del, help);
sync();
list.push(entry);
rows.append(row);
};
for (const o of options || []) addRow(o);
box.append(h("button", { type: "button", class: "small", onclick: () => addRow(null) }, "+ Add option"));
const inh = (inherited || []).filter((o) => !(options || []).some((x) => x.code === o.code));
if (inh.length) {
box.append(h("div", { class: "opt-inherited" }, "Also sent, from above: ",
inh.map((o, i) => [i ? "; " : "", h("code", null, o.name + "=" + o.value)]),
". An option set here wins."));
}
box.value = () => list.map((e) => e.value()).filter(Boolean);
return box;
}
const examples = {
ip: "10.0.10.1", ips: "10.0.10.1, 10.0.10.2", string: "text", uint8: "0-255", uint16: "1500", uint32: "86400",
int32: "-3600", bool: "true or false", domains: "lan, example.com", routes: "10.20.0.0/16 via 10.0.10.1", hex: "01:02:0a",
};
// ---- pools
// poolsEditor edits a subnet's pools; value() is ["first-last", ...].
// Each pool has its own address order, or the subnet's; ↑ moves a pool up
// (with pools in the order listed, the first is used first).
export function poolsEditor(pools, placeholder) {
const rows = h("div", { class: "pool-rows" });
const box = h("div", null, rows);
let list = [];
const addRow = (v, order) => {
const it = { inp: input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" }) };
it.sel = select([["", "subnet's order"], ...ADDRESS_ORDERS.map((o) => [o, o])], order || "", { title: "The order addresses of this pool are handed out in" });
it.row = h("div", { class: "pool-row" }, it.inp, it.sel,
h("button", { type: "button", class: "small", title: "Move this pool up: it is used before the ones below", onclick: () => {
const i = list.indexOf(it);
if (i > 0) { list.splice(i, 1); list.splice(i - 1, 0, it); rows.insertBefore(it.row, list[i].row); }
} }, "↑"),
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕"));
list.push(it);
rows.append(it.row);
return it.inp;
};
for (const p of pools || []) addRow(p.start + "-" + p.end, p.order);
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
box.value = () => list.map((it) => {
const v = it.inp.value.trim();
return v && it.sel.value && !v.includes(":") ? v + ":" + it.sel.value : v;
}).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].inp.value) list[0].inp.value = v; };
return box;
}
export const ADDRESS_ORDERS = ["sequential", "reverse", "random"];
// poolText shows a pool, with its own address order.
export function poolText(p) {
return p.start + "-" + p.end + (p.order ? " (" + p.order + ")" : "");
}
// orderText says how a subnet hands out new addresses, or "" for the
// defaults.
export function orderText(s) {
const parts = [];
if (s.pool_order === "random") parts.push("a pool at random");
if (s.address_order) parts.push(s.address_order + " addresses");
return parts.join(", ");
}
// ---- the subnet form
// v4 helpers for suggestions.
function ip2n(s) { const p = s.split(".").map(Number); return ((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]; }
function n2ip(n) { return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."); }
// suggestPool proposes a pool for a prefix: in a /24, .100-.199; else the
// second half, without the broadcast address.
export function suggestPool(prefix) {
const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(prefix || "");
if (!m) return "";
const bits = parseInt(m[2], 10);
if (bits < 8 || bits > 29) return "";
const size = 2 ** (32 - bits);
const net = (ip2n(m[1]) & ~(size - 1)) >>> 0;
if (bits === 24) return n2ip(net + 100) + "-" + n2ip(net + 199);
return n2ip(net + size / 2) + "-" + n2ip(net + size - 2);
}
// subnetForm builds the fields of a subnet; values() returns the API
// input. sn is the subnet to edit (null for a new one).
export async function subnetForm(sn, settings) {
const ifaces = await get("/interfaces");
const name = input({ value: sn ? sn.name : "", placeholder: "office, iot, guests…" });
const ifaceNames = ifaces.map((i) => i.name);
const ifaceSel = select([["", "choose…"], ...ifaces.map((i) => [i.name, i.name + (i.addresses.length ? " (" + i.addresses.join(", ") + ")" : " (no IPv4 address)")]), ["other", "another name…"]],
sn ? (ifaceNames.includes(sn.interface) ? sn.interface : "other") : "");
const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" });
const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
const pools = poolsEditor(sn ? sn.pools : []);
const poolOrder = select([["", "in the order listed"], ["random", "at random (weighted by free addresses)"]], sn ? sn.pool_order || "" : "");
const addrOrder = select([["", "the settings' (" + (settings.address_order || "sequential") + ")"], ...ADDRESS_ORDERS.map((o) => [o, o])], sn ? sn.address_order || "" : "");
const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) });
const opts = await optionsEditor(sn ? sn.options : [], settings.options);
const comment = input({ value: sn ? sn.comment : "" });
const ifaceHint = h("div", { class: "help" });
const ifaceValue = () => (ifaceSel.value === "other" ? ifaceOther.value.trim() : ifaceSel.value);
const syncIface = () => {
ifaceOther.classList.toggle("hidden", ifaceSel.value !== "other");
const i = ifaces.find((x) => x.name === ifaceSel.value);
mount(ifaceHint, "Only clients on this interface get the subnet's addresses.",
i && i.subnets.length && (!sn || !i.subnets.includes(sn.id)) ? " It already serves " + i.subnets.length + (i.subnets.length === 1 ? " subnet" : " subnets") + "; clients get addresses from each in turn." : "");
// A new subnet: suggest the interface's network and a pool.
if (!sn && i && i.addresses.length && !prefix.value) {
const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(i.addresses[0]);
if (m) {
const size = 2 ** (32 - parseInt(m[2], 10));
prefix.value = n2ip((ip2n(m[1]) & ~(size - 1)) >>> 0) + "/" + m[2];
pools.setFirst(suggestPool(prefix.value));
}
}
};
ifaceSel.addEventListener("change", syncIface);
prefix.addEventListener("change", () => { if (!sn) pools.setFirst(suggestPool(prefix.value.trim())); });
syncIface();
const disabled = checkbox("Disabled: keep the subnet and its leases, but don't answer its clients", sn ? sn.disabled : false);
const grid = h("div", { class: "form-grid" },
field("Name", name),
field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."),
field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."),
field("Pool order", poolOrder, "Which pool a new client's address comes from."),
field("Address order", addrOrder, "Which address of a pool: sequential (the lowest), reverse (the highest) or random. A pool can set its own. New clients get an address never handed out before; when there are none, the one free the longest. Returning clients keep theirs."),
field("Lease time", lease, "12h, 1d, 30m; empty for the default."),
field("Options", opts),
field("Comment", comment),
sn ? [h("label"), disabled] : null);
grid.values = () => {
const lt = parseSeconds(lease.value);
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m");
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), pool_order: poolOrder.value || "listed", address_order: addrOrder.value || "default", lease_time: lt, options: opts.value(), comment: comment.value };
if (sn) v.disabled = disabled.input.checked;
return v;
};
grid.focusFirst = () => (sn ? name : ifaceSel).focus();
return grid;
}
// subnetLabel names a subnet: "office (10.0.10.0/24)".
export function subnetLabel(s) {
return s.name ? s.name + " (" + s.prefix + ")" : s.prefix;
}
// stateBadge shows a lease's state.
export function stateText(st) {
return { active: "active", expired: "expired", released: "released", declined: "held back" }[st] || st;
}