- interfaces.exclude in steward.yaml (names or shell patterns) leaves interfaces out of GET /interfaces, the web UI's picker and 'steward interface list'. Subnets on them are still served. - With the ping check on, active leases are pinged every minute; a client that answers gets a last-seen time (leases.last_seen, migration 4). Not answering never takes a client offline. - The activity log filters by subnet (--subnet, the Activity page, GET /events?subnet=). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
5.1 KiB
JavaScript
93 lines
5.1 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) },
|
||
{ 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.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();
|
||
}
|