- stewardd init asks for the first administrator's name on a terminal.
- Tokens can be rotated (steward token rotate, POST /tokens/{id}/rotate,
Rotate in the web UI), with a grace period for the old secret, as in
Jarl. Schema migration 2 adds the token columns.
- Read-only users see the settings and a subnet's settings locked,
instead of editable fields without a Save button.
- The New token dialog only offers roles within what the user may do.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
237 lines
8.8 KiB
JavaScript
237 lines
8.8 KiB
JavaScript
// 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;
|
|
}
|
|
|
|
// readOnly locks a form for someone who may look but not change: every
|
|
// control is disabled, and the editors' add and remove buttons hidden.
|
|
export function readOnly(el) {
|
|
for (const c of el.querySelectorAll("input, select, textarea")) c.disabled = true;
|
|
for (const b of el.querySelectorAll("button")) b.classList.add("hidden");
|
|
return el;
|
|
}
|