- 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>
117 lines
7.4 KiB
JavaScript
117 lines
7.4 KiB
JavaScript
// Reservations: fixed addresses for clients.
|
||
import { h, mount, table, time, input, modal, errorBox, field, confirmDialog, select, debounce, badge, checkbox } from "../ui.js";
|
||
import { get, change, can } from "../api.js";
|
||
import { optionsEditor, subnetLabel, fmtSeconds, parseSeconds } from "../widgets.js";
|
||
import { onLeave } from "../app.js";
|
||
|
||
// reservationsTable lists reservations with Edit and Delete.
|
||
export function reservationsTable(rs, done, opts = {}) {
|
||
const write = can("config", true);
|
||
return table([
|
||
{ label: "Address", render: (r) => [h("span", { class: "mono" }, r.ip), r.disabled ? [" ", badge("disabled", "off")] : null] },
|
||
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.mac || "", r.mac && r.client_id ? h("span", { class: "muted small" }, " or ") : "", r.client_id ? "id " + r.client_id : "") },
|
||
{ label: "Host name", render: (r) => r.hostname || h("span", { class: "muted" }, "–") },
|
||
opts.subnet ? null : { label: "Subnet", render: (r) => r.subnet_id ? h("a", { href: "#/subnets/" + r.subnet_id }, r.subnet) : "–" },
|
||
{ label: "Online", render: (r) => r.lease
|
||
? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface),
|
||
r.lease.last_seen ? h("span", { class: "muted small", title: "last answered a ping" }, ", seen ", time(r.lease.last_seen)) : null)
|
||
: badge("offline") },
|
||
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" },
|
||
{ label: "Comment", render: (r) => r.comment },
|
||
write ? { label: "", tdClass: "actions-cell", render: (r) => [
|
||
h("button", { class: "small reveal", onclick: () => reservationDialog(r, done) }, "Edit"), " ",
|
||
h("button", { class: "small reveal", title: r.disabled ? "Use this reservation again" : "Keep it, but ignore it: the client gets a pool address like any other",
|
||
onclick: () => toggle(r, done) }, r.disabled ? "Enable" : "Disable"), " ",
|
||
h("button", { class: "small reveal", onclick: () => del(r, done) }, "Delete"),
|
||
] } : null,
|
||
].filter(Boolean), rs, { empty: opts.empty || "No reservations.", rowClass: (r) => r.disabled ? "disabled" : "" });
|
||
}
|
||
|
||
async function toggle(r, done) {
|
||
try {
|
||
await change("PATCH", "/reservations/" + r.id, { disabled: !r.disabled });
|
||
done();
|
||
} catch (e) { modal(r.disabled ? "Not enabled" : "Not disabled", errorBox(e)); }
|
||
}
|
||
|
||
async function del(r, done) {
|
||
if (!await confirmDialog("Delete reservation", "Delete the reservation of " + r.ip + " for " + (r.hostname || r.mac || r.client_id) + "? The client keeps its address until its lease runs out.", "Delete", "danger")) return;
|
||
try {
|
||
await change("DELETE", "/reservations/" + r.id);
|
||
done();
|
||
} catch (e) { modal("Not deleted", errorBox(e)); }
|
||
}
|
||
|
||
// reservationDialog adds (r null) or edits a reservation. preset may give
|
||
// {ip, mac, hostname} for a new one.
|
||
export async function reservationDialog(r, done, preset = {}) {
|
||
const [settings, subnets] = await Promise.all([get("/settings"), get("/subnets")]);
|
||
// What the client gets from above: the settings', then its subnet's.
|
||
const sub = subnets.find((x) => x.id === (r && r.subnet_id));
|
||
const inherited = [...settings.options.filter((o) => !(sub && sub.options.some((x) => x.code === o.code))), ...(sub ? sub.options : [])];
|
||
const ip = input({ value: r ? r.ip : preset.ip || "", placeholder: "10.0.10.20", class: "mono" });
|
||
const mac = input({ value: r ? r.mac || "" : preset.mac || "", placeholder: "aa:bb:cc:dd:ee:ff", class: "mono" });
|
||
const cid = input({ value: r ? r.client_id || "" : preset.client_id || "", placeholder: "optional: option 61, in hex", class: "mono" });
|
||
const lease = input({ value: r && r.lease_time ? fmtSeconds(r.lease_time) : "", placeholder: "the subnet's" });
|
||
const host = input({ value: r ? r.hostname : preset.hostname || "", placeholder: "nas" });
|
||
const opts = await optionsEditor(r ? r.options : [], inherited);
|
||
const comment = input({ value: r ? r.comment : "" });
|
||
const disabled = checkbox("Disabled: keep it, but ignore it (the client gets a pool address like any other)", r ? r.disabled : false);
|
||
const err = h("div");
|
||
const body = h("div", null, h("div", { class: "form-grid" },
|
||
field("Address", ip, "Its subnet is the one whose network holds it. Inside a pool is fine: nobody else gets it."),
|
||
field("MAC address", mac),
|
||
field("Client identifier", cid, "Optional: the client's identifier (option 61, hex). With a MAC address too, either one gets the client this address (a machine that boots two systems sends different identifiers)."),
|
||
field("Lease time", lease, "This client's lease time (12h, 7d); empty for the subnet's."),
|
||
field("Host name", host, "Sent to the client."),
|
||
field("Options", opts, "Only for this client; they win over the subnet's."),
|
||
field("Comment", comment),
|
||
r ? [h("label"), disabled] : null), err);
|
||
await modal(r ? "Edit reservation " + r.ip : "New reservation", body, [
|
||
{ label: "Cancel", value: null },
|
||
{ label: r ? "Save" : "Add reservation", kind: "primary", onclick: async () => {
|
||
mount(err);
|
||
const lt = parseSeconds(lease.value);
|
||
if (lt === null) { mount(err, h("div", { class: "msg err" }, "Lease time: write it as 12h, 7d or 30m")); return false; }
|
||
const v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), lease_time: lt, comment: comment.value };
|
||
if (r) v.disabled = disabled.input.checked;
|
||
try {
|
||
await change(r ? "PATCH" : "POST", r ? "/reservations/" + r.id : "/reservations", v);
|
||
done();
|
||
return true;
|
||
} catch (e) {
|
||
mount(err, errorBox(e));
|
||
return false;
|
||
}
|
||
} },
|
||
], { wide: true, sticky: true });
|
||
}
|
||
|
||
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 search = input({ type: "search", placeholder: "address, MAC or host name" });
|
||
const out = h("div");
|
||
let gen = 0;
|
||
const draw = async () => {
|
||
const my = ++gen;
|
||
const q = search.value.trim().toLowerCase();
|
||
const rs = (await get("/reservations", { subnet: subnetSel.value })).filter((r) =>
|
||
!q || r.ip.includes(q) || (r.mac || "").includes(q) || (r.hostname || "").toLowerCase().includes(q) || (r.client_id || "").includes(q));
|
||
if (my !== gen) return; // a newer draw (typing, a change) won
|
||
mount(out, reservationsTable(rs, draw, { empty: q || subnetSel.value ? "None match." : "No reservations yet." }));
|
||
};
|
||
subnetSel.addEventListener("change", draw);
|
||
search.addEventListener("input", debounce(draw, 200));
|
||
mount(el,
|
||
h("div", { class: "page-head" },
|
||
h("div", { class: "grow" }, h("h1", null, "Reservations"), h("div", { class: "muted" }, "Fixed addresses for clients, by MAC address or client identifier. Refreshed every 15 seconds.")),
|
||
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => reservationDialog(null, draw) }, "New reservation")) : null),
|
||
h("div", { class: "filter-row" }, subnetSel, search),
|
||
h("div", { class: "panel" }, out));
|
||
await draw();
|
||
// Online and last seen change as clients come and go.
|
||
const t = setInterval(() => { if (!document.hidden) draw().catch(() => {}); }, 15000);
|
||
onLeave(() => clearInterval(t));
|
||
}
|