Files
steward/internal/web/static/assets/pages/subnet.js
T
abrown38andClaude Opus 5.5 3c0808591f Steward 0.1.0 (in development): a DHCPv4 server with a web UI, API and CLI
Subnets served per interface, pools, reservations, options at three
levels, leases in SQLite saved before the ACK, ping and decline conflict
checks, validated changes with history and undo, users and tokens, an
audit log, a Kea importer, a sandboxed systemd unit and Debian packages.
Tested with real udhcpc and dhcpcd clients in Docker (scripts/e2e.sh).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 12:14:07 +01:00

84 lines
4.3 KiB
JavaScript

// A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce } from "../ui.js";
import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } 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"], ["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((p) => p.start + "-" + p.end).join(", ")) : "none",
" · lease time ", s.lease_time ? fmtSeconds(s.lease_time) : "the default")),
h("div", { class: "subnet-usage" }, usageBar(s.usage))),
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 await settingsTab(body, s);
}
async function leasesTab(el, s) {
const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div");
let all = false;
const toggle = h("a", { href: "#", class: "small", onclick: (e) => { e.preventDefault(); all = !all; toggle.textContent = all ? "Only current leases" : "Also expired and released"; draw(); } }, "Also expired and released");
const draw = async () => {
let ls = await get("/leases", { subnet: s.id, q: search.value.trim() });
if (!all) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
mount(out, leasesTable(ls, rerender, { subnet: true, empty: all ? "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");
if (!can("config", true)) {
mount(el, h("div", { class: "panel" }, h("pre", null, JSON.stringify(s, null, 2))));
return;
}
const form = await subnetForm(s, settings);
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"))));
}