You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@tooling.apache.org by sb...@apache.org on 2026/09/04 08:43:21 UTC

(tooling-trusted-releases) branch sbp updated: Paginate the admin list of user sessions

This is an automated email from the ASF dual-hosted git repository.

sbp pushed a commit to branch sbp
in repository https://gitbox.apache.org/repos/asf/tooling-trusted-releases.git


The following commit(s) were added to refs/heads/sbp by this push:
     new 45d062d6 Paginate the admin list of user sessions
45d062d6 is described below

commit 45d062d6aca74183f8d5df0fecfac783e2b982a2
Author: Sean B. Palmer <se...@miscoranda.com>
AuthorDate: Fri Sep 4 09:42:52 2026 +0100

    Paginate the admin list of user sessions
---
 atr/admin/__init__.py | 76 +++++++++++++++++++++++++++++++++++++--------------
 atr/sessions.py       |  4 ++-
 2 files changed, 58 insertions(+), 22 deletions(-)

diff --git a/atr/admin/__init__.py b/atr/admin/__init__.py
index 6b84d908..76488a4f 100644
--- a/atr/admin/__init__.py
+++ b/atr/admin/__init__.py
@@ -67,6 +67,7 @@ import atr.noisy as noisy
 import atr.paths as paths
 import atr.pgp as pgp
 import atr.principal as principal
+import atr.render as render
 import atr.shared as shared
 import atr.shared.catalogue_diff as catalogue_diff
 import atr.shared.catalogue_import as catalogue_import
@@ -135,6 +136,11 @@ class ReleaseAgeRow(NamedTuple):
     inactive_bold: bool
 
 
+@dataclasses.dataclass
+class SessionsQuery(web.PageQuery):
+    limit: int = 250
+
+
 class RestoreBannerForm(form.Form):
     banner_id: form.Int = form.label("Banner ID", widget=form.Widget.HIDDEN)
 
@@ -2298,8 +2304,12 @@ async def toggle_view_post(
 
 
 @admin.typed
-async def users_get(session: web.Committer, _users: Literal["users"]) -> str:
-    return await _users_page(session, quart.request.args.get("tab", "browse-as"))
+async def users_get(session: web.Committer, _users: Literal["users"], query_args: SessionsQuery) -> str:
+    try:
+        validation.pagination_args_validate(query_args)
+    except ValueError as e:
+        raise exceptions.BadRequest(str(e))
+    return await _users_page(session, quart.request.args.get("tab", "browse-as"), query_args)
 
 
 @admin.typed
@@ -2310,7 +2320,7 @@ async def users_post(
         case BrowseAsUserForm():
             return await _users_browse_as(session, users_form)
         case LdapLookupForm():
-            return await _users_page(session, "ldap", ldap_form=users_form)
+            return await _users_page(session, "ldap", SessionsQuery(), ldap_form=users_form)
         case RevokeUserTokensForm():
             return await _users_revoke_tokens(session, users_form)
         case RevokeAllTokensForm():
@@ -2839,11 +2849,13 @@ async def _users_ldap_tab(lookup_form: LdapLookupForm | None) -> htm.Element:
     return htm.div[markupsafe.Markup(content)]
 
 
-async def _users_page(session: web.Committer, active_tab: str, ldap_form: LdapLookupForm | None = None) -> str:
+async def _users_page(
+    session: web.Committer, active_tab: str, query_args: SessionsQuery, ldap_form: LdapLookupForm | None = None
+) -> str:
     tab_items = [
         htm.Tab("browse-as", "Browse as user", _users_browse_as_tab),
         htm.Tab("ldap", "LDAP lookup", lambda: _users_ldap_tab(ldap_form)),
-        htm.Tab("sessions", "Sessions", _users_sessions_tab),
+        htm.Tab("sessions", "Sessions", lambda: _users_sessions_tab(query_args)),
         htm.Tab("revoke-tokens", "Revoke tokens", _users_revoke_tokens_tab),
         htm.Tab("revoke-all-tokens", "Revoke all tokens", _users_revoke_all_tokens_tab),
         htm.Tab("revoke-ssh-keys", "Revoke SSH keys", _users_revoke_ssh_keys_tab),
@@ -3028,9 +3040,19 @@ async def _users_rotate_jwt_tab() -> htm.Element:
     return block.collect()
 
 
-async def _users_sessions_tab() -> htm.Element:
+def _users_sessions_page_url(limit: int, offset: int | None) -> str | None:
+    if offset is None:
+        return None
+    return util.as_url(users_get, tab="sessions", limit=limit, offset=offset)
+
+
+async def _users_sessions_tab(query_args: SessionsQuery) -> htm.Element:
+    sessions = await asfquart.APP.sessions.current()
+    page_sessions = sessions[query_args.offset : (query_args.offset + query_args.limit)]
+    pagination = web.page_nav(query_args.offset, query_args.limit, len(sessions), len(page_sessions))
+    paged = (pagination.previous_offset is not None) or (pagination.next_offset is not None)
     tbody = htm.Block(htm.tbody)
-    for user_session in await asfquart.APP.sessions.current():
+    for user_session in page_sessions:
         tbody.tr[
             htm.td[htm.code[user_session.uid]],
             htm.td[htm.code[user_session.admin_uid] if user_session.admin_uid else ""],
@@ -3041,20 +3063,32 @@ async def _users_sessions_tab() -> htm.Element:
     block = htm.Block()
     block.h2["Sessions"]
     block.p["Every recorded session, most recently active first."]
-    block.append(
-        htm.table(".table.table-sm.table-striped.table-bordered")[
-            htm.thead[
-                htm.tr[
-                    htm.th["User ID"],
-                    htm.th["Admin"],
-                    htm.th["Created"],
-                    htm.th["Last active"],
-                    htm.th["IP address"],
-                ]
-            ],
-            tbody.collect(),
-        ]
-    )
+    if paged and page_sessions:
+        block.p[f"Showing sessions {pagination.start} to {pagination.end} of {len(sessions)}."]
+    if sessions and (not page_sessions):
+        block.div(".alert.alert-info")[f"No sessions to show on this page ({len(sessions)} total)."]
+    else:
+        block.append(
+            htm.table(".table.table-sm.table-striped.table-bordered")[
+                htm.thead[
+                    htm.tr[
+                        htm.th["User ID"],
+                        htm.th["Admin"],
+                        htm.th["Created"],
+                        htm.th["Last active"],
+                        htm.th["IP address"],
+                    ]
+                ],
+                tbody.collect(),
+            ]
+        )
+    if paged:
+        render.html_page_nav(
+            block,
+            aria_label="Session pages",
+            previous_url=_users_sessions_page_url(query_args.limit, pagination.previous_offset),
+            next_url=_users_sessions_page_url(query_args.limit, pagination.next_offset),
+        )
     return block.collect()
 
 
diff --git a/atr/sessions.py b/atr/sessions.py
index 01d230c9..43cd516e 100644
--- a/atr/sessions.py
+++ b/atr/sessions.py
@@ -189,7 +189,9 @@ class Store:
         if max_session_age > 0:
             statement = statement.where(sql.UserSession.cts >= (now - max_session_age))
         async with db.session() as data:
-            result = await data.execute(statement.order_by(via(sql.UserSession.uts).desc()))
+            result = await data.execute(
+                statement.order_by(via(sql.UserSession.uts).desc(), via(sql.UserSession.sid_hash))
+            )
             return list(result.scalars().all())
 
     async def destroy(self, hsid: str) -> None:


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@tooling.apache.org
For additional commands, e-mail: commits-help@tooling.apache.org