feat(web): Stage E dashboard — wide cards, per-item actions, toast stack
A large, cohesive redesign of the web dashboard (all pieces depend on each other, so this lands as one commit): Layout / display: - Host/Runners/VMware/Cache split into a Host-VMware-Runners-Cache info row; Host card keeps uptime/load/cores/mem-total now that CPU%/RAM are promoted to the status-header tiles (new gauge tiles, red/amber/ green thresholds shared with the existing disk tile). - VMware modules rendered as a table; Live VMs/Templates/Artifacts/ Services/Maintenance span the full grid width (card--wide). - Backups moved from a standalone Files-panel table into an expandable per-template row on the Templates panel (backups_by_template), with a per-backup Delete action and a per-row Backup button replacing the old host-card template dropdown. - New Maintenance card: cleanup-orphans and cache-purge forms consolidated into one danger-zone card (red rule + row separators), each op's max-age control on its own row so it reads as the op's parameter, not decoration; cache purge gets an explicit "Empty all" button alongside the age-based "Purge". - Artifacts gain a per-row Delete action. Action/feedback layer: - Every mutating route now sets HX-Trigger: ci:refresh on its result, so polled panels reload immediately instead of waiting for their next poll interval; _result_partial takes an explicit level (ok/info/error) independent of the ok bool, so e.g. an age-based purge that finds nothing to remove reads as neutral "info" instead of a misleading green "ok" or a red "error". - Results render as toasts appended to a fixed #toast-stack (base.html) instead of swapping a single #action-slot -- concurrent actions (two backups running at once) now each keep their own toast instead of the later one clobbering the earlier. Per-state colour is a left stripe + tint (result-ok/info/error), fixing a cascade bug where the plain .toast background silently beat the state class. - The confirm dialog is a real modal <dialog> (app.js: showModal(), Cancel/ESC clear the slot); confirming closes it immediately and shows a busy spinner toast in the stack (since a real backup can run for minutes) instead of leaving the dialog open with no feedback. - host_backup now runs backup_template via asyncio.to_thread so a multi-GB 7z run no longer blocks the event loop (and every other dashboard poll) for its whole duration. - Static assets are served with a mtime-based ?v= query param so a redeploy is picked up without a hard refresh.
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
dialog.showModal();
|
||||
}
|
||||
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 });
|
||||
}
|
||||
|
||||
document.body.addEventListener("htmx:afterSwap", function (evt) {
|
||||
var target = evt.detail && evt.detail.target;
|
||||
if (target && target.id === "action-slot") {
|
||||
processSlot(target);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
observeStack();
|
||||
processSlot(document.getElementById("action-slot"));
|
||||
});
|
||||
})();
|
||||
Reference in New Issue
Block a user