mirror of
https://github.com/zvx-echo6/central.git
synced 2026-06-10 20:04:43 +02:00
Merge pull request #56 from zvx-echo6/feat/layout-pagination
feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3)
This commit is contained in:
commit
76d519bcd5
5 changed files with 391 additions and 70 deletions
|
|
@ -2611,10 +2611,12 @@ async def api_keys_delete(
|
||||||
|
|
||||||
class EventsQueryResult:
|
class EventsQueryResult:
|
||||||
"""Result from events query."""
|
"""Result from events query."""
|
||||||
def __init__(self, events: list, next_cursor: str | None, error: str | None = None):
|
def __init__(self, events: list, next_cursor: str | None, error: str | None = None,
|
||||||
|
total: int | None = None):
|
||||||
self.events = events
|
self.events = events
|
||||||
self.next_cursor = next_cursor
|
self.next_cursor = next_cursor
|
||||||
self.error = error
|
self.error = error
|
||||||
|
self.total = total # filtered grand total (offset-mode only); None for cursor-mode
|
||||||
|
|
||||||
|
|
||||||
# --- v0.7.1 filtering: shared constants + pure helpers ----------------------
|
# --- v0.7.1 filtering: shared constants + pure helpers ----------------------
|
||||||
|
|
@ -2761,7 +2763,44 @@ def _build_active_pills(parsed: dict, adapter_total: int) -> list[dict]:
|
||||||
return pills
|
return pills
|
||||||
|
|
||||||
|
|
||||||
def _parse_events_params(params, default_time: str | None = None) -> tuple[dict | None, str | None]:
|
PER_PAGE_OPTIONS = [25, 50, 100, 250]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_pagination(total: int | None, offset: int, limit: int) -> dict:
|
||||||
|
"""Compute offset-mode paginator state for the GUI table.
|
||||||
|
|
||||||
|
Returns page/total_pages, the 1-based showing range (start..end), prev/next
|
||||||
|
offsets, and a windowed list of page descriptors -- {page, offset, current}
|
||||||
|
dicts interleaved with {"ellipsis": True} markers (1 ... 4 5 [6] 7 8 ... 47).
|
||||||
|
"""
|
||||||
|
total = total or 0
|
||||||
|
limit = max(1, limit)
|
||||||
|
total_pages = max(1, (total + limit - 1) // limit)
|
||||||
|
page = min(offset // limit + 1, total_pages)
|
||||||
|
window = {1, total_pages}
|
||||||
|
for p in range(page - 2, page + 3):
|
||||||
|
if 1 <= p <= total_pages:
|
||||||
|
window.add(p)
|
||||||
|
pages, prev_p = [], 0
|
||||||
|
for p in sorted(window):
|
||||||
|
if prev_p and p - prev_p > 1:
|
||||||
|
pages.append({"ellipsis": True})
|
||||||
|
pages.append({"page": p, "offset": (p - 1) * limit, "current": p == page})
|
||||||
|
prev_p = p
|
||||||
|
return {
|
||||||
|
"total": total, "offset": offset, "limit": limit,
|
||||||
|
"page": page, "total_pages": total_pages,
|
||||||
|
"start": offset + 1 if total else 0,
|
||||||
|
"end": min(offset + limit, total),
|
||||||
|
"prev_offset": offset - limit if page > 1 else None,
|
||||||
|
"next_offset": offset + limit if page < total_pages else None,
|
||||||
|
"pages": pages,
|
||||||
|
"per_page_options": PER_PAGE_OPTIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_events_params(params, default_time: str | None = None,
|
||||||
|
default_offset: int | None = None) -> tuple[dict | None, str | None]:
|
||||||
"""
|
"""
|
||||||
Parse and validate events query parameters.
|
Parse and validate events query parameters.
|
||||||
|
|
||||||
|
|
@ -2781,8 +2820,23 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
|
||||||
except ValueError:
|
except ValueError:
|
||||||
return None, f"Invalid limit value: {limit_str}"
|
return None, f"Invalid limit value: {limit_str}"
|
||||||
|
|
||||||
if limit < 1 or limit > 200:
|
if limit < 1 or limit > 250:
|
||||||
return None, "limit must be between 1 and 200"
|
return None, "limit must be between 1 and 250"
|
||||||
|
|
||||||
|
# Offset pagination (GUI). Presence of `offset` selects offset-mode in
|
||||||
|
# _fetch_events; its absence keeps the cursor path for events.json consumers.
|
||||||
|
offset = None
|
||||||
|
offset_str = params.get("offset")
|
||||||
|
if offset_str is not None and offset_str != "":
|
||||||
|
try:
|
||||||
|
offset = int(offset_str)
|
||||||
|
except ValueError:
|
||||||
|
return None, f"Invalid offset value: {offset_str}"
|
||||||
|
if offset < 0:
|
||||||
|
return None, "offset must be >= 0"
|
||||||
|
elif default_offset is not None:
|
||||||
|
# GUI defaults to offset-mode (page 1) even when the URL omits offset.
|
||||||
|
offset = default_offset
|
||||||
|
|
||||||
# Multi-value filters (comma-separated; a single value is just a CSV of
|
# Multi-value filters (comma-separated; a single value is just a CSV of
|
||||||
# length 1, preserving the old single-value API). Unknown severity labels
|
# length 1, preserving the old single-value API). Unknown severity labels
|
||||||
|
|
@ -2892,6 +2946,7 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
"q": q,
|
"q": q,
|
||||||
"adapters": adapters,
|
"adapters": adapters,
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
|
|
@ -2946,6 +3001,8 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
bbox = parsed_params["bbox"]
|
bbox = parsed_params["bbox"]
|
||||||
cursor_time = parsed_params["cursor_time"]
|
cursor_time = parsed_params["cursor_time"]
|
||||||
cursor_id = parsed_params["cursor_id"]
|
cursor_id = parsed_params["cursor_id"]
|
||||||
|
offset = parsed_params.get("offset")
|
||||||
|
offset_mode = offset is not None # GUI; else cursor-mode (events.json)
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
conditions = []
|
conditions = []
|
||||||
|
|
@ -3016,7 +3073,20 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
if conditions:
|
if conditions:
|
||||||
where_clause = "WHERE " + " AND ".join(conditions)
|
where_clause = "WHERE " + " AND ".join(conditions)
|
||||||
|
|
||||||
# Fetch limit+1 to check for next page
|
# Offset-mode (GUI): exact page slice + grand total in one roundtrip via a
|
||||||
|
# window count. Cursor-mode (events.json): fetch limit+1 to detect next page.
|
||||||
|
if offset_mode:
|
||||||
|
total_select = ", count(*) OVER() AS total_count"
|
||||||
|
limit_clause = f"LIMIT ${param_idx} OFFSET ${param_idx + 1}"
|
||||||
|
query_params.append(limit)
|
||||||
|
query_params.append(offset)
|
||||||
|
param_idx += 2
|
||||||
|
else:
|
||||||
|
total_select = ""
|
||||||
|
limit_clause = f"LIMIT ${param_idx}"
|
||||||
|
query_params.append(limit + 1)
|
||||||
|
param_idx += 1
|
||||||
|
|
||||||
query = f"""
|
query = f"""
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
|
|
@ -3027,13 +3097,12 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
severity,
|
severity,
|
||||||
ST_AsGeoJSON(geom) as geometry,
|
ST_AsGeoJSON(geom) as geometry,
|
||||||
payload as data,
|
payload as data,
|
||||||
regions
|
regions{total_select}
|
||||||
FROM public.events
|
FROM public.events
|
||||||
{where_clause}
|
{where_clause}
|
||||||
ORDER BY time DESC, id DESC
|
ORDER BY time DESC, id DESC
|
||||||
LIMIT ${param_idx}
|
{limit_clause}
|
||||||
"""
|
"""
|
||||||
query_params.append(limit + 1)
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
async with pool.acquire() as conn:
|
async with pool.acquire() as conn:
|
||||||
|
|
@ -3042,7 +3111,11 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
logger.error(f"Database error in _fetch_events: {e}")
|
logger.error(f"Database error in _fetch_events: {e}")
|
||||||
return EventsQueryResult([], None, "Database error")
|
return EventsQueryResult([], None, "Database error")
|
||||||
|
|
||||||
# Check if there is a next page
|
total = None
|
||||||
|
has_next = False
|
||||||
|
if offset_mode:
|
||||||
|
total = rows[0].get("total_count", 0) if rows else 0
|
||||||
|
else:
|
||||||
has_next = len(rows) > limit
|
has_next = len(rows) > limit
|
||||||
if has_next:
|
if has_next:
|
||||||
rows = rows[:limit]
|
rows = rows[:limit]
|
||||||
|
|
@ -3073,14 +3146,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
|
||||||
event["subject"] = _derive_subject(event)
|
event["subject"] = _derive_subject(event)
|
||||||
events.append(event)
|
events.append(event)
|
||||||
|
|
||||||
# Build next_cursor if there are more results
|
# Build next_cursor (cursor-mode only) when there are more results.
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
if has_next and events:
|
if has_next and events:
|
||||||
last_event = rows[-1]
|
last_event = rows[-1]
|
||||||
cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
|
cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
|
||||||
next_cursor = base64.b64encode(cursor_data.encode("utf-8")).decode("utf-8")
|
next_cursor = base64.b64encode(cursor_data.encode("utf-8")).decode("utf-8")
|
||||||
|
|
||||||
return EventsQueryResult(events, next_cursor)
|
return EventsQueryResult(events, next_cursor, total=total)
|
||||||
|
|
||||||
|
|
||||||
def _geometry_summary(geometry: dict | None) -> str:
|
def _geometry_summary(geometry: dict | None) -> str:
|
||||||
|
|
@ -3108,14 +3181,16 @@ def _geometry_summary(geometry: dict | None) -> str:
|
||||||
|
|
||||||
|
|
||||||
def _format_event_time(iso: str | None) -> str:
|
def _format_event_time(iso: str | None) -> str:
|
||||||
"""Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds)."""
|
"""Format an ISO-8601 timestamp as 'MM-DD HH:MM UTC' (24h, no seconds, no
|
||||||
|
year) for a single-line, stable-height table cell. The full timestamp
|
||||||
|
(with year) stays available in the cell tooltip + the expanded detail row."""
|
||||||
if not iso:
|
if not iso:
|
||||||
return ""
|
return ""
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
|
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return iso
|
return iso
|
||||||
return dt.strftime("%m-%d-%Y %H:%M") + " UTC"
|
return dt.strftime("%m-%d %H:%M") + " UTC"
|
||||||
|
|
||||||
|
|
||||||
def _decorate_table_events(events: list[dict]) -> None:
|
def _decorate_table_events(events: list[dict]) -> None:
|
||||||
|
|
@ -3123,13 +3198,16 @@ def _decorate_table_events(events: list[dict]) -> None:
|
||||||
|
|
||||||
These are for the table chrome only and are deliberately NOT added in
|
These are for the table chrome only and are deliberately NOT added in
|
||||||
_fetch_events, so the /events.json payload is unchanged. adapter_display
|
_fetch_events, so the /events.json payload is unchanged. adapter_display
|
||||||
is sourced from the registry (display_name), with the bare name as fallback.
|
is sourced from the registry (display_name), with the bare name as fallback;
|
||||||
|
adapter_color is the same positional palette color the map + legend use.
|
||||||
"""
|
"""
|
||||||
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
|
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
|
||||||
|
color = {a["name"]: a["color"] for a in _adapter_filter_options()[0]}
|
||||||
for event in events:
|
for event in events:
|
||||||
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
|
||||||
event["time_human"] = _format_event_time(event.get("time"))
|
event["time_human"] = _format_event_time(event.get("time"))
|
||||||
event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter"))
|
event["adapter_display"] = display.get(event.get("adapter"), event.get("adapter"))
|
||||||
|
event["adapter_color"] = color.get(event.get("adapter"), "#888")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -3182,7 +3260,7 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
|
|
||||||
# Parse parameters (GUI defaults to Last 24h when no time filter is given).
|
# Parse parameters (GUI defaults to Last 24h when no time filter is given).
|
||||||
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
|
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0)
|
||||||
|
|
||||||
# Get system settings for map tiles + DISTINCT filter-option lists.
|
# Get system settings for map tiles + DISTINCT filter-option lists.
|
||||||
pool = get_pool()
|
pool = get_pool()
|
||||||
|
|
@ -3205,6 +3283,7 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
|
total = 0
|
||||||
if error:
|
if error:
|
||||||
# Validation error - show error banner but don't fail the page
|
# Validation error - show error banner but don't fail the page
|
||||||
pass
|
pass
|
||||||
|
|
@ -3215,9 +3294,12 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
else:
|
else:
|
||||||
events = result.events
|
events = result.events
|
||||||
next_cursor = result.next_cursor
|
next_cursor = result.next_cursor
|
||||||
|
total = result.total or 0
|
||||||
|
|
||||||
_decorate_table_events(events)
|
_decorate_table_events(events)
|
||||||
|
|
||||||
|
pagination = _build_pagination(total, (parsed or {}).get("offset") or 0,
|
||||||
|
(parsed or {}).get("limit") or 50)
|
||||||
adapters_flat, adapters_grouped = _adapter_filter_options()
|
adapters_flat, adapters_grouped = _adapter_filter_options()
|
||||||
pstate = parsed or {}
|
pstate = parsed or {}
|
||||||
filter_state = {
|
filter_state = {
|
||||||
|
|
@ -3235,7 +3317,9 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
"limit": str(pstate.get("limit", 50)),
|
"limit": str(pstate.get("limit", 50)),
|
||||||
}
|
}
|
||||||
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []
|
active_pills = _build_active_pills(pstate, len(adapters_flat)) if parsed else []
|
||||||
query_string = urlencode([(k, v) for k, v in _query_items(params) if k != "cursor"])
|
# Paginator links append offset; keep cursor + offset out of the carried qs.
|
||||||
|
query_string = urlencode([(k, v) for k, v in _query_items(params)
|
||||||
|
if k not in ("cursor", "offset")])
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
|
|
@ -3245,6 +3329,7 @@ async def events_list(request: Request) -> HTMLResponse:
|
||||||
"csrf_token": csrf_token,
|
"csrf_token": csrf_token,
|
||||||
"events": events,
|
"events": events,
|
||||||
"next_cursor": next_cursor,
|
"next_cursor": next_cursor,
|
||||||
|
"pagination": pagination,
|
||||||
"filter_error": error,
|
"filter_error": error,
|
||||||
"tile_url": tile_url,
|
"tile_url": tile_url,
|
||||||
"tile_attribution": tile_attribution,
|
"tile_attribution": tile_attribution,
|
||||||
|
|
@ -3270,10 +3355,11 @@ async def events_rows(request: Request) -> HTMLResponse:
|
||||||
params = request.query_params
|
params = request.query_params
|
||||||
|
|
||||||
# Parse parameters (same GUI default as the page).
|
# Parse parameters (same GUI default as the page).
|
||||||
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME)
|
parsed, error = _parse_events_params(params, default_time=DEFAULT_TIME, default_offset=0)
|
||||||
|
|
||||||
events = []
|
events = []
|
||||||
next_cursor = None
|
next_cursor = None
|
||||||
|
total = 0
|
||||||
if error:
|
if error:
|
||||||
pass
|
pass
|
||||||
else:
|
else:
|
||||||
|
|
@ -3283,12 +3369,16 @@ async def events_rows(request: Request) -> HTMLResponse:
|
||||||
else:
|
else:
|
||||||
events = result.events
|
events = result.events
|
||||||
next_cursor = result.next_cursor
|
next_cursor = result.next_cursor
|
||||||
|
total = result.total or 0
|
||||||
|
|
||||||
_decorate_table_events(events)
|
_decorate_table_events(events)
|
||||||
|
|
||||||
|
pagination = _build_pagination(total, (parsed or {}).get("offset") or 0,
|
||||||
|
(parsed or {}).get("limit") or 50)
|
||||||
adapters_flat, _ = _adapter_filter_options()
|
adapters_flat, _ = _adapter_filter_options()
|
||||||
active_pills = _build_active_pills(parsed or {}, len(adapters_flat)) if parsed else []
|
active_pills = _build_active_pills(parsed or {}, len(adapters_flat)) if parsed else []
|
||||||
query_string = urlencode([(k, v) for k, v in _query_items(params) if k != "cursor"])
|
query_string = urlencode([(k, v) for k, v in _query_items(params)
|
||||||
|
if k not in ("cursor", "offset")])
|
||||||
|
|
||||||
response = templates.TemplateResponse(
|
response = templates.TemplateResponse(
|
||||||
request=request,
|
request=request,
|
||||||
|
|
@ -3296,6 +3386,7 @@ async def events_rows(request: Request) -> HTMLResponse:
|
||||||
context={
|
context={
|
||||||
"events": events,
|
"events": events,
|
||||||
"next_cursor": next_cursor,
|
"next_cursor": next_cursor,
|
||||||
|
"pagination": pagination,
|
||||||
"filter_error": error,
|
"filter_error": error,
|
||||||
"active_pills": active_pills,
|
"active_pills": active_pills,
|
||||||
"query_string": query_string,
|
"query_string": query_string,
|
||||||
|
|
|
||||||
|
|
@ -38,16 +38,18 @@
|
||||||
data-subject="{{ subject_summary | trim }}"
|
data-subject="{{ subject_summary | trim }}"
|
||||||
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
|
||||||
<td><button type="button" class="expand-row" aria-label="Expand">▸</button></td>
|
<td><button type="button" class="expand-row" aria-label="Expand">▸</button></td>
|
||||||
<td title="{{ event.time }}">{{ event.time_human }}</td>
|
<td class="cell-time" title="{{ event.time }}">{{ event.time_human }}</td>
|
||||||
<td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
|
<td>{% if loc_parts %}{{ loc_parts | join(', ') }}{% elif gc.get('landclass') %}{{ gc.landclass }}{% else %}—{% endif %}</td>
|
||||||
<td>{{ subject_summary | trim or '—' }}</td>
|
<td>{{ subject_summary | trim or '—' }}</td>
|
||||||
<td>{{ event.adapter_display }}</td>
|
<td><span class="adapter-chip" title="{{ event.adapter_display }}"><span class="adapter-chip-swatch" style="background:{{ event.adapter_color }}"></span>{{ event.adapter }}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr class="event-detail" hidden>
|
<tr class="event-detail" hidden>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<dl class="event-detail-list">
|
<dl class="event-detail-list">
|
||||||
<dt>Event ID</dt>
|
<dt>Event ID</dt>
|
||||||
<dd><code>{{ event.id }}</code></dd>
|
<dd><code>{{ event.id }}</code></dd>
|
||||||
|
<dt>Time</dt>
|
||||||
|
<dd>{{ event.time }}</dd>
|
||||||
<dt>Received</dt>
|
<dt>Received</dt>
|
||||||
<dd>{{ event.received }}</dd>
|
<dd>{{ event.received }}</dd>
|
||||||
{% if event.regions %}
|
{% if event.regions %}
|
||||||
|
|
@ -68,21 +70,37 @@
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<div class="pagination-info">
|
{# Real offset paginator (v0.7.3). Each link carries offset + the filter
|
||||||
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
|
query_string (which excludes cursor/offset); limit persists via query_string. #}
|
||||||
{% if next_cursor %}
|
{% macro page_link(off, label, cls, extra) %}
|
||||||
{% set next_qs = "cursor=" ~ next_cursor ~ ("&" ~ query_string if query_string else "") %}
|
{% set qs = "offset=" ~ off ~ ("&" ~ query_string if query_string else "") %}
|
||||||
<a href="/events?{{ next_qs }}"
|
<a href="/events?{{ qs }}" role="button" class="page-link {{ cls }}"
|
||||||
role="button"
|
hx-get="/events/rows?{{ qs }}" hx-target="#events-rows" hx-push-url="true" {{ extra }}>{{ label }}</a>
|
||||||
hx-get="/events/rows?{{ next_qs }}"
|
{% endmacro %}
|
||||||
hx-target="#events-rows"
|
<nav class="paginator" aria-label="Pagination">
|
||||||
hx-push-url="true">
|
<div class="paginator-pages">
|
||||||
Next →
|
{% if pagination.prev_offset is not none %}{{ page_link(pagination.prev_offset, "‹ Previous", "page-prev", "") }}{% else %}<span class="page-link disabled">‹ Previous</span>{% endif %}
|
||||||
</a>
|
{% for p in pagination.pages %}
|
||||||
{% else %}
|
{% if p.ellipsis %}<span class="page-ellipsis">…</span>
|
||||||
<span><em>End of results</em></span>
|
{% elif p.current %}<span class="page-link current" aria-current="page">{{ p.page }}</span>
|
||||||
{% endif %}
|
{% else %}{{ page_link(p.offset, p.page, "", "") }}{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if pagination.next_offset is not none %}{{ page_link(pagination.next_offset, "Next ›", "page-next", "") }}{% else %}<span class="page-link disabled">Next ›</span>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="paginator-meta">
|
||||||
|
{% if pagination.total %}
|
||||||
|
<span>Page {{ pagination.page }} of {{ pagination.total_pages }} (showing {{ "{:,}".format(pagination.start) }}–{{ "{:,}".format(pagination.end) }} of {{ "{:,}".format(pagination.total) }})</span>
|
||||||
|
{% else %}<span>No events.</span>{% endif %}
|
||||||
|
<label class="per-page">Per page:
|
||||||
|
{# JS-only: syncs the form's hidden #filter-limit and re-submits at page 1. #}
|
||||||
|
<select id="per-page-select">
|
||||||
|
{% for n in pagination.per_page_options %}
|
||||||
|
<option value="{{ n }}" {{ 'selected' if n == pagination.limit else '' }}>{{ n }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
{% else %}
|
{% else %}
|
||||||
<article>
|
<article>
|
||||||
<p>No events match the filters.</p>
|
<p>No events match the filters.</p>
|
||||||
|
|
|
||||||
|
|
@ -152,6 +152,47 @@
|
||||||
.map-filter-toggle { display: inline-flex; align-items: center; gap: 0.35rem;
|
.map-filter-toggle { display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
font-size: 0.85rem; cursor: pointer; }
|
font-size: 0.85rem; cursor: pointer; }
|
||||||
.map-filter-toggle input { width: auto; margin: 0; }
|
.map-filter-toggle input { width: auto; margin: 0; }
|
||||||
|
/* --- v0.7.3 row stability: fixed layout, single-line cells, ellipsis --- */
|
||||||
|
.events-table { table-layout: fixed; width: 100%; }
|
||||||
|
.events-table th:nth-child(1), .events-table td:nth-child(1) { width: 2rem; }
|
||||||
|
.events-table th:nth-child(2), .events-table td:nth-child(2) { width: 8.5rem; }
|
||||||
|
.events-table th:nth-child(3), .events-table td:nth-child(3) { width: 22%; }
|
||||||
|
.events-table th:nth-child(5), .events-table td:nth-child(5) { width: 9rem; }
|
||||||
|
.events-table tbody tr.event-row > td {
|
||||||
|
height: 37px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.cell-time { font-variant-numeric: tabular-nums; }
|
||||||
|
.adapter-chip { display: inline-flex; align-items: center; gap: 0.35rem;
|
||||||
|
max-width: 100%; overflow: hidden; }
|
||||||
|
.adapter-chip-swatch { width: 0.7rem; height: 0.7rem; border-radius: 2px; flex: 0 0 auto; }
|
||||||
|
.adapter-chip { white-space: nowrap; text-overflow: ellipsis; }
|
||||||
|
/* --- v0.7.3 collapsible grouped legend --- */
|
||||||
|
.legend-toggle { font-size: 0.8rem; padding: 0.25rem 0.6rem; }
|
||||||
|
.legend-body { display: grid; grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
|
||||||
|
gap: 0.2rem 0.75rem; margin-top: 0.5rem; }
|
||||||
|
.legend-group { break-inside: avoid; }
|
||||||
|
.legend-group-header { font-size: 0.7rem; font-weight: 600; text-transform: uppercase;
|
||||||
|
color: var(--pico-muted-color); margin: 0.3rem 0 0.15rem; }
|
||||||
|
.legend-chip { display: flex; align-items: center; gap: 0.4rem; width: 100%;
|
||||||
|
background: none; border: none; padding: 0.12rem 0.2rem; cursor: pointer;
|
||||||
|
color: var(--pico-color); font-size: 0.82rem; text-align: left; }
|
||||||
|
.legend-chip:hover { background: var(--pico-primary-focus); }
|
||||||
|
.legend-chip-swatch { width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: 0 0 auto; }
|
||||||
|
.legend-chip-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
/* --- v0.7.3 paginator --- */
|
||||||
|
.paginator { display: flex; flex-wrap: wrap; justify-content: space-between;
|
||||||
|
align-items: center; gap: 0.5rem; margin-top: 1rem; }
|
||||||
|
.paginator-pages { display: flex; flex-wrap: wrap; gap: 0.25rem; align-items: center; }
|
||||||
|
.page-link { padding: 0.2rem 0.55rem; font-size: 0.85rem; border-radius: 0.3rem;
|
||||||
|
text-decoration: none; }
|
||||||
|
.page-link.current { background: var(--pico-primary); color: var(--pico-primary-inverse);
|
||||||
|
font-weight: 600; }
|
||||||
|
.page-link.disabled { opacity: 0.4; pointer-events: none; }
|
||||||
|
.page-ellipsis { padding: 0 0.2rem; color: var(--pico-muted-color); }
|
||||||
|
.paginator-meta { display: flex; align-items: center; gap: 0.75rem; font-size: 0.85rem;
|
||||||
|
color: var(--pico-muted-color); }
|
||||||
|
.per-page select { width: auto; display: inline-block; padding: 0.1rem 1.5rem 0.1rem 0.4rem;
|
||||||
|
margin: 0; height: auto; }
|
||||||
</style>
|
</style>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
@ -212,7 +253,7 @@
|
||||||
<input type="hidden" id="region_south" name="region_south" value="{{ filter_state.region_south }}">
|
<input type="hidden" id="region_south" name="region_south" value="{{ filter_state.region_south }}">
|
||||||
<input type="hidden" id="region_east" name="region_east" value="{{ filter_state.region_east }}">
|
<input type="hidden" id="region_east" name="region_east" value="{{ filter_state.region_east }}">
|
||||||
<input type="hidden" id="region_west" name="region_west" value="{{ filter_state.region_west }}">
|
<input type="hidden" id="region_west" name="region_west" value="{{ filter_state.region_west }}">
|
||||||
<input type="hidden" name="limit" value="{{ filter_state.limit }}">
|
<input type="hidden" name="limit" id="filter-limit" value="{{ filter_state.limit }}">
|
||||||
{# Map-filter toggle state. Disabled (omitted from the URL) when off; the
|
{# Map-filter toggle state. Disabled (omitted from the URL) when off; the
|
||||||
map-controls checkbox below syncs + enables it. #}
|
map-controls checkbox below syncs + enables it. #}
|
||||||
<input type="hidden" name="map_filter" id="filter-map_filter" value="1"
|
<input type="hidden" name="map_filter" id="filter-map_filter" value="1"
|
||||||
|
|
@ -229,14 +270,26 @@
|
||||||
|
|
||||||
<div id="events-map"></div>
|
<div id="events-map"></div>
|
||||||
<div class="map-controls">
|
<div class="map-controls">
|
||||||
|
{# Adapter legend: collapsed by default; expands to domain-grouped chips
|
||||||
|
(same grouping as the v0.7.1 chip-picker). Clicking a chip toggles that
|
||||||
|
adapter's filter (reuses the chip-picker's hidden CSV via syncField). #}
|
||||||
<div class="map-legend">
|
<div class="map-legend">
|
||||||
{% for a in adapters %}
|
<button type="button" id="legend-toggle" class="legend-toggle outline secondary"
|
||||||
<div class="map-legend-item">
|
aria-expanded="false">{{ adapters | length }} adapters · Show legend ▾</button>
|
||||||
<div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
|
<div id="legend-body" class="legend-body" hidden>
|
||||||
<span>{{ a.display_name }}</span>
|
{% for group_label, items in adapters_grouped %}
|
||||||
|
<div class="legend-group">
|
||||||
|
<div class="legend-group-header">{{ group_label }}</div>
|
||||||
|
{% for opt in items %}
|
||||||
|
<button type="button" class="legend-chip" data-adapter="{{ opt.value }}" title="{{ opt.label }}">
|
||||||
|
<span class="legend-chip-swatch" style="background:{{ opt.color }}"></span>
|
||||||
|
<span class="legend-chip-label">{{ opt.label }}</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<label class="map-filter-toggle">
|
<label class="map-filter-toggle">
|
||||||
<input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}>
|
<input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}>
|
||||||
Filter table by map view
|
Filter table by map view
|
||||||
|
|
@ -808,6 +861,38 @@
|
||||||
}
|
}
|
||||||
if (e.target.closest("[data-clear-all]")) { window.location.href = "/events"; }
|
if (e.target.closest("[data-clear-all]")) { window.location.href = "/events"; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Legend: collapse/expand toggle.
|
||||||
|
var legendToggle = document.getElementById("legend-toggle");
|
||||||
|
var legendBody = document.getElementById("legend-body");
|
||||||
|
if (legendToggle && legendBody) {
|
||||||
|
legendToggle.addEventListener("click", function () {
|
||||||
|
var open = legendBody.hidden;
|
||||||
|
legendBody.hidden = !open;
|
||||||
|
legendToggle.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
|
legendToggle.textContent = legendBody.querySelectorAll(".legend-chip").length +
|
||||||
|
" adapters · " + (open ? "Hide legend ▴" : "Show legend ▾");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Legend chip click -> toggle that adapter in the adapter chip-picker filter.
|
||||||
|
document.addEventListener("click", function (e) {
|
||||||
|
var chip = e.target.closest(".legend-chip");
|
||||||
|
if (!chip) return;
|
||||||
|
var name = chip.dataset.adapter;
|
||||||
|
var cb = form.querySelector('.chip-cb[data-field="adapter"][value="' + name + '"]');
|
||||||
|
if (cb) { cb.checked = !cb.checked; syncField("adapter"); submitForm(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Per-page selector (rendered inside #events-rows) -> set hidden limit and
|
||||||
|
// re-submit at page 1 (the form carries no offset, so it resets to 0).
|
||||||
|
var limitHidden = document.getElementById("filter-limit");
|
||||||
|
document.addEventListener("change", function (e) {
|
||||||
|
if (e.target.id === "per-page-select" && limitHidden) {
|
||||||
|
limitHidden.value = e.target.value;
|
||||||
|
submitForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -248,14 +248,14 @@ class TestEventsFeedFrontendAuthenticated:
|
||||||
assert context["events"] == []
|
assert context["events"] == []
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_events_with_limit_shows_next_button(self):
|
async def test_events_with_limit_builds_pagination(self):
|
||||||
"""GET /events?limit=5 shows Next button when more events exist."""
|
"""GET /events?limit=5 (offset-mode): pagination reflects total/next page."""
|
||||||
mock_request = MagicMock()
|
mock_request = MagicMock()
|
||||||
mock_request.state.operator = MagicMock(id=1, username="admin")
|
mock_request.state.operator = MagicMock(id=1, username="admin")
|
||||||
mock_request.state.csrf_token = "test_csrf_token"
|
mock_request.state.csrf_token = "test_csrf_token"
|
||||||
mock_request.query_params = {"limit": "5"}
|
mock_request.query_params = {"limit": "5"}
|
||||||
|
|
||||||
# Return 6 events (limit+1) to trigger pagination
|
# 5 rows for page 1; total_count=12 (the window count) => 3 pages.
|
||||||
mock_events = [
|
mock_events = [
|
||||||
{
|
{
|
||||||
"id": f"event_{i}",
|
"id": f"event_{i}",
|
||||||
|
|
@ -267,8 +267,9 @@ class TestEventsFeedFrontendAuthenticated:
|
||||||
"geometry": None,
|
"geometry": None,
|
||||||
"data": {},
|
"data": {},
|
||||||
"regions": [],
|
"regions": [],
|
||||||
|
"total_count": 12,
|
||||||
}
|
}
|
||||||
for i in range(6)
|
for i in range(5)
|
||||||
]
|
]
|
||||||
|
|
||||||
mock_conn = AsyncMock()
|
mock_conn = AsyncMock()
|
||||||
|
|
@ -291,8 +292,11 @@ class TestEventsFeedFrontendAuthenticated:
|
||||||
|
|
||||||
assert result.status_code == 200
|
assert result.status_code == 200
|
||||||
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
assert context["next_cursor"] is not None
|
pg = context["pagination"]
|
||||||
assert len(context["events"]) == 5 # Should be trimmed to limit
|
assert pg["total"] == 12 and pg["total_pages"] == 3 and pg["page"] == 1
|
||||||
|
assert pg["next_offset"] == 5 and pg["prev_offset"] is None
|
||||||
|
assert context["next_cursor"] is None # offset-mode (GUI) doesn't use cursor
|
||||||
|
assert len(context["events"]) == 5
|
||||||
|
|
||||||
|
|
||||||
class TestEventsRowsFragment:
|
class TestEventsRowsFragment:
|
||||||
|
|
@ -531,8 +535,9 @@ class TestCrossEndpointParity:
|
||||||
assert html_context["events"][0]["category"] == "Weather Alert"
|
assert html_context["events"][0]["category"] == "Weather Alert"
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_cursor_pagination_both_endpoints(self):
|
async def test_pagination_modes_split_cleanly_by_endpoint(self):
|
||||||
"""Cursor pagination works identically on both endpoints."""
|
"""v0.7.3: events.json keeps CURSOR pagination (next_cursor set); the
|
||||||
|
GUI events page uses OFFSET pagination (next_cursor None + pagination)."""
|
||||||
first_page = [
|
first_page = [
|
||||||
{
|
{
|
||||||
"id": f"event_{i}",
|
"id": f"event_{i}",
|
||||||
|
|
@ -585,9 +590,10 @@ class TestCrossEndpointParity:
|
||||||
await events_list(html_request)
|
await events_list(html_request)
|
||||||
|
|
||||||
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
html_context = mock_templates.TemplateResponse.call_args.kwargs.get("context")
|
||||||
html_cursor = html_context["next_cursor"]
|
# events.json paginates by cursor; the GUI by offset (no cursor, has paginator).
|
||||||
|
assert json_cursor is not None
|
||||||
assert json_cursor == html_cursor
|
assert html_context["next_cursor"] is None
|
||||||
|
assert "pagination" in html_context
|
||||||
|
|
||||||
|
|
||||||
class TestErrorSemantics:
|
class TestErrorSemantics:
|
||||||
|
|
@ -700,15 +706,18 @@ class TestEventRowDataAttributes:
|
||||||
|
|
||||||
def _events_context(events):
|
def _events_context(events):
|
||||||
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
|
"""Minimal context for rendering _events_rows.html as a standalone fragment."""
|
||||||
|
n = len(events)
|
||||||
return {
|
return {
|
||||||
"events": events,
|
"events": events,
|
||||||
"next_cursor": None,
|
"next_cursor": None,
|
||||||
"filter_error": None,
|
"query_string": "",
|
||||||
"filter_values": {
|
"pagination": {
|
||||||
"adapter": "", "category": "", "since": "", "until": "",
|
"total": n, "offset": 0, "limit": 50, "page": 1, "total_pages": 1,
|
||||||
"region_north": "", "region_south": "", "region_east": "",
|
"start": 1 if n else 0, "end": n, "prev_offset": None, "next_offset": None,
|
||||||
"region_west": "", "limit": "50",
|
"pages": [{"page": 1, "offset": 0, "current": True}] if n else [],
|
||||||
|
"per_page_options": [25, 50, 100, 250],
|
||||||
},
|
},
|
||||||
|
"filter_error": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -873,27 +882,28 @@ def _first_row_cells(html):
|
||||||
|
|
||||||
|
|
||||||
class TestEventTimeFormat:
|
class TestEventTimeFormat:
|
||||||
"""(A) Server-side 'MM-DD-YYYY HH:MM UTC' formatting (24h, no seconds)."""
|
"""(A) Server-side 'MM-DD HH:MM UTC' formatting (24h, no seconds, no year;
|
||||||
|
v0.7.3 dropped the year from the cell for single-line stable rows)."""
|
||||||
|
|
||||||
def test_format_basic_utc(self):
|
def test_format_basic_utc(self):
|
||||||
from central.gui.routes import _format_event_time
|
from central.gui.routes import _format_event_time
|
||||||
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21-2026 06:00 UTC"
|
assert _format_event_time("2026-05-21T06:00:00+00:00") == "05-21 06:00 UTC"
|
||||||
|
|
||||||
def test_format_converts_offset_to_utc(self):
|
def test_format_converts_offset_to_utc(self):
|
||||||
from central.gui.routes import _format_event_time
|
from central.gui.routes import _format_event_time
|
||||||
# 19:30 at -06:00 is 01:30 UTC the next day.
|
# 19:30 at -06:00 is 01:30 UTC the next day.
|
||||||
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21-2026 01:30 UTC"
|
assert _format_event_time("2026-05-20T19:30:00-06:00") == "05-21 01:30 UTC"
|
||||||
|
|
||||||
def test_format_empty_and_none(self):
|
def test_format_empty_and_none(self):
|
||||||
from central.gui.routes import _format_event_time
|
from central.gui.routes import _format_event_time
|
||||||
assert _format_event_time("") == ""
|
assert _format_event_time("") == ""
|
||||||
assert _format_event_time(None) == ""
|
assert _format_event_time(None) == ""
|
||||||
|
|
||||||
def test_format_no_seconds_no_offset_suffix(self):
|
def test_format_no_seconds_no_year_no_offset_suffix(self):
|
||||||
from central.gui.routes import _format_event_time
|
from central.gui.routes import _format_event_time
|
||||||
out = _format_event_time("2026-01-02T03:04:59+00:00")
|
out = _format_event_time("2026-01-02T03:04:59+00:00")
|
||||||
assert out == "01-02-2026 03:04 UTC"
|
assert out == "01-02 03:04 UTC"
|
||||||
assert ":59" not in out and "+00" not in out
|
assert ":59" not in out and "+00" not in out and "2026" not in out
|
||||||
|
|
||||||
|
|
||||||
class TestTableDisplayDecoration:
|
class TestTableDisplayDecoration:
|
||||||
|
|
@ -1084,8 +1094,11 @@ class TestTableRendersThroughHTTP:
|
||||||
resp = self._client().get("/events")
|
resp = self._client().get("/events")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
cells = _first_row_cells(resp.text)
|
cells = _first_row_cells(resp.text)
|
||||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
# v0.7.3: single-line MM-DD HH:MM UTC (no year in the cell).
|
||||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
assert cells[1].endswith("UTC") and "2026" not in cells[1] and len(cells[1].split()) == 3
|
||||||
|
# Adapter cell is now a chip showing the short name; display_name is the tooltip.
|
||||||
|
assert cells[4] == "usgs_quake"
|
||||||
|
assert self._expected_adapter_display() in resp.text # display_name in title=
|
||||||
|
|
||||||
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
|
def test_events_rows_fragment_time_and_adapter_cells_populated(self):
|
||||||
mock_pool = self._mock_pool()
|
mock_pool = self._mock_pool()
|
||||||
|
|
@ -1094,8 +1107,9 @@ class TestTableRendersThroughHTTP:
|
||||||
resp = self._client().get("/events/rows")
|
resp = self._client().get("/events/rows")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
cells = _first_row_cells(resp.text)
|
cells = _first_row_cells(resp.text)
|
||||||
assert cells[1].endswith("UTC") and "2026" in cells[1] # Time non-empty
|
assert cells[1].endswith("UTC") and "2026" not in cells[1] and len(cells[1].split()) == 3
|
||||||
assert cells[4] == self._expected_adapter_display() # Adapter display_name
|
assert cells[4] == "usgs_quake"
|
||||||
|
assert self._expected_adapter_display() in resp.text
|
||||||
|
|
||||||
|
|
||||||
# --- feat(events-json-subject): JSON subject derivation ------------------
|
# --- feat(events-json-subject): JSON subject derivation ------------------
|
||||||
|
|
|
||||||
113
tests/test_events_pagination.py
Normal file
113
tests/test_events_pagination.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""Tests for v0.7.3 offset pagination: offset/limit parsing, offset-vs-cursor
|
||||||
|
query shape, and the _build_pagination windowing math. No live DB (captured SQL).
|
||||||
|
"""
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from central.gui import routes
|
||||||
|
|
||||||
|
|
||||||
|
# --- offset / limit parsing -------------------------------------------------
|
||||||
|
|
||||||
|
def test_offset_parse_valid():
|
||||||
|
parsed, err = routes._parse_events_params({"offset": "100", "limit": "50", "time": "all"})
|
||||||
|
assert err is None and parsed["offset"] == 100 and parsed["limit"] == 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_negative_errors():
|
||||||
|
parsed, err = routes._parse_events_params({"offset": "-1", "time": "all"})
|
||||||
|
assert parsed is None and "offset" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_offset_noninteger_errors():
|
||||||
|
parsed, err = routes._parse_events_params({"offset": "abc", "time": "all"})
|
||||||
|
assert parsed is None and "offset" in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_limit_max_is_250():
|
||||||
|
parsed, err = routes._parse_events_params({"limit": "250", "time": "all"})
|
||||||
|
assert err is None and parsed["limit"] == 250
|
||||||
|
over, err2 = routes._parse_events_params({"limit": "251", "time": "all"})
|
||||||
|
assert over is None and "250" in err2
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_offset_selects_mode():
|
||||||
|
"""GUI passes default_offset=0 -> offset-mode; events.json omits -> cursor-mode."""
|
||||||
|
gui, _ = routes._parse_events_params({"time": "all"}, default_offset=0)
|
||||||
|
assert gui["offset"] == 0
|
||||||
|
api, _ = routes._parse_events_params({"time": "all"})
|
||||||
|
assert api["offset"] is None
|
||||||
|
|
||||||
|
|
||||||
|
# --- query shape (captured SQL) ---------------------------------------------
|
||||||
|
|
||||||
|
async def _capture(parsed):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_fetch(query, *args):
|
||||||
|
captured["query"] = query
|
||||||
|
captured["params"] = list(args)
|
||||||
|
return []
|
||||||
|
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.fetch = fake_fetch
|
||||||
|
pool = MagicMock()
|
||||||
|
pool.acquire.return_value.__aenter__ = AsyncMock(return_value=conn)
|
||||||
|
pool.acquire.return_value.__aexit__ = AsyncMock(return_value=None)
|
||||||
|
with patch("central.gui.routes.get_pool", return_value=pool):
|
||||||
|
await routes._fetch_events(parsed)
|
||||||
|
return captured
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_offset_mode_query_uses_limit_offset_and_window_count():
|
||||||
|
parsed, _ = routes._parse_events_params({"time": "all"}, default_offset=100)
|
||||||
|
cap = await _capture(parsed)
|
||||||
|
assert "LIMIT $" in cap["query"] and "OFFSET $" in cap["query"]
|
||||||
|
assert "count(*) OVER() AS total_count" in cap["query"]
|
||||||
|
assert 100 in cap["params"] # offset bound as a param
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_cursor_mode_query_has_no_offset_no_window_count():
|
||||||
|
parsed, _ = routes._parse_events_params({"time": "all"}) # no offset -> cursor-mode
|
||||||
|
cap = await _capture(parsed)
|
||||||
|
assert "OFFSET" not in cap["query"]
|
||||||
|
assert "count(*) OVER()" not in cap["query"]
|
||||||
|
|
||||||
|
|
||||||
|
# --- _build_pagination math -------------------------------------------------
|
||||||
|
|
||||||
|
def test_build_pagination_basic_windowing():
|
||||||
|
pg = routes._build_pagination(2341, 100, 50)
|
||||||
|
assert pg["page"] == 3 and pg["total_pages"] == 47
|
||||||
|
assert pg["start"] == 101 and pg["end"] == 150
|
||||||
|
assert pg["prev_offset"] == 50 and pg["next_offset"] == 150
|
||||||
|
nums = [p["page"] for p in pg["pages"] if "page" in p]
|
||||||
|
assert 1 in nums and 47 in nums and 3 in nums
|
||||||
|
assert any(p.get("ellipsis") for p in pg["pages"])
|
||||||
|
assert pg["per_page_options"] == [25, 50, 100, 250]
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pagination_first_page_has_no_prev():
|
||||||
|
pg = routes._build_pagination(120, 0, 50)
|
||||||
|
assert pg["page"] == 1 and pg["prev_offset"] is None and pg["next_offset"] == 50
|
||||||
|
assert pg["start"] == 1 and pg["end"] == 50 and pg["total_pages"] == 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pagination_last_page_has_no_next():
|
||||||
|
pg = routes._build_pagination(120, 100, 50)
|
||||||
|
assert pg["page"] == 3 and pg["next_offset"] is None and pg["end"] == 120
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pagination_zero_results():
|
||||||
|
pg = routes._build_pagination(0, 0, 50)
|
||||||
|
assert pg["total"] == 0 and pg["total_pages"] == 1
|
||||||
|
assert pg["start"] == 0 and pg["end"] == 0
|
||||||
|
assert pg["prev_offset"] is None and pg["next_offset"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_pagination_single_page_no_next():
|
||||||
|
pg = routes._build_pagination(30, 0, 50)
|
||||||
|
assert pg["total_pages"] == 1 and pg["next_offset"] is None and pg["end"] == 30
|
||||||
Loading…
Add table
Add a link
Reference in a new issue