After PR #311's invalidation wiring and HTTP policy fix, a user reported
the routes list still showed stale data for ~30s after a PUT. The HAR
they sent actually showed the fix working (x-cache: MISS after PUT,
fresh data returned) but had been captured with DevTools' 'Disable
cache' enabled — so it didn't represent normal browsing. We had four
competing hypotheses and no way to pick between them.
Add structured INFO logging at the two points that matter:
* meshcore_hub.api.cache_invalidation._drop now emits
'Cache invalidate start: prefix=... backend=...' and
'Cache invalidate ok: prefix=...'. The backend= field
distinguishes RedisCacheBackend from NullCache in one glance,
catching 'REDIS_ENABLED is actually false' cases.
* meshcore_hub.common.redis.RedisCacheBackend.delete now emits
'Redis cache delete: prefix=... full_prefix=... keys_deleted=N
scan_iterations=N'. keys_deleted=0 after a mutation that should
have invalidated entries is the smoking gun for a cache-key
mismatch between the store path (key_builder) and the delete
path (prefix glob).
* NullCache.delete emits a DEBUG line for the same reason.
The error paths are also enriched with full_prefix for greppability.
No behavioral changes — invalidation still swallows errors so cache
outages never break a write.
After deploy, a single route-edit repro will produce log output whose
shape (start/ok/warning/missing, keys_deleted count) unambiguously
identifies which of the four hypotheses is correct, so we can write
a targeted fix instead of guessing.
After PR #311 wired server-side invalidation, a user reported that editing
a Route still showed old values on the routes list for ~30s. Root cause:
the api_cache_middleware emitted 'Cache-Control: private, max-age=30' on
@cached GETs, which let the browser serve its local HTTP cache copy
without revalidating. The Redis invalidation fired correctly but never
mattered — the browser never asked the server.
Switch all /api/* GET responses to 'private, no-cache' (synonymous with
'max-age=0, must-revalidate'). The browser now sends If-None-Match on
every navigation; the server answers 304 when Redis is warm and unchanged
(cheap — no body) or 200 after an invalidation.
The per-endpoint Redis TTL (30s default, 300s dashboard/route-detail)
still bounds cache.set lifetime; only the HTTP-layer max-age disappears.
Most navigations are still 304s, so the cost is one tiny round-trip per
page load while guaranteeing freshness after any mutation.
Adds an end-to-end regression test (test_routes_list_refresh_after_mutation)
modelling the exact reported scenario: cache-fill, conditional 304,
PUT mutation, conditional 200 with fresh body.
Mutation handlers (POST/PUT/DELETE) on channels, routes, user profiles,
node tags, and adoptions now drop the corresponding Redis cache entries
after commit so the UI reflects changes on the next page load instead of
waiting for the 30s/300s TTL.
Adds meshcore_hub.api.cache_invalidation with seven helpers that
encapsulate the two cache-key formats (endpoint-name keys like 'nodes:'
vs URL-path keys like '/api/v1/channels:') and swallow backend errors
so a cache outage never breaks a successful write. The helper is a
no-op when Redis is disabled.
Cross-entity embeddings are covered: node-tag writes invalidate nodes,
messages, advertisements, and dashboard; adoption writes invalidate
nodes, profiles, advertisements, and dashboard.
CLI/collector mutations remain TTL-bound (infrequent, operator-driven).
Bundles four independent improvements to API caching and route evaluator
accuracy that all touch the same files in the cache/routes stack.
## HTTP Cache-Control headers (api_cache_control_enabled)
The API now emits HTTP Cache-Control on every /api/v1/* response and
handles ETag / If-None-Match -> 304 Not Modified on @cached endpoints.
- @cached GETs: private, max-age=<redis_ttl> + strong SHA-256 ETag.
Matching If-None-Match returns 304 with empty body.
- Uncached GETs under /api/v1/*: private, max-age=0, must-revalidate.
- POST/PUT/DELETE/PATCH and /health*: no-store.
- All private (several @cached endpoints are role-aware).
- @cached decorator stores new envelope {body, etag} in Redis; legacy
bare-JSON entries still read (hashed on the fly) and auto-migrate on
the next miss.
- X-Cache: HIT|MISS observability header is always emitted.
- Kill switch: API_CACHE_CONTROL_ENABLED (default true).
## Dashboard cache TTL bump (30s -> 300s)
REDIS_CACHE_TTL_DASHBOARD default raised from 30s to 300s. Covers all
/dashboard/* endpoints and /api/v1/routes/{id}/history. These return
trend/aggregation data where minute-level staleness is invisible but
every MISS costs seconds of SQL/aggregation. Also resolves reported
short-TTL behaviour on the per-route healthchart (which shares this
setting). Operators with explicit env var: unchanged. Old Redis entries
expire naturally within <=30s; no flush needed.
## Route recent-packets hop truncation
In the routes page expanded card, path hops are now truncated to
2 + ellipsis + 2 when length > 5, matching the packet-detail page
styling. Reuses the existing packets.hops_hidden i18n key as the
tooltip on the ellipsis badge.
## Route event-hash deduplication
A single advert/message/telemetry/trace retransmitted or flooded
through the mesh produces one RawPacket per on-air copy, each with a
fresh wire packet_hash. Counting those as distinct matches let a single
underlying event satisfy packet_count_threshold within seconds and
biased route health.
- Add nullable event_hash column to raw_packets and packet_path_hops
(alembic 6b3430fd84f4). No backfill; legacy rows keep NULL and the
evaluator falls back to wire packet_hash until they age out of
window_hours.
- Subscriber denormalizes the structured event's event_hash onto the
captured raw packet after handler dispatch.
- Route evaluator prefers event_hash when set, collapsing all
receptions of the same underlying event into one match.
The route card header wrapped onto two lines when from/to labels were
long. Replace the inline "from -> to" title with a two-row CSS grid
(auto | 1fr) so labels align vertically, each preceded by a dedicated
SVG icon (iconRouteFrom/iconRouteTo: arrow departing/arriving at a
dot). Long labels now ellipsize with a title tooltip.
Move the reversibility indicator (reversible <-> vs one-way ->) out of
the title into a ghost badge beside the quality badge, where it no
longer fights the grid layout.
Also nudge spacing between "Recent Packets" and its path rows (mt-1 ->
mt-2) for a little breathing room.
Squashes four formerly-separate tail migrations into a single migration
(ec40c67c8c83) that builds the final schema directly off 57bb65130b97:
- 8f2a3c4d5e6f route health tables + packet_path_hops backfill
- c0d1e2f3a4b5 deduplicate nodes by public_key (idempotent data fix)
- 76513f4c57e9 ix_packet_path_hops_received_at index
- 71f6e01e4bf6 rename degraded_threshold -> clear_threshold
The rename runs as a no-op: routes.clear_threshold and
route_results.effective_clear are born with their final names. The
received_at index is created with the table, and the node dedup is
preserved verbatim (no-op on a clean DB).
Also drops the redundant uq_route_results_route_id unique constraint:
route_results.route_id now has a single unique index matching the model,
eliminating a pre-existing autogenerate drift.
Net schema is identical to running all four migrations. Verified with a
fresh alembic upgrade head + alembic check (no drift) on SQLite.
Test data used a hardcoded _NOW (2026-07-12 12:00 UTC) while
run_evaluation used real datetime.now(), causing a time-window flake
once wall-clock time exceeded _NOW + window_hours (24h). Tests now
pass now=_NOW explicitly; production callers are unaffected (defaults
to datetime.now(timezone.utc) when not provided).
The Route UI's observer search was hitting GET /api/v1/nodes without
the observer filter, showing all nodes instead of only observer nodes.
The backend already supports ?observer=true via an indexed column —
just needed to pass the param from handleObsSearch and handleObsKeydown.
Production DB restore bypassed UNIQUE enforcement on nodes.public_key,
leaving duplicate rows that caused MultipleResultsFound in node lookups
(GET /api/v1/nodes/{public_key} and collector find-or-create handlers).
Migration merges duplicates (winner = earliest first_seen), re-points all
14 FK columns referencing nodes.id, merges scalar fields, and re-creates
the UNIQUE index. Superset of b1c2d3e4f5a6 which only covered 9 FKs.
Route identity is now a (from_label, to_label) composite unique pair
instead of a single name column. The three route migrations (create
tables, add reversible, replace name) are collapsed into one clean
migration since the feature hasn't shipped yet — 8f2a3c4d5e6f creates
all five tables with from_label/to_label + reversible from the start,
no intermediate steps.
Engine: recent_matches now returns the sliced subpath between the
matched endpoints, not the full path. Diagnosis text moved to a hover
tooltip on the quality badge. Cards sorted by from_label. Seed YAML
switched to a list format with from/to keys; upsert by (from_label,
to_label). Plan/tasks docs updated to present the final schema.
Routes with match_width smaller than the mesh protocol's path hash width
would never highlight matched nodes in recent matches. The collector's
is_subsequence uses startswith() (prefix match), but the JS lookup used
Map.get() (exact match). Truncate node_hash to 2*match_width chars
before lookup so 'a1b2c3' matches expected_hash 'a1'.
Routes now default to bidirectional matching — packets traversing the
configured path in reverse (C->B->A instead of A->B->C) also count
towards health. A 'reversible' toggle lets users constrain a route
to one direction when needed.
- Route model: new 'reversible' column (default true, server_default 1)
- Migration: a1b2c3d4e5f6 (additive ALTER TABLE)
- Matching engine: _match_hops + _fetch_candidate_paths_maybe_bidirectional
helpers shared across evaluate_route, recent_matches, and preview_route
- API schemas: reversible on RouteCreate/Update/Read/Detail/PreviewRequest
- SPA: toggle in modal; card path chips show <-> for reversible, -> otherwise
- Seed loader + example YAML: reads 'reversible' key
- 4 new collector tests (reverse match, non-reversible, both-directions, API)
Add complete route health monitoring feature that tracks whether packets
traverse expected multi-hop paths through the mesh network.
Models & migration:
- 5 new models: PacketPathHop, Route, RouteNode, RouteObserver, RouteResult
- Alembic migration with keyset-paginated backfill of existing packets
Collector:
- store_raw_packet refactored to persist path hops via bulk insert
- Matching engine (collector/routes.py) with subsequence matching, quality
bands (clear/marginal/failing/no_coverage), and collision detection
- Background route evaluator (60s loop) wired into subscriber lifespan
- Route seed loader in CLI (resolves by public_key, matching YAML format)
API:
- 6 CRUD endpoints + preview endpoint under /api/v1/routes
- Schemas accept node_public_keys (64-char hex) instead of internal UUIDs
- 5 Prometheus gauges for route health metrics
- Packet groups endpoint reads from hop table
Web UI:
- Full SPA routes page with summary strip, grouped cards, expandable detail
- Routes nav entry in both desktop (spa.html) and mobile (app.js) navbars
- Home page nav card with feature gate
- API proxy access mapping for v1/routes endpoints
- Visual node-search path builder with autocomplete dropdown, ordered chips
with reorder/remove controls, and paste-64-char-key support
- Observer picker with same search UX (unordered chips)
- i18n strings in en.json and nl.json
Config:
- feature_routes flag (default: true)
- route_evaluator_interval_seconds (default: 60)
- routes_file seed path
- example/seed/routes.yaml
Switch the Adverts and Messages observer filter from one badge per observer
node to one badge per unique area tag value (e.g. IP2, IP3, IP4). Toggling
an area badge enables/disables all observers in that area together.
Observers without an area tag are hidden from the filter row. Uses a new
localStorage key (meshcore-observer-areas-disabled) to avoid stale public
keys being misread as area codes. The old key is cleaned up on SPA boot.
Frontend-only: no backend, API, schema, or migration changes.
Add F8/T9 + Phase 8 seed loader (links.yaml via existing meshcore-hub seed command, mirroring channels.yaml), referencing path nodes by public_key with expected_hash derived by the importer. Refine visibility to keep auth-scope but default to community (like channels).
Plan for a new Link entity that monitors whether packets traverse a configured ordered sequence of mesh nodes within a time window + count threshold, with background evaluation and Prometheus exposure. Includes shareable plain-language overview.