// Panel progressive enhancement. Everything here is optional convenience: the // pages are fully usable with JavaScript disabled or blocked, and nothing is // sent to the server from this file. (function () { "use strict"; // --- Copy buttons --------------------------------------------------- // copy_field joins a read-only input or textarea to a Copy button marked // data-copy; the two sit together in one .has-addons field, and the value is // read from the field itself, so it can never drift from what is shown. Where // the clipboard API is absent (plain HTTP outside localhost) the value is // selected instead, ready for the operator's own Ctrl+C. The button's icon // stays; only its word changes for a moment. W3C Clipboard API, § Navigator // interface: navigator.clipboard is [SecureContext]. document.addEventListener("click", function (ev) { var button = ev.target.closest("button[data-copy]"); if (!button) { return; } var group = button.closest(".has-addons"); var field = group && group.querySelector("input, textarea"); if (!field) { return; } if (!navigator.clipboard) { field.focus(); field.select(); return; } navigator.clipboard.writeText(field.value).then(function () { var label = button.lastChild; if (!label || label.nodeType !== Node.TEXT_NODE) { return; } var original = label.nodeValue; label.nodeValue = "Copied"; setTimeout(function () { label.nodeValue = original; }, 1500); }, function () { field.focus(); field.select(); }); }); // --- Fill buttons ----------------------------------------------------- // A type="button" with data-fill="" and data-fill-value="" // puts the text in that input and tells the page it changed. It only fills: // saving stays the form's own button. Without scripts the button does nothing // and the field can be typed in. document.addEventListener("click", function (ev) { var button = ev.target.closest("button[data-fill]"); if (!button) { return; } var input = document.getElementById(button.dataset.fill); if (!input) { return; } input.value = button.dataset.fillValue || ""; input.dispatchEvent(new Event("input", { bubbles: true })); input.dispatchEvent(new Event("change", { bubbles: true })); input.focus(); }); // --- Secrets shown once ------------------------------------------------ // A password shown once is in a field marked data-secret. Cache-Control: // no-store keeps the page out of HTTP caches, but a browser may still keep // the page in its back/forward cache and show it again on Back (RFC 9111 // § 6: a history mechanism "might honor" no-store). Emptying the field when // the page is left means a restored page shows nothing to copy. HTML Living // Standard, § 7.5.9 "Unloading documents", "unload a Document", step 10.2: // pagehide is fired whether or not the document is kept for later history // traversal (its salvageable state, the event's persisted). window.addEventListener("pagehide", function () { var secrets = document.querySelectorAll("[data-secret]"); // defaultValue too: it is the input's value attribute and the // textarea's text, which the kept page still holds after .value is // emptied (HTML, "The input element": defaultValue reflects the value // content attribute; "The textarea element": its setter replaces the // child text). for (var i = 0; i < secrets.length; i++) { secrets[i].value = ""; secrets[i].defaultValue = ""; } }); // --- Confirmation on destructive forms -------------------------------- // Forms that delete something or invalidate a working credential carry a // data-confirm message. The prompt lives here rather than in an inline // onsubmit attribute because the panel's Content-Security-Policy allows no // inline script. The listener is delegated from the document, // so it also covers markup swapped in by HTMX. With JavaScript disabled the // form submits without asking — exactly as the inline handler behaved. // HTML Living Standard, form-control-infrastructure.html#form-submission-algorithm: // submit is fired with bubbles and cancelable true, and a cancelled one // ends the submission. document.addEventListener("submit", function (ev) { var form = ev.target.closest("form[data-confirm]"); if (form && !window.confirm(form.dataset.confirm)) { ev.preventDefault(); } }); // --- Conditional field visibility ------------------------------------ // Several forms hide a block until a select, checkbox or file input says it // applies. One rule table drives them all so the five near-identical helpers // do not drift. var showWhenRules = [ { match: "select[data-list-mode]", target: "[data-addresses]", visible: function (el) { return el.value === el.dataset.listMode; } }, { match: "select[data-custom-mode]", target: "[data-custom-address]", visible: function (el) { return el.value === el.dataset.customMode; } }, { match: "select[data-ratelimit-mode]", target: "[data-manual-fields]", visible: function (el) { return el.value === "manual"; } }, { match: "select[data-ratelimit-mode]", target: "[data-auto-fields]", visible: function (el) { return el.value === "auto"; } }, { match: "select[data-global-role]", target: "[data-domain-pick]", visible: function (el) { return el.value !== el.dataset.globalRole; } }, { match: "input[data-encrypt-toggle]", target: "[data-encrypt-fields]", visible: function (el) { return el.checked; }, clearWhenHidden: true }, { match: "input[data-import-file]", target: "[data-import-password-fields]", visible: function (el) { var name = (el.files && el.files[0] && el.files[0].name || "").toLowerCase(); return name !== "" && !/\.json$/.test(name); }, clearWhenHidden: true } ]; function syncShowWhen(control) { var form = control.closest("form"); if (!form) { return; } // A control can drive more than one block (the rate-limit select shows the // manual fields or the auto ones), so every rule that matches is applied. for (var i = 0; i < showWhenRules.length; i++) { var rule = showWhenRules[i]; if (!control.matches(rule.match)) { continue; } // A block can be several elements (the user form's two lists of domains). var show = rule.visible(control); form.querySelectorAll(rule.target).forEach(function (target) { // hidden alone does not hide an element whose own stylesheet gives it a // display (Bulma's .box), so Bulma's is-hidden helper goes with it. // HTML Living Standard, rendering.html#hidden-elements: [hidden] is // display: none in the user agent's sheet, which an author rule such // as Bulma 1.0.4's .box { display: block } overrides; .is-hidden is // display: none !important. target.hidden = !show; target.classList.toggle("is-hidden", !show); if (!show && rule.clearWhenHidden) { target.querySelectorAll("input").forEach(function (input) { input.value = ""; }); } }); } } function initShowWhen(root) { showWhenRules.forEach(function (rule) { root.querySelectorAll(rule.match).forEach(syncShowWhen); }); } // One listener for the document rather than one per control: a form that // arrives in an htmx swap is covered without being wired up again. document.addEventListener("change", function (ev) { if (ev.target && ev.target.matches) { syncShowWhen(ev.target); } }); document.addEventListener("DOMContentLoaded", function () { initShowWhen(document); }); // htmx:load fires for each top-level element htmx puts into the page, so // swapped-in markup starts with its blocks shown or hidden the way a full // page does. htmx 2.0.4, src/htmx.js insertNodesBefore() queues // makeAjaxLoadTask(child) for each element of the swapped content, which // triggers htmx:load on it (detail.elt); initShowWhen looks inside it. document.addEventListener("htmx:load", function (ev) { initShowWhen(ev.detail.elt); }); // --- Adaptive monitoring polling --------------------------------------- // The four monitoring fragments carry data-poll and hx-trigger="load" for // the first fetch only (htmx 2.0.4, src/htmx.js addTriggerHandler(): "load" // fires once, when the element is first processed). panel.js schedules the rest: 5 s while the operator // is active on the page, 30 s when the tab is visible but idle, and nothing // while the tab is hidden. hx-trigger="every Ns [expr]" could express some // of that, but the filter is evaluated with `new Function`, which the // panel's CSP (default-src 'self', no 'unsafe-eval') would break: htmx // 2.0.4, src/htmx.js maybeGenerateConditional() compiles the filter with // Function(); the EvalError is caught, logged as htmx:syntax:error, and the // trigger is kept without its filter, so it would fire on every tick. var pollActiveMs = 5000; var pollIdleMs = 30000; // A refresh that failed - the panel restarting, the network gone, an // error answer - is tried again, later each time: the wait doubles with // each failure in a row, up to a minute, and is back to normal after the // first refresh that works (audit 2026-10-12, M11). var pollMaxMs = 60000; var pollFailures = Object.create(null); // No pointer/keyboard/scroll input for this long → treat the tab as idle. var userIdleMs = 30000; var lastActivity = Date.now(); var pollTimers = Object.create(null); ["mousedown", "mousemove", "keydown", "scroll", "touchstart"].forEach(function (evt) { document.addEventListener(evt, function () { lastActivity = Date.now(); }, { passive: true }); }); function pollDelayMs(id) { var base = Date.now() - lastActivity < userIdleMs ? pollActiveMs : pollIdleMs; var n = pollFailures[id] || 0; return n ? Math.min(base * Math.pow(2, n), pollMaxMs) : base; } // The box is the request's source, so htmx fires the request's events on // it and not on the body (htmx 2.0.4, src/htmx.js ajaxHelper(): // issueAjaxRequest(verb, path, resolveTarget(context.source), ...), and // with no source issueAjaxRequest() falls back to the document's body). function triggerPoll(el) { htmx.ajax("GET", el.getAttribute("hx-get"), { source: el, target: "#" + el.id, swap: el.getAttribute("hx-swap") || "outerHTML" }); } function schedulePoll(el) { if (!el || !el.id || !el.hasAttribute("data-poll")) { return; } if (pollTimers[el.id]) { clearTimeout(pollTimers[el.id]); delete pollTimers[el.id]; } if (document.hidden) { return; } var id = el.id; pollTimers[id] = setTimeout(function () { delete pollTimers[id]; var current = document.getElementById(id); if (!current || !current.hasAttribute("data-poll")) { return; } if (document.hidden) { schedulePoll(current); return; } triggerPoll(current); }, pollDelayMs(id)); } document.body.addEventListener("htmx:afterSwap", function (ev) { var el = ev.detail.elt; if (!el || !el.hasAttribute || !el.hasAttribute("data-poll")) { return; } // Swapped-in markup still carries hx-trigger="load"; strip it so htmx does // not issue a duplicate GET on top of the response we just received. // htmx 2.0.4, src/htmx.js swap(): htmx:afterSwap fires on the new // elements before doSettle() runs the settle tasks, and only those // (makeAjaxLoadTask -> processNode) act on hx-trigger. el.removeAttribute("hx-trigger"); }); // The next refresh is scheduled when a request of the box ends, however // it ended. htmx 2.0.4, src/htmx.js issueAjaxRequest(): htmx:afterRequest // is fired on load, error, abort and timeout alike, and after a swap that // took the box out of the page it is fired again on the nearest parent // still in it - the event that reaches the body names the box by its // target. A refresh that ended in an error answer is not successful // (handleAjaxResponse(): responseInfo.successful = !isError); one cut off // never got that far. document.body.addEventListener("htmx:afterRequest", function (ev) { var target = ev.detail.target; if (!target || !target.id) { return; } var el = document.getElementById(target.id); if (!el || !el.hasAttribute("data-poll")) { return; } if (ev.detail.successful) { delete pollFailures[el.id]; } else { pollFailures[el.id] = (pollFailures[el.id] || 0) + 1; } schedulePoll(el); }); document.body.addEventListener("htmx:beforeRequest", function (ev) { if (document.hidden && ev.target.hasAttribute && ev.target.hasAttribute("data-poll")) { ev.preventDefault(); } }); document.addEventListener("visibilitychange", function () { if (document.hidden) { Object.keys(pollTimers).forEach(function (id) { clearTimeout(pollTimers[id]); delete pollTimers[id]; }); return; } document.querySelectorAll("[data-poll]").forEach(schedulePoll); }); })();