The card's own htmx poll re-renders the whole table from the server every
5s, always starting fresh at "relative" -- so a toggled-to-absolute cell
snapped back on the next poll. Each button now has a stable id
(started-{run_id}-{attempt}, or started-vm-{vmx} on an unmatched-VM row);
app.js remembers which format was showing per id and re-applies it to the
fresh button after every swap, rather than freezing the node itself (which
would also freeze its relative-time text, badge, duration, everything else
in that row).
257 lines
9.3 KiB
JavaScript
257 lines
9.3 KiB
JavaScript
/*
|
|
* Dashboard action behaviour: turns the htmx-swapped confirm/result partials
|
|
* into a modal <dialog> (confirm) and a stack of auto-dismissing toasts
|
|
* (results + busy spinners). Pure vanilla JS, no dependencies beyond htmx's
|
|
* own swap/request events.
|
|
*
|
|
* - confirm.html -> <dialog class="modal"> in #action-slot : opened with
|
|
* showModal() so it is a real focus-trapping, backdrop-dimming modal;
|
|
* Cancel / ESC clear the slot. (No-JS path renders <dialog open> instead.)
|
|
* - result.html -> .toast appended to #toast-stack : the confirm form swaps
|
|
* its terminal result there with hx-swap="beforeend", so concurrent actions
|
|
* (e.g. two template backups at once) each keep their own toast instead of
|
|
* the later one replacing the earlier. A MutationObserver wires each new
|
|
* toast; a close button dismisses it and everything but an error also
|
|
* auto-dismisses after AUTODISMISS_MS.
|
|
* - busy toast -> .toast.toast-busy appended to #toast-stack while a
|
|
* confirmed (possibly slow, multi-GB) action is in flight; removed when its
|
|
* request settles.
|
|
*/
|
|
(function () {
|
|
"use strict";
|
|
|
|
var AUTODISMISS_MS = 6000;
|
|
var FADE_MS = 200;
|
|
|
|
function toastStack() {
|
|
return document.getElementById("toast-stack");
|
|
}
|
|
|
|
function clearSlot() {
|
|
var slot = document.getElementById("action-slot");
|
|
if (slot) {
|
|
slot.innerHTML = "";
|
|
}
|
|
}
|
|
|
|
function dismissToast(toast) {
|
|
if (!toast || toast.dataset.closing === "true") {
|
|
return;
|
|
}
|
|
toast.dataset.closing = "true";
|
|
toast.classList.add("toast--closing");
|
|
window.setTimeout(function () {
|
|
if (toast.parentNode) {
|
|
toast.parentNode.removeChild(toast);
|
|
}
|
|
}, FADE_MS);
|
|
}
|
|
|
|
function wireToast(toast) {
|
|
if (toast.dataset.wired === "true") {
|
|
return;
|
|
}
|
|
toast.dataset.wired = "true";
|
|
var closeBtn = toast.querySelector("[data-toast-close]");
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener("click", function () {
|
|
dismissToast(toast);
|
|
});
|
|
}
|
|
// Errors stay put until dismissed (data-autodismiss="false"); every other
|
|
// result fades on its own after a few seconds.
|
|
if (toast.dataset.autodismiss !== "false") {
|
|
window.setTimeout(function () {
|
|
dismissToast(toast);
|
|
}, AUTODISMISS_MS);
|
|
}
|
|
}
|
|
|
|
function showBusyToast(dialog) {
|
|
// A non-dismissable spinner toast shown while a (possibly slow) confirmed
|
|
// action is in flight -- e.g. a multi-GB template backup. Appended to the
|
|
// stack so it coexists with any other running action's toast, and returned
|
|
// so the caller can remove it once the request settles.
|
|
var stack = toastStack();
|
|
if (!stack) {
|
|
return null;
|
|
}
|
|
var label = dialog.getAttribute("data-action-label") || "Working";
|
|
var toast = document.createElement("div");
|
|
toast.className = "toast toast-busy";
|
|
toast.setAttribute("role", "status");
|
|
toast.setAttribute("aria-live", "polite");
|
|
toast.innerHTML =
|
|
'<span class="spinner" aria-hidden="true"></span>' +
|
|
'<div class="toast-body"><strong class="toast-title"></strong>' +
|
|
'<p class="toast-detail">Working… this can take a while for large templates.</p></div>';
|
|
// textContent, so a template/label can never inject markup.
|
|
toast.querySelector(".toast-title").textContent = label + " — in progress";
|
|
stack.appendChild(toast);
|
|
return toast;
|
|
}
|
|
|
|
function wireModal(dialog) {
|
|
// Upgrade an inert/closed dialog into a true modal where supported. On the
|
|
// no-JS full-page path it already renders `open`; closing then re-opening
|
|
// via showModal() gives the backdrop + focus trap without a double-open.
|
|
if (typeof dialog.showModal === "function") {
|
|
if (dialog.open) {
|
|
dialog.close();
|
|
}
|
|
// showModal() is called here from an htmx response handler, not
|
|
// synchronously inside the user's own click -- some browsers then
|
|
// swallow the very first click on the dialog's contents (the
|
|
// top-layer promotion/focus handoff hasn't settled), so Confirm
|
|
// silently does nothing until clicked a second time. One animation
|
|
// frame is enough for that to settle; explicitly focusing Confirm
|
|
// afterwards makes the browser's hit-testing land on the button.
|
|
requestAnimationFrame(function () {
|
|
dialog.showModal();
|
|
var confirmBtn = dialog.querySelector('button[type="submit"]');
|
|
if (confirmBtn) {
|
|
confirmBtn.focus();
|
|
}
|
|
});
|
|
}
|
|
var cancel = dialog.querySelector("[data-modal-cancel]");
|
|
if (cancel) {
|
|
cancel.addEventListener("click", function () {
|
|
if (dialog.open) {
|
|
dialog.close();
|
|
}
|
|
clearSlot();
|
|
});
|
|
}
|
|
// ESC / backdrop dismiss fires `cancel` (not `close`, and never on a
|
|
// programmatic close()); clear the slot once the native close settles.
|
|
dialog.addEventListener("cancel", function () {
|
|
window.setTimeout(clearSlot, 0);
|
|
});
|
|
// On Confirm: close the modal at once and show a busy spinner toast in the
|
|
// stack until the request settles. The terminal result is appended to the
|
|
// stack by htmx (hx-swap="beforeend"); on afterRequest we drop our busy
|
|
// toast and clear the now-defunct dialog from #action-slot. Uses
|
|
// htmx:beforeSend so the XHR is already prepared -- closing the dialog here
|
|
// cannot cancel it. busyEl is per-request, captured in this closure.
|
|
var form = dialog.querySelector("form");
|
|
if (form) {
|
|
var busyEl = null;
|
|
form.addEventListener("htmx:beforeSend", function () {
|
|
if (dialog.open) {
|
|
dialog.close();
|
|
}
|
|
busyEl = showBusyToast(dialog);
|
|
});
|
|
form.addEventListener("htmx:afterRequest", function () {
|
|
if (busyEl) {
|
|
dismissToast(busyEl);
|
|
busyEl = null;
|
|
}
|
|
// The dialog stays in #action-slot after the result went to the stack;
|
|
// remove it now that the action is done. isConnected guards a slot
|
|
// already cleared by an ESC/Cancel race.
|
|
if (dialog.isConnected) {
|
|
clearSlot();
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
function processSlot(root) {
|
|
// #action-slot only ever holds a confirm <dialog> now; the no-JS full-page
|
|
// result path also drops a bare .toast here, so wire that too for that case.
|
|
if (!root || !root.querySelector) {
|
|
return;
|
|
}
|
|
var dialog = root.querySelector("dialog.modal");
|
|
if (dialog) {
|
|
wireModal(dialog);
|
|
}
|
|
var toast = root.querySelector(".toast");
|
|
if (toast) {
|
|
wireToast(toast);
|
|
}
|
|
}
|
|
|
|
function observeStack() {
|
|
// Result toasts arrive via htmx beforeend swaps (not into #action-slot), so
|
|
// wire them as they are inserted. Busy toasts are created + managed by
|
|
// showBusyToast/wireModal, so skip them here.
|
|
var stack = toastStack();
|
|
if (!stack || typeof MutationObserver !== "function") {
|
|
return;
|
|
}
|
|
new MutationObserver(function (mutations) {
|
|
mutations.forEach(function (mutation) {
|
|
Array.prototype.forEach.call(mutation.addedNodes, function (node) {
|
|
if (
|
|
node.nodeType === 1 &&
|
|
node.classList.contains("toast") &&
|
|
!node.classList.contains("toast-busy")
|
|
) {
|
|
wireToast(node);
|
|
}
|
|
});
|
|
});
|
|
}).observe(stack, { childList: true });
|
|
}
|
|
|
|
// Executions "Started" cell: click swaps between the relative and
|
|
// absolute time already rendered into the button's own data attributes --
|
|
// no request. Delegated on body (not the button itself) so it keeps
|
|
// working after the Executions card's own 5s htmx poll replaces the row.
|
|
//
|
|
// The poll re-renders the whole card from the server every 5s, always
|
|
// starting back at "relative" -- so the choice is remembered here, keyed
|
|
// by the button's own id (stable across polls: run id/attempt or vmx),
|
|
// and re-applied to the fresh button restoreTimeToggles() finds after each
|
|
// swap, rather than freezing the node itself (which would also freeze its
|
|
// relative-time text, badge, duration -- everything else in the row).
|
|
var timeToggleState = {};
|
|
|
|
function applyTimeToggle(btn, showing) {
|
|
var shown = showing === "relative" ? "absolute" : "relative";
|
|
btn.textContent = btn.dataset[showing];
|
|
btn.title = btn.dataset[shown];
|
|
btn.dataset.showing = showing;
|
|
}
|
|
|
|
function restoreTimeToggles(root) {
|
|
var buttons = root.querySelectorAll ? root.querySelectorAll(".time-toggle[id]") : [];
|
|
Array.prototype.forEach.call(buttons, function (btn) {
|
|
var saved = timeToggleState[btn.id];
|
|
if (saved && saved !== btn.dataset.showing) {
|
|
applyTimeToggle(btn, saved);
|
|
}
|
|
});
|
|
}
|
|
|
|
document.body.addEventListener("click", function (evt) {
|
|
var btn = evt.target.closest(".time-toggle");
|
|
if (!btn) {
|
|
return;
|
|
}
|
|
var next = btn.dataset.showing === "relative" ? "absolute" : "relative";
|
|
applyTimeToggle(btn, next);
|
|
if (btn.id) {
|
|
timeToggleState[btn.id] = next;
|
|
}
|
|
});
|
|
|
|
document.body.addEventListener("htmx:afterSwap", function (evt) {
|
|
var target = evt.detail && evt.detail.target;
|
|
if (target && target.id === "action-slot") {
|
|
processSlot(target);
|
|
}
|
|
if (target) {
|
|
restoreTimeToggles(target);
|
|
}
|
|
});
|
|
|
|
document.addEventListener("DOMContentLoaded", function () {
|
|
observeStack();
|
|
processSlot(document.getElementById("action-slot"));
|
|
});
|
|
})();
|