// A subnet's page: its leases, its reservations and its settings. import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox, select, field } from "../ui.js"; import { get, change, can } from "../api.js"; import { usageBar, subnetForm, fmtSeconds, subnetLabel, poolText, orderText } from "../widgets.js"; import { leasesTable } from "./leases.js"; import { reservationsTable, reservationDialog } from "./reservations.js"; import { rerender } from "../app.js"; const tabs = [["leases", "Leases"], ["reservations", "Reservations"], ["access", "Access"], ["settings", "Settings"]]; export async function render(el, params) { const id = params.id; const tab = params.tab || "leases"; const s = await get("/subnets/" + id); const body = h("div"); mount(el, h("nav", { class: "back-link" }, h("a", { href: "#/subnets" }, "← Subnets")), h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, subnetLabel(s), " ", s.disabled ? badge("disabled", "off") : null), h("div", { class: "muted" }, "On ", h("span", { class: "mono" }, s.interface), " · pools ", s.pools.length ? h("span", { class: "mono" }, s.pools.map(poolText).join(", ")) : "none", orderText(s) ? " · " + orderText(s) : "", " · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default", s.access === "allow-list" ? [" · ", badge("allow-list", "accent")] : "")), h("div", { class: "subnet-usage" }, usageBar(s.usage)), can("config", true) ? h("div", { class: "actions" }, h("button", { title: s.disabled ? "Answer this subnet's clients again" : "Stop answering this subnet's clients; its configuration and leases stay", onclick: () => toggleSubnet(s) }, s.disabled ? "Enable subnet" : "Disable subnet")) : null), s.disabled ? h("div", { class: "msg warn" }, "This subnet is disabled: its clients get no answers. Its configuration and leases are kept.") : null, h("div", { class: "tabs" }, tabs.map(([k, label]) => h("a", { href: "#/subnets/" + id + "/" + k, class: tab === k ? "active" : "" }, label))), body); if (tab === "leases") await leasesTab(body, s); else if (tab === "reservations") await reservationsTab(body, s); else if (tab === "access") accessTab(body, s); else await settingsTab(body, s); } // matchList edits an allow or deny list: entries with a comment each. function matchList(entries, placeholder) { const rows = h("div", { class: "pool-rows" }); let list = []; const addRow = (m) => { const it = { match: input({ value: m ? m.match : "", placeholder, class: "mono" }), comment: input({ value: m ? m.comment || "" : "", placeholder: "what it is (optional)" }) }; it.row = h("div", { class: "pool-row" }, it.match, it.comment, h("button", { type: "button", class: "small", title: "Remove", onclick: () => { it.row.remove(); list = list.filter((x) => x !== it); } }, "✕")); list.push(it); rows.append(it.row); return it.match; }; for (const m of entries || []) addRow(m); const box = h("div", null, rows, h("button", { type: "button", class: "small", onclick: () => addRow(null).focus() }, "+ Add")); box.value = () => list.map((it) => ({ match: it.match.value.trim(), comment: it.comment.value.trim() })).filter((m) => m.match); return box; } // accessTab: who the subnet answers. function accessTab(el, s) { const mode = select([["deny-list", "Deny-list: every client except those denied"], ["allow-list", "Allow-list: only clients allowed or with a reservation"]], s.access || "deny-list"); const ph = "aa:bb:cc:dd:ee:ff, aa:bb:cc:* or id 01aabb…"; const allow = matchList(s.allow, ph), deny = matchList(s.deny, ph); // The allow list only counts in an allow-list subnet. const sync = () => allow.classList.toggle("dimmed", mode.value !== "allow-list"); mode.addEventListener("change", sync); const err = h("div"); const save = async (dry) => { mount(err); try { const r = await change("PATCH", "/subnets/" + s.id, { access: mode.value, allow: allow.value(), deny: deny.value() }, { query: dry ? { dry_run: 1 } : undefined }); if (!dry && r.applied) rerender(); } catch (e) { mount(err, errorBox(e)); } }; const form = h("div", { class: "form-grid" }, field("Who gets an address", mode), field("Allow list", allow, "Used by an allow-list subnet. Clients with a reservation in the subnet are allowed too."), field("Deny list", deny, "Never answered, in either mode, even with a reservation.")); const write = can("config", true); mount(el, h("div", { class: "panel" }, h("p", { class: "muted" }, "Entries are a MAC address, a prefix for one maker's devices (aa:bb:cc:*) or a client identifier (id 01aabb…). A refused client gets no answer, and shows as DENIED in Activity; one with a lease gets a NAK when it next renews. Phones use a private MAC address per network unless told not to."), form, err, write ? h("div", { class: "actions", style: { marginTop: "14px" } }, h("button", { class: "primary", onclick: () => save(false) }, "Save"), h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check")) : null)); if (!write) readOnly(el.querySelector(".panel")); sync(); } export async function toggleSubnet(s) { if (!s.disabled && !await confirmDialog("Disable subnet", "Stop answering the clients of " + subnetLabel(s) + "? They keep their addresses until their leases run out, then have none. The configuration and leases stay; Enable brings it back.", "Disable", "danger")) return; try { await change("PATCH", "/subnets/" + s.id, { disabled: !s.disabled }); rerender(); } catch (e) { modal(s.disabled ? "Not enabled" : "Not disabled", errorBox(e)); } } async function leasesTab(el, s) { const search = input({ type: "search", placeholder: "address, MAC or host name" }); const out = h("div"); const toggle = checkbox("Include ended leases (expired and released)", false); toggle.input.addEventListener("change", () => draw()); const draw = async () => { let ls = await get("/leases", { subnet: s.id, q: search.value.trim() }); if (!toggle.input.checked) ls = ls.filter((l) => l.state === "active" || l.state === "declined"); mount(out, leasesTable(ls, rerender, { subnet: true, empty: toggle.input.checked ? "No leases." : "No current leases." })); }; search.addEventListener("input", debounce(draw, 300)); mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out)); await draw(); } async function reservationsTab(el, s) { const rs = await get("/reservations", { subnet: s.id }); mount(el, can("config", true) ? h("div", { class: "filter-row" }, h("button", { class: "primary", onclick: () => reservationDialog(null, rerender) }, "New reservation")) : null, h("div", { class: "panel" }, reservationsTable(rs, rerender, { subnet: true, empty: "No reservations in this subnet." }))); } async function settingsTab(el, s) { const settings = await get("/settings"); const form = await subnetForm(s, settings); if (!can("config", true)) { mount(el, h("div", { class: "msg info" }, "You can see the subnet's settings but not change them (that needs config:write)."), readOnly(h("div", { class: "panel" }, form))); return; } const err = h("div"); const save = async (dry) => { mount(err); try { const r = await change("PATCH", "/subnets/" + s.id, form.values(), { query: dry ? { dry_run: 1 } : undefined }); if (!dry && r.applied) rerender(); } catch (e) { mount(err, errorBox(e)); } }; const del = async () => { const n = s.usage.reservations; if (!await confirmDialog("Delete subnet", "Delete " + subnetLabel(s) + (n ? " and its " + n + (n === 1 ? " reservation" : " reservations") : "") + "? Its clients get no answers from then on; their leases are kept until they run out. History can undo it.", "Delete", "danger")) return; try { await change("DELETE", "/subnets/" + s.id); location.hash = "#/subnets"; } catch (e) { modal("Not deleted", errorBox(e)); } }; mount(el, h("div", { class: "panel" }, form, err, h("div", { class: "actions", style: { marginTop: "14px" } }, h("button", { class: "primary", onclick: () => save(false) }, "Save"), h("button", { onclick: () => save(true), title: "Check the change without saving it" }, "Check"), h("span", { class: "grow" }), h("button", { class: "danger", onclick: del }, "Delete subnet")))); }