The audit log, the server log and the admin quota view were three pages (the quota one a Stats tab),
each with its own filters, and the background runs had no list at all. Now one page ("Napló" /
"Activity") reads all four, each still its own table on the server: a shared bar (text, a period of
1 h to 90 days defaulting to 7, an account) and each tab's own filters, the whole view in the URL.
The old page ids open their tab.
Server side: the audit log and the new run list (`/api/admin/runs`) filter and page like the server
log, through one keyset helper (`routes/_keyset`) the log now shares; an audit row keeps the request
it was made in (migration 0111), so its log lines are a pivot away, as a run's are (`lrun`). The
quota breakdown takes since/until and one account. The Scheduler rows get a history button to their
job's runs.
Also fixed on the way: re-saving the value in force on the Configuration page staged its override
without committing it (the commit sat inside the audit branch), so the save was lost.
67 lines
2.7 KiB
Python
67 lines
2.7 KiB
Python
"""Keyset paging over `(timestamp desc, id desc)`, shared by the admin history lists (server log,
|
|
audit log, background runs).
|
|
|
|
A timestamp alone is not unique (a browser log row's `ts` is clamped into its consent window; a run
|
|
and its siblings can start in the same microsecond), and an offset would skip or repeat rows while
|
|
writers add new ones at the top — so a page continues from the last row's `(timestamp, id)`.
|
|
"""
|
|
|
|
import base64
|
|
import binascii
|
|
import json
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy import and_, or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import User
|
|
|
|
PAGE_MAX = 200
|
|
|
|
|
|
def utc(value: datetime | None) -> datetime | None:
|
|
"""A bound without a zone is UTC — the columns are `timestamptz`, and a naive value would be read
|
|
in the database session's zone instead."""
|
|
if value is None or value.tzinfo is not None:
|
|
return value
|
|
return value.replace(tzinfo=timezone.utc)
|
|
|
|
|
|
def encode_cursor(ts: datetime, row_id: int) -> str:
|
|
return base64.urlsafe_b64encode(json.dumps([ts.isoformat(), row_id]).encode()).decode()
|
|
|
|
|
|
def decode_cursor(cursor: str) -> tuple[datetime, int]:
|
|
try:
|
|
ts, row_id = json.loads(base64.urlsafe_b64decode(cursor.encode()))
|
|
at = datetime.fromisoformat(ts)
|
|
return (at if at.tzinfo is not None else at.replace(tzinfo=timezone.utc)), int(row_id)
|
|
except (binascii.Error, json.JSONDecodeError, ValueError, TypeError) as exc:
|
|
raise HTTPException(status_code=400, detail={"code": "bad_cursor"}) from exc
|
|
|
|
|
|
def older_than(ts_col, id_col, cursor: str):
|
|
"""The rows after `cursor` in `(ts desc, id desc)` order. The `ts <=` conjunct is implied by the
|
|
OR; it is there so the planner can bound the timestamp index scan instead of filtering every
|
|
newer row first."""
|
|
ts, row_id = decode_cursor(cursor)
|
|
return and_(ts_col <= ts, or_(ts_col < ts, and_(ts_col == ts, id_col < row_id)))
|
|
|
|
|
|
def page(rows: list, limit: int, ts_of) -> tuple[list, str | None]:
|
|
"""`rows` was read with `limit + 1` (to know whether there is a next page without a count query):
|
|
the page itself, and the cursor that continues after it (None on the last page)."""
|
|
more = len(rows) > limit
|
|
rows = rows[:limit]
|
|
return rows, encode_cursor(ts_of(rows[-1]), rows[-1].id) if more else None
|
|
|
|
|
|
def accounts(db: Session, user_ids: set[int]) -> dict[str, str]:
|
|
"""The accounts a page names, by id → address. An id with no account left (erased after the row
|
|
was written) is simply absent, and the client shows the bare id."""
|
|
if not user_ids:
|
|
return {}
|
|
rows = db.execute(select(User.id, User.email).where(User.id.in_(user_ids))).all()
|
|
return {str(uid): email for uid, email in rows}
|