Files
local-ci-cd-system/src/ci_orchestrator/web/static/app.js
T
Simone bfa90190ea fix(web): Started toggle survives the Executions card's 5s poll
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).
2026-07-26 00:25:33 +02:00

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"));
});
})();