perf(web): cancel in-flight requests on navigation; consolidate dashboard stats

Fix dashboard pages stalling under rapid navigation, plus reduce the cost
of the heaviest dashboard endpoint.

SPA request cancellation: apiGet never passed an AbortSignal, so navigating
away left a page's in-flight requests running — the homepage alone fires
three (/stats + two charts), the slowest being /stats. Under rapid
navigation these piled up, holding browser connections and API threadpool
threads, so the page actually wanted queued behind stale work; a late
resolver could also clobber the new page's DOM.

  - api.js: apiGet accepts an optional { signal } and forwards it to fetch;
    export isAbortError().
  - router.js: each navigation gets an AbortController; the previous one is
    aborted at the start of _handleRoute and its signal is passed to the page
    handler. A navigation-generation guard stops a superseded route from
    hiding the loader for the page that replaced it.
  - app.js: pageHandler swallows AbortError (an intentional cancel is not an
    error).
  - all 11 page modules: thread params.signal into on-load apiGet calls and
    guard their catch blocks with isAbortError.

dashboard/stats consolidation: collapse the 11 sequential COUNT(*) queries
into 4 using portable conditional aggregation (func.sum(case(...))) for
nodes, messages, advertisements, and user profiles. Responses are
unchanged.

Docs: extend the v0.12 "Read-Path Query Optimisations" note and add a
"Dashboard Navigation Responsiveness" note (front-end only, no action
required).

Tests: add test_stats_time_bucket_counts asserting the active/today/24h/7d
buckets. SPA bundles are gitignored and rebuilt by the Docker/CI build, so
only committed source changed; the esbuild build was run locally to
validate the JS.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Louis King
2026-06-11 13:29:53 +01:00
parent 1f43ba3607
commit 6804fc0b99
17 changed files with 220 additions and 134 deletions
+66
View File
@@ -42,6 +42,72 @@ class TestDashboardStats:
assert data["total_messages"] == 1
assert data["total_advertisements"] == 1
def test_stats_time_bucket_counts(self, client_no_auth, api_db_session):
"""Conditional-aggregation buckets (active/today/24h/7d) count the
correct rows across recent and older records."""
now = datetime.now(timezone.utc)
# Nodes: one active (seen now), one stale (seen 2 days ago).
api_db_session.add_all(
[
Node(public_key="a" * 64, name="Active", last_seen=now),
Node(
public_key="b" * 64,
name="Stale",
last_seen=now - timedelta(days=2),
),
]
)
# Messages (contact type → always channel-visible): today, 3 days ago,
# 10 days ago.
api_db_session.add_all(
[
Message(message_type="contact", text="now", received_at=now),
Message(
message_type="contact",
text="3d",
received_at=now - timedelta(days=3),
),
Message(
message_type="contact",
text="10d",
received_at=now - timedelta(days=10),
),
]
)
# Flood advertisements: now (24h), 3 days ago (7d), 10 days ago (older).
api_db_session.add_all(
[
Advertisement(public_key="c" * 64, route_type="flood", received_at=now),
Advertisement(
public_key="d" * 64,
route_type="flood",
received_at=now - timedelta(days=3),
),
Advertisement(
public_key="e" * 64,
route_type="flood",
received_at=now - timedelta(days=10),
),
]
)
api_db_session.commit()
data = client_no_auth.get("/api/v1/dashboard/stats").json()
assert data["total_nodes"] == 2
assert data["active_nodes"] == 1
assert data["total_messages"] == 3
assert data["messages_today"] == 1
assert data["messages_7d"] == 2 # now + 3d (10d excluded)
assert data["total_advertisements"] == 3
assert data["advertisements_24h"] == 1
assert data["advertisements_7d"] == 2 # now + 3d (10d excluded)
class TestDashboardHtmlRemoved:
"""Tests that legacy HTML dashboard endpoint has been removed."""