- A subnet is a deny-list subnet (the default: everyone not denied) or
an allow-list subnet (only clients allowed or with a reservation);
the deny list counts in both. Entries are MAC addresses, prefixes
(aa:bb:cc:*) or client IDs. A refused client gets no answer (DENIED
in the activity log, with an Allow button) or a NAK on renewal.
steward access, the subnet's Access tab. Schema migration 7.
- The activity log keeps each message's header fields and options,
decoded into words (dhcp4.Describe): the parameter request list by
name, client IDs, lease times. ▸ on an Activity row, steward
activity show N / --details, GET /events/{id}.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
148 lines
8.4 KiB
JavaScript
148 lines
8.4 KiB
JavaScript
// 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"))));
|
|
}
|