// 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; }