- 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>
249 lines
13 KiB
JavaScript
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;
|
|
}
|