// 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 } 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.client_id ? "id " + r.client_id : r.mac) }, { 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 || "" : "", placeholder: "only for clients known by a client identifier", class: "mono" }); 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: match the client's identifier (option 61, hex) instead of its MAC address."), 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 v = { ip: ip.value.trim(), mac: mac.value.trim(), client_id: cid.value.trim(), hostname: host.value.trim(), options: opts.value(), 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)); }