Files
steward/internal/web/static/assets/widgets.js
T
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:07 +01:00

219 lines
11 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", ...].
export function poolsEditor(pools, placeholder) {
const rows = h("div", { class: "pool-rows" });
const box = h("div", null, rows);
const list = [];
const addRow = (v) => {
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" });
const row = h("div", { class: "pool-row" }, inp,
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕"));
list.push(inp);
rows.append(row);
return inp;
};
for (const p of pools || []) addRow(p.start + "-" + p.end);
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== "");
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; };
return box;
}
// ---- 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 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("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(), 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;
}