feat(layout-pagination): collapse legend, stabilize rows, real offset paginator (v0.7.3)

PR #4 of the v0.7.x GUI rework arc. Production code; central-gui restart only.

- Adapter legend: collapsed by default ("{n} adapters · Show legend ▾"). Expands
  to domain-grouped chips (same grouping as the v0.7.1 chip-picker) with uniform
  ellipsis-truncated names + full-name title tooltips. Clicking a legend chip
  toggles that adapter's filter (reuses the chip-picker's hidden CSV via
  syncField), so the legend doubles as a filter affordance.
- Row stability: time cell is single-line MM-DD HH:MM UTC (year dropped from the
  cell; full ISO in the cell tooltip + a new Time row in the expanded detail).
  Adapter cell is a chip (color swatch + short name; display_name is the
  tooltip). table-layout:fixed + per-column widths + fixed 37px row height with
  nowrap/ellipsis cells -> no per-row wrap variation.
- Real paginator: _fetch_events offset-mode returns the exact page slice plus the
  grand total via count(*) OVER() in one roundtrip. Previous/Next + windowed page
  numbers (1 ... 4 5 [6] 7 8 ... 47) + "showing X-Y of N" + a 25/50/100/250
  per-page selector. URL state persists offset + limit. events.json keeps cursor
  pagination (back-compat): offset param presence selects offset-mode, its
  absence keeps the cursor path -- cleanly separable by endpoint.

Adds TestEventsPagination (12 tests: offset/limit parse incl. max 250,
offset-vs-cursor query shape, _build_pagination windowing). Updates the time
format + adapter-cell + pagination-mode assertions in the existing frontend
tests to the new contract.

Full suite: 674 passed, 1 skipped (central and unprivileged zvx). count(*) OVER()
is ~7.5ms at current volume; vanilla JS + HTMX; CSS functional-only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Matt Johnson 2026-05-25 02:04:23 +00:00
commit f8d89d53d4
5 changed files with 391 additions and 70 deletions

View file

@ -2611,10 +2611,12 @@ async def api_keys_delete(
class EventsQueryResult:
"""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.next_cursor = next_cursor
self.error = error
self.total = total # filtered grand total (offset-mode only); None for cursor-mode
# --- 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
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.
@ -2781,8 +2820,23 @@ def _parse_events_params(params, default_time: str | None = None) -> tuple[dict
except ValueError:
return None, f"Invalid limit value: {limit_str}"
if limit < 1 or limit > 200:
return None, "limit must be between 1 and 200"
if limit < 1 or limit > 250:
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
# 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 {
"limit": limit,
"offset": offset,
"q": q,
"adapters": adapters,
"categories": categories,
@ -2946,6 +3001,8 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
bbox = parsed_params["bbox"]
cursor_time = parsed_params["cursor_time"]
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
conditions = []
@ -3016,7 +3073,20 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
if 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"""
SELECT
id,
@ -3027,13 +3097,12 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
severity,
ST_AsGeoJSON(geom) as geometry,
payload as data,
regions
regions{total_select}
FROM public.events
{where_clause}
ORDER BY time DESC, id DESC
LIMIT ${param_idx}
{limit_clause}
"""
query_params.append(limit + 1)
try:
async with pool.acquire() as conn:
@ -3042,10 +3111,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
logger.error(f"Database error in _fetch_events: {e}")
return EventsQueryResult([], None, "Database error")
# Check if there is a next page
has_next = len(rows) > limit
if has_next:
rows = rows[:limit]
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
if has_next:
rows = rows[:limit]
# Build response
events = []
@ -3073,14 +3146,14 @@ async def _fetch_events(parsed_params: dict) -> EventsQueryResult:
event["subject"] = _derive_subject(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
if has_next and events:
last_event = rows[-1]
cursor_data = f"{last_event['time'].isoformat()}|{last_event['id']}"
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:
@ -3108,14 +3181,16 @@ def _geometry_summary(geometry: dict | 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:
return ""
try:
dt = datetime.fromisoformat(iso).astimezone(timezone.utc)
except (ValueError, TypeError):
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:
@ -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
_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()}
color = {a["name"]: a["color"] for a in _adapter_filter_options()[0]}
for event in events:
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
event["time_human"] = _format_event_time(event.get("time"))
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
# 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.
pool = get_pool()
@ -3205,6 +3283,7 @@ async def events_list(request: Request) -> HTMLResponse:
events = []
next_cursor = None
total = 0
if error:
# Validation error - show error banner but don't fail the page
pass
@ -3215,9 +3294,12 @@ async def events_list(request: Request) -> HTMLResponse:
else:
events = result.events
next_cursor = result.next_cursor
total = result.total or 0
_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()
pstate = parsed or {}
filter_state = {
@ -3235,7 +3317,9 @@ async def events_list(request: Request) -> HTMLResponse:
"limit": str(pstate.get("limit", 50)),
}
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(
request=request,
@ -3245,6 +3329,7 @@ async def events_list(request: Request) -> HTMLResponse:
"csrf_token": csrf_token,
"events": events,
"next_cursor": next_cursor,
"pagination": pagination,
"filter_error": error,
"tile_url": tile_url,
"tile_attribution": tile_attribution,
@ -3270,10 +3355,11 @@ async def events_rows(request: Request) -> HTMLResponse:
params = request.query_params
# 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 = []
next_cursor = None
total = 0
if error:
pass
else:
@ -3283,12 +3369,16 @@ async def events_rows(request: Request) -> HTMLResponse:
else:
events = result.events
next_cursor = result.next_cursor
total = result.total or 0
_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()
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(
request=request,
@ -3296,6 +3386,7 @@ async def events_rows(request: Request) -> HTMLResponse:
context={
"events": events,
"next_cursor": next_cursor,
"pagination": pagination,
"filter_error": error,
"active_pills": active_pills,
"query_string": query_string,

View file

@ -38,16 +38,18 @@
data-subject="{{ subject_summary | trim }}"
{% if event.geometry %}data-geometry='{{ event.geometry | tojson }}'{% endif %}>
<td><button type="button" class="expand-row" aria-label="Expand">&#9656;</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>{{ 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 class="event-detail" hidden>
<td colspan="5">
<dl class="event-detail-list">
<dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd>
<dt>Time</dt>
<dd>{{ event.time }}</dd>
<dt>Received</dt>
<dd>{{ event.received }}</dd>
{% if event.regions %}
@ -68,21 +70,37 @@
</tbody>
</table>
<div class="pagination-info">
<span>Showing {{ events | length }} event{{ 's' if events | length != 1 else '' }}.</span>
{% if next_cursor %}
{% set next_qs = "cursor=" ~ next_cursor ~ ("&" ~ query_string if query_string else "") %}
<a href="/events?{{ next_qs }}"
role="button"
hx-get="/events/rows?{{ next_qs }}"
hx-target="#events-rows"
hx-push-url="true">
Next &rarr;
</a>
{% else %}
<span><em>End of results</em></span>
{% endif %}
</div>
{# Real offset paginator (v0.7.3). Each link carries offset + the filter
query_string (which excludes cursor/offset); limit persists via query_string. #}
{% macro page_link(off, label, cls, extra) %}
{% set qs = "offset=" ~ off ~ ("&" ~ query_string if query_string else "") %}
<a href="/events?{{ qs }}" role="button" class="page-link {{ cls }}"
hx-get="/events/rows?{{ qs }}" hx-target="#events-rows" hx-push-url="true" {{ extra }}>{{ label }}</a>
{% endmacro %}
<nav class="paginator" aria-label="Pagination">
<div class="paginator-pages">
{% if pagination.prev_offset is not none %}{{ page_link(pagination.prev_offset, " Previous", "page-prev", "") }}{% else %}<span class="page-link disabled"> Previous</span>{% endif %}
{% for p in pagination.pages %}
{% if p.ellipsis %}<span class="page-ellipsis"></span>
{% elif p.current %}<span class="page-link current" aria-current="page">{{ p.page }}</span>
{% 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 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 %}
<article>
<p>No events match the filters.</p>

View file

@ -152,6 +152,47 @@
.map-filter-toggle { display: inline-flex; align-items: center; gap: 0.35rem;
font-size: 0.85rem; cursor: pointer; }
.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>
{% endblock %}
@ -212,7 +253,7 @@
<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_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-controls checkbox below syncs + enables it. #}
<input type="hidden" name="map_filter" id="filter-map_filter" value="1"
@ -229,13 +270,25 @@
<div id="events-map"></div>
<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">
{% for a in adapters %}
<div class="map-legend-item">
<div class="map-legend-swatch" style="background-color: {{ palette[loop.index0 % palette|length] }};"></div>
<span>{{ a.display_name }}</span>
<button type="button" id="legend-toggle" class="legend-toggle outline secondary"
aria-expanded="false">{{ adapters | length }} adapters · Show legend ▾</button>
<div id="legend-body" class="legend-body" hidden>
{% 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>
{% endfor %}
</div>
{% endfor %}
</div>
<label class="map-filter-toggle">
<input type="checkbox" id="map-filter-toggle" {{ 'checked' if filter_state.map_filter else '' }}>
@ -808,6 +861,38 @@
}
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>