Files
steward/internal/web/static/assets/pages/leases.js
T
abrown38andClaude Opus 5.5 5e6c61cc8e Hidden interfaces, last-seen pings, activity by subnet
- 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>
2026-10-18 17:50:21 +01:00

93 lines
5.1 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) },
{ 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();
}