feat(L-c): operator /events table polish — readable Time, Location, Subject, Adapter columns; sortable; plain-language summaries

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
zvx 2026-05-21 07:05:20 +00:00
commit 5d4320bc73
17 changed files with 381 additions and 17 deletions

View file

@ -4,7 +4,7 @@ import base64
import json
import logging
import re
from datetime import datetime
from datetime import datetime, timezone
from typing import Any
logger = logging.getLogger("central.gui.routes")
@ -2870,6 +2870,31 @@ def _geometry_summary(geometry: dict | None) -> str:
return geom_type
def _format_event_time(iso: str | None) -> str:
"""Format an ISO-8601 timestamp as 'MM-DD-YYYY HH:MM UTC' (24h, no seconds)."""
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"
def _decorate_table_events(events: list[dict]) -> None:
"""Add display-only fields used by the HTML events table (in place).
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.
"""
display = {cls.name: cls.display_name for cls in discover_adapters().values()}
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"))
@router.get("/events.json")
async def events_json(request: Request):
@ -2958,9 +2983,8 @@ async def events_list(request: Request) -> HTMLResponse:
events = result.events
next_cursor = result.next_cursor
# Add geometry summary to each event
for event in events:
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
# Add table-only display fields (time_human, adapter_display, geometry_summary)
_decorate_table_events(events)
# Registry-derived adapter list for the filter <select> and map legend.
# Sorted by name for stable ordering; index drives the legend color palette.
@ -3022,9 +3046,8 @@ async def events_rows(request: Request) -> HTMLResponse:
events = result.events
next_cursor = result.next_cursor
# Add geometry summary to each event
for event in events:
event["geometry_summary"] = _geometry_summary(event.get("geometry"))
# Add table-only display fields (time_human, adapter_display, geometry_summary)
_decorate_table_events(events)
return templates.TemplateResponse(
request=request,

View file

@ -0,0 +1,2 @@
{# No per-adapter summary; the Subject cell falls back to "—" and the map
popup omits the subject line. #}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('frp') is not none or d.get('confidence') %}Fire detected{% if d.get('frp') is not none %} — {{ d.frp }} MW radiative power{% endif %}{% if d.get('confidence') == 'high' %} (high confidence){% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif %}{% if d.get('alertlevel') %} — {{ d.alertlevel }} alert{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('title') %}{{ d.title }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('value') is not none %}Water reading: {{ d.value }}{% if d.get('unit_of_measure') %} {{ d.unit_of_measure }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('event') %}{{ d.event }}{% endif %}{% if d.get('severity') %} — {{ d.severity }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('product_id') or d.get('message') %}Space weather alert{% if d.get('product_id') %} {{ d.product_id }}{% endif %}{% if d.get('message') %}: {{ d.message | replace('\r', ' ') | replace('\n', ' ') | truncate(80) }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('Kp') is not none %}Geomagnetic activity (Kp index): {{ d.Kp }}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('flux') is not none %}Solar proton flux: {{ d.flux | round(2) }} pfu{% if d.get('energy') %} at {{ d.energy }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('magnitude') is not none %}Magnitude {{ d.magnitude | round(1) }}{% if d.get('place') %} — {{ d.place }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('county') or d.get('state') %}Wildfire incident — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}

View file

@ -0,0 +1,2 @@
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{%- if d.get('county') or d.get('state') %}Wildfire perimeter — {% if d.get('county') %}{{ d.county }}{% if d.get('state') %}, {% endif %}{% endif %}{% if d.get('state') %}{{ d.state }}{% endif %}{% endif -%}

View file

@ -10,30 +10,40 @@
<tr>
<th style="width: 2rem;"></th>
<th>Time</th>
<th>Adapter</th>
<th>Category</th>
<th>Geometry</th>
<th>Location</th>
<th>Subject</th>
<th>Adapter</th>
</tr>
</thead>
<tbody>
{% for event in events %}
{# Per-adapter one-line summary, dispatched by adapter name with a generic
fallback (no hardcoded list). Captured once so it serves both the
Subject cell and the map popup (via data-subject). #}
{% set subject_summary %}{% include ["_event_summaries/" ~ event.adapter ~ ".html", "_event_summaries/_default.html"] %}{% endset %}
{# Location: generic _enriched.geocoder reader, then top-level named
fields, then coordinates. No adapter-specific logic. #}
{% set d = (event.data.get('data') or {}).get('data') or {} %}
{% set gc = (d.get('_enriched') or {}).get('geocoder') or {} %}
{% set loc_local = gc.get('city') or d.get('city') or gc.get('county') or d.get('county') %}
{% set loc_state = gc.get('state') or d.get('state') %}
{% set loc_country = gc.get('country') or d.get('country') %}
{% set loc_parts = [loc_local, loc_state, loc_country] | select | list %}
<tr class="event-row" data-row-idx="{{ loop.index0 }}"
data-event-id="{{ event.id }}"
data-adapter="{{ event.adapter }}"
data-category="{{ event.category }}"
data-time="{{ event.time }}"
data-subject="{{ event.subject or '' }}"
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>{{ event.time }}</td>
<td>{{ event.adapter }}</td>
<td>{{ event.category }}</td>
<td>{{ event.geometry_summary }}</td>
<td>{{ event.subject or '—' }}</td>
<td 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>
</tr>
<tr class="event-detail" hidden>
<td colspan="6">
<td colspan="5">
<dl class="event-detail-list">
<dt>Event ID</dt>
<dd><code>{{ event.id }}</code></dd>

View file

@ -465,8 +465,67 @@
// Fit to results button
document.getElementById("fit-to-results").addEventListener("click", fitToAllLayers);
// Client-side sort of the displayed rows; state persists across HTMX swaps.
var sortState = { col: null, dir: 1 }; // dir: 1 asc, -1 desc
function sortKey(row, col) {
// Time sorts on the ISO timestamp for true chronological order.
if (col === 1) return row.dataset.time || "";
var cell = row.children[col];
return cell ? cell.textContent.trim().toLowerCase() : "";
}
function applySort() {
if (sortState.col === null) return;
var tbody = document.querySelector("#events-rows table.events-table tbody");
if (!tbody) return;
var pairs = [];
tbody.querySelectorAll("tr.event-row").forEach(function(r) {
pairs.push([r, r.nextElementSibling]); // main row + its detail row
});
// Array.prototype.sort is stable (ES2019+), so equal keys keep order.
pairs.sort(function(a, b) {
var ka = sortKey(a[0], sortState.col), kb = sortKey(b[0], sortState.col);
if (ka < kb) return -sortState.dir;
if (ka > kb) return sortState.dir;
return 0;
});
pairs.forEach(function(p) {
tbody.appendChild(p[0]);
if (p[1]) tbody.appendChild(p[1]);
});
}
function updateSortIndicators(ths) {
ths.forEach(function(th, idx) {
th.querySelectorAll(".sort-ind").forEach(function(s) { s.remove(); });
if (idx === sortState.col) {
var s = document.createElement("span");
s.className = "sort-ind";
s.textContent = sortState.dir === 1 ? " ▲" : " ▼";
th.appendChild(s);
}
});
}
function bindSortHandlers() {
var ths = document.querySelectorAll("#events-rows table.events-table thead th");
ths.forEach(function(th, idx) {
if (idx === 0) return; // expand column is not sortable
th.style.cursor = "pointer";
th.onclick = function() {
if (sortState.col === idx) { sortState.dir *= -1; }
else { sortState.col = idx; sortState.dir = 1; }
updateSortIndicators(ths);
applySort();
};
});
updateSortIndicators(ths);
}
// Initial load - bind layers and fit bounds
rebindEventLayers(); // Initial load only
bindSortHandlers();
if (false) { // DISABLED: map never auto-fits
fitToAllLayers();
isInitialLoad = false;
@ -477,6 +536,9 @@
document.body.addEventListener("htmx:afterSwap", function(evt) {
if (evt.detail.target.id === "events-rows") {
rebindEventLayers();
// Re-bind sort handlers to the new rows and re-apply the active sort.
bindSortHandlers();
applySort();
// Do NOT call fitToAllLayers - preserve user viewport
}
});