UI: activity client links, online badges, ended-leases toggle, All activity

- Activity: a client's MAC address opens its leases; "Only this" filters
  the list to that client.
- Reservations: Online is a green online / grey offline badge.
- A subnet's leases: "Include ended leases" is a checkbox.
- Dashboard: "All activity" is a button beside the heading.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
abrown38andClaude Opus 5.5 committed 2026-10-14 13:52:36 +01:00
1 parent 8a69038ab5
commit d0ee2c8844
5 files changed
+16 -10

No files matched your search

+4 -2
View File
@@ -20,7 +20,9 @@ export async function render(el, params, query) {
h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })), h("td", { class: "mono small" }, new Date(e.time).toLocaleTimeString(undefined, { hourCycle: "h23" })),
h("td", { class: "mono" }, e.interface), h("td", { class: "mono" }, e.interface),
h("td", null, h("strong", { class: "type-" + t.split(" ")[0] }, t)), 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 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", { class: "mono" }, e.ip || ""),
h("td", null, e.hostname || ""), h("td", null, e.hostname || ""),
h("td", { class: "detail" }, e.detail || "")); h("td", { class: "detail" }, e.detail || ""));
@@ -30,7 +32,7 @@ export async function render(el, params, query) {
iface.addEventListener("change", draw); iface.addEventListener("change", draw);
mount(el, mount(el,
h("div", { class: "page-head" }, h("div", { class: "grow" }, h("h1", null, "Activity"), 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: "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, iface, pause), h("div", { class: "filter-row" }, mac, iface, pause),
h("div", { class: "panel" }, out)); h("div", { class: "panel" }, out));
await draw(); await draw();
@@ -50,7 +50,7 @@ export async function render(el) {
problemsList(st.problems), problemsList(st.problems),
st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null, st.ping_problem ? h("div", { class: "msg warn" }, st.ping_problem) : null) : null,
events.length ? h("div", { class: "panel" }, events.length ? h("div", { class: "panel" },
h("h2", null, "Latest DHCP messages ", h("a", { href: "#/activity", class: "small" }, "all")), h("div", { class: "panel-head" }, h("h2", null, "Latest DHCP messages"), h("a", { class: "button small", href: "#/activity" }, "All activity")),
table([ table([
{ label: "When", render: (e) => time(e.time) }, { label: "When", render: (e) => time(e.time) },
{ label: "Interface", render: (e) => e.interface }, { label: "Interface", render: (e) => e.interface },
@@ -1,5 +1,5 @@
// Reservations: fixed addresses for clients. // Reservations: fixed addresses for clients.
import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce } from "../ui.js"; import { h, mount, table, input, modal, errorBox, field, confirmDialog, select, debounce, badge } from "../ui.js";
import { get, change, can } from "../api.js"; import { get, change, can } from "../api.js";
import { optionsEditor, subnetLabel } from "../widgets.js"; import { optionsEditor, subnetLabel } from "../widgets.js";
@@ -11,7 +11,9 @@ export function reservationsTable(rs, done, opts = {}) {
{ label: "Client", render: (r) => h("span", { class: "mono" }, r.client_id ? "id " + r.client_id : r.mac) }, { 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" }, "–") }, { 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) : "–" }, 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: "Online", render: (r) => r.lease
? h("span", { title: "lease until " + new Date(r.lease.end).toLocaleString() }, badge("online", "ok"), h("span", { class: "muted small" }, " on " + r.lease.interface))
: badge("offline") },
{ label: "Options", render: (r) => r.options.length ? h("span", { class: "mono small" }, r.options.map((o) => o.name + "=" + o.value).join("; ")) : "" }, { 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 }, { label: "Comment", render: (r) => r.comment },
write ? { label: "", tdClass: "actions-cell", render: (r) => [ write ? { label: "", tdClass: "actions-cell", render: (r) => [
+5 -5
View File
@@ -1,5 +1,5 @@
// A subnet's page: its leases, its reservations and its settings. // A subnet's page: its leases, its reservations and its settings.
import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly } from "../ui.js"; import { h, mount, badge, errorBox, confirmDialog, modal, input, debounce, readOnly, checkbox } from "../ui.js";
import { get, change, can } from "../api.js"; import { get, change, can } from "../api.js";
import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js"; import { usageBar, subnetForm, fmtSeconds, subnetLabel } from "../widgets.js";
import { leasesTable } from "./leases.js"; import { leasesTable } from "./leases.js";
@@ -32,12 +32,12 @@ export async function render(el, params) {
async function leasesTab(el, s) { async function leasesTab(el, s) {
const search = input({ type: "search", placeholder: "address, MAC or host name" }); const search = input({ type: "search", placeholder: "address, MAC or host name" });
const out = h("div"); const out = h("div");
let all = false; const toggle = checkbox("Include ended leases (expired and released)", 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"); toggle.input.addEventListener("change", () => draw());
const draw = async () => { const draw = async () => {
let ls = await get("/leases", { subnet: s.id, q: search.value.trim() }); let ls = await get("/leases", { subnet: s.id, q: search.value.trim() });
if (!all) ls = ls.filter((l) => l.state === "active" || l.state === "declined"); if (!toggle.input.checked) ls = ls.filter((l) => l.state === "active" || l.state === "declined");
mount(out, leasesTable(ls, rerender, { subnet: true, empty: all ? "No leases." : "No current leases." })); mount(out, leasesTable(ls, rerender, { subnet: true, empty: toggle.input.checked ? "No leases." : "No current leases." }));
}; };
search.addEventListener("input", debounce(draw, 300)); search.addEventListener("input", debounce(draw, 300));
mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out)); mount(el, h("div", { class: "filter-row" }, search, toggle), h("div", { class: "panel" }, out));
+2
View File
@@ -353,3 +353,5 @@ table.activity td.detail { white-space: normal; color: var(--muted); }
td.mono, span.mono { font-family: var(--mono); font-size: 13px; } td.mono, span.mono { font-family: var(--mono); font-size: 13px; }
.mt { margin-top: 10px; } .mt { margin-top: 10px; }
input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; } input:disabled, select:disabled, textarea:disabled { background: var(--info-soft); color: var(--muted); cursor: not-allowed; opacity: 1; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 8px; }
.panel-head h2 { margin: 0; }