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>
92 lines
5.0 KiB
JavaScript
92 lines
5.0 KiB
JavaScript
// Leases: every address handed out, with filters; reserve or delete one.
|
||
import { h, mount, table, time, input, select, confirmDialog, toast, modal, errorBox, debounce, badge } from "../ui.js";
|
||
import { get, api, change, can } from "../api.js";
|
||
import { stateText, subnetLabel } from "../widgets.js";
|
||
|
||
// leaseHost is the host name to show: the reservation's, or what the
|
||
// client said.
|
||
function leaseHost(l) {
|
||
if (l.reserved_hostname && l.hostname && l.reserved_hostname !== l.hostname) return [l.reserved_hostname, h("span", { class: "muted small" }, " (says " + l.hostname + ")")];
|
||
return l.reserved_hostname || l.hostname || h("span", { class: "muted" }, "–");
|
||
}
|
||
|
||
// leasesTable shows leases with their actions; after a change, done() is
|
||
// called.
|
||
export function leasesTable(leases, done, opts = {}) {
|
||
const write = can("leases", true), config = can("config", true);
|
||
return table([
|
||
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
|
||
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
|
||
{ label: "Host name", render: leaseHost },
|
||
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
|
||
{ label: "Ends", render: (l) => time(l.end) },
|
||
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
|
||
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
|
||
{ label: "", tdClass: "actions-cell", render: (l) => [
|
||
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
|
||
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
|
||
] },
|
||
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
|
||
}
|
||
|
||
function stateBadge(st) {
|
||
return badge(stateText(st), { active: "ok", declined: "warn" }[st] || "");
|
||
}
|
||
|
||
async function reserve(l, done) {
|
||
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
|
||
const err = h("div");
|
||
await modal("Reserve " + l.ip, h("div", null,
|
||
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
|
||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
|
||
{ label: "Cancel", value: null },
|
||
{ label: "Reserve", kind: "primary", onclick: async () => {
|
||
try {
|
||
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
|
||
done();
|
||
return true;
|
||
} catch (e) {
|
||
mount(err, errorBox(e));
|
||
return false;
|
||
}
|
||
} },
|
||
]);
|
||
}
|
||
|
||
async function del(l, done) {
|
||
if (!await confirmDialog("Delete lease", "Forget the lease of " + l.ip + (l.mac ? " (" + l.mac + ")" : "") + "? The address is free at once; if the client still uses it, it gets a NAK when it next renews and asks for a new address.", "Delete", "danger")) return;
|
||
try {
|
||
await api("DELETE", "/leases/" + l.ip);
|
||
toast("Deleted the lease of " + l.ip);
|
||
done();
|
||
} catch (e) { toast(e.message, "err", 8000); }
|
||
}
|
||
|
||
export async function render(el, params, query) {
|
||
const subnets = await get("/subnets");
|
||
const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || "");
|
||
const stateSel = select([["current", "active and held back"], ["active", "active"], ["expired", "expired"], ["released", "released"], ["declined", "held back"], ["", "all"]], query.get("state") || "current");
|
||
const search = input({ type: "search", placeholder: "address, MAC or host name", value: query.get("q") || "" });
|
||
const out = h("div");
|
||
const draw = async () => {
|
||
const st = stateSel.value === "current" ? "" : stateSel.value;
|
||
let leases = await get("/leases", { subnet: subnetSel.value, state: st, q: search.value.trim() });
|
||
if (stateSel.value === "current") leases = leases.filter((l) => l.state === "active" || l.state === "declined");
|
||
const q = new URLSearchParams();
|
||
if (subnetSel.value) q.set("subnet", subnetSel.value);
|
||
if (stateSel.value !== "current") q.set("state", stateSel.value);
|
||
if (search.value.trim()) q.set("q", search.value.trim());
|
||
history.replaceState(null, "", "#/leases" + (q.toString() ? "?" + q : ""));
|
||
mount(out, h("div", { class: "muted small" }, leases.length + (leases.length === 1 ? " lease" : " leases")), leasesTable(leases, draw));
|
||
};
|
||
subnetSel.addEventListener("change", draw);
|
||
stateSel.addEventListener("change", draw);
|
||
search.addEventListener("input", debounce(draw, 300));
|
||
mount(el,
|
||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Leases"),
|
||
h("div", { class: "muted" }, "Addresses handed out. Expired and released leases are kept a while, so a client that comes back gets its old address."))),
|
||
h("div", { class: "filter-row" }, subnetSel, stateSel, search, h("button", { onclick: draw }, "Refresh")),
|
||
h("div", { class: "panel" }, out));
|
||
await draw();
|
||
}
|