Files
steward/internal/web/static/assets/pages/leases.js
T
abrown38andClaude Opus 5.5 1bc9f9f63a Let the Reserve button pick another address of the lease's subnet
The Reserve dialog fills in the lease's address but lets it be changed;
POST /leases/{ip}/reserve takes an optional ip, steward lease reserve
takes --ip. The address must be in the lease's subnet.

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

100 lines
6.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: "Client", render: (l) => h("span", { class: "mono" }, l.mac || "–",
l.client_id ? h("div", { class: "muted small", title: "Client identifier (option 61)" }, "id " + (l.client_id.length > 20 ? l.client_id.slice(0, 18) + "…" : l.client_id)) : null) },
{ 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) },
{ label: "Last seen", render: (l) => l.last_seen ? time(l.last_seen) : "" },
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.client_id) && 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 ip = input({ value: l.ip, class: "mono" });
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
const by = select([["both", "MAC address or client ID (either one)"], ["mac", "MAC address only"], ["client_id", "client ID only"]], "both");
const err = h("div");
await modal("Reserve " + l.ip, h("div", null,
h("p", null, (l.mac || "The client") + " keeps this address from now on."),
h("div", { class: "form-grid" }, h("label", null, "Address"), ip, h("label"),
h("div", { class: "help" }, "Another address of the same subnet moves the client there when it next renews."),
h("label", null, "Host name"), host,
l.client_id && l.mac ? [h("label", null, "Know it by"), by, h("label"),
h("div", { class: "help" }, "It sent the client ID ", h("span", { class: "mono" }, l.client_id), ". With either one, it gets the address whichever system it boots.")] : null), err), [
{ label: "Cancel", value: null },
{ label: "Reserve", kind: "primary", onclick: async () => {
try {
await change("POST", "/leases/" + l.ip + "/reserve", { ip: ip.value.trim(), hostname: host.value.trim(), by: l.client_id && l.mac ? by.value : undefined });
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();
}