// Activity: the latest DHCP messages, refreshed every few seconds. import { h, mount, input, select, debounce, toast, modal, errorBox } from "../ui.js"; import { get, change, can } from "../api.js"; import { onLeave } from "../app.js"; import { subnetLabel } from "../widgets.js"; // allowClient puts a refused client on its subnet's allow list. async function allowClient(e) { try { const s = await get("/subnets/" + e.subnet_id); if ((s.allow || []).some((m) => m.match === e.mac)) { toast(e.mac + " is already allowed on " + subnetLabel(s)); return; } const allow = [...(s.allow || []), { match: e.mac, comment: "" }]; await change("PATCH", "/subnets/" + e.subnet_id, { allow }); toast(e.mac + " is allowed on " + subnetLabel(s) + "; it gets an address when it next asks"); } catch (err) { modal("Not allowed", errorBox(err)); } } // details shows a message's header fields and options in words. function details(e) { if (!e) return h("div", { class: "muted small" }, "Loading…"); if (e.error) return h("div", { class: "msg err" }, e.error); const who = e.direction === "in" ? "Received from the client" : "Sent by Steward"; return h("div", { class: "event-detail" }, h("div", { class: "muted small" }, who), h("table", { class: "kv" }, h("tbody", null, e.fields.map((f) => h("tr", null, h("th", null, f.name), h("td", { class: "mono" }, f.value))), (e.options || []).map((o) => h("tr", null, h("th", null, o.name, h("span", { class: "muted small" }, " (" + o.code + ")")), h("td", { class: "mono" }, o.value)))))); } export async function render(el, params, query) { const [ifaces, subnets] = await Promise.all([get("/interfaces"), get("/subnets")]); const subnetSel = select([["", "all subnets"], ...subnets.map((s) => [String(s.id), subnetLabel(s)])], query.get("subnet") || ""); 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"); // Messages opened to show their options, kept across refreshes. const open = new Map(); // id -> the event with its details, or null while loading 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(), subnet: subnetSel.value, 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/, ""); const row = h("tr", null, h("td", null, e.direction ? h("button", { class: "small", title: open.has(e.id) ? "Hide the options" : "Show every option of this message", onclick: () => toggle(e.id) }, open.has(e.id) ? "▾" : "▸") : ""), 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 nowrap" }, e.mac ? [ h("a", { href: "#/leases?q=" + encodeURIComponent(e.mac), title: "This client's leases" }, e.mac), " ", mac.value.trim() ? null : h("button", { class: "small reveal", title: "Show only this client's messages", onclick: () => { mac.value = e.mac; draw(); } }, "Only this")] : ""), h("td", { class: "mono" }, e.ip || ""), h("td", null, e.hostname || ""), h("td", { class: "detail" }, e.detail || "", e.type === "DENIED" && e.subnet_id && e.mac && (e.detail || "").includes("allow list") && can("config", true) ? [" ", h("button", { class: "small", title: "Put this client on the subnet's allow list", onclick: () => allowClient(e) }, "Allow")] : null)); return open.has(e.id) ? [row, h("tr", { class: "event-details" }, h("td"), h("td", { colspan: 7 }, details(open.get(e.id))))] : row; })))) : h("div", { class: "empty" }, "No DHCP messages yet" + (mac.value || subnetSel.value || iface.value ? " that match." : "."))); }; const toggle = async (id) => { if (open.has(id)) { open.delete(id); draw(); return; } open.set(id, null); draw(); try { open.set(id, await get("/events/" + id)); } catch (e) { open.set(id, { error: e.message }); } draw(); }; mac.addEventListener("input", debounce(draw, 300)); subnetSel.addEventListener("change", draw); 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. A MAC address leads to that client's leases; Only this follows just that client."))), h("div", { class: "filter-row" }, mac, subnetSel, iface, pause), h("div", { class: "panel" }, out)); await draw(); const t = setInterval(() => { if (live) draw().catch(() => {}); }, 3000); onLeave(() => clearInterval(t)); }