Files
bvandeusenandClaude Opus 5.5 7275fad767
CI / lint (push) Successful in 2s
CI / integration (push) Successful in 51s
CI / unit (push) Successful in 1m13s
CI / publish (push) Successful in 1m7s
feat(ui): audit log, alert history and Ansible runs load as you scroll (#5211)
#5211 "Lists load as you scroll: audit, alert history, Ansible runs"
(milestone 477), per preference 172.

- core/paging.py: keyset paging over (time, id), newest first, with an
  opaque "<iso>|<id>" cursor; a page reads one row past itself to know
  whether another follows. No OFFSET, so rows added while someone scrolls
  never shift the pages after them.
- _macros.html scroll_more / scroll_end and static/scroll-more.js, written
  once: an IntersectionObserver with a 600px margin fetches the next page
  before the reader reaches the end; "Loading more…" while in flight,
  "End of the list." at the end, and a failed page says so with a Retry
  button instead of re-firing on every scroll.
- Audit log: no "Load more" link and no 500-row cap.
- Alert history: no 200-row stop with "pick a shorter range";
  alert_svc.history takes the cursor, so the MCP tool and the page share
  one query.
- Ansible runs: no silent cap of 50.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-07 00:10:53 -04:00

60 lines
2.3 KiB
JavaScript

/* Lists that continue themselves (#5211, pref 172).
*
* A [data-scroll-more] sentinel (the scroll_more macro) fetches the next page
* when it comes within MARGIN of the viewport, so the rows arrive before the
* reader reaches the end, and is replaced by them. While a page is in flight
* the sentinel says "Loading more…"; a failed page says so with a Retry button
* and is not re-fetched by scrolling (a dead endpoint must not become a request
* loop). The last page ends with scroll_end's "End of the list." instead of a sentinel.
*/
(function () {
var MARGIN = "600px 0px";
function load(el) {
el.setAttribute("data-scroll-state", "loading");
htmx.trigger(el, "scroll-more");
}
var observer = "IntersectionObserver" in window ? new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
var el = entry.target;
if (!entry.isIntersecting || el.hasAttribute("data-scroll-state")) return;
observer.unobserve(el);
load(el);
});
}, { rootMargin: MARGIN }) : null;
function watch(root) {
if (!root || !root.querySelectorAll) return;
var found = Array.prototype.slice.call(root.querySelectorAll("[data-scroll-more]"));
if (root.matches && root.matches("[data-scroll-more]")) found.push(root);
found.forEach(function (el) {
if (el.hasAttribute("data-scroll-state")) return;
if (observer) observer.observe(el); else load(el);
});
}
function failed(evt) {
var el = evt.detail && evt.detail.elt;
if (!el || !el.hasAttribute || !el.hasAttribute("data-scroll-more")) return;
el.setAttribute("data-scroll-state", "failed");
var status = el.querySelector("[data-scroll-status]");
if (!status) return;
status.textContent = "Couldn't load more. ";
var retry = document.createElement("button");
retry.type = "button";
retry.className = "btn btn-sm btn-ghost";
retry.textContent = "Retry";
retry.addEventListener("click", function () {
status.textContent = "Loading more…";
load(el);
});
status.appendChild(retry);
}
document.addEventListener("htmx:load", function (evt) { watch(evt.detail.elt); });
document.addEventListener("htmx:responseError", failed);
document.addEventListener("htmx:sendError", failed);
document.addEventListener("htmx:timeout", failed);
})();