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>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 12:14:07 +01:00
commit 3c0808591f
111 files changed
+17851

No files matched your search

@@ -0,0 +1,71 @@
// The dashboard: interfaces, subnets and how full they are, problems,
// recent activity.
import { h, mount, problemsList, table, time, plural } from "../ui.js";
import { get, can } from "../api.js";
import { usageBar, subnetLabel } from "../widgets.js";
import { onLeave } from "../app.js";
export async function render(el) {
const draw = async () => {
const st = await get("/status");
const events = can("leases") ? await get("/events", { limit: 8 }) : [];
const leased = st.leases.active || 0;
const reservations = st.subnets.reduce((n, s) => n + s.usage.reservations, 0);
const poolSize = st.subnets.reduce((n, s) => n + (s.disabled ? 0 : s.usage.pool_size), 0);
const notServing = st.interfaces.filter((i) => !i.listening);
const errors = st.problems.filter((p) => p.severity === "error");
mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Dashboard"))),
h("div", { class: "cards" },
card("Subnets", st.subnets.length, plural(st.interfaces.length, "interface"), "#/subnets"),
card("Active leases", leased, poolSize ? Math.round(leased * 100 / poolSize) + "% of " + poolSize + " pool addresses" : "no pools yet", "#/leases"),
card("Reservations", reservations, "fixed addresses", "#/reservations"),
card("Problems", st.problems.length, errors.length ? plural(errors.length, "error") : notServing.length ? plural(notServing.length, "interface") + " not serving" : "none", null)),
st.subnets.length === 0 ? h("div", { class: "panel" },
h("h2", null, "Getting started"),
h("p", null, "Steward serves no subnets yet. Add one for each network, on the interface its clients are on: ", h("a", { href: "#/subnets?new=1" }, "New subnet"), "."),
h("p", null, "Moving from Kea? On the server, ", h("code", null, "sudo steward import kea /etc/kea/kea-dhcp4.conf --leases /var/lib/kea/kea-leases4.csv"),
" brings over the subnets, reservations, options and current leases.")) : null,
h("div", { class: "panel" },
h("h2", null, "Interfaces"),
st.interfaces.length ? table([
{ label: "Interface", tdClass: "nowrap", render: (i) => [h("span", { class: "dot " + (i.listening ? "ok" : "err") }), h("span", { class: "mono" }, h("strong", null, i.name))] },
{ label: "Addresses", render: (i) => h("span", { class: "mono small" }, i.addresses.join(", ") || "none") },
{ label: "DHCP", render: (i) => i.listening ? h("span", { class: "muted" }, "serving") : h("span", { class: "err-text small" }, "not serving: " + i.error) },
{ label: "Subnets", render: (i) => i.subnets.map((id, n) => {
const s = st.subnets.find((x) => x.id === id);
return [n ? ", " : "", s ? h("a", { href: "#/subnets/" + id }, subnetLabel(s)) : "#" + id];
}) },
], st.interfaces) : h("div", { class: "muted" }, "DHCP is served on the interfaces that have subnets.")),
st.subnets.length ? h("div", { class: "panel" },
h("h2", null, "Subnets"),
table([
{ label: "Subnet", render: (s) => h("a", { href: "#/subnets/" + s.id }, subnetLabel(s)) },
{ label: "Interface", render: (s) => h("span", { class: "mono" }, s.interface) },
{ label: "Pools in use", render: (s) => s.disabled ? h("span", { class: "badge off" }, "disabled") : usageBar(s.usage) },
{ label: "Reservations", render: (s) => s.usage.reservations ? s.usage.reservations_leased + " of " + s.usage.reservations + " online" : "–" },
], st.subnets)) : null,
st.problems.length || st.ping_problem ? h("div", { class: "panel" },
h("h2", null, "Problems"),
problemsList(st.problems),
st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null,
events.length ? h("div", { class: "panel" },
h("h2", null, "Latest DHCP messages ", h("a", { href: "#/activity", class: "small" }, "all")),
table([
{ label: "When", render: (e) => time(e.time) },
{ label: "Interface", render: (e) => e.interface },
{ label: "Message", render: (e) => h("span", { class: "type-" + e.type.replace(/^DHCP/, "").split(" ")[0] }, e.type.replace(/^DHCP/, "")) },
{ label: "Client", render: (e) => h("span", { class: "mono" }, e.mac || "") },
{ label: "Address", render: (e) => h("span", { class: "mono" }, e.ip || "") },
{ label: "Host name", render: (e) => e.hostname || "" },
], events, { })) : null);
};
await draw();
const t = setInterval(() => { draw().catch(() => {}); }, 15000);
onLeave(() => clearInterval(t));
}
function card(label, value, sub, href) {
const body = [h("div", { class: "label" }, label), h("div", { class: "value" }, String(value)), h("div", { class: "sub" }, sub)];
return href ? h("a", { class: "card link", href }, body) : h("div", { class: "card" }, body);
}