diff --git a/steward/alerts/routes.py b/steward/alerts/routes.py index 32863cf..b104e18 100644 --- a/steward/alerts/routes.py +++ b/steward/alerts/routes.py @@ -5,6 +5,7 @@ from steward.core.audit import log_audit from sqlalchemy import select from steward.auth.middleware import require_role from steward.alerts import service as alert_svc +from steward.core.paging import PAGE_SIZE, next_page from steward.core.time_range import parse_range from steward.ansible import sources as src_module from steward.models.alerts import AlertRule, AlertOperator, AlertState, MaintenanceWindow @@ -117,9 +118,6 @@ async def active_fragment(): return await render_template("alerts/_active.html", active=active) -HISTORY_LIMIT = 200 - - def _history_row(event, rule) -> dict: """One transition as the history page shows it.""" from steward.core.alerts import MUTED, RELEASED @@ -147,15 +145,20 @@ def _history_row(event, rule) -> dict: @alerts_bp.get("/history") @require_role(UserRole.viewer) async def alert_history(): - """Every alert transition in a range, newest first; one rule's with ?rule=.""" + """Every alert transition in a range, newest first; one rule's with ?rule=. + A page at a time as the reader scrolls: ?before= is the next + page's rows alone (core.paging).""" since, range_key = parse_range(request.args.get("range") or "7d") + before = request.args.get("before") async with current_app.db_sessionmaker() as db: rules = await alert_svc.list_rules(db) chosen = next((r for r in rules if r.id == request.args.get("rule")), None) - rows = await alert_svc.history(db, since, chosen.id if chosen else None, HISTORY_LIMIT) - ctx = {"events": [_history_row(e, r) for e, r in rows[:HISTORY_LIMIT]], - "truncated": len(rows) > HISTORY_LIMIT, "limit": HISTORY_LIMIT, + rows = await alert_svc.history(db, since, chosen.id if chosen else None, PAGE_SIZE, before) + rows, after = next_page(rows, lambda row: (row[0].transitioned_at, row[0].id)) + ctx = {"events": [_history_row(e, r) for e, r in rows], "after": after, "rules": rules, "chosen": chosen, "current_range": range_key} + if before: + return await render_template("alerts/_history_page.html", **ctx) if request.headers.get("HX-Request"): return await render_template("alerts/_history_rows.html", **ctx) return await render_template("alerts/history.html", **ctx) diff --git a/steward/alerts/service.py b/steward/alerts/service.py index 8f92f69..82dd997 100644 --- a/steward/alerts/service.py +++ b/steward/alerts/service.py @@ -241,17 +241,18 @@ async def list_rules(db) -> list[AlertRule]: async def history(db, since: datetime, rule_id: str | None = None, - limit: int = 200) -> list[tuple[AlertEvent, AlertRule]]: + limit: int = 200, before: str | None = None) -> list[tuple[AlertEvent, AlertRule]]: """Alert transitions since `since`, newest first, with their rule; one - rule's when `rule_id` is given. Returns up to limit+1 rows so a caller can - tell the list was cut.""" + rule's when `rule_id` is given; only those older than the `before` cursor + when given (core.paging). Returns up to limit+1 rows so a caller can tell + the list was cut.""" + from steward.core.paging import keyset stmt = (select(AlertEvent, AlertRule) .join(AlertRule, AlertEvent.rule_id == AlertRule.id) - .where(AlertEvent.transitioned_at >= since) - .order_by(AlertEvent.transitioned_at.desc()) - .limit(limit + 1)) + .where(AlertEvent.transitioned_at >= since)) if rule_id: stmt = stmt.where(AlertEvent.rule_id == rule_id) + stmt = keyset(stmt, AlertEvent.transitioned_at, AlertEvent.id, before, limit) return [tuple(row) for row in (await db.execute(stmt)).all()] diff --git a/steward/ansible/routes.py b/steward/ansible/routes.py index 4d6d165..583f7ee 100644 --- a/steward/ansible/routes.py +++ b/steward/ansible/routes.py @@ -12,6 +12,7 @@ from steward.ansible import executor, guards, sources as src_module from steward.ansible.inventory_gen import run_title, scope_labels from steward.auth.middleware import require_role from steward.core.audit import audit_request as _audit +from steward.core.paging import keyset, next_page from steward.models.ansible import AnsibleRun, AnsibleRunStatus from steward.models.ansible_schedule import AnsibleSchedule from steward.models.users import User, UserRole @@ -36,14 +37,19 @@ def _get_sources() -> list[dict]: @ansible_bp.get("/") @require_role(UserRole.viewer) async def index(): + """Every run, newest first, a page at a time as the reader scrolls; + ?before= is the next page's rows alone (core.paging).""" + before = request.args.get("before") async with current_app.db_sessionmaker() as db: - result = await db.execute( - select(AnsibleRun).order_by(AnsibleRun.started_at.desc()).limit(50) - ) - runs = result.scalars().all() + stmt = keyset(select(AnsibleRun), AnsibleRun.started_at, AnsibleRun.id, before) + runs, after = next_page((await db.execute(stmt)).scalars().all(), + lambda r: (r.started_at, r.id)) labels = await scope_labels(db, [r.inventory_scope for r in runs]) now = datetime.now(timezone.utc) - return await render_template("ansible/index.html", runs=[run_view(r, labels, now) for r in runs]) + ctx = {"runs": [run_view(r, labels, now) for r in runs], "after": after} + if before: + return await render_template("ansible/_runs_page.html", **ctx) + return await render_template("ansible/index.html", **ctx) def run_duration(run: AnsibleRun, now: datetime) -> float | None: diff --git a/steward/audit/routes.py b/steward/audit/routes.py index 0819568..6516fd4 100644 --- a/steward/audit/routes.py +++ b/steward/audit/routes.py @@ -4,6 +4,7 @@ from quart import Blueprint, render_template, request, current_app from sqlalchemy import select from steward.audit.service import entity_labels from steward.auth.middleware import require_role +from steward.core.paging import keyset, next_page from steward.models.audit import AuditEvent from steward.models.users import UserRole @@ -13,16 +14,16 @@ audit_bp = Blueprint("audit", __name__, url_prefix="/audit") @audit_bp.get("/") @require_role(UserRole.admin) async def list_events(): - limit = min(int(request.args.get("limit", 200)), 500) + """Who changed what, newest first, a page at a time as the reader scrolls; + ?before= is the next page's rows alone (core.paging).""" action_filter = request.args.get("action", "").strip() async with current_app.db_sessionmaker() as db: - stmt = select(AuditEvent).order_by(AuditEvent.timestamp.desc()).limit(limit) + stmt = select(AuditEvent) if action_filter: - stmt = select(AuditEvent).where( - AuditEvent.action.like(f"{action_filter}%") - ).order_by(AuditEvent.timestamp.desc()).limit(limit) - result = await db.execute(stmt) - events = result.scalars().all() + stmt = stmt.where(AuditEvent.action.like(f"{action_filter}%")) + stmt = keyset(stmt, AuditEvent.timestamp, AuditEvent.id, request.args.get("before")) + events, after = next_page((await db.execute(stmt)).scalars().all(), + lambda e: (e.timestamp, e.id)) # Parse detail_json for display parsed = [] @@ -40,9 +41,7 @@ async def list_events(): e = item["event"] item["entity"] = labels.get((e.entity_type, str(e.entity_id))) if e.entity_id else None - return await render_template( - "audit/list.html", - events=parsed, - limit=limit, - action_filter=action_filter, - ) + ctx = {"events": parsed, "after": after, "action_filter": action_filter} + if request.args.get("before"): + return await render_template("audit/_rows.html", **ctx) + return await render_template("audit/list.html", **ctx) diff --git a/steward/core/paging.py b/steward/core/paging.py new file mode 100644 index 0000000..7fa527b --- /dev/null +++ b/steward/core/paging.py @@ -0,0 +1,59 @@ +"""Keyset paging for newest-first lists that load as you scroll (#5211, pref 172). + +A page is the rows strictly older than a cursor, ordered (time, id) newest +first, so a row added while someone scrolls never shifts the pages after it, +and no page needs an OFFSET. The cursor is "|": opaque to the +browser, which only hands back the one the last page gave it. + + stmt = keyset(select(AuditEvent), AuditEvent.timestamp, AuditEvent.id, request.args.get("before")) + rows = (await db.execute(stmt)).scalars().all() + rows, after = next_page(rows, lambda e: (e.timestamp, e.id)) + +Templates end each page with scroll_more() while +`after` is set, and scroll_end() once it is not (_macros.html). +""" +from __future__ import annotations + +from datetime import datetime +from typing import Callable + +from sqlalchemy import tuple_ + +# Rows per page: enough to fill a tall screen past the scroll margin, so the +# next page starts loading before the reader reaches the end of this one. +PAGE_SIZE = 100 + + +def cursor(at: datetime, row_id) -> str: + return f"{at.isoformat()}|{row_id}" + + +def parse_cursor(raw: str | None) -> tuple[datetime, str] | None: + """(time, id) from a cursor, or None for no cursor or one that does not parse + (a mangled cursor starts the list over rather than failing the page).""" + if not raw or "|" not in raw: + return None + at, row_id = raw.split("|", 1) + try: + return datetime.fromisoformat(at), row_id + except ValueError: + return None + + +def keyset(stmt, at_col, id_col, before: str | None, size: int = PAGE_SIZE): + """`stmt` limited to one page older than `before`, newest first. Fetches + one row more than a page so next_page can tell whether another follows.""" + parsed = parse_cursor(before) + if parsed is not None: + stmt = stmt.where(tuple_(at_col, id_col) < tuple_(*parsed)) + return stmt.order_by(at_col.desc(), id_col.desc()).limit(size + 1) + + +def next_page(rows, key: Callable, size: int = PAGE_SIZE) -> tuple[list, str | None]: + """(this page's rows, the cursor for the next page or None at the end). + `key(row)` is the row's (time, id).""" + rows = list(rows) + if len(rows) <= size: + return rows, None + rows = rows[:size] + return rows, cursor(*key(rows[-1])) diff --git a/steward/static/scroll-more.js b/steward/static/scroll-more.js new file mode 100644 index 0000000..1456996 --- /dev/null +++ b/steward/static/scroll-more.js @@ -0,0 +1,59 @@ +/* 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); +})(); diff --git a/steward/templates/_macros.html b/steward/templates/_macros.html index a9b2716..c48537c 100644 --- a/steward/templates/_macros.html +++ b/steward/templates/_macros.html @@ -84,3 +84,20 @@ {%- if kwargs.get('confirm') %} data-confirm="{{ kwargs.confirm }}"{% endif %}>{{ icon }} {%- endif -%} {%- endmacro %} + +{# A list that continues itself (#5211, pref 172; static/scroll-more.js). + scroll_more ends a page while there are more rows: it fetches `url` (the + next page, built with core.paging's cursor) when it comes near the + viewport, and is replaced by that page's rows. scroll_end ends the last + page. Pass `cols` inside a table body, to render as a full-width row. #} +{% macro scroll_more(url, cols=none) -%} +{%- if cols %} +{%- else %}
{% endif -%} +Loading more… +{%- if cols %}{% else %}
{% endif %} +{%- endmacro %} + +{% macro scroll_end(cols=none, text="End of the list.") -%} +{%- if cols %}{{ text }} +{%- else %}

{{ text }}

{% endif %} +{%- endmacro %} diff --git a/steward/templates/alerts/_history_page.html b/steward/templates/alerts/_history_page.html new file mode 100644 index 0000000..b46349b --- /dev/null +++ b/steward/templates/alerts/_history_page.html @@ -0,0 +1,53 @@ +{# alerts/_history_page.html — one page of alert transitions, then the sentinel + that loads the next page as the reader nears it (core.paging, pref 172). #} +{% from "_macros.html" import status_badge, scroll_more, scroll_end %} + {% for ev in events %} + + + {{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }} + + + {{ ev.rule.name }} +
{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}
+ + + {{ status_badge(ev.from_state) }} + → + {{ status_badge(ev.to_state) }} + {% for n in ev.notes %} +
{{ n }}
+ {% endfor %} + + + {{ ev.rule.metric_name }} = {{ ev.value }} +
rule: {{ ev.condition }}
+ + + {% if ev.muted_by %} + muted · {{ ev.muted_by }} + {% if ev.released %}
notified when the window ended
{% endif %} + {% endif %} + {% for label, sent, error in ev.deliveries %} +
+ {% if sent %} + {{ label }} sent + {% else %} + {{ label }} failed + {% if error %}
{{ error }}
{% endif %} + {% endif %} +
+ {% endfor %} + {% if not ev.muted_by and not ev.deliveries %}—{% endif %} + + + {% if ev.ansible_run_id %} + Ansible run + {% else %}—{% endif %} + + + {% endfor %} +{% if after %} +{{ scroll_more("/alerts/history?before=" ~ (after|urlencode) ~ "&range=" ~ (current_range|urlencode) ~ (("&rule=" ~ (chosen.id|urlencode)) if chosen else ""), cols=6) }} +{% else %} +{{ scroll_end(cols=6) }} +{% endif %} diff --git a/steward/templates/alerts/_history_rows.html b/steward/templates/alerts/_history_rows.html index e91cff5..12761b1 100644 --- a/steward/templates/alerts/_history_rows.html +++ b/steward/templates/alerts/_history_rows.html @@ -1,4 +1,5 @@ -{# alerts/_history_rows.html — the history table; swapped in on a range or rule change #} +{# alerts/_history_rows.html — the history table; swapped in on a range or rule + change. Its rows come a page at a time (alerts/_history_page.html). #} {% from "_macros.html" import status_badge, empty_state %} {% if events %}
@@ -14,59 +15,10 @@ - {% for ev in events %} - - - {{ ev.at.strftime("%Y-%m-%d %H:%M:%S") }} - - - {{ ev.rule.name }} -
{{ ev.rule.source_module }}/{{ ev.rule.resource_name }}
- - - {{ status_badge(ev.from_state) }} - → - {{ status_badge(ev.to_state) }} - {% for n in ev.notes %} -
{{ n }}
- {% endfor %} - - - {{ ev.rule.metric_name }} = {{ ev.value }} -
rule: {{ ev.condition }}
- - - {% if ev.muted_by %} - muted · {{ ev.muted_by }} - {% if ev.released %}
notified when the window ended
{% endif %} - {% endif %} - {% for label, sent, error in ev.deliveries %} -
- {% if sent %} - {{ label }} sent - {% else %} - {{ label }} failed - {% if error %}
{{ error }}
{% endif %} - {% endif %} -
- {% endfor %} - {% if not ev.muted_by and not ev.deliveries %}—{% endif %} - - - {% if ev.ansible_run_id %} - Ansible run - {% else %}—{% endif %} - - - {% endfor %} + {% include "alerts/_history_page.html" %}
-{% if truncated %} -

- Showing the newest {{ limit }}. Pick a shorter range or one rule to see the rest. -

-{% endif %} {% else %}
{{ empty_state("No alert changed state in the last " ~ current_range ~ ((" for " ~ chosen.name) if chosen else "") ~ ".") }}
{% endif %} diff --git a/steward/templates/ansible/_runs_page.html b/steward/templates/ansible/_runs_page.html new file mode 100644 index 0000000..0fe55ff --- /dev/null +++ b/steward/templates/ansible/_runs_page.html @@ -0,0 +1,21 @@ +{# ansible/_runs_page.html — one page of runs, then the sentinel that loads the + next page as the reader nears it (core.paging, pref 172). #} +{% from "_macros.html" import status_badge, scroll_more, scroll_end %} + {% for row in runs %} + {% set run = row.run %} + + + {{ row.title }} + {% if row.check %}dry run{% endif %} +
{{ run.source_name }}
+ + {{ status_badge(run.status) }} + {{ run.started_at|ago }} + {{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %} + + {% endfor %} +{% if after %} +{{ scroll_more("/ansible/?before=" ~ (after|urlencode), cols=4) }} +{% else %} +{{ scroll_end(cols=4) }} +{% endif %} diff --git a/steward/templates/ansible/index.html b/steward/templates/ansible/index.html index 90a06c5..5ad9d35 100644 --- a/steward/templates/ansible/index.html +++ b/steward/templates/ansible/index.html @@ -21,19 +21,7 @@ - {% for row in runs %} - {% set run = row.run %} - - - {{ row.title }} - {% if row.check %}dry run{% endif %} -
{{ run.source_name }}
- - {{ status_badge(run.status) }} - {{ run.started_at|ago }} - {{ row.duration_s|duration }}{% if not run.finished_at and run.status.value in ('running', 'queued') %} so far{% endif %} - - {% endfor %} + {% include "ansible/_runs_page.html" %} diff --git a/steward/templates/audit/_rows.html b/steward/templates/audit/_rows.html new file mode 100644 index 0000000..b0e78f5 --- /dev/null +++ b/steward/templates/audit/_rows.html @@ -0,0 +1,33 @@ +{# audit/_rows.html — one page of the audit log's rows, then the sentinel that + loads the next page as the reader nears it (core.paging, pref 172). #} +{% from "_macros.html" import scroll_more, scroll_end %} + {% for item in events %} + {% set e = item.event %} + {% set d = item.detail %} + + {{ e.timestamp|ago }} + {{ e.username }} + {{ e.action }} + + {% if item.entity %} + {{ e.entity_type|replace('_', ' ') }} + {% if item.entity.url %}{{ item.entity.label }}{% else %}{{ item.entity.label }}{% endif %} + {% if item.entity.gone %}(deleted){% endif %} + {% elif e.entity_type %}{{ e.entity_type|replace('_', ' ') }}{% endif %} + + + {% if d %} + {% for k, v in d.items() %} + {{ k }}: + {{ v }} + {% if not loop.last %}  ·  {% endif %} + {% endfor %} + {% endif %} + + + {% endfor %} +{% if after %} +{{ scroll_more("/audit/?before=" ~ (after|urlencode) ~ (("&action=" ~ (action_filter|urlencode)) if action_filter else ""), cols=5) }} +{% else %} +{{ scroll_end(cols=5) }} +{% endif %} diff --git a/steward/templates/audit/list.html b/steward/templates/audit/list.html index 6becffc..45fd6a6 100644 --- a/steward/templates/audit/list.html +++ b/steward/templates/audit/list.html @@ -27,40 +27,10 @@ - {% for item in events %} - {% set e = item.event %} - {% set d = item.detail %} - - {{ e.timestamp|ago }} - {{ e.username }} - {{ e.action }} - - {% if item.entity %} - {{ e.entity_type|replace('_', ' ') }} - {% if item.entity.url %}{{ item.entity.label }}{% else %}{{ item.entity.label }}{% endif %} - {% if item.entity.gone %}(deleted){% endif %} - {% elif e.entity_type %}{{ e.entity_type|replace('_', ' ') }}{% endif %} - - - {% if d %} - {% for k, v in d.items() %} - {{ k }}: - {{ v }} - {% if not loop.last %}  ·  {% endif %} - {% endfor %} - {% endif %} - - - {% endfor %} + {% include "audit/_rows.html" %} -

- Showing last {{ limit }} events. - {% if events | length == limit %} - Load more - {% endif %} -

{% else %}
{{ empty_state("No audit events recorded yet.") }}
{% endif %} diff --git a/steward/templates/base.html b/steward/templates/base.html index 0898d80..fce5e31 100644 --- a/steward/templates/base.html +++ b/steward/templates/base.html @@ -27,6 +27,7 @@ + {% block head %}{% endblock %} diff --git a/tests/core/test_ansible_runs_page.py b/tests/core/test_ansible_runs_page.py index fd4ae90..8ef2762 100644 --- a/tests/core/test_ansible_runs_page.py +++ b/tests/core/test_ansible_runs_page.py @@ -50,7 +50,7 @@ async def test_the_runs_list_names_each_run_with_one_badge(app): )] async with app.test_request_context("/ansible/"): session["user_role"] = "viewer" - page = await render_template("ansible/index.html", runs=runs) + page = await render_template("ansible/index.html", runs=runs, after="2026-10-07T01:00:00+00:00|x") main = page[page.index("update.yml on web-1' in main @@ -58,6 +58,8 @@ async def test_the_runs_list_names_each_run_with_one_badge(app): assert 'Running' in main assert 'Failed' in main assert 'Succeeded' in main + # The list continues itself as the reader scrolls (#5211): no cap of 50. + assert 'data-scroll-more hx-get="/ansible/?before=2026-10-07T01%3A00%3A00%2B00%3A00%7Cx"' in main assert "2m 5s" in main and "40s" in main and " so far" in main assert "dry run" in main and "ago" in main assert "RUNNING" not in main and "FAILED" not in main # the old shouted status text is gone diff --git a/tests/core/test_audit_page.py b/tests/core/test_audit_page.py index 8911d4a..70ea7e3 100644 --- a/tests/core/test_audit_page.py +++ b/tests/core/test_audit_page.py @@ -39,7 +39,20 @@ async def test_the_page_names_and_links_the_entity(app): "entity": {"label": "api check", "url": None, "gone": True}}, ] async with app.test_request_context("/audit/"): - page = await render_template("audit/list.html", events=events, limit=200, action_filter="") + page = await render_template("audit/list.html", events=events, after=None, action_filter="") assert 'web-1' in page assert "api check" in page and "(deleted)" in page assert "h1" not in page # no bare id where a name is known + + + +async def test_the_log_continues_itself_instead_of_a_load_more_link(app): + events = [{"event": _ev("host", "h1", "host.renamed"), "detail": {}, "entity": None}] + async with app.test_request_context("/audit/"): + more = await render_template("audit/list.html", events=events, + after="2026-10-07T03:00:00+00:00|0190abc", action_filter="host.") + last = await render_template("audit/_rows.html", events=events, after=None, action_filter="") + assert ('data-scroll-more hx-get="/audit/?before=2026-10-07T03%3A00%3A00%2B00%3A00%7C0190abc' + '&action=host."') in more + assert "Load more" not in more + assert "data-scroll-more" not in last and "End of the list." in last diff --git a/tests/core/test_paging.py b/tests/core/test_paging.py new file mode 100644 index 0000000..43bcd45 --- /dev/null +++ b/tests/core/test_paging.py @@ -0,0 +1,34 @@ +"""Keyset paging for lists that load as you scroll (#5211): a cursor round-trips, +a mangled one starts over, and a page says whether another follows.""" +from datetime import datetime, timezone +from types import SimpleNamespace as NS + +from sqlalchemy import Column, DateTime, MetaData, String, Table, select +from sqlalchemy.dialects import postgresql + +from steward.core.paging import cursor, keyset, next_page, parse_cursor + +AT = datetime(2026, 10, 7, 3, 0, 5, 123456, tzinfo=timezone.utc) + + +def test_a_cursor_round_trips_and_a_mangled_one_starts_over(): + assert parse_cursor(cursor(AT, "0190ab|cd")) == (AT, "0190ab|cd") # an id may hold the separator + assert parse_cursor(None) is None and parse_cursor("") is None + assert parse_cursor("not a cursor") is None and parse_cursor("yesterday|x") is None + + +def test_a_page_says_whether_another_follows(): + rows = [NS(at=AT, id=str(i)) for i in range(5)] + page, after = next_page(rows, lambda r: (r.at, r.id), size=3) + assert [r.id for r in page] == ["0", "1", "2"] and after == cursor(AT, "2") + page, after = next_page(rows[:3], lambda r: (r.at, r.id), size=3) + assert len(page) == 3 and after is None # exactly a page: the end + + +def test_the_query_reads_one_row_past_the_page_older_than_the_cursor(): + t = Table("t", MetaData(), Column("at", DateTime(timezone=True)), Column("id", String)) + first = str(keyset(select(t), t.c.at, t.c.id, None, size=10).compile(dialect=postgresql.dialect())) + assert "WHERE" not in first and "ORDER BY t.at DESC, t.id DESC" in first and "LIMIT" in first + later = str(keyset(select(t), t.c.at, t.c.id, cursor(AT, "x"), size=10) + .compile(dialect=postgresql.dialect())) + assert "WHERE (t.at, t.id) < (" in later diff --git a/tests/integration/test_alert_history_page.py b/tests/integration/test_alert_history_page.py index 69d4214..bdd85da 100644 --- a/tests/integration/test_alert_history_page.py +++ b/tests/integration/test_alert_history_page.py @@ -1,7 +1,7 @@ """Integration: /alerts/history (#5066) against real Postgres. A viewer can read it; it shows the transition, value against the condition, delivery outcome with its error, and a maintenance mute; ?rule= narrows it to one -rule, and an HTMX request gets only the rows.""" +rule, an HTMX request gets only the rows, and ?before= the next page.""" from __future__ import annotations import asyncio @@ -77,6 +77,16 @@ def test_a_viewer_reads_the_alert_history(app): assert "