Files
steward/internal/web/static/assets/pages/leases.js
T
abrown38andClaude Opus 5.5 4fd72998ec Reservation lease time; reservations by MAC address or client ID
- A reservation can have its own lease time (--lease-time, the
  reservation dialog), over the subnet's. Schema migration 6.
- A reservation with a MAC address and a client ID matches either one
  (a dual-boot machine sends two client IDs from one MAC); before, it
  was found by MAC and then refused. A lease of the reserved address by
  the client's other identity counts as its own.
- The Leases page shows a client's client ID; reserving a lease uses
  both, or one with --by / the dialog's choice.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:26:24 +01:00

97 lines
5.7 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 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 " + l.ip + " from now on."),
h("div", { class: "form-grid" }, 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", { 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();
}