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:
commit
3c0808591f
111 files changed
+17851
No files matched your search
@@ -0,0 +1,82 @@
|
||||
// API client and shared application state.
|
||||
import { toast } from "./ui.js";
|
||||
|
||||
export const state = {
|
||||
me: null, // whoami response
|
||||
csrf: "",
|
||||
catalog: null,
|
||||
};
|
||||
|
||||
export class APIError extends Error {
|
||||
constructor(status, body) {
|
||||
const e = (body && body.error) || {};
|
||||
super(e.message || ("HTTP " + status));
|
||||
this.status = status;
|
||||
this.code = e.code || "";
|
||||
this.details = e;
|
||||
}
|
||||
}
|
||||
|
||||
let onUnauthorized = () => {};
|
||||
export function setUnauthorizedHandler(fn) { onUnauthorized = fn; }
|
||||
|
||||
// api calls the REST API. opts: {body, query}.
|
||||
export async function api(method, path, opts = {}) {
|
||||
const headers = { "Accept": "application/json" };
|
||||
let body;
|
||||
if (opts.body !== undefined) {
|
||||
headers["Content-Type"] = "application/json";
|
||||
body = JSON.stringify(opts.body);
|
||||
}
|
||||
if (method !== "GET" && state.csrf) headers["X-CSRF-Token"] = state.csrf;
|
||||
let url = "/api/v1" + path;
|
||||
if (opts.query) {
|
||||
const q = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(opts.query)) if (v !== undefined && v !== null && v !== "") q.set(k, v);
|
||||
const s = q.toString();
|
||||
if (s) url += (url.includes("?") ? "&" : "?") + s;
|
||||
}
|
||||
const resp = await fetch(url, { method, headers, body, credentials: "same-origin" });
|
||||
if (resp.status === 204) return null;
|
||||
const ct = resp.headers.get("Content-Type") || "";
|
||||
const data = ct.includes("application/json") ? await resp.json() : await resp.text();
|
||||
if (!resp.ok) {
|
||||
if (resp.status === 401 && !path.startsWith("/auth/login")) onUnauthorized();
|
||||
throw new APIError(resp.status, data);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
export const get = (path, query) => api("GET", path, { query });
|
||||
|
||||
// change sends a configuration change and reports it: a toast with the
|
||||
// history entry (to undo it from History) and one per new warning. It
|
||||
// returns the response.
|
||||
export async function change(method, path, body, opts = {}) {
|
||||
const r = await api(method, path, { body, query: opts.query });
|
||||
if (!opts.quiet) report(r);
|
||||
return r;
|
||||
}
|
||||
|
||||
export function report(r) {
|
||||
if (!r) return;
|
||||
if (r.dry_run) toast("Checked: nothing was changed");
|
||||
else if (r.applied) toast("Saved (history #" + r.history_id + ")");
|
||||
else if (!(r.changes || []).length) toast("Nothing to change");
|
||||
for (const w of r.warnings || []) toast(w.message + (w.object ? " (" + w.object + ")" : ""), "warn", 10000);
|
||||
}
|
||||
|
||||
// can reports whether the user may read (or write) a resource.
|
||||
export function can(resource, write) {
|
||||
const me = state.me;
|
||||
if (!me) return false;
|
||||
if (me.admin) return true;
|
||||
const perms = me.permissions || [];
|
||||
return perms.includes(resource + ":write") || (!write && perms.includes(resource + ":read"));
|
||||
}
|
||||
|
||||
// catalog returns the option catalog (cached).
|
||||
export async function catalog() {
|
||||
if (!state.catalog) state.catalog = await get("/catalog");
|
||||
return state.catalog;
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
// Steward web UI: router, layout and login.
|
||||
import { h, mount, toast, errorBox } from "./ui.js";
|
||||
import { wordmark, logo } from "./brand.js";
|
||||
import { api, get, state, can, setUnauthorizedHandler } from "./api.js";
|
||||
import * as dashboard from "./pages/dashboard.js";
|
||||
import * as subnets from "./pages/subnets.js";
|
||||
import * as subnet from "./pages/subnet.js";
|
||||
import * as leases from "./pages/leases.js";
|
||||
import * as reservations from "./pages/reservations.js";
|
||||
import * as activity from "./pages/activity.js";
|
||||
import * as settings from "./pages/settings.js";
|
||||
import * as history from "./pages/history.js";
|
||||
import * as audit from "./pages/audit.js";
|
||||
import * as users from "./pages/users.js";
|
||||
import * as account from "./pages/account.js";
|
||||
|
||||
const routes = [
|
||||
{ pattern: /^$/, page: dashboard, nav: "dashboard" },
|
||||
{ pattern: /^subnets$/, page: subnets, nav: "subnets" },
|
||||
{ pattern: /^subnets\/(\d+)(?:\/(\w+))?$/, page: subnet, nav: "subnets", params: ["id", "tab"] },
|
||||
{ pattern: /^leases$/, page: leases, nav: "leases" },
|
||||
{ pattern: /^reservations$/, page: reservations, nav: "reservations" },
|
||||
{ pattern: /^activity$/, page: activity, nav: "activity" },
|
||||
{ pattern: /^settings$/, page: settings, nav: "settings" },
|
||||
{ pattern: /^history$/, page: history, nav: "history" },
|
||||
{ pattern: /^history\/(\d+)$/, page: history, nav: "history", params: ["id"] },
|
||||
{ pattern: /^audit$/, page: audit, nav: "audit" },
|
||||
{ pattern: /^users$/, page: users, nav: "users" },
|
||||
{ pattern: /^account$/, page: account, nav: "account" },
|
||||
];
|
||||
|
||||
// The top bar. ["key", "#/href", "Label", permission?]
|
||||
const navItems = [
|
||||
["dashboard", "#/", "Dashboard"],
|
||||
["subnets", "#/subnets", "Subnets", "config"],
|
||||
["leases", "#/leases", "Leases", "leases"],
|
||||
["reservations", "#/reservations", "Reservations", "config"],
|
||||
["activity", "#/activity", "Activity", "leases"],
|
||||
["history", "#/history", "History", "config"],
|
||||
{ menu: "Server", items: [
|
||||
["settings", "#/settings", "Settings and options", "config"],
|
||||
["audit", "#/audit", "Audit log", "audit"],
|
||||
] },
|
||||
];
|
||||
|
||||
const app = document.getElementById("app");
|
||||
let mainEl, navEl, current = { key: "" }, routeGen = 0;
|
||||
|
||||
function parseHash() {
|
||||
const raw = location.hash.replace(/^#\/?/, "");
|
||||
const [path, qs] = raw.split("?");
|
||||
return { path: path.replace(/\/$/, ""), query: new URLSearchParams(qs || "") };
|
||||
}
|
||||
|
||||
function renderLogin(message) {
|
||||
const user = h("input", { name: "username", autocomplete: "username", placeholder: "Username", required: true });
|
||||
const pass = h("input", { name: "password", type: "password", autocomplete: "current-password", placeholder: "Password", required: true });
|
||||
const err = h("div");
|
||||
const form = h("form", { onsubmit: async (e) => {
|
||||
e.preventDefault();
|
||||
mount(err);
|
||||
try {
|
||||
const me = await api("POST", "/auth/login", { body: { username: user.value, password: pass.value } });
|
||||
await loggedIn(me);
|
||||
} catch (ex) {
|
||||
mount(err, errorBox(ex));
|
||||
pass.select();
|
||||
}
|
||||
} }, user, pass, h("button", { class: "primary", type: "submit" }, "Log in"), err);
|
||||
mount(app, h("div", { class: "login panel" },
|
||||
logo(104, "login-logo"),
|
||||
h("h1", null, wordmark()),
|
||||
h("div", { class: "tag" }, "Keeper of the hold's addresses"),
|
||||
message ? h("div", { class: "msg warn" }, message) : null,
|
||||
form));
|
||||
user.focus();
|
||||
}
|
||||
|
||||
async function loggedIn(me) {
|
||||
state.me = me;
|
||||
state.csrf = me.csrf_token || "";
|
||||
layout();
|
||||
window.addEventListener("hashchange", route);
|
||||
await route();
|
||||
}
|
||||
|
||||
function layout() {
|
||||
navEl = h("nav", { class: "main" });
|
||||
mainEl = h("main");
|
||||
const me = state.me;
|
||||
const header = h("header", { class: "top" },
|
||||
h("a", { class: "brand", href: "#/", title: "Dashboard" },
|
||||
logo(30),
|
||||
wordmark({ rule: false, height: 17, cls: "brand-name" })),
|
||||
navEl,
|
||||
h("div", { class: "header-right" },
|
||||
menu(h("span", { class: "user", title: (me.permissions || []).join("\n") }, me.user.username), [
|
||||
h("a", { href: "#/account" }, "Account"),
|
||||
me.admin ? h("a", { href: "#/users" }, "Users") : null,
|
||||
h("button", { type: "button", class: "menu-item", onclick: logout }, "Log out"),
|
||||
], { right: true, active: ["account", "users"].includes(current.nav), label: "Account menu" })));
|
||||
mount(app, h("div", { class: "shell" }, header, mainEl, footer(me.build)));
|
||||
renderNav();
|
||||
}
|
||||
|
||||
// footer names the build: the release, or the commit for other builds.
|
||||
function footer(b) {
|
||||
if (!b) return null;
|
||||
let title = b.tag ? "Steward " + b.version + " (release " + b.tag + ")" : "Steward " + b.version;
|
||||
if (!b.tag && b.commit) {
|
||||
title = "Development build of commit " + b.commit + (b.modified ? " with uncommitted changes" : "");
|
||||
if (b.since) title += b.ahead ? ", " + b.ahead + (b.ahead === 1 ? " commit" : " commits") + " after " + b.since : ", at " + b.since;
|
||||
}
|
||||
return h("footer", { class: "site" }, "Powered by ",
|
||||
h("a", { href: b.url, target: "_blank", rel: "noopener", title }, "Steward " + (b.tag || b.commit ? b.label : b.version)));
|
||||
}
|
||||
|
||||
function renderNav() {
|
||||
const allowed = (n) => !n[3] || can(n[3], false);
|
||||
const link = ([key, href, label]) => h("a", { href, class: current.nav === key ? "active" : "" }, label);
|
||||
mount(navEl, navItems.map((n) => {
|
||||
if (!n.menu) return allowed(n) ? link(n) : null;
|
||||
const items = n.items.filter(allowed);
|
||||
if (!items.length) return null;
|
||||
return menu(n.menu, items.map(link), { active: items.some((i) => i[0] === current.nav), label: n.menu + " menu" });
|
||||
}));
|
||||
}
|
||||
|
||||
// menu is a drop-down that closes when an item is picked, on a click
|
||||
// elsewhere and on Escape.
|
||||
function menu(label, items, opts = {}) {
|
||||
const d = h("details", { class: "menu" + (opts.right ? " right" : "") },
|
||||
h("summary", { class: opts.active ? "active" : "", "aria-label": opts.label || null }, label, h("span", { class: "caret", "aria-hidden": "true" }, "▾")),
|
||||
h("div", { class: "menu-list", role: "menu" }, items.filter(Boolean)));
|
||||
d.addEventListener("click", (e) => { if (e.target.closest(".menu-list a, .menu-list button")) d.open = false; });
|
||||
return d;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) if (!d.contains(e.target)) d.open = false;
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key !== "Escape") return;
|
||||
for (const d of document.querySelectorAll("details.menu[open]")) { d.open = false; d.querySelector("summary").focus(); }
|
||||
});
|
||||
|
||||
async function logout() {
|
||||
try { await api("POST", "/auth/logout"); } catch { /* ignore */ }
|
||||
state.me = null;
|
||||
state.csrf = "";
|
||||
window.removeEventListener("hashchange", route);
|
||||
renderLogin("You have logged out.");
|
||||
}
|
||||
|
||||
// stopTimers lets a page that refreshes itself stop when it is left.
|
||||
let pageCleanup = null;
|
||||
export function onLeave(fn) { pageCleanup = fn; }
|
||||
|
||||
async function route() {
|
||||
if (!state.me) return;
|
||||
if (pageCleanup) { pageCleanup(); pageCleanup = null; }
|
||||
const { path, query } = parseHash();
|
||||
for (const r of routes) {
|
||||
const m = path.match(r.pattern);
|
||||
if (!m) continue;
|
||||
const params = {};
|
||||
(r.params || []).forEach((p, i) => { params[p] = m[i + 1]; });
|
||||
current = { key: path, nav: r.nav, page: r.page, params, query };
|
||||
renderNav();
|
||||
const gen = ++routeGen;
|
||||
const old = mainEl.firstChild;
|
||||
const slow = setTimeout(() => { if (gen === routeGen && mainEl.firstChild === old) mount(mainEl, h("div", { class: "muted" }, "Loading…")); }, 300);
|
||||
try {
|
||||
await r.page.render(mainEl, params, query);
|
||||
} catch (e) {
|
||||
if (gen === routeGen) mount(mainEl, h("h1", null, "Something went wrong"), errorBox(e));
|
||||
} finally {
|
||||
clearTimeout(slow);
|
||||
}
|
||||
return;
|
||||
}
|
||||
mount(mainEl, h("h1", null, "Not found"), h("p", null, h("a", { href: "#/" }, "Back to the dashboard")));
|
||||
}
|
||||
|
||||
// rerender draws the current page again (after a change).
|
||||
export function rerender() {
|
||||
if (current.page) route();
|
||||
}
|
||||
|
||||
setUnauthorizedHandler(() => {
|
||||
if (state.me) {
|
||||
state.me = null;
|
||||
window.removeEventListener("hashchange", route);
|
||||
renderLogin("Your session has expired. Please log in again.");
|
||||
}
|
||||
});
|
||||
|
||||
(async function boot() {
|
||||
try {
|
||||
await loggedIn(await get("/auth/whoami"));
|
||||
} catch (e) {
|
||||
if (e.status === 401) renderLogin();
|
||||
else mount(app, h("div", { class: "login panel" }, h("h1", null, "Steward"), errorBox(e)));
|
||||
}
|
||||
})();
|
||||
|
||||
window.addEventListener("unhandledrejection", (e) => {
|
||||
toast(e.reason && e.reason.message ? e.reason.message : String(e.reason), "err");
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
// The Steward logo and the login page's wordmark, drawn as inline SVG so
|
||||
// they appear together with the page instead of loading afterwards.
|
||||
//
|
||||
// The "STEWARD" wordmark: the angular letters of Jarl's wordmark (its A
|
||||
// and R are the same), drawn as paths so no font is needed. The login page
|
||||
// shows it over a rule with a DHCP node; the header shows the letters
|
||||
// alone. The letters use the text color and the rule the accent color.
|
||||
import * as logoData from "./logo.js";
|
||||
|
||||
const NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function el(tag, attrs, ...children) {
|
||||
const e = document.createElementNS(NS, tag);
|
||||
for (const [k, v] of Object.entries(attrs || {})) e.setAttribute(k, v);
|
||||
for (const c of children) e.append(c instanceof Node ? c : document.createTextNode(c));
|
||||
return e;
|
||||
}
|
||||
|
||||
// Letters on a 100-unit cap height; counters are cut with evenodd.
|
||||
const letters = [
|
||||
// S: squared, with a downward barb on the top bar and an upward one on
|
||||
// the bottom bar (Jarl's L turned around)
|
||||
{ x: 0, d: "M10 0 H54 L58 30 L40 16 H18 V41 H48 L58 51 V90 L48 100 H4 L0 70 L18 84 H40 V59 H10 L0 49 V10 Z" },
|
||||
// T: a bar with barbed ends
|
||||
{ x: 70, d: "M0 0 H70 L66 22 L52 18 H44 V100 H26 V18 H18 L4 22 Z" },
|
||||
// E: the bottom arm ends in Jarl's L spike
|
||||
{ x: 152, d: "M0 0 H52 L56 18 H18 V41 H44 V59 H18 V82 H40 L58 70 L54 100 H0 Z" },
|
||||
// W: flat-topped middle peak
|
||||
{ x: 222, d: "M0 0 H18 L28 66 L38 30 H52 L62 66 L72 0 H90 L76 100 H54 L45 66 L36 100 H14 Z" },
|
||||
// A and R: Jarl's
|
||||
{ x: 324, d: "M35 0 L46 10 L64 90 L70 100 H50 L44 76 L35 62 L26 76 L20 100 H0 L6 90 L24 10 Z M35 26 L41 40 L35 54 L29 40 Z" },
|
||||
{ x: 406, d: "M0 0 H48 L60 12 V40 L50 50 L66 100 H46 L34 60 H18 V100 H0 Z M18 14 H41 L46 19 V35 L41 46 H18 Z" },
|
||||
// D: cut corners, like R's bowl
|
||||
{ x: 484, d: "M0 0 H44 L64 20 V80 L44 100 H0 Z M18 16 H38 L46 24 V76 L38 84 H18 Z" },
|
||||
];
|
||||
|
||||
const W = 548; // the letters' width
|
||||
|
||||
// wordmark returns the login page's wordmark, letters over the rule.
|
||||
// wordmark({ rule: false, height: 19, cls: "brand-name" }) returns just the
|
||||
// letters, height pixels tall.
|
||||
export function wordmark({ rule = true, height, cls = "wordmark" } = {}) {
|
||||
const letterG = el("g", { fill: "currentColor", "fill-rule": "evenodd" },
|
||||
...letters.map((l) => el("path", { d: l.d, transform: `translate(${l.x} 0)` })));
|
||||
if (!rule) {
|
||||
const attrs = { class: cls, viewBox: `-2 -2 ${W + 4} 104`, role: "img", "aria-label": "Steward" };
|
||||
if (height) Object.assign(attrs, { height, width: Math.round(height * (W + 4) / 104) });
|
||||
return el("svg", attrs, el("title", null, "Steward"), letterG);
|
||||
}
|
||||
const c = W / 2;
|
||||
const ruleG = el("g", { class: "wordmark-rule", transform: "translate(0 118)" },
|
||||
el("path", { d: `M2 18 L${c - 60} 15.6 V20.4 Z M${W - 2} 18 L${c + 60} 15.6 V20.4 Z`, class: "fill" }),
|
||||
el("circle", { cx: c - 52, cy: 18, r: 4.6, class: "line" }),
|
||||
el("path", { d: `M${c - 47.4} 18 H${c - 30} M${c + 30} 18 H${c + 47.4}`, class: "line" }),
|
||||
el("circle", { cx: c, cy: 18, r: 30, class: "line" }),
|
||||
el("circle", { cx: c + 52, cy: 18, r: 4.6, class: "line" }),
|
||||
el("text", { x: c, y: 24.5, "text-anchor": "middle", class: "tag" }, "DHCP"));
|
||||
return el("svg", { class: cls, viewBox: `-4 -4 ${W + 8} 162`, role: "img", "aria-label": "Steward" },
|
||||
el("title", null, "Steward"), letterG, ruleG);
|
||||
}
|
||||
|
||||
// logo returns the Steward logo (artwork/logo.svg, via scripts/logo.py) as
|
||||
// an SVG of the given size in pixels.
|
||||
export function logo(size, cls) {
|
||||
return el("svg", { class: cls || "logo", width: size, height: size, viewBox: logoData.viewBox, "aria-hidden": "true", focusable: "false" },
|
||||
...logoData.shapes.map(([tag, attrs]) => el(tag, attrs)));
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// The Steward logo, from artwork/logo.svg by scripts/logo.py; don't edit.
|
||||
// brand.js draws it; logo.svg is the same picture.
|
||||
export const viewBox = "30 22 940 940";
|
||||
export const shapes = [
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M452 540 V890 L478 940 H522 L548 890 V540 Z"}],
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"}],
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M420 450 H580 L606 498 L580 546 H420 L394 498 Z"}],
|
||||
["path", {"fill": "#fff", "stroke": "#1d5287", "stroke-width": "26", "stroke-linejoin": "miter", "d": "M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"}],
|
||||
["path", {"d": "M287 418 Q500 368 713 418", "stroke": "#1d5287", "stroke-width": "22", "fill": "none"}],
|
||||
["path", {"stroke": "#1d5287", "stroke-width": "22", "fill": "none", "d": "M500 200 V258 M463 312 L414 334 M537 312 L586 334"}],
|
||||
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "500", "cy": "170", "r": "30"}],
|
||||
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "390", "cy": "344", "r": "27"}],
|
||||
["circle", {"stroke": "#1d5287", "stroke-width": "22", "fill": "#fff", "cx": "610", "cy": "344", "r": "27"}],
|
||||
["circle", {"cx": "500", "cy": "296", "r": "40", "fill": "#1d5287"}],
|
||||
];
|
||||
@@ -0,0 +1,17 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="30 22 940 940">
|
||||
<!-- The Steward logo: a key whose bow is a crown with Jarl's network nodes. The master; scripts/logo.py builds the web UI's copies. -->
|
||||
<g fill="#fff" stroke="#1d5287" stroke-width="26" stroke-linejoin="miter">
|
||||
<path d="M452 540 V890 L478 940 H522 L548 890 V540 Z"/>
|
||||
<path d="M548 690 H688 V742 H640 V782 H712 V832 H664 V890 H548 Z"/>
|
||||
<path d="M420 450 H580 L606 498 L580 546 H420 L394 498 Z"/>
|
||||
<path d="M305 490 L245 135 L382 238 L500 45 L618 238 L755 135 L695 490 Q500 445 305 490 Z"/>
|
||||
</g>
|
||||
<path d="M287 418 Q500 368 713 418" stroke="#1d5287" stroke-width="22" fill="none"/>
|
||||
<g stroke="#1d5287" stroke-width="22" fill="none">
|
||||
<path d="M500 200 V258 M463 312 L414 334 M537 312 L586 334"/>
|
||||
<circle cx="500" cy="170" r="30" fill="#fff"/>
|
||||
<circle cx="390" cy="344" r="27" fill="#fff"/>
|
||||
<circle cx="610" cy="344" r="27" fill="#fff"/>
|
||||
</g>
|
||||
<circle cx="500" cy="296" r="40" fill="#1d5287"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 976 B |
@@ -0,0 +1,63 @@
|
||||
// Your account: password and API tokens.
|
||||
import { h, mount, table, time, modal, input, field, select, errorBox, toast, copyBox } from "../ui.js";
|
||||
import { get, api, state, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
import { revoke } from "./users.js";
|
||||
|
||||
export async function render(el) {
|
||||
const me = state.me;
|
||||
const [tokens, cat] = await Promise.all([get("/me/tokens"), catalog()]);
|
||||
const cur = input({ type: "password", autocomplete: "current-password" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password" });
|
||||
const pw2 = input({ type: "password", autocomplete: "new-password" });
|
||||
const err = h("div");
|
||||
const change = async () => {
|
||||
mount(err);
|
||||
if (pw.value !== pw2.value) { mount(err, h("div", { class: "msg err" }, "The new passwords don't match.")); return; }
|
||||
try {
|
||||
await api("POST", "/me/password", { body: { current: cur.value, password: pw.value } });
|
||||
toast("Password changed; your other sessions are logged out");
|
||||
cur.value = pw.value = pw2.value = "";
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, me.user.username),
|
||||
h("div", { class: "muted" }, me.admin ? "Administrator" : "May: " + (me.permissions.join(", ") || "nothing")))),
|
||||
me.method === "session" ? h("div", { class: "panel" },
|
||||
h("h2", null, "Password"),
|
||||
h("div", { class: "form-grid" }, field("Current password", cur), field("New password", pw), field("Repeat", pw2)), err,
|
||||
h("div", { class: "actions", style: { marginTop: "12px" } }, h("button", { class: "primary", onclick: change }, "Change password"))) : null,
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h2", null, "API tokens"),
|
||||
h("div", { class: "muted" }, "For the steward CLI and scripts: 'steward config set token'.")),
|
||||
h("button", { onclick: () => newToken(cat) }, "New token")),
|
||||
table([
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? time(t.last_used_at) : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
async function newToken(cat) {
|
||||
const name = input({ placeholder: "laptop, backup script…" });
|
||||
const role = select([["", "everything you may do"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const expires = select([["", "never"], ["30d", "30 days"], ["90d", "90 days"], ["365d", "a year"]], "90d");
|
||||
const err = h("div");
|
||||
await modal("New API token", h("div", null, h("div", { class: "form-grid" }, field("Name", name), field("May do", role), field("Expires", expires)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Create", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const t = await api("POST", "/me/tokens", { body: { name: name.value.trim(), role: role.value || undefined, expires_in: expires.value || undefined } });
|
||||
await modal("Token created", h("div", null, h("p", null, "Copy it now: it is not shown again."), copyBox(t.secret)));
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Activity: the latest DHCP messages, refreshed every few seconds.
|
||||
import { h, mount, input, select, debounce } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
import { onLeave } from "../app.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const ifaces = await get("/interfaces");
|
||||
const mac = input({ placeholder: "MAC address", value: query.get("mac") || "", class: "mono" });
|
||||
const iface = select([["", "all interfaces"], ...ifaces.filter((i) => i.subnets.length).map((i) => [i.name, i.name])], query.get("interface") || "");
|
||||
const out = h("div");
|
||||
let live = true;
|
||||
const pause = h("button", { onclick: () => { live = !live; pause.textContent = live ? "Pause" : "Resume"; } }, "Pause");
|
||||
const draw = async () => {
|
||||
const evs = await get("/events", { mac: mac.value.trim(), interface: iface.value, limit: 300 });
|
||||
mount(out, evs.length ? h("div", { class: "table-wrap" }, h("table", { class: "activity" },
|
||||
h("thead", null, h("tr", null, ["Time", "Interface", "Message", "Client", "Address", "Host name", ""].map((t) => h("th", null, t)))),
|
||||
h("tbody", null, evs.map((e) => {
|
||||
const t = e.type.replace(/^DHCP/, "");
|
||||
return h("tr", null,
|
||||
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
|
||||
h("td", { class: "mono" }, e.interface),
|
||||
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)),
|
||||
h("td", { class: "mono" }, e.mac ? h("a", { href: "#", onclick: (ev) => { ev.preventDefault(); mac.value = e.mac; draw(); } }, e.mac) : ""),
|
||||
h("td", { class: "mono" }, e.ip || ""),
|
||||
h("td", null, e.hostname || ""),
|
||||
h("td", { class: "detail" }, e.detail || ""));
|
||||
})))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || iface.value ? " that match." : ".")));
|
||||
};
|
||||
mac.addEventListener("input", debounce(draw, 300));
|
||||
iface.addEventListener("change", draw);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"),
|
||||
h("div", { class: "muted" }, "The latest DHCP messages Steward received and sent, newest first. Click a MAC address to follow one client."))),
|
||||
h("div", { class: "filter-row" }, mac, iface, pause),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000);
|
||||
onLeave(() => clearInterval(t));
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
// The audit log: logins, users, tokens, changes, lease deletions.
|
||||
import { h, mount, table, time, input, debounce, badge } from "../ui.js";
|
||||
import { get } from "../api.js";
|
||||
|
||||
export async function render(el) {
|
||||
const search = input({ type: "search", placeholder: "user, action, object or detail" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const q = search.value.trim();
|
||||
const evs = await get("/audit", { q, limit: 300 });
|
||||
mount(out, table([
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "User", render: (e) => [e.username || "–", e.auth ? h("span", { class: "muted small" }, " " + e.auth) : null] },
|
||||
{ label: "From", render: (e) => h("span", { class: "mono small" }, e.ip || "") },
|
||||
{ label: "Action", render: (e) => h("span", { class: "mono" }, e.action) },
|
||||
{ label: "Object", render: (e) => e.object },
|
||||
{ label: "Result", render: (e) => badge(e.result, e.result === "success" ? "ok" : "err") },
|
||||
{ label: "Detail", render: (e) => h("span", { class: "small" }, e.detail) },
|
||||
], evs, { empty: "Nothing logged" + (q ? " that matches." : " yet.") }));
|
||||
};
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el, h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Audit log"))),
|
||||
h("div", { class: "filter-row" }, search), h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
// History: every configuration change, with undo.
|
||||
import { h, mount, table, time, confirmDialog, modal, errorBox, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el, params) {
|
||||
if (params.id) return entry(el, params.id);
|
||||
const hs = await get("/history", { limit: 200 });
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "History"),
|
||||
h("div", { class: "muted" }, "Every configuration change, newest first. Undo applies a change backwards, if nothing it touched has changed since."))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "#", render: (e) => h("a", { href: "#/history/" + e.id }, String(e.id)) },
|
||||
{ label: "When", render: (e) => time(e.time) },
|
||||
{ label: "By", render: (e) => [e.username, h("span", { class: "muted small" }, " via " + e.via)] },
|
||||
{ label: "Change", render: (e) => [h("a", { href: "#/history/" + e.id }, e.description),
|
||||
e.reverted_by ? [" ", badge("undone by #" + e.reverted_by, "")] : null, e.revert_of ? [" ", badge("undoes #" + e.revert_of, "accent")] : null] },
|
||||
can("config", true) ? { label: "", tdClass: "actions-cell", render: (e) => e.reverted_by ? null : h("button", { class: "small reveal", onclick: () => undo(e) }, "Undo") } : null,
|
||||
].filter(Boolean), hs, { empty: "No changes yet." })));
|
||||
}
|
||||
|
||||
async function undo(e) {
|
||||
if (!await confirmDialog("Undo change #" + e.id, "Undo “" + e.description + "”? This is recorded as a new change, which can be undone too.", "Undo")) return;
|
||||
try {
|
||||
await change("POST", "/history/" + e.id + "/revert");
|
||||
rerender();
|
||||
} catch (err) { modal("Can't undo", errorBox(err)); }
|
||||
}
|
||||
|
||||
async function entry(el, id) {
|
||||
const e = await get("/history/" + id);
|
||||
mount(el,
|
||||
h("nav", { class: "back-link" }, h("a", { href: "#/history" }, "← History")),
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "#" + e.id + ": " + e.description),
|
||||
h("div", { class: "muted" }, new Date(e.time).toLocaleString(), " by ", e.username, " via ", e.via,
|
||||
e.reverted_by ? [" · undone by ", h("a", { href: "#/history/" + e.reverted_by }, "#" + e.reverted_by)] : null,
|
||||
e.revert_of ? [" · undoes ", h("a", { href: "#/history/" + e.revert_of }, "#" + e.revert_of)] : null)),
|
||||
can("config", true) && !e.reverted_by ? h("div", { class: "actions" }, h("button", { onclick: () => undo(e) }, "Undo")) : null),
|
||||
e.changes.map((c) => h("div", { class: "panel" },
|
||||
h("h3", null, action(c) + " " + c.kind + " ", h("span", { class: "mono" }, c.label)),
|
||||
fieldsTable(c))));
|
||||
}
|
||||
|
||||
function action(c) {
|
||||
if (!c.before) return "Created";
|
||||
if (!c.after) return "Deleted";
|
||||
return "Changed";
|
||||
}
|
||||
|
||||
// fieldsTable lists the fields that differ (all of them for a create or
|
||||
// delete).
|
||||
function fieldsTable(c) {
|
||||
const a = c.before || {}, b = c.after || {};
|
||||
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
||||
const show = (v, k) => {
|
||||
if (v === undefined || v === "") return "–";
|
||||
if (k === "options") return v.length ? v.map((o) => o.name + "=" + o.value).join("; ") : "none";
|
||||
if (k === "pools") return v.length ? v.map((p) => p.start + "-" + p.end).join(", ") : "none";
|
||||
if (k === "lease_time" || k === "decline_hold") return v ? v + "s" : "default";
|
||||
return typeof v === "string" ? v : JSON.stringify(v);
|
||||
};
|
||||
const rows = keys.filter((k) => k !== "id").filter((k) => JSON.stringify(a[k]) !== JSON.stringify(b[k]) || !c.before || !c.after);
|
||||
return table([
|
||||
{ label: "Field", render: (k) => h("strong", null, k) },
|
||||
c.before ? { label: "Before", render: (k) => h("span", { class: "mono small" }, show(a[k], k)) } : null,
|
||||
c.after ? { label: c.before ? "After" : "Value", render: (k) => h("span", { class: "mono small" }, show(b[k], k)) } : null,
|
||||
].filter(Boolean), rows);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
// Leases: every address handed out, with filters; reserve or delete one.
|
||||
import { h, mount, table, time, input, select, confirmDialog, toast, modal, errorBox, debounce, badge } from "../ui.js";
|
||||
import { get, api, change, can } from "../api.js";
|
||||
import { stateText, subnetLabel } from "../widgets.js";
|
||||
|
||||
// leaseHost is the host name to show: the reservation's, or what the
|
||||
// client said.
|
||||
function leaseHost(l) {
|
||||
if (l.reserved_hostname && l.hostname && l.reserved_hostname !== l.hostname) return [l.reserved_hostname, h("span", { class: "muted small" }, " (says " + l.hostname + ")")];
|
||||
return l.reserved_hostname || l.hostname || h("span", { class: "muted" }, "–");
|
||||
}
|
||||
|
||||
// leasesTable shows leases with their actions; after a change, done() is
|
||||
// called.
|
||||
export function leasesTable(leases, done, opts = {}) {
|
||||
const write = can("leases", true), config = can("config", true);
|
||||
return table([
|
||||
{ label: "Address", render: (l) => h("span", { class: "mono" }, l.ip) },
|
||||
{ label: "MAC address", render: (l) => h("span", { class: "mono" }, l.mac || "–") },
|
||||
{ label: "Host name", render: leaseHost },
|
||||
{ label: "State", render: (l) => [stateBadge(l.state), l.reservation ? [" ", badge("reserved", "accent")] : null] },
|
||||
{ label: "Ends", render: (l) => time(l.end) },
|
||||
opts.subnet ? null : { label: "Subnet", render: (l) => l.subnet || "–" },
|
||||
{ label: "Interface", render: (l) => h("span", { class: "mono" }, l.interface) },
|
||||
{ label: "", tdClass: "actions-cell", render: (l) => [
|
||||
config && !l.reservation && l.mac && l.state !== "declined" ? h("button", { class: "small reveal", title: "Give this client this address from now on", onclick: () => reserve(l, done) }, "Reserve") : null, " ",
|
||||
write ? h("button", { class: "small reveal", title: "Forget this lease: the address is free at once", onclick: () => del(l, done) }, "Delete") : null,
|
||||
] },
|
||||
].filter(Boolean), leases, { empty: opts.empty || "No leases." });
|
||||
}
|
||||
|
||||
function stateBadge(st) {
|
||||
return badge(stateText(st), { active: "ok", declined: "warn" }[st] || "");
|
||||
}
|
||||
|
||||
async function reserve(l, done) {
|
||||
const host = input({ value: l.hostname || "", placeholder: "host name (optional)" });
|
||||
const err = h("div");
|
||||
await modal("Reserve " + l.ip, h("div", null,
|
||||
h("p", null, l.mac + " keeps " + l.ip + " from now on."),
|
||||
h("div", { class: "form-grid" }, h("label", null, "Host name"), host), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Reserve", kind: "primary", onclick: async () => {
|
||||
try {
|
||||
await change("POST", "/leases/" + l.ip + "/reserve", { hostname: host.value.trim() });
|
||||
done();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
]);
|
||||
}
|
||||
|
||||
async function del(l, done) {
|
||||
if (!await confirmDialog("Delete lease", "Forget the lease of " + l.ip + (l.mac ? " (" + l.mac + ")" : "") + "? The address is free at once; if the client still uses it, it gets a NAK when it next renews and asks for a new address.", "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/leases/" + l.ip);
|
||||
toast("Deleted the lease of " + l.ip);
|
||||
done();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
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 stateSel = select([["current", "active and held back"], ["active", "active"], ["expired", "expired"], ["released", "released"], ["declined", "held back"], ["", "all"]], query.get("state") || "current");
|
||||
const search = input({ type: "search", placeholder: "address, MAC or host name", value: query.get("q") || "" });
|
||||
const out = h("div");
|
||||
const draw = async () => {
|
||||
const st = stateSel.value === "current" ? "" : stateSel.value;
|
||||
let leases = await get("/leases", { subnet: subnetSel.value, state: st, q: search.value.trim() });
|
||||
if (stateSel.value === "current") leases = leases.filter((l) => l.state === "active" || l.state === "declined");
|
||||
const q = new URLSearchParams();
|
||||
if (subnetSel.value) q.set("subnet", subnetSel.value);
|
||||
if (stateSel.value !== "current") q.set("state", stateSel.value);
|
||||
if (search.value.trim()) q.set("q", search.value.trim());
|
||||
history.replaceState(null, "", "#/leases" + (q.toString() ? "?" + q : ""));
|
||||
mount(out, h("div", { class: "muted small" }, leases.length + (leases.length === 1 ? " lease" : " leases")), leasesTable(leases, draw));
|
||||
};
|
||||
subnetSel.addEventListener("change", draw);
|
||||
stateSel.addEventListener("change", draw);
|
||||
search.addEventListener("input", debounce(draw, 300));
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Leases"),
|
||||
h("div", { class: "muted" }, "Addresses handed out. Expired and released leases are kept a while, so a client that comes back gets its old address."))),
|
||||
h("div", { class: "filter-row" }, subnetSel, stateSel, search, h("button", { onclick: draw }, "Refresh")),
|
||||
h("div", { class: "panel" }, out));
|
||||
await draw();
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
// Reservations: fixed addresses for clients.
|
||||
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, subnetLabel } from "../widgets.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) },
|
||||
{ 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() }, "yes, on " + r.lease.interface) : h("span", { class: "muted" }, "no") },
|
||||
{ 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", onclick: () => del(r, done) }, "Delete"),
|
||||
] } : null,
|
||||
].filter(Boolean), rs, { empty: opts.empty || "No reservations." });
|
||||
}
|
||||
|
||||
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 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)), 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 };
|
||||
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");
|
||||
const draw = async () => {
|
||||
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));
|
||||
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.")),
|
||||
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();
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Settings: the default lease time, conflict checks, authority, and the
|
||||
// options every subnet gets.
|
||||
import { h, mount, input, field, checkbox, errorBox } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { optionsEditor, fmtSeconds, parseSeconds } from "../widgets.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const s = await get("/settings");
|
||||
const write = can("config", true);
|
||||
const lease = input({ value: fmtSeconds(s.lease_time || 43200) });
|
||||
const ping = checkbox("Ping an address before offering it, and skip it if something answers", s.ping_check);
|
||||
const timeout = input({ type: "number", min: 50, max: 5000, value: s.ping_timeout_ms || 500 });
|
||||
timeout.style.width = "7em";
|
||||
const hold = input({ value: fmtSeconds(s.decline_hold || 86400) });
|
||||
const auth = checkbox("Authoritative: refuse (NAK) requests for addresses that don't belong on the interface", !s.not_authoritative);
|
||||
const opts = await optionsEditor(s.options);
|
||||
const err = h("div");
|
||||
const save = async (dry) => {
|
||||
mount(err);
|
||||
const lt = parseSeconds(lease.value), dh = parseSeconds(hold.value);
|
||||
if (lt === null || dh === null) { mount(err, h("div", { class: "msg err" }, "Write times as 12h, 1d or 30m.")); return; }
|
||||
try {
|
||||
const r = await change("PATCH", "/settings", { lease_time: lt, ping_check: ping.input.checked, ping_timeout_ms: parseInt(timeout.value, 10) || 0,
|
||||
decline_hold: dh, not_authoritative: !auth.input.checked, options: opts.value() }, { query: dry ? { dry_run: 1 } : undefined });
|
||||
if (!dry && r.applied) rerender();
|
||||
} catch (e) { mount(err, errorBox(e)); }
|
||||
};
|
||||
mount(el,
|
||||
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Settings and options"),
|
||||
h("div", { class: "muted" }, "Server-wide settings, and the options every subnet sends. A subnet's or a reservation's own option of the same name wins."))),
|
||||
h("div", { class: "panel" },
|
||||
h("div", { class: "form-grid" },
|
||||
field("Default lease time", lease, "How long clients keep an address unless a subnet says otherwise (12h, 1d). They renew halfway."),
|
||||
field("Options for every subnet", opts, "Usually domain-name-servers and domain-name; routers belong to each subnet."),
|
||||
field("Conflict check", h("div", null, ping, h("div", { class: "small" }, "wait ", timeout, " ms for an answer"))),
|
||||
field("Hold back for", hold, "How long an address a client declined (or one that answered a ping) isn't handed out."),
|
||||
field("Authority", auth, "Turn this off only if another DHCP server serves the same network.")),
|
||||
err,
|
||||
write ? h("div", { class: "actions", style: { marginTop: "14px" } },
|
||||
h("button", { class: "primary", onclick: () => save(false) }, "Save"),
|
||||
h("button", { onclick: () => save(true), title: "Check without saving" }, "Check")) : null));
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
// 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"))));
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// The subnets, and the New subnet dialog.
|
||||
import { h, mount, table, modal, errorBox, badge } from "../ui.js";
|
||||
import { get, change, can } from "../api.js";
|
||||
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
|
||||
|
||||
export async function render(el, params, query) {
|
||||
const subnets = await get("/subnets");
|
||||
const poolsText = (s) => s.pools.length ? s.pools.map((p) => p.start + "-" + p.end).join(", ") : "–";
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Subnets"),
|
||||
h("div", { class: "muted" }, "Each subnet is served on one interface: only clients on that interface get its addresses.")),
|
||||
can("config", true) ? h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => newSubnet() }, "New subnet")) : null),
|
||||
h("div", { class: "panel" }, 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", render: (s) => h("span", { class: "mono small" }, poolsText(s)) },
|
||||
{ label: "In use", render: (s) => usageBar(s.usage) },
|
||||
{ label: "Reserved", class: "num", tdClass: "num", render: (s) => String(s.usage.reservations) },
|
||||
{ label: "Lease time", render: (s) => s.lease_time ? fmtSeconds(s.lease_time) : h("span", { class: "muted" }, "default") },
|
||||
{ label: "", render: (s) => s.disabled ? badge("disabled", "off") : null },
|
||||
], subnets, { empty: "No subnets yet. Add one for each network Steward should serve." })));
|
||||
if (query.get("new") === "1" && can("config", true)) newSubnet();
|
||||
}
|
||||
|
||||
export async function newSubnet() {
|
||||
const settings = await get("/settings");
|
||||
const form = await subnetForm(null, settings);
|
||||
const err = h("div");
|
||||
const res = await modal("New subnet", h("div", null, form, err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: "Add subnet", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
try {
|
||||
const r = await change("POST", "/subnets", form.values());
|
||||
location.hash = "#/subnets/" + (r.object ? r.object.id : "");
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { wide: true, sticky: true });
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
// Users and everyone's tokens (administrators).
|
||||
import { h, mount, table, time, modal, input, field, checkbox, select, errorBox, confirmDialog, toast, badge } from "../ui.js";
|
||||
import { get, api, catalog } from "../api.js";
|
||||
import { rerender } from "../app.js";
|
||||
|
||||
export async function render(el) {
|
||||
const [users, tokens, cat] = await Promise.all([get("/users"), get("/tokens"), catalog()]);
|
||||
mount(el,
|
||||
h("div", { class: "page-head" },
|
||||
h("div", { class: "grow" }, h("h1", null, "Users"), h("div", { class: "muted" }, "Administrators may do everything. Others get a role or grants: config, leases and audit, each read or write.")),
|
||||
h("div", { class: "actions" }, h("button", { class: "primary", onclick: () => userDialog(null, cat) }, "New user"))),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (u) => [h("strong", null, u.username), u.disabled ? [" ", badge("disabled", "off")] : null] },
|
||||
{ label: "May", render: (u) => u.admin ? badge("administrator", "accent") : h("span", { class: "mono small" }, u.grant_strings.join(" ") || "nothing") },
|
||||
{ label: "Password", render: (u) => u.has_password ? "yes" : h("span", { class: "muted" }, "tokens only") },
|
||||
{ label: "Last login", render: (u) => u.last_login_at ? time(u.last_login_at) : "never" },
|
||||
{ label: "Comment", render: (u) => u.comment },
|
||||
{ label: "", tdClass: "actions-cell", render: (u) => [
|
||||
h("button", { class: "small reveal", onclick: () => userDialog(u, cat) }, "Edit"), " ",
|
||||
h("button", { class: "small reveal", onclick: () => del(u) }, "Delete")] },
|
||||
], users)),
|
||||
h("h2", null, "API tokens"),
|
||||
h("div", { class: "panel" }, table([
|
||||
{ label: "User", render: (t) => t.username },
|
||||
{ label: "Name", render: (t) => t.name },
|
||||
{ label: "May", render: (t) => h("span", { class: "mono small" }, t.grant_strings.join(" ")) },
|
||||
{ label: "Expires", render: (t) => t.expires_at ? time(t.expires_at) : "never" },
|
||||
{ label: "Last used", render: (t) => t.last_used_at ? [time(t.last_used_at), h("span", { class: "muted small" }, " from " + t.last_used_ip)] : "never" },
|
||||
{ label: "", tdClass: "actions-cell", render: (t) => h("button", { class: "small reveal", onclick: () => revoke(t) }, "Revoke") },
|
||||
], tokens, { empty: "No tokens." })));
|
||||
}
|
||||
|
||||
export async function revoke(t) {
|
||||
if (!await confirmDialog("Revoke token", "Revoke " + t.username + "'s token “" + t.name + "”? Anything using it is refused from now on.", "Revoke", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/tokens/" + t.id);
|
||||
toast("Revoked");
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function del(u) {
|
||||
if (!await confirmDialog("Delete user", "Delete " + u.username + " and their tokens?", "Delete", "danger")) return;
|
||||
try {
|
||||
await api("DELETE", "/users/" + u.id);
|
||||
rerender();
|
||||
} catch (e) { toast(e.message, "err", 8000); }
|
||||
}
|
||||
|
||||
async function userDialog(u, cat) {
|
||||
const name = input({ value: u ? u.username : "", autocomplete: "off" });
|
||||
const pw = input({ type: "password", autocomplete: "new-password", placeholder: u ? "unchanged" : "at least 8 characters" });
|
||||
const admin = checkbox("Administrator", u ? u.admin : false);
|
||||
const role = select([["", "custom grants"], ...Object.keys(cat.roles).map((r) => [r, r + ": " + cat.roles[r].join(" ")])], "");
|
||||
const grants = input({ value: u ? u.grant_strings.join(" ") : "", placeholder: "config:read leases:write", class: "mono" });
|
||||
const disabled = checkbox("Disabled (logs them out)", u ? u.disabled : false);
|
||||
const comment = input({ value: u ? u.comment : "" });
|
||||
const err = h("div");
|
||||
await modal(u ? "Edit " + u.username : "New user", h("div", null, h("div", { class: "form-grid" },
|
||||
field("Username", name),
|
||||
field("Password", pw, u ? "Leave empty to keep it." : "Leave empty for a service account that only uses API tokens."),
|
||||
h("label"), admin,
|
||||
field("Role", role),
|
||||
field("Grants", grants, "resource:access, separated by spaces; resources config, leases, audit; access read or write."),
|
||||
u ? [h("label"), disabled] : null,
|
||||
field("Comment", comment)), err), [
|
||||
{ label: "Cancel", value: null },
|
||||
{ label: u ? "Save" : "Add user", kind: "primary", onclick: async () => {
|
||||
mount(err);
|
||||
const body = { username: name.value.trim(), admin: admin.input.checked, comment: comment.value };
|
||||
if (role.value) body.role = role.value;
|
||||
else body.grants = grants.value.split(/[\s,]+/).filter(Boolean);
|
||||
if (pw.value || !u) body.password = pw.value;
|
||||
if (u) body.disabled = disabled.input.checked;
|
||||
try {
|
||||
await api(u ? "PATCH" : "POST", u ? "/users/" + u.id : "/users", { body });
|
||||
rerender();
|
||||
return true;
|
||||
} catch (e) {
|
||||
mount(err, errorBox(e));
|
||||
return false;
|
||||
}
|
||||
} },
|
||||
], { sticky: true });
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
/* Steward web UI. Plain CSS, light and dark themes (the same as Jarl's). */
|
||||
:root {
|
||||
--bg: #f6f7f9;
|
||||
--panel: #ffffff;
|
||||
--text: #1d2330;
|
||||
--muted: #5f6b7d;
|
||||
--hatch: rgba(95, 107, 125, 0.13);
|
||||
--border: #d9dee6;
|
||||
--accent: #2f5fb3;
|
||||
--accent-text: #ffffff;
|
||||
--accent-soft: #e5edfa;
|
||||
--ok: #1e7a46;
|
||||
--ok-soft: #e3f4ea;
|
||||
--warn: #8a5a00;
|
||||
--warn-soft: #fdf2d9;
|
||||
--err: #b3261e;
|
||||
--err-soft: #fbe7e5;
|
||||
--info-soft: #eef1f6;
|
||||
--code-bg: #f0f2f5;
|
||||
--add: #e6f6ea;
|
||||
--add-text: #135c2c;
|
||||
--del: #fde8e7;
|
||||
--del-text: #8c1d18;
|
||||
--hunk: #e8eefb;
|
||||
--shadow: 0 1px 2px rgba(20, 30, 50, 0.08);
|
||||
--radius: 6px;
|
||||
--vc-green: #3d8160; --vc-teal: #2f7c80; --vc-purple: #7a5ba8; --vc-pink: #a8557f;
|
||||
--vc-orange: #a8652f; --vc-yellow: #8a7424; --vc-red: #b0534c; --vc-grey: #6b7280;
|
||||
--mono: ui-monospace, "SFMono-Regular", Menlo, Consolas, "Liberation Mono", monospace;
|
||||
color-scheme: light;
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) {
|
||||
--bg: #14171c;
|
||||
--panel: #1c2027;
|
||||
--text: #e3e7ee;
|
||||
--muted: #9aa5b5;
|
||||
--hatch: rgba(154, 165, 181, 0.12);
|
||||
--border: #2f3540;
|
||||
--accent: #6d9be8;
|
||||
--accent-text: #0f1520;
|
||||
--accent-soft: #23314a;
|
||||
--ok: #59c287;
|
||||
--ok-soft: #173326;
|
||||
--warn: #e5b450;
|
||||
--warn-soft: #3a2f15;
|
||||
--err: #f08077;
|
||||
--err-soft: #3c1f1d;
|
||||
--info-soft: #232833;
|
||||
--code-bg: #242933;
|
||||
--add: #183a25;
|
||||
--add-text: #9fe3b5;
|
||||
--del: #43201e;
|
||||
--del-text: #f7b2ac;
|
||||
--hunk: #23314a;
|
||||
--shadow: none;
|
||||
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||
color-scheme: dark;
|
||||
}
|
||||
}
|
||||
:root[data-theme="dark"] {
|
||||
--bg: #14171c; --panel: #1c2027; --text: #e3e7ee; --muted: #9aa5b5; --border: #2f3540; --hatch: rgba(154, 165, 181, 0.12);
|
||||
--accent: #6d9be8; --accent-text: #0f1520; --accent-soft: #23314a; --ok: #59c287; --ok-soft: #173326;
|
||||
--warn: #e5b450; --warn-soft: #3a2f15; --err: #f08077; --err-soft: #3c1f1d; --info-soft: #232833;
|
||||
--code-bg: #242933; --add: #183a25; --add-text: #9fe3b5; --del: #43201e; --del-text: #f7b2ac; --hunk: #23314a;
|
||||
--shadow: none; color-scheme: dark;
|
||||
--vc-green: #8fcfa9; --vc-teal: #87cdd0; --vc-purple: #bca6e6; --vc-pink: #e6a6c6;
|
||||
--vc-orange: #ebbb8f; --vc-yellow: #e3d08e; --vc-red: #eba29c; --vc-grey: #aab2bf;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, Ubuntu, sans-serif;
|
||||
}
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
code, pre, .mono, textarea.mono, input.mono { font-family: var(--mono); font-size: 13px; }
|
||||
pre { background: var(--code-bg); padding: 10px 12px; border-radius: var(--radius); overflow: auto; margin: 0; white-space: pre; }
|
||||
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||
h2 { font-size: 16px; margin: 18px 0 8px; }
|
||||
h3 { font-size: 14px; margin: 14px 0 6px; }
|
||||
.muted { color: var(--muted); }
|
||||
.small { font-size: 12px; }
|
||||
.nowrap { white-space: nowrap; }
|
||||
.warn-text { color: var(--warn); }
|
||||
.right { text-align: right; }
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* Layout */
|
||||
header.top {
|
||||
display: flex; align-items: center; gap: 16px; flex-wrap: wrap;
|
||||
background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px; position: sticky; top: 0; z-index: 10;
|
||||
}
|
||||
header.top .brand { display: inline-flex; align-items: center; gap: 8px; color: var(--text); text-decoration: none; }
|
||||
header.top .brand .logo { display: block; transition: transform 0.15s; }
|
||||
header.top .brand .brand-name { display: block; color: var(--text); }
|
||||
header.top .brand:hover { text-decoration: none; }
|
||||
header.top .brand:hover .brand-name { color: var(--accent); }
|
||||
header.top .brand:hover .logo { transform: scale(1.06); }
|
||||
nav.main { display: flex; gap: 2px; flex-wrap: wrap; }
|
||||
nav.main a { padding: 6px 10px; border-radius: var(--radius); color: var(--text); }
|
||||
nav.main a:hover { background: var(--info-soft); text-decoration: none; }
|
||||
nav.main a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
header.top .spacer { flex: 1; }
|
||||
header.top form.search input { width: 220px; }
|
||||
header.top .user { color: var(--muted); }
|
||||
main { padding: 16px; max-width: 1400px; margin: 0 auto; }
|
||||
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
|
||||
background: var(--warn-soft); color: var(--text); border-bottom: 1px solid var(--border);
|
||||
padding: 8px 16px;
|
||||
}
|
||||
.page-head { display: flex; align-items: flex-start; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.page-head .grow { flex: 1; min-width: 200px; }
|
||||
.actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
|
||||
|
||||
/* Panels and cards */
|
||||
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow); margin-bottom: 14px; }
|
||||
.panel > h2:first-child, .panel > h3:first-child { margin-top: 0; }
|
||||
/* Tiles share a row and grow to fill it, so a wrapped tile never sits
|
||||
alone beside an empty space. */
|
||||
.cards { display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 14px; }
|
||||
.cards > * { flex: 1 1 170px; min-width: 0; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 12px 14px; box-shadow: var(--shadow); }
|
||||
.card .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
|
||||
.card .value { font-size: 18px; font-weight: 600; margin-top: 2px; }
|
||||
.card .sub { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
|
||||
/* Tables */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { border-collapse: collapse; width: 100%; background: var(--panel); }
|
||||
th, td { text-align: left; padding: 6px 8px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
th { font-size: 12px; color: var(--muted); font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; white-space: nowrap; }
|
||||
tr:hover td { background: var(--info-soft); }
|
||||
td.data { font-family: var(--mono); font-size: 13px; word-break: break-all; }
|
||||
td.name { font-family: var(--mono); font-size: 13px; white-space: nowrap; }
|
||||
td.actions-cell { white-space: nowrap; text-align: right; width: 1%; }
|
||||
tr.editing td { background: var(--accent-soft); }
|
||||
tr.disabled td:not(.actions-cell), tr.disabled td strong { color: var(--muted); }
|
||||
/* Disabled records: hatched, so they stand out from live and pending rows. */
|
||||
tr.disabled td { background-image: repeating-linear-gradient(135deg, transparent 0 7px, var(--hatch) 7px 9px); }
|
||||
.badge.off { background: var(--muted); color: var(--panel); }
|
||||
button.toggle { min-width: 5.2em; }
|
||||
.empty { color: var(--muted); padding: 18px; text-align: center; }
|
||||
|
||||
/* Forms */
|
||||
input, select, textarea, button {
|
||||
font: inherit; color: var(--text);
|
||||
}
|
||||
input[type=text], input[type=password], input[type=number], input[type=search], input:not([type]), select, textarea {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 5px 8px; min-width: 0;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent-soft); border-color: var(--accent); }
|
||||
textarea { width: 100%; min-height: 80px; resize: vertical; }
|
||||
.form-grid { display: grid; grid-template-columns: minmax(140px, 200px) 1fr; gap: 8px 14px; align-items: start; }
|
||||
.form-grid > label { padding-top: 6px; color: var(--muted); }
|
||||
.form-grid .help { grid-column: 2; margin-top: -4px; color: var(--muted); font-size: 12px; }
|
||||
.form-grid input, .form-grid select { width: 100%; max-width: 560px; }
|
||||
.form-grid input[type=checkbox] { width: auto; }
|
||||
.inline-form { display: flex; gap: 6px; flex-wrap: wrap; align-items: flex-end; }
|
||||
.inline-form .f { display: flex; flex-direction: column; gap: 2px; }
|
||||
.inline-form .f label:not(.check) { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.3px; }
|
||||
.inline-form .grow { flex: 1; min-width: 220px; }
|
||||
.inline-form .grow input { width: 100%; }
|
||||
.hint { color: var(--muted); font-size: 12px; margin-top: 4px; }
|
||||
.check { display: inline-flex; gap: 6px; align-items: center; cursor: pointer; }
|
||||
|
||||
button, .button {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 5px 12px; cursor: pointer; white-space: nowrap; color: var(--text);
|
||||
}
|
||||
button:hover, .button:hover { background: var(--info-soft); text-decoration: none; }
|
||||
button.primary, .button.primary { background: var(--accent); border-color: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||
button.primary:hover { filter: brightness(1.08); background: var(--accent); }
|
||||
button.danger { color: var(--err); border-color: var(--err); }
|
||||
button.danger:hover { background: var(--err-soft); }
|
||||
button.small, .button.small { padding: 2px 8px; font-size: 12px; }
|
||||
button.link { border: none; background: none; color: var(--accent); padding: 0 4px; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
|
||||
/* Badges and messages */
|
||||
.badge { display: inline-block; padding: 1px 7px; border-radius: 10px; font-size: 11px; font-weight: 600; background: var(--info-soft); color: var(--muted); white-space: nowrap; }
|
||||
.badge.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.badge.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.badge.err { background: var(--err-soft); color: var(--err); }
|
||||
.badge.accent { background: var(--accent-soft); color: var(--accent); }
|
||||
.msg { border-radius: var(--radius); padding: 8px 12px; margin: 8px 0; border: 1px solid transparent; }
|
||||
.msg.err { background: var(--err-soft); color: var(--err); border-color: var(--err); }
|
||||
.msg.warn { background: var(--warn-soft); color: var(--warn); }
|
||||
.msg.ok { background: var(--ok-soft); color: var(--ok); }
|
||||
.msg.info { background: var(--info-soft); }
|
||||
.msg ul { margin: 4px 0 0; padding-left: 18px; }
|
||||
.problems { list-style: none; padding: 0; margin: 6px 0; }
|
||||
.problems li { padding: 4px 8px; border-left: 3px solid var(--border); margin-bottom: 3px; background: var(--info-soft); border-radius: 0 var(--radius) var(--radius) 0; }
|
||||
.problems li.error { border-color: var(--err); background: var(--err-soft); }
|
||||
.problems li.warning { border-color: var(--warn); background: var(--warn-soft); }
|
||||
.problems li .where { color: var(--muted); font-size: 12px; margin-left: 6px; }
|
||||
|
||||
/* Diff */
|
||||
|
||||
/* Tabs */
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.tabs a { padding: 7px 12px; border: 1px solid transparent; border-bottom: none; border-radius: var(--radius) var(--radius) 0 0; color: var(--muted); }
|
||||
.tabs a.active { background: var(--panel); border-color: var(--border); color: var(--text); font-weight: 600; margin-bottom: -1px; }
|
||||
|
||||
/* Modal */
|
||||
.modal-back { position: fixed; inset: 0; background: rgba(10, 15, 25, 0.45); display: flex; align-items: flex-start; justify-content: center; padding: 60px 16px; z-index: 50; overflow: auto; }
|
||||
.modal { background: var(--panel); border-radius: 8px; border: 1px solid var(--border); width: min(760px, 100%); padding: 18px 20px; box-shadow: 0 10px 40px rgba(0,0,0,0.25); }
|
||||
.modal.wide { width: min(1100px, 100%); }
|
||||
.modal h2 { margin-top: 0; }
|
||||
.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
|
||||
/* Toasts */
|
||||
#toasts { position: fixed; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 8px; z-index: 100; max-width: min(480px, calc(100% - 32px)); }
|
||||
.toast { background: var(--panel); border: 1px solid var(--border); border-left: 4px solid var(--accent); padding: 10px 14px; border-radius: var(--radius); box-shadow: 0 4px 16px rgba(0,0,0,0.18); }
|
||||
.toast.ok { border-left-color: var(--ok); }
|
||||
.toast.err { border-left-color: var(--err); }
|
||||
.toast.warn { border-left-color: var(--warn); }
|
||||
|
||||
/* Login */
|
||||
.login { max-width: 360px; margin: 10vh auto; }
|
||||
.login-logo { display: block; margin: 8px auto 10px; }
|
||||
.login h1 { text-align: center; margin: 0 0 8px; }
|
||||
.wordmark { display: block; width: 220px; height: auto; margin: 0 auto; color: var(--text); }
|
||||
.wordmark-rule .fill { fill: var(--accent); }
|
||||
.wordmark-rule .line { fill: none; stroke: var(--accent); stroke-width: 2.6; }
|
||||
.login .tag { text-align: center; color: var(--muted); margin-bottom: 18px; }
|
||||
.login form { display: flex; flex-direction: column; gap: 10px; }
|
||||
.login input { width: 100%; padding: 8px 10px; }
|
||||
.login button { justify-content: center; padding: 8px; }
|
||||
|
||||
.kv { display: grid; grid-template-columns: max-content 1fr; gap: 4px 16px; }
|
||||
.kv dt { color: var(--muted); }
|
||||
.kv dd { margin: 0; }
|
||||
.grants { font-family: var(--mono); font-size: 12px; }
|
||||
.copy-box { display: flex; gap: 6px; align-items: center; }
|
||||
.login.wide { max-width: 520px; }
|
||||
.copy-box input { flex: 1; font-family: var(--mono); }
|
||||
.copy-box input { min-width: 0; }
|
||||
td.break { word-break: break-all; }
|
||||
@media (max-width: 700px) {
|
||||
.form-grid { grid-template-columns: 1fr; }
|
||||
.form-grid .help { grid-column: 1; }
|
||||
header.top form.search input { width: 140px; }
|
||||
}
|
||||
|
||||
|
||||
.refreshing { opacity: 0.6; transition: opacity 0.2s; }
|
||||
/* Secondary row actions show when the row is hovered or focused; touch
|
||||
screens, which can't hover, always show them. Opacity keeps them in the
|
||||
layout (no shifting) and reachable with Tab. */
|
||||
tr .reveal { opacity: 0; transition: opacity 0.1s; }
|
||||
tr:hover .reveal, tr:focus-within .reveal { opacity: 1; }
|
||||
@media (hover: none) { tr .reveal { opacity: 1; } }
|
||||
.filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||
.segmented { display: inline-flex; border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; background: var(--panel); }
|
||||
.segmented a { padding: 4px 12px; color: var(--text); border-right: 1px solid var(--border); text-decoration: none; font-variant-numeric: tabular-nums; }
|
||||
.segmented a:last-child { border-right: none; }
|
||||
.segmented a:hover { background: var(--info-soft); }
|
||||
.segmented a.active { background: var(--accent); color: var(--accent-text); font-weight: 600; }
|
||||
/* the "← Zones" link above a page's heading */
|
||||
.back-link { font-size: 13px; margin-bottom: 2px; }
|
||||
|
||||
tr.selected td { background: var(--accent-soft); }
|
||||
/* the page footer with the Steward build */
|
||||
.shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.shell > main { flex: 1; width: 100%; }
|
||||
footer.site { padding: 12px 16px 16px; text-align: center; font-size: 12px; color: var(--muted); }
|
||||
footer.site a { color: var(--muted); text-decoration: underline; text-decoration-color: var(--border); text-underline-offset: 2px; }
|
||||
footer.site a:hover { color: var(--accent); text-decoration-color: currentColor; }
|
||||
|
||||
/* a request in progress */
|
||||
.spinner { display: inline-block; width: 11px; height: 11px; margin-right: 6px; vertical-align: -1px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@media (prefers-reduced-motion: reduce) { .spinner { animation: none; border-top-color: var(--border); background: var(--accent-soft); } }
|
||||
|
||||
.err-text { color: var(--err); }
|
||||
ul.plain { list-style: none; margin: 8px 0; padding: 0; }
|
||||
ul.plain li { padding: 3px 0; }
|
||||
/* Drop-down menus in the top bar (Server, the user). */
|
||||
details.menu { position: relative; }
|
||||
details.menu > summary { list-style: none; cursor: pointer; padding: 6px 10px; border-radius: var(--radius); color: var(--text); white-space: nowrap; }
|
||||
details.menu > summary::-webkit-details-marker { display: none; }
|
||||
details.menu > summary:hover, details.menu[open] > summary { background: var(--info-soft); }
|
||||
details.menu > summary.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
details.menu > summary .caret { margin-left: 4px; font-size: 0.8em; opacity: 0.7; }
|
||||
details.menu > summary .user { color: inherit; }
|
||||
details.menu .menu-list {
|
||||
position: absolute; top: calc(100% + 4px); left: 0; z-index: 20; min-width: 190px;
|
||||
display: flex; flex-direction: column; padding: 4px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
details.menu.right .menu-list { left: auto; right: 0; }
|
||||
details.menu .menu-list a, details.menu .menu-list .menu-item {
|
||||
display: block; padding: 7px 10px; border-radius: var(--radius); color: var(--text); text-align: left;
|
||||
background: none; border: 0; font: inherit; cursor: pointer; white-space: nowrap;
|
||||
}
|
||||
details.menu .menu-list a:hover, details.menu .menu-list .menu-item:hover { background: var(--info-soft); text-decoration: none; }
|
||||
details.menu .menu-list a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
|
||||
nav.main { align-items: center; }
|
||||
header.top .header-right { display: flex; align-items: center; gap: 12px; margin-left: auto; }
|
||||
@media (max-width: 1200px) {
|
||||
header.top form.search input { width: 160px; }
|
||||
header.top { gap: 10px; }
|
||||
}
|
||||
|
||||
button.chip { padding: 1px 8px; font-size: 12px; font-family: var(--mono); border-radius: 999px; background: var(--info-soft); border: 1px solid var(--border); color: var(--text); }
|
||||
button.chip:hover { background: var(--accent-soft); border-color: var(--accent); }
|
||||
|
||||
/* ---- Steward */
|
||||
.wordmark-rule .tag { fill: var(--accent); font: 700 17px system-ui, sans-serif; letter-spacing: 0.5px; }
|
||||
.login .wordmark { width: 290px; }
|
||||
.subnet-usage { width: 260px; }
|
||||
/* How full a subnet's pools are. */
|
||||
.usage { min-width: 120px; }
|
||||
.usage-bar { display: flex; height: 8px; border-radius: 4px; background: var(--info-soft); overflow: hidden; border: 1px solid var(--border); margin-top: 3px; }
|
||||
.usage-bar .seg.leased { background: var(--accent); }
|
||||
.usage-bar .seg.declined { background: var(--warn); }
|
||||
.usage-bar.full .seg.leased { background: var(--err); }
|
||||
.usage-text { font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
/* Interfaces: serving or not. */
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 6px; vertical-align: 1px; background: var(--muted); }
|
||||
.dot.ok { background: var(--ok); }
|
||||
.dot.err { background: var(--err); }
|
||||
.iface-list { display: flex; flex-direction: column; gap: 6px; }
|
||||
.iface-list .iface { display: flex; gap: 10px; flex-wrap: wrap; align-items: baseline; }
|
||||
.iface-list .iface .name { font-family: var(--mono); font-weight: 600; min-width: 8ch; }
|
||||
/* The options editor: a row per option. */
|
||||
.opt-rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.opt-row { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap; }
|
||||
.opt-row select { width: 15em; }
|
||||
.opt-row input.value { flex: 1; min-width: 14em; font-family: var(--mono); }
|
||||
.opt-row select.type { width: 8em; }
|
||||
.opt-row .help { flex-basis: 100%; color: var(--muted); font-size: 12px; margin: -2px 0 2px; }
|
||||
.opt-inherited { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
.opt-inherited code { font-family: var(--mono); }
|
||||
/* Pools editor. */
|
||||
.pool-rows { display: flex; flex-direction: column; gap: 6px; }
|
||||
.pool-row { display: flex; gap: 6px; align-items: center; }
|
||||
.pool-row input { font-family: var(--mono); width: 18em; }
|
||||
/* Activity: one line per message. */
|
||||
table.activity td { white-space: nowrap; }
|
||||
table.activity td.detail { white-space: normal; color: var(--muted); }
|
||||
.type-OFFER, .type-ACK { color: var(--ok); }
|
||||
.type-NAK, .type-DECLINE, .type-CONFLICT, .type-NO { color: var(--err); }
|
||||
.type-RELEASE { color: var(--muted); }
|
||||
td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
|
||||
@@ -0,0 +1,228 @@
|
||||
// Small DOM helpers and widgets used by every page.
|
||||
|
||||
// h creates an element. attrs may contain class, style, on<event>
|
||||
// handlers, dataset, value/checked/disabled and any other attribute.
|
||||
export function h(tag, attrs, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (k === "class") el.className = v;
|
||||
else if (k === "style" && typeof v === "object") Object.assign(el.style, v);
|
||||
else if (k.startsWith("on") && typeof v === "function") el.addEventListener(k.slice(2), v);
|
||||
else if (k === "dataset") Object.assign(el.dataset, v);
|
||||
else if (k === "value") el.value = v;
|
||||
else if (k === "checked" || k === "disabled" || k === "selected" || k === "multiple" || k === "required" || k === "readOnly") el[k] = !!v;
|
||||
else el.setAttribute(k, v === true ? "" : v);
|
||||
}
|
||||
}
|
||||
append(el, children);
|
||||
return el;
|
||||
}
|
||||
|
||||
function append(el, children) {
|
||||
for (const c of children.flat(Infinity)) {
|
||||
if (c === null || c === undefined || c === false) continue;
|
||||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(el) {
|
||||
while (el.firstChild) el.removeChild(el.firstChild);
|
||||
return el;
|
||||
}
|
||||
|
||||
export function mount(el, ...children) {
|
||||
clear(el);
|
||||
append(el, children);
|
||||
return el;
|
||||
}
|
||||
|
||||
// ---- formatting
|
||||
|
||||
export function fmtTime(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return d.toLocaleString(undefined, { year: "numeric", month: "short", day: "2-digit", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
|
||||
}
|
||||
|
||||
export function relTime(iso) {
|
||||
if (!iso) return "";
|
||||
const diff = (Date.now() - new Date(iso).getTime()) / 1000;
|
||||
const s = Math.abs(diff);
|
||||
if (s < 60) return "just now";
|
||||
let n;
|
||||
if (s < 3600) n = Math.floor(s / 60) + " min";
|
||||
else if (s < 86400) n = Math.floor(s / 3600) + " h";
|
||||
else if (s < 86400 * 30) n = plural(Math.floor(s / 86400), "day");
|
||||
else return fmtTime(iso);
|
||||
return diff > 0 ? n + " ago" : "in " + n; // a future time (an expiry)
|
||||
}
|
||||
|
||||
export function time(iso) {
|
||||
return h("span", { title: fmtTime(iso), class: "nowrap" }, relTime(iso));
|
||||
}
|
||||
|
||||
export function plural(n, word, words) {
|
||||
return n + " " + (n === 1 ? word : (words || word + "s"));
|
||||
}
|
||||
|
||||
export function badge(text, kind) {
|
||||
return h("span", { class: "badge" + (kind ? " " + kind : "") }, text);
|
||||
}
|
||||
|
||||
// ---- toasts
|
||||
|
||||
export function toast(message, kind = "ok", ms = 3500) {
|
||||
const box = document.getElementById("toasts");
|
||||
const t = h("div", { class: "toast " + kind, onclick: () => t.remove() }, message);
|
||||
// A repeated message replaces the earlier one instead of stacking.
|
||||
for (const old of [...box.children]) if (old.textContent === t.textContent) old.remove();
|
||||
box.append(t);
|
||||
while (box.children.length > 3) box.firstChild.remove();
|
||||
setTimeout(() => t.remove(), ms);
|
||||
}
|
||||
|
||||
// ---- modal dialogs
|
||||
|
||||
// modal shows a dialog. buttons: [{label, kind, value, onclick}].
|
||||
// onclick may return false to keep the dialog open, or a promise.
|
||||
export function modal(title, body, buttons = [{ label: "Close", value: null }], opts = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const back = h("div", { class: "modal-back" });
|
||||
const close = (v) => { back.remove(); document.removeEventListener("keydown", onKey); resolve(v); };
|
||||
const onKey = (e) => { if (e.key === "Escape") close(null); };
|
||||
document.addEventListener("keydown", onKey);
|
||||
const btns = buttons.map((b) =>
|
||||
h("button", {
|
||||
class: b.kind || "", type: "button",
|
||||
onclick: async (e) => {
|
||||
if (b.onclick) {
|
||||
e.target.disabled = true;
|
||||
try {
|
||||
const r = await b.onclick();
|
||||
if (r === false) return;
|
||||
} finally { e.target.disabled = false; }
|
||||
}
|
||||
close(b.value === undefined ? b.label : b.value);
|
||||
},
|
||||
}, b.label));
|
||||
const dialog = h("div", { class: "modal" + (opts.wide ? " wide" : ""), role: "dialog" }, h("h2", null, title), body, h("div", { class: "buttons" }, btns));
|
||||
back.append(dialog);
|
||||
back.addEventListener("mousedown", (e) => { if (e.target === back && !opts.sticky) close(null); });
|
||||
document.body.append(back);
|
||||
const first = dialog.querySelector("input, select, textarea");
|
||||
if (first) first.focus();
|
||||
});
|
||||
}
|
||||
|
||||
export async function confirmDialog(title, message, okLabel = "OK", kind = "primary") {
|
||||
const r = await modal(title, h("p", null, message), [
|
||||
{ label: "Cancel", value: false },
|
||||
{ label: okLabel, kind, value: true },
|
||||
]);
|
||||
return r === true;
|
||||
}
|
||||
|
||||
// ---- forms
|
||||
|
||||
// field returns a label + control pair for .form-grid layouts.
|
||||
export function field(label, control, help) {
|
||||
// The label points at the control itself, also when it is wrapped.
|
||||
const target = control.matches && !control.matches("input, select, textarea") ? control.querySelector("input, select, textarea") || control : control;
|
||||
const id = target.id || ("f" + Math.random().toString(36).slice(2, 9));
|
||||
target.id = id;
|
||||
return [h("label", { for: id }, label), control, help ? h("div", { class: "help" }, help) : null];
|
||||
}
|
||||
|
||||
export function input(attrs = {}) {
|
||||
return h("input", { type: "text", autocomplete: "off", spellcheck: "false", ...attrs });
|
||||
}
|
||||
|
||||
export function select(options, value, attrs = {}) {
|
||||
const s = h("select", attrs);
|
||||
for (const o of options) {
|
||||
const [v, label] = Array.isArray(o) ? o : [o, o];
|
||||
s.append(h("option", { value: v, selected: String(v) === String(value) }, label));
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
export function textarea(value, attrs = {}) {
|
||||
return h("textarea", { spellcheck: "false", ...attrs, value: value || "" });
|
||||
}
|
||||
|
||||
export function checkbox(label, checked, attrs = {}) {
|
||||
const c = h("input", { type: "checkbox", checked, ...attrs });
|
||||
const l = h("label", { class: "check" }, c, label);
|
||||
l.input = c;
|
||||
return l;
|
||||
}
|
||||
|
||||
// lines splits a textarea's value into non-empty trimmed lines.
|
||||
export function lines(text) {
|
||||
return text.split(/\r?\n/).map((s) => s.trim()).filter((s) => s !== "");
|
||||
}
|
||||
|
||||
// ---- tables
|
||||
|
||||
// table renders rows with columns [{label, render(row) => node|string, class}].
|
||||
export function table(columns, rows, opts = {}) {
|
||||
if (!rows.length) return h("div", { class: "empty" }, opts.empty || "Nothing here yet.");
|
||||
const thead = h("thead", null, h("tr", null, columns.map((c) => h("th", { class: c.class || "" }, c.label))));
|
||||
const tbody = h("tbody");
|
||||
rows.forEach((row, i) => {
|
||||
const tr = h("tr", { class: opts.rowClass ? opts.rowClass(row, i) : "" });
|
||||
for (const c of columns) {
|
||||
const v = c.render(row, i);
|
||||
tr.append(h("td", { class: c.tdClass ? (typeof c.tdClass === "function" ? c.tdClass(row, i) : c.tdClass) : "" }, v));
|
||||
}
|
||||
tbody.append(tr);
|
||||
});
|
||||
return h("div", { class: "table-wrap" }, h("table", null, thead, tbody));
|
||||
}
|
||||
|
||||
// ---- problems and errors
|
||||
|
||||
export function problemsList(problems) {
|
||||
if (!problems || !problems.length) return null;
|
||||
return h("ul", { class: "problems" }, problems.map((p) =>
|
||||
h("li", { class: p.severity }, h("strong", null, p.severity + ": "), p.message, p.object ? h("span", { class: "where" }, p.object) : null)));
|
||||
}
|
||||
|
||||
// errorBox shows an API error with its problems.
|
||||
export function errorBox(err) {
|
||||
if (!err) return null;
|
||||
const d = err.details || {};
|
||||
let problems = d.problems || [];
|
||||
// Don't repeat a single problem that is already the headline.
|
||||
if (problems.length === 1 && err.message.includes(problems[0].message)) problems = [];
|
||||
return h("div", { class: "msg err" }, h("div", null, err.message), problemsList(problems));
|
||||
}
|
||||
|
||||
export function copyBox(value) {
|
||||
const inp = input({ value, readOnly: true });
|
||||
const btn = h("button", { type: "button", class: "small", onclick: async () => {
|
||||
try { await navigator.clipboard.writeText(value); toast("Copied"); } catch { inp.select(); document.execCommand("copy"); toast("Copied"); }
|
||||
} }, "Copy");
|
||||
return h("div", { class: "copy-box" }, inp, btn);
|
||||
}
|
||||
|
||||
// debounce delays fn until calls stop for ms.
|
||||
export function debounce(fn, ms = 250) {
|
||||
let t;
|
||||
return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); };
|
||||
}
|
||||
|
||||
// backLink is the "← Zones" link above a page's heading, back to the
|
||||
// page it was opened from.
|
||||
export function backLink(label, href) {
|
||||
return h("nav", { class: "back-link", "aria-label": "Back" }, h("a", { href }, "← ", label));
|
||||
}
|
||||
|
||||
// safeBack returns a "back" address taken from the URL if it is one of
|
||||
// the UI's own (#/...), else null: it ends up in an href.
|
||||
export function safeBack(v) {
|
||||
return v && /^#\/[A-Za-z0-9\/?=&%._~+-]*$/.test(v) ? v : null;
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
// Widgets shared by several pages: usage bars, the options and pools
|
||||
// editors, the subnet form, durations.
|
||||
import { h, mount, input, select, field, checkbox } from "./ui.js";
|
||||
import { catalog, get } from "./api.js";
|
||||
|
||||
// ---- durations
|
||||
|
||||
// fmtSeconds writes seconds the short way: 43200 is "12h", 5400 "1h30m".
|
||||
export function fmtSeconds(n) {
|
||||
if (!n || n <= 0) return "";
|
||||
const parts = [];
|
||||
for (const [s, u] of [[86400, "d"], [3600, "h"], [60, "m"], [1, "s"]]) {
|
||||
if (n >= s) { parts.push(Math.floor(n / s) + u); n %= s; }
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// parseSeconds reads "12h", "30m", "1d12h" or plain seconds; null when
|
||||
// it can't.
|
||||
export function parseSeconds(s) {
|
||||
s = String(s || "").trim();
|
||||
if (s === "") return 0;
|
||||
if (/^\d+$/.test(s)) return parseInt(s, 10);
|
||||
const re = /(\d+)\s*([dhms])/g;
|
||||
let total = 0, m, used = "";
|
||||
while ((m = re.exec(s))) {
|
||||
total += parseInt(m[1], 10) * { d: 86400, h: 3600, m: 60, s: 1 }[m[2]];
|
||||
used += m[0];
|
||||
}
|
||||
return used.replace(/\s/g, "") === s.replace(/\s/g, "") && total > 0 ? total : null;
|
||||
}
|
||||
|
||||
// ---- usage
|
||||
|
||||
// usageBar shows how much of a subnet's pools is leased.
|
||||
export function usageBar(u) {
|
||||
if (!u || !u.pool_size) return h("span", { class: "muted small" }, u && u.reservations ? "reservations only" : "no pools");
|
||||
const pct = (n) => Math.min(100, (n * 100) / u.pool_size);
|
||||
const full = u.leased + u.declined >= u.pool_size;
|
||||
const leased = h("div", { class: "seg leased" }), declined = h("div", { class: "seg declined" });
|
||||
leased.style.width = pct(u.leased) + "%";
|
||||
declined.style.width = pct(u.declined) + "%";
|
||||
return h("div", { class: "usage", title: u.leased + " leased, " + u.declined + " held back, " + (u.pool_size - u.leased - u.declined) + " free of " + u.pool_size },
|
||||
h("div", { class: "usage-text" }, u.leased + " / " + u.pool_size + " (" + Math.round(pct(u.leased)) + "%)", u.declined ? " · " + u.declined + " held back" : ""),
|
||||
h("div", { class: "usage-bar" + (full ? " full" : "") }, leased, declined));
|
||||
}
|
||||
|
||||
// ---- options
|
||||
|
||||
// optionsEditor edits a list of options. inherited are the options this
|
||||
// level gets from above (shown, and overridden by an option of the same
|
||||
// code here). Its value() is the list for the API ({option, value, type}).
|
||||
export async function optionsEditor(options, inherited = []) {
|
||||
const cat = await catalog();
|
||||
const rows = h("div", { class: "opt-rows" });
|
||||
const box = h("div", null, rows);
|
||||
const byCode = new Map(cat.options.map((o) => [o.code, o]));
|
||||
const choices = [
|
||||
["", "choose an option…"],
|
||||
...cat.options.filter((o) => o.common).map((o) => [String(o.code), o.name + " (" + o.code + ")"]),
|
||||
...cat.options.filter((o) => !o.common).map((o) => [String(o.code), o.name + " (" + o.code + ")"]),
|
||||
["custom", "another option, by number…"],
|
||||
];
|
||||
const list = [];
|
||||
const addRow = (opt) => {
|
||||
const known = opt && byCode.has(opt.code);
|
||||
const sel = select(choices, opt ? (known ? String(opt.code) : "custom") : "");
|
||||
const code = input({ type: "number", min: 1, max: 254, placeholder: "code", value: opt && !known ? opt.code : "" });
|
||||
code.style.width = "6em";
|
||||
const typ = select(cat.types, (opt && opt.type) || "hex", { class: "type" });
|
||||
const val = input({ class: "value", value: opt ? opt.value : "", placeholder: "value" });
|
||||
const help = h("div", { class: "help" });
|
||||
const row = h("div", { class: "opt-row" });
|
||||
const sync = () => {
|
||||
const custom = sel.value === "custom";
|
||||
code.classList.toggle("hidden", !custom);
|
||||
typ.classList.toggle("hidden", !custom);
|
||||
const d = byCode.get(parseInt(sel.value, 10));
|
||||
mount(help, d ? d.type + ": " + d.help : custom ? "Steward doesn't know this option: give its number and how its value is written" : "");
|
||||
if (d) val.placeholder = examples[d.type] || "value";
|
||||
};
|
||||
sel.addEventListener("change", sync);
|
||||
const entry = {
|
||||
row,
|
||||
value: () => {
|
||||
if (sel.value === "") return null;
|
||||
if (sel.value === "custom") return { option: String(code.value), value: val.value, type: typ.value };
|
||||
return { option: sel.value, value: val.value };
|
||||
},
|
||||
};
|
||||
const del = h("button", { type: "button", class: "small", title: "Remove this option", onclick: () => { row.remove(); list.splice(list.indexOf(entry), 1); } }, "✕");
|
||||
mount(row, sel, code, typ, val, del, help);
|
||||
sync();
|
||||
list.push(entry);
|
||||
rows.append(row);
|
||||
};
|
||||
for (const o of options || []) addRow(o);
|
||||
box.append(h("button", { type: "button", class: "small", onclick: () => addRow(null) }, "+ Add option"));
|
||||
const inh = (inherited || []).filter((o) => !(options || []).some((x) => x.code === o.code));
|
||||
if (inh.length) {
|
||||
box.append(h("div", { class: "opt-inherited" }, "Also sent, from above: ",
|
||||
inh.map((o, i) => [i ? "; " : "", h("code", null, o.name + "=" + o.value)]),
|
||||
". An option set here wins."));
|
||||
}
|
||||
box.value = () => list.map((e) => e.value()).filter(Boolean);
|
||||
return box;
|
||||
}
|
||||
|
||||
const examples = {
|
||||
ip: "10.0.10.1", ips: "10.0.10.1, 10.0.10.2", string: "text", uint8: "0-255", uint16: "1500", uint32: "86400",
|
||||
int32: "-3600", bool: "true or false", domains: "lan, example.com", routes: "10.20.0.0/16 via 10.0.10.1", hex: "01:02:0a",
|
||||
};
|
||||
|
||||
// ---- pools
|
||||
|
||||
// poolsEditor edits a subnet's pools; value() is ["first-last", ...].
|
||||
export function poolsEditor(pools, placeholder) {
|
||||
const rows = h("div", { class: "pool-rows" });
|
||||
const box = h("div", null, rows);
|
||||
const list = [];
|
||||
const addRow = (v) => {
|
||||
const inp = input({ value: v || "", placeholder: placeholder || "10.0.10.100-10.0.10.199" });
|
||||
const row = h("div", { class: "pool-row" }, inp,
|
||||
h("button", { type: "button", class: "small", title: "Remove this pool", onclick: () => { row.remove(); list.splice(list.indexOf(inp), 1); } }, "✕"));
|
||||
list.push(inp);
|
||||
rows.append(row);
|
||||
return inp;
|
||||
};
|
||||
for (const p of pools || []) addRow(p.start + "-" + p.end);
|
||||
box.append(h("button", { type: "button", class: "small", onclick: () => addRow("").focus() }, "+ Add pool"));
|
||||
box.value = () => list.map((i) => i.value.trim()).filter((s) => s !== "");
|
||||
box.setFirst = (v) => { if (!list.length) addRow(v); else if (!list[0].value) list[0].value = v; };
|
||||
return box;
|
||||
}
|
||||
|
||||
// ---- the subnet form
|
||||
|
||||
// v4 helpers for suggestions.
|
||||
function ip2n(s) { const p = s.split(".").map(Number); return ((p[0] << 24) >>> 0) + (p[1] << 16) + (p[2] << 8) + p[3]; }
|
||||
function n2ip(n) { return [n >>> 24, (n >>> 16) & 255, (n >>> 8) & 255, n & 255].join("."); }
|
||||
|
||||
// suggestPool proposes a pool for a prefix: in a /24, .100-.199; else the
|
||||
// second half, without the broadcast address.
|
||||
export function suggestPool(prefix) {
|
||||
const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(prefix || "");
|
||||
if (!m) return "";
|
||||
const bits = parseInt(m[2], 10);
|
||||
if (bits < 8 || bits > 29) return "";
|
||||
const size = 2 ** (32 - bits);
|
||||
const net = (ip2n(m[1]) & ~(size - 1)) >>> 0;
|
||||
if (bits === 24) return n2ip(net + 100) + "-" + n2ip(net + 199);
|
||||
return n2ip(net + size / 2) + "-" + n2ip(net + size - 2);
|
||||
}
|
||||
|
||||
// subnetForm builds the fields of a subnet; values() returns the API
|
||||
// input. sn is the subnet to edit (null for a new one).
|
||||
export async function subnetForm(sn, settings) {
|
||||
const ifaces = await get("/interfaces");
|
||||
const name = input({ value: sn ? sn.name : "", placeholder: "office, iot, guests…" });
|
||||
const ifaceNames = ifaces.map((i) => i.name);
|
||||
const ifaceSel = select([["", "choose…"], ...ifaces.map((i) => [i.name, i.name + (i.addresses.length ? " (" + i.addresses.join(", ") + ")" : " (no IPv4 address)")]), ["other", "another name…"]],
|
||||
sn ? (ifaceNames.includes(sn.interface) ? sn.interface : "other") : "");
|
||||
const ifaceOther = input({ value: sn && !ifaceNames.includes(sn.interface) ? sn.interface : "", placeholder: "eth0.30 (one that comes up later)" });
|
||||
const prefix = input({ value: sn ? sn.prefix : "", placeholder: "10.0.10.0/24", class: "mono" });
|
||||
const pools = poolsEditor(sn ? sn.pools : []);
|
||||
const lease = input({ value: sn ? fmtSeconds(sn.lease_time) : "", placeholder: "the default: " + fmtSeconds(settings.lease_time || 43200) });
|
||||
const opts = await optionsEditor(sn ? sn.options : [], settings.options);
|
||||
const comment = input({ value: sn ? sn.comment : "" });
|
||||
const ifaceHint = h("div", { class: "help" });
|
||||
const ifaceValue = () => (ifaceSel.value === "other" ? ifaceOther.value.trim() : ifaceSel.value);
|
||||
const syncIface = () => {
|
||||
ifaceOther.classList.toggle("hidden", ifaceSel.value !== "other");
|
||||
const i = ifaces.find((x) => x.name === ifaceSel.value);
|
||||
mount(ifaceHint, "Only clients on this interface get the subnet's addresses.",
|
||||
i && i.subnets.length && (!sn || !i.subnets.includes(sn.id)) ? " It already serves " + i.subnets.length + (i.subnets.length === 1 ? " subnet" : " subnets") + "; clients get addresses from each in turn." : "");
|
||||
// A new subnet: suggest the interface's network and a pool.
|
||||
if (!sn && i && i.addresses.length && !prefix.value) {
|
||||
const m = /^(\d+\.\d+\.\d+\.\d+)\/(\d+)$/.exec(i.addresses[0]);
|
||||
if (m) {
|
||||
const size = 2 ** (32 - parseInt(m[2], 10));
|
||||
prefix.value = n2ip((ip2n(m[1]) & ~(size - 1)) >>> 0) + "/" + m[2];
|
||||
pools.setFirst(suggestPool(prefix.value));
|
||||
}
|
||||
}
|
||||
};
|
||||
ifaceSel.addEventListener("change", syncIface);
|
||||
prefix.addEventListener("change", () => { if (!sn) pools.setFirst(suggestPool(prefix.value.trim())); });
|
||||
syncIface();
|
||||
const disabled = checkbox("Disabled: keep the subnet and its leases, but don't answer its clients", sn ? sn.disabled : false);
|
||||
const grid = h("div", { class: "form-grid" },
|
||||
field("Name", name),
|
||||
field("Interface", h("div", null, ifaceSel, " ", ifaceOther)), ifaceHint,
|
||||
field("Network", prefix, "The subnet's prefix; the mask clients get comes from it."),
|
||||
field("Pools", pools, "Ranges handed out to any client: FIRST-LAST, or a prefix. Reserved addresses inside a pool are never handed out to others."),
|
||||
field("Lease time", lease, "12h, 1d, 30m; empty for the default."),
|
||||
field("Options", opts),
|
||||
field("Comment", comment),
|
||||
sn ? [h("label"), disabled] : null);
|
||||
grid.values = () => {
|
||||
const lt = parseSeconds(lease.value);
|
||||
if (lt === null) throw new Error("Lease time: write it as 12h, 1d or 30m");
|
||||
const v = { name: name.value.trim(), interface: ifaceValue(), prefix: prefix.value.trim(), pools: pools.value(), lease_time: lt, options: opts.value(), comment: comment.value };
|
||||
if (sn) v.disabled = disabled.input.checked;
|
||||
return v;
|
||||
};
|
||||
grid.focusFirst = () => (sn ? name : ifaceSel).focus();
|
||||
return grid;
|
||||
}
|
||||
|
||||
// subnetLabel names a subnet: "office (10.0.10.0/24)".
|
||||
export function subnetLabel(s) {
|
||||
return s.name ? s.name + " (" + s.prefix + ")" : s.prefix;
|
||||
}
|
||||
|
||||
// stateBadge shows a lease's state.
|
||||
export function stateText(st) {
|
||||
return { active: "active", expired: "expired", released: "released", declined: "held back" }[st] || st;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Steward</title>
|
||||
<link rel="icon" href="/assets/logo.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/assets/style.css">
|
||||
<script type="module" src="/assets/app.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<noscript><p>Steward's web interface needs JavaScript. The REST API and the <code>steward</code> CLI work without it.</p></noscript>
|
||||
<div id="app"></div>
|
||||
<div id="toasts"></div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user