Files
steward/internal/web/static/assets/pages/leases.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

92 lines
5.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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();
}