feat(alerts): Steward's own confirm dialog, a verdict-first rules list, and a rule page in three sections (#5314, #5315, #5317)
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 55s
CI / unit (push) Failing after 1m25s
CI / publish (push) Skipped

Confirm dialog (#5314):
- Every data-confirm form now asks in one native <dialog> (base.html + static/confirm.js)
  instead of window.confirm.
- The question becomes the heading and what follows it the detail; the act's
  button is named after the form's own button; Cancel has the focus and Esc
  backs out. Optional data-confirm-title/-detail/-action/-dismiss attributes.
- All 27 existing uses work as they are. Cancelling a run says 'Keep running'.

Alerts list (#5315):
- A verdict line (what is firing, how many rules, where notifications go,
  or 'notifies nobody yet' with a link) replaces the empty Active card.
- Each rule's name opens it; rows show 'last fired' and fold fire/clear into
  the condition; History and Disable stay; Delete leaves the list.
- .docker-verdict becomes the shared .page-verdict.

Rule page (#5317):
- Named for the rule (title and crumb), with when it last fired and how many
  changes of state it holds.
- Three sections: what it watches, when it fires (checks side by side,
  seconds echoed as a duration), what happens (where notifications go, no-data,
  the playbook, whose fields stay folded until ticked).
- Delete at the foot, behind the dialog, saying its history goes with it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-07 22:58:38 -04:00
co-authored by Claude Opus 5.5
parent 7cb6cb1fb5
commit 0c66c1e7d3
14 changed files with 401 additions and 131 deletions
+51
View File
@@ -0,0 +1,51 @@
// Steward's own confirm dialog (#5314): a form, or its submit button, with
// data-confirm="Question? What follows." asks in the #confirm-dialog that
// base.html renders before it submits, never through window.confirm.
//
// The prompt is attribute text read through dataset and written with
// textContent, never parsed as HTML or JavaScript, so a name inside it
// cannot run as script (#5099). Optional attributes beside data-confirm:
// data-confirm-title the heading (default: the prompt up to its first "? ")
// data-confirm-detail the line under it (default: the rest of the prompt)
// data-confirm-action the button that goes ahead (default: the submit
// button's own label, or the prompt's first word)
// data-confirm-dismiss the button that backs out (default: "Cancel")
// Cancel has the focus, and Esc backs out.
(function () {
function label(btn, title) {
var own = btn ? (btn.dataset.confirmAction || btn.textContent || btn.value || '').trim() : '';
if (own.length > 2) return own; // "×" and other icons say nothing
return title.split(' ')[0].replace(/\?$/, '');
}
function ask(form, btn) {
var src = btn && btn.dataset.confirm ? btn : form;
var prompt = src.dataset.confirm;
var cut = prompt.indexOf('? ');
var title = src.dataset.confirmTitle || (cut > 0 ? prompt.slice(0, cut + 1) : prompt);
var detail = src.dataset.confirmDetail || (cut > 0 ? prompt.slice(cut + 2) : '');
var d = document.getElementById('confirm-dialog');
d.querySelector('[data-part="title"]').textContent = title;
var p = d.querySelector('[data-part="detail"]');
p.textContent = detail;
p.hidden = !detail;
d.querySelector('[data-part="go"]').textContent = src.dataset.confirmAction || label(btn, title);
d.querySelector('[data-part="back"]').textContent = src.dataset.confirmDismiss || 'Cancel';
d.returnValue = '';
d.onclose = function () {
if (d.returnValue !== 'go') return;
form.dataset.confirmed = '1';
form.requestSubmit(btn && btn.form === form ? btn : undefined);
};
d.showModal();
}
document.addEventListener('submit', function (ev) {
var form = ev.target, btn = ev.submitter;
if (!((btn && btn.dataset.confirm) || form.dataset.confirm)) return;
if (form.dataset.confirmed === '1') { delete form.dataset.confirmed; return; } // the asked-for submit
ev.preventDefault();
ev.stopPropagation(); // before HTMX sees it: an hx-post form waits for the answer too
ask(form, btn);
}, true);
})();
+16 -1
View File
@@ -208,7 +208,8 @@ strong, b, th { font-weight: 500; }
.cell-sub { font-size: 0.75rem; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.table td.cell-image { max-width: 14rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.8rem; color: var(--text-muted); }
/* The Docker page's one-line answer, above its tiles (#5298). */
.docker-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.page-verdict { margin: 0 0 0.75rem; font-size: 0.95rem; }
.table tr.row-off > td { opacity: 0.55; }
/* A problem section's heading carries its own count, so no tile repeats it (#5214). */
.section-count { margin-bottom: 0.6rem; }
.section-count > span:first-child { margin-left: 0.25rem; }
@@ -373,6 +374,20 @@ a.stat-tile:hover { border-color: var(--border-mid); background: var(--bg-elevat
border: 1px solid var(--border); border-radius: var(--fs-radius-md); box-shadow: var(--fs-shadow-3); }
.modal::backdrop { background: var(--fs-overlay); }
.modal-title { font-family: var(--font-serif); font-size: 1.25rem; font-weight: 500; margin: 0 0 1rem; }
/* The confirm dialog (#5314): a question, what follows from it, then Cancel and the act. */
.modal-confirm { width: min(440px, calc(100vw - 2rem)); }
.modal-confirm .modal-title { margin-bottom: 0.5rem; overflow-wrap: anywhere; }
.confirm-detail { color: var(--text-muted); font-size: 0.9rem; margin: 0 0 1.25rem; overflow-wrap: anywhere; }
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
/* A form page in sections (#5317): capped, left-aligned under its crumbs. */
.form-page { max-width: 760px; }
.form-section > .section-title { margin-bottom: 0.9rem; }
.form-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(14rem, 100%), 1fr)); gap: 0 1rem; }
.form-pair[hidden] { display: none; }
.ansible-fields { padding: 0.9rem 1rem 0.25rem; margin-bottom: 0.75rem; border: 1px solid var(--border); border-radius: var(--fs-radius-md); background: var(--bg-elevated); }
.form-actions { display: flex; gap: 0.75rem; margin: 0.5rem 0 1.5rem; }
.danger-zone { display: flex; align-items: center; justify-content: space-between; gap: 1rem; flex-wrap: wrap;
border-color: color-mix(in srgb, var(--fs-error) 45%, var(--border)); }
/* A filled bar for a percentage; the fill takes its grade from status_class. */
.meter { height: 8px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: 4px; overflow: hidden; }
.meter-fill { height: 100%; }