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:
2026-07-24 22:04:48 +02:00
parent 2395e3e79f
commit d18321d4a0
25 changed files with 1695 additions and 351 deletions
+198
View File
@@ -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"));
});
})();