- 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>
97 lines
5.7 KiB
JavaScript
97 lines
5.7 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: "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();
|
||
}
|