feat: route health history, compact cards, clear_threshold rename

- Add per-route health history endpoint (GET /routes/{id}/history)
- Optimize history fetch with _fetch_matching_hops (SQL-level prefix filter)
- Add standalone received_at index on packet_path_hops
- Fix cache key collision by including URL path in _routes_key_builder
- Fix Chart.js canvas reuse with Chart.getChart() guard
- Remove fleet overview endpoint (502 timeout on large datasets)
- Redesign route cards: always expanded, compact icon stats row,
  per-segment date labels, loading spinner, bottom-pinned admin buttons
- Rename degraded_threshold -> clear_threshold across model, schema,
  API, metrics, CLI, frontend, tests, and seed config
- Add iconClock to icons.js
- Update AGENTS.md: random revision IDs, never build images
- Two Alembic migrations: received_at index, column rename
This commit is contained in:
Louis King
2026-07-15 23:37:03 +01:00
parent ae22dff4bc
commit ad07c1521c
24 changed files with 1820 additions and 161 deletions
+2
View File
@@ -11,6 +11,8 @@
- Use Python (version in `.python-version`); activate a venv in `.venv` before running pytest, pre-commit, or alembic locally.
- **All other operations run inside the compose stack** — never invoke `meshcore-hub` or `npm` directly on the host; build/run/exec via `docker compose` (see Development).
- **Never `git push` without explicit confirmation** — staging and committing discrete changes is fine.
- **Never build the Docker images or run `make build` / `make up`** — the user builds manually to test. Stop after code changes + tests + pre-commit pass.
- **Always generate random Alembic revision IDs** — use `python -c "import secrets; print(secrets.token_hex(6))"` or let `alembic revision` auto-generate. Never hand-pick sequential or guessable IDs like `a1b2c3d4e5f6` — they collide with existing migrations and cause cycle errors at upgrade time.
- Before committing: run targeted `pytest --no-cov tests/test_<component>/` then `pre-commit run --all-files`.
## Setup
@@ -0,0 +1,32 @@
"""add standalone received_at index to packet_path_hops
Revision ID: 76513f4c57e9
Revises: c0d1e2f3a4b5
Create Date: 2026-07-15 21:00:00.000000+00:00
"""
from typing import Sequence, Union
from alembic import op
revision: str = "76513f4c57e9"
down_revision: Union[str, None] = "c0d1e2f3a4b5"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_index(
"ix_packet_path_hops_received_at",
"packet_path_hops",
["received_at"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
"ix_packet_path_hops_received_at",
table_name="packet_path_hops",
)
@@ -0,0 +1,39 @@
"""Rename degraded_threshold to clear_threshold.
Revision ID: 71f6e01e4bf6
Revises: 76513f4c57e9
Create Date: 2026-07-15 23:00:00
"""
from alembic import op
revision = "71f6e01e4bf6"
down_revision = "76513f4c57e9"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"routes",
"degraded_threshold",
new_column_name="clear_threshold",
)
op.alter_column(
"route_results",
"effective_degraded",
new_column_name="effective_clear",
)
def downgrade() -> None:
op.alter_column(
"route_results",
"effective_clear",
new_column_name="effective_degraded",
)
op.alter_column(
"routes",
"clear_threshold",
new_column_name="degraded_threshold",
)
@@ -0,0 +1,569 @@
# Route Health History (7-Day Charts)
## Summary
Add two Chart.js visualizations of route health over the last 7 days, backed by
**compute-on-read** re-evaluation of the existing matching engine against
`packet_path_hops`. The Routes overview page gains a **fleet distribution
chart** — vertical stacked bars per day, each bar showing how many routes were
in each quality band (clear / marginal / failing / no_coverage / disabled).
The per-route detail expand gains a **status strip** — a single horizontal bar
split into 7 day-segments, each colored by that day's quality band (green /
amber / red / grey), like an uptime-monitor status bar.
No new database tables or migrations are required. Health history is
reconstructed on demand by re-running the existing `evaluate_route` matching
logic per calendar day over the retained `packet_path_hops` rows. The history
horizon therefore tracks `effective_raw_packet_retention_days` automatically (default 7)
— bumping that setting widens the chart's available window with no code change.
Both endpoints are Redis-cached behind the same `redis_cache_ttl_dashboard` TTL
used by the dashboard timeseries endpoints, and the entire Chart.js color/date
infrastructure already in `charts.js` is reused.
## Background & Motivation
The Routes feature (`20260705-2306-mesh-link-monitoring`, shipped across commits
`14fbc45``ae22dff`) introduced the `Route` entity, the `packet_path_hops`
ingest index, the `evaluate_route` matching engine, the 60-second background
evaluator, and the `/routes` status-board page. **Health is, however, strictly
point-in-time:** the evaluator upserts a single `RouteResult` row per route in
place (`upsert_route_result`, `collector/routes.py:415`), overwriting
`state`/`quality`/`matched_count` every cycle. Past values are lost — the
feature's own Non-Goals listed "historical route-health time series" as future
work.
Operators can see *right now* whether a route is clear/marginal/failing, and
Prometheus exposes the current gauges for external alerting, but **the UI gives
no sense of how a route (or the fleet) has been trending over the week.** A
route that flapped red overnight and recovered shows only green today. This
plan closes that gap.
The key enabler is that **evaluation is a pure, deterministic query over
`packet_path_hops`** (`evaluate_route`, `collector/routes.py:341`), and those
hop rows are retained for 7 days by default (`RAW_PACKET_RETENTION_DAYS=7`,
cascade-deleted with `raw_packets` per `cleanup.py:118-121`). Re-running the
matcher per calendar day reconstructs the daily quality band without storing
anything new. The compute cost is trivial relative to existing load — the
background evaluator already runs N evaluations every 60 s (~1440N/day); a 7-day
on-demand chart is 7N evaluations served behind the Redis cache.
The dashboard packet-breakdown charts (`20260704-1429-packet-breakdown-charts`,
shipped as `createStackedBarChart` in `charts.js:243`) established the exact
patterns this plan mirrors: a cached aggregation endpoint, a `BreakdownBucket`-
style schema, and a stacked-bar Chart.js helper.
## Goals
- Surface 7-day route health **without a migration or new persistence**
compute-on-read over the existing `packet_path_hops` index.
- Add a **fleet distribution chart** on the Routes overview: vertical stacked
bars per day, x-axis = 7 dates, segments = count of routes per quality band.
- Add a **per-route status strip** in the detail expand: a single horizontal
bar, 7 day-segments, each colored by that day's quality band (the uptime-bar
idiom).
- Make the history horizon **track `RAW_PACKET_RETENTION_DAYS` automatically**
so operators who raise retention get a wider window for free.
- Reuse the existing Chart.js v4 vendoring, the `ChartColors`/date infrastructure
in `charts.js`, and the `@cached` dashboard-cache pattern — no new frontend
dependencies and no new caching plumbing.
## Non-Goals
- **No `route_result_history` table.** Persisted history is explicitly deferred
to a future plan. Compute-on-read is capped at the raw-packet retention window
(default 7 days); if operators later need longer history, the persist-history
table is the follow-on (the matching engine is already structured to append).
- **No range picker.** The window is fixed at 7 days. A configurable 7/14/30-day
selector only becomes meaningful once the persist-history table exists (since
compute-on-read cannot exceed `RAW_PACKET_RETENTION_DAYS`).
- **No changes to the 60-second evaluator hot path.** History is computed by a
sibling function; `evaluate_route` and `upsert_route_result` are untouched.
- **No Prometheus changes.** The existing `meshcore_route_quality` /
`meshcore_route_healthy` gauges already expose current state; this plan adds
no new metrics (history is a UI concern, not an alerting one).
- **No sub-day granularity.** One quality band per day per route. Finer buckets
(4h/3h) were considered and rejected — they multiply the evaluation cost and
add visual noise without changing the alerting story.
- **No seeding or config changes** — the feature reads only existing route
configuration and hop data.
## Requirements
### Functional Requirements
- **FR-1 — Fleet history endpoint.** `GET /api/v1/routes/history?days=7` returns
a `RouteFleetHistory` payload: one `RouteFleetDayPoint` per calendar day in
the window, each carrying per-band counts of routes:
```json
{
"days": 7,
"data": [
{ "date": "2026-07-09", "clear": 4, "marginal": 1, "failing": 0,
"no_coverage": 1, "disabled": 2 },
...
]
}
```
Each enabled route contributes to exactly one band per day based on its
`RouteQuality` (clear / marginal / failing / unknown — the `unknown` band is
labelled "no_coverage" in the chart, matching the existing UI at
`routes.js:51-68`). Disabled routes contribute to the `disabled` counter for
every day and are **not** evaluated.
- **FR-2 — Per-route history endpoint.** `GET /api/v1/routes/{route_id}/history
?days=7` returns a `RouteHistory` payload — one `RouteDayQuality` per day:
```json
{
"route_id": "...",
"days": 7,
"data": [
{ "date": "2026-07-09", "quality": "clear", "state": "healthy",
"matched_count": 12 },
...
]
}
```
The `quality` value is one of the `RouteQuality` enum values: `clear` /
`marginal` / `failing` / `unknown`. For a disabled route, every day's
`state` is `no_coverage` and `quality` is `unknown` (consistent with how the
list endpoint renders disabled routes).
- **FR-3 — Window semantics.** `days` defaults to 7 and is **clamped to
`settings.effective_raw_packet_retention_days`** so the endpoint never claims
a horizon the retained hop data cannot satisfy. The oldest complete day is
`today - days`; each day-bucket covers `[00:00, next 00:00)` UTC. The fleet
overview chart **excludes today** (partial day, consistent with dashboard
daily-activity charts at `dashboard.py:462-467`); the per-route detail strip
**includes today** as its final segment (route health is a live concern —
seeing the current partial-day state is the point) labeled with the locale's
"today".
- **FR-4 — Visibility scoping.** Both endpoints are role-scoped exactly like the
existing route endpoints (`VISIBILITY_LEVELS` + `get_max_visibility_level`,
per `api/routes/routes.py:226-229`). The fleet endpoint counts only routes
the caller may read; the per-route endpoint returns 404 for routes above the
caller's level (matching `get_route`'s posture). Both are guarded by
`RequireRead`.
- **FR-5 — Caching.** Both endpoints are Redis-cached under
`redis_cache_ttl_dashboard` via the existing `@cached` decorator, keyed by the
existing `_routes_key_builder` (role + sorted query string, at
`api/routes/routes.py:44`) so the per-role visibility scoping is part of the
cache key.
- **FR-6 — Fleet chart (overview).** On the `/routes` page, when
`features.routes !== false`, a chart card renders **vertical stacked bars**:
x-axis = the 7 day labels, one dataset per band (clear / marginal / failing /
no_coverage / disabled), `scales.x.stacked = true` and
`scales.y.stacked = true`, `y.beginAtZero = true`. Legend lists the bands in
fixed semantic order. Tooltip (index mode) lists the non-zero bands for the
hovered day with their counts.
- **FR-7 — Status strip (detail).** In the route card expand, a chart renders
**one horizontal bar divided into 7 equal day-segments**, each segment colored
by that day's `quality` band (clear=green, marginal=amber, failing=red,
no_coverage/unknown=grey). The strip has a date axis beneath it (oldest →
newest, left → right) and today's segment labeled. Tooltip per segment shows
the date, the quality label, and `matched_count`.
- **FR-8 — Empty / no-data handling.** If no routes are visible to the caller,
the fleet chart card renders with a blank canvas and a "no routes" state
(mirroring `createLineChart`'s empty-data early return at `charts.js:155`).
If a single route has no retained hops for the full window (e.g. created
recently), its strip renders with all segments in the `unknown` band color.
- **FR-9 — Feature gating.** Both the frontend charts and the data fetch are
gated on `features.routes !== false` (client-side, the same idiom the routes
page nav/route registration uses in `app.js`). The endpoints themselves carry
only `RequireRead` (no server-side feature flag) — consistent with how the
dashboard breakdown endpoint relates to `feature_packets`.
### Technical Requirements
- **TR-1 — Day-bounded evaluation helper.** Add
`evaluate_route_day(session, route, day_start, day_end) -> tuple[str, str,
int]` to `collector/routes.py` as a sibling of `evaluate_route`
(`collector/routes.py:341`). It mirrors `evaluate_route` exactly but bounds
the candidate fetch to `received_at >= day_start AND received_at < day_end`
(the current function only takes a `since`). Reuse the existing private
helpers unchanged: `_route_expected_hashes(route)`,
`fetch_candidate_paths(...)` (extended with an optional `until` param that
appends `PacketPathHop.received_at < until` to the subquery at line 262 —
the composite index `ix_packet_path_hops_node_hash_received_at` covers this
range scan), `_fetch_candidate_paths_maybe_bidirectional(...)` (extended to
pass `until` through to `fetch_candidate_paths`), `_match_hops(...)`,
`effective_degraded_threshold(route)`, `derive_quality(state, matched_count,
threshold, effective_degraded)` (`routes.py:61`), and
`_has_any_hops_in_window(...)` (extended with `until`). Implementing it as a
sibling (not a flag on `evaluate_route`) keeps the 60-second hot path
untouched and avoids regressions in the shipped evaluator.
- **TR-2 — History batch helper.** Add
`evaluate_route_history(session, route, days, *, include_today=False) ->
list[tuple[date, str, str, int]]` to `collector/routes.py`. It computes the
day boundaries (UTC midnight buckets, oldest first) and calls
`evaluate_route_day` per day. For a disabled route it returns
`(date, RouteQuality.UNKNOWN.value, RouteState.NO_COVERAGE.value, 0)`
for every day without hitting the DB. This is the single function both
endpoints call.
- **TR-3 — Schemas.** Add to `common/schemas/routes.py` (mirroring the
`DailyActivity` / `BreakdownBucket` idioms in `common/schemas/messages.py`):
```python
class RouteDayQuality(BaseModel):
date: date
quality: str
state: str
matched_count: int
class RouteHistory(BaseModel):
route_id: str
days: int
data: list[RouteDayQuality]
class RouteFleetDayPoint(BaseModel):
date: date
clear: int = 0
marginal: int = 0
failing: int = 0
no_coverage: int = 0
disabled: int = 0
class RouteFleetHistory(BaseModel):
days: int
data: list[RouteFleetDayPoint]
```
Use `datetime.date` (not `datetime`) for `date` fields so payloads serialize
as `YYYY-MM-DD` with no time component. The `no_coverage` field aggregates
all routes whose quality is `unknown` — matching the existing UI's label
mapping at `routes.js:51-68`.
- **TR-4 — Fleet endpoint.** Add to `api/routes/routes.py` on the existing
router (already mounted at `/api/v1/routes`):
```python
@router.get("/history", response_model=RouteFleetHistory)
@cached("routes/history", ttl_setting="redis_cache_ttl_dashboard",
key_builder=_routes_key_builder)
def get_route_fleet_history(
_: RequireRead,
session: DbSession,
request: Request,
days: int = 7,
) -> RouteFleetHistory:
```
Load the routes visible to the caller's role (reuse the visibility filter
from `get_routes`). Clamp `days = min(days, retention_days)` where
`retention_days` comes from `settings.effective_raw_packet_retention_days`.
For each enabled route call `evaluate_route_history(session, route, days,
include_today=False)`; bucket each day's quality into the band counters
(quality `unknown` → `no_coverage`, matching the existing UI mapping at
`routes.js:51-68`). Disabled routes add to `disabled` for every day.
Return oldest-day-first.
**Route ordering note:** this endpoint must be declared **before** the
`/{route_id}` path routes so FastAPI does not match `history` as a
`route_id` path parameter.
- **TR-5 — Per-route endpoint.** Add to `api/routes/routes.py`:
```python
@router.get("/{route_id}/history", response_model=RouteHistory)
@cached("routes/{id}/history", ttl_setting="redis_cache_ttl_dashboard",
key_builder=_routes_key_builder)
def get_route_history(
_: RequireRead,
session: DbSession,
route_id: str,
request: Request,
days: int = 7,
) -> RouteHistory:
```
Fetch the route, apply the visibility check (mirror `get_route` at lines
220229 — 404 when not found or above caller's level), clamp `days = min(days,
settings.effective_raw_packet_retention_days)`, call
`evaluate_route_history(session, route, days, include_today=True)`.
- **TR-6 — Quality color tokens.** Add a semantic `quality` map to
`ChartColors` in `charts.js` (alongside the existing `breakdown` palette at
lines 5664). Use hardcoded oklch values — the same approach the `breakdown`
palette uses — rather than CSS custom properties, since `app.css` defines only
page/section colors (`--color-nodes`, `--color-routes`, etc.) and no
semantic status colors. The values render consistently in both light and dark
themes:
```js
quality: {
clear: 'oklch(0.72 0.17 145)',
marginal: 'oklch(0.75 0.18 85)',
failing: 'oklch(0.62 0.24 25)',
no_coverage: 'oklch(0.65 0.15 250)', // info-blue
disabled: 'oklch(0.55 0 0)', // neutral grey
}
```
- **TR-7 — Fleet chart helper.** Add `createRouteOverviewChart(canvasId,
fleetData)` to `charts.js`. `type: 'bar'` (vertical), labels =
`formatDateLabels(fleetData.data)` (reuses the existing helper at
`charts.js:137`). One dataset per band in fixed semantic order (clear,
marginal, failing, no_coverage, disabled), each with `data` = per-day count
and `backgroundColor` = the band's `ChartColors.quality` color.
`scales.x.stacked = true`, `scales.y.stacked = true`,
`scales.y.beginAtZero = true`. Reuse `createChartOptions(true)` for legend/
tooltip theming, overriding the tooltip `label` callback to list non-zero
bands per hovered day. Return `null` on empty/no-routes data (matching
`createLineChart`'s idiom).
- **TR-8 — Status strip helper.** Add `createRouteDetailStrip(canvasId,
routeData)` to `charts.js`. Produces a single horizontal bar of 7 equal
colored segments:
- `type: 'bar'`, `indexAxis: 'y'`, `labels: ['']` (one row).
- One dataset per day: `{ label: <date>, data: [1], backgroundColor:
ChartColors.quality[day.quality]() }`. With `scales.x.stacked = true` and
`scales.y.stacked = true`, the seven unit-width datasets render as one bar
split into seven equal colored segments.
- Hide both axes' ticks; render a date-axis row beneath the canvas via a
separate labels element (HTML, not Chart.js) so the dates align under each
segment without Chart.js category-axis clutter.
- Tooltip per segment: date + quality label + `matched_count`.
- Return `null` when `routeData` is empty.
- **TR-9 — Overview wiring.** In `spa/pages/routes.js`, add a chart card
containing `<canvas id="routeOverviewChart">` adjacent to the existing
summary strip (`renderSummaryStrip`, `routes.js:51`). After the routes list
loads in `renderPage`, fire `apiGet('/api/v1/routes/history', { days: 7 },
{ signal })` and call `createRouteOverviewChart`. Manage the Chart.js
instance lifecycle — create a `chartIds` array and return a cleanup function
that destroys each instance on page unmount, mirroring the dashboard page's
chart cleanup pattern at `dashboard.js:324-333`. Include abort support via
`{ signal }` for fetch cancellation on page unmount.
- **TR-10 — Detail wiring.** In `spa/pages/routes.js`, inside the expanded-card
detail content (`renderDetailContent`), add a `<canvas>` and fetch
`/api/v1/routes/${route.id}/history?days=7`, then call
`createRouteDetailStrip`. Render only when the card is expanded, matching the
existing lazy-load pattern used for the `detail` payload. Destroy the strip
instance when the card collapses.
- **TR-11 — i18n.** Add keys under the existing `routes.*` block to both
`src/meshcore_hub/web/static/locales/en.json` and `nl.json`:
- `routes.history_title` — "Health (last 7 days)" / "Gezondheid (laatste 7
dagen)" (fleet chart card title).
- `routes.history_detail_title` — "Last 7 days" / "Laatste 7 dagen" (strip
label).
- `routes.history_today` — "Today" / "Vandaag" (the final partial segment
label on the detail strip).
- The band labels (`routes.quality_clear`, `routes.quality_marginal`,
`routes.quality_failing`, `routes.quality_no_coverage`,
`routes.quality_unknown`, `routes.disabled`) already exist at
`routes.js:24-34` and are reused for the legend and tooltips.
- **TR-12 — Backend agnosticism.** All queries use SQLAlchemy Core/ORM with
Python-computed day boundaries (`datetime` arithmetic, never `NOW() -
INTERVAL`), consistent with the backend-agnostic convention established by the
Routes plan (T6). The day-bounded fetch is a simple `received_at >= day_start
AND received_at < day_end` range — sargable on both SQLite and Postgres.
## Implementation Plan
### Phase 1: Day-bounded matching engine + tests
- **`src/meshcore_hub/collector/routes.py`**:
- Extend `_fetch_candidate_paths_maybe_bidirectional` and
`_has_any_hops_in_window` with an optional `until: Optional[datetime]`
parameter that appends `PacketPathHop.received_at < until` to the existing
`received_at >= since` filter. The composite index
`ix_packet_path_hops_node_hash_received_at` covers the bounded range scan.
- Add `evaluate_route_day(session, route, day_start, day_end)` per TR-1 — a
line-for-line mirror of `evaluate_route` (`routes.py:341-389`) but calling
the bounded fetch helpers. Reuses `_route_expected_hashes`,
`effective_degraded_threshold`, `_match_hops`, `derive_quality` unchanged.
- Add `evaluate_route_history(session, route, days, *, include_today=False)`
per TR-2.
- **Tests** — extend `tests/test_collector/test_routes.py`:
- `evaluate_route_day` returns the correct band for clear / marginal /
failing / no_coverage (seed hops inside vs. outside the day window).
- Day boundaries are strict: hops in the adjacent day do not leak across the
`day_end` bound.
- `evaluate_route_history` returns `days` entries oldest-first, with the
correct `include_today` behavior (one extra partial-day entry when true).
- A disabled route returns `unknown`/`no_coverage`/`0` for every day without
a DB hit.
### Phase 2: Schemas + API endpoints + tests
- **`src/meshcore_hub/common/schemas/routes.py`**: add `RouteDayQuality`,
`RouteHistory`, `RouteFleetDayPoint`, `RouteFleetHistory` per TR-3.
- **`src/meshcore_hub/api/routes/routes.py`**:
- Add `GET /history` (fleet) per TR-4. **Declare it before the `/{route_id}`
routes** (FastAPI matches path patterns in declaration order; without this,
`history` would be captured as a `route_id`).
- Add `GET /{route_id}/history` (per-route) per TR-5.
- Import `evaluate_route_history` from `collector.routes`; import the new
schemas; read `raw_packet_retention_days` from settings for the `days` clamp.
- Visibility filtering reuses `VISIBILITY_LEVELS` / `get_max_visibility_level`
/ `resolve_user_role` already imported at the top of the file.
- **Tests** — `tests/test_api/test_routes.py`:
- Fleet endpoint: response shape, per-day band counts sum to the visible-route
count, disabled routes count only to `disabled`, visibility filtering (a
low-role caller does not see admin-only routes in the counts), `days` clamp
to `RAW_PACKET_RETENTION_DAYS`, caching (role-keyed).
- Per-route endpoint: per-day quality/state/matched_count shape, 404 for a
hidden route, 404 for an unknown route, includes today as the final segment.
- Route-ordering guard: confirm `GET /history` is not shadowed by
`GET /{route_id}`.
### Phase 3: Chart helpers + quality palette
- **`src/meshcore_hub/web/static/js/charts.js`**:
- Add the `quality` color map to `ChartColors` (TR-6), using hardcoded oklch
values (same approach as the `breakdown` palette at lines 5664).
- Add `createRouteOverviewChart(canvasId, fleetData)` (TR-7) — vertical stacked
bars, one dataset per band.
- Add `createRouteDetailStrip(canvasId, routeData)` (TR-8) — single horizontal
bar, one dataset per day.
- **Manual check**: `make up`, then exercise both helpers from the browser
console with mock data to confirm rendering (stacked scales, segment colors,
tooltips) before wiring the SPA pages.
### Phase 4: SPA wiring + i18n
- **`src/meshcore_hub/web/static/js/spa/pages/routes.js`**:
- Add the overview chart card + `apiGet` fetch + `createRouteOverviewChart`
call + lifecycle management (TR-9). Create a `chartIds` array and return a
cleanup function that destroys Chart.js instances on page unmount (mirroring
`dashboard.js:324-333` — the routes page currently has no chart lifecycle).
- Add the detail strip inside the expand content + lazy fetch +
`createRouteDetailStrip` + destroy-on-collapse (TR-10).
- **i18n**: add the three new keys (TR-11) to `en.json` and `nl.json`.
### Phase 5: Verify
```bash
pytest --no-cov tests/test_collector/test_routes.py tests/test_api/test_routes.py tests/test_web/
pre-commit run --all-files
make build # SPA bundle rebuild via Docker pipeline
make up
```
- Visually verify `/routes`: fleet chart renders with 7 stacked bars; tooltips
list non-zero bands per day; legend in semantic order.
- Expand a route card: status strip renders 7 colored segments oldest→newest,
today labeled, tooltips show date + band + matched_count.
- Empty-state: no visible routes → blank canvas + "no routes"; a route with no
hops in window → all-grey strip.
- Role scoping: log in as a low-role user and confirm the fleet counts and the
per-route 404 reflect visibility.
## Open Questions
- **"Today" in the overview.** The plan excludes today from the fleet overview
(consistency with dashboard daily-activity charts, which use complete days)
but includes it on the detail strip (live-health concern). If the inconsistency
reads awkwardly, the simplest reconciliation is to include today in both
(partial-day bars are acceptable for a status board whose purpose is
at-a-glance current state). Decide during implementation review.
- **Status-strip date axis.** TR-8 proposes an HTML date row beneath the canvas
rather than a Chart.js category axis, to avoid clutter on a single-row chart.
If that proves fiddly to align, an acceptable fallback is a Chart.js category
x-axis with `ticks.maxRotation: 0` and `maxTicksLimit: 7`. Pick whichever
aligns cleanly during the Phase 3 manual check.
- **Cache staleness.** Both history endpoints are cached under
`redis_cache_ttl_dashboard` (default 30 s). A route config change
(threshold / window / nodes / enabled) will serve stale daily evaluations for
up to one TTL window until the cache expires naturally — the same behaviour as
the dashboard activity endpoints. No explicit invalidation is needed for the
initial implementation; if the 30 s lag becomes noticeable in practice, a
simple invalidation call on the route CRUD endpoints (create/update/delete)
can be added later.
## References
- `docs/plans/20260705-2306-mesh-link-monitoring/plan.md` — the Routes feature
this extends. Defines `Route`/`RouteResult`/`packet_path_hops`, the
`evaluate_route` engine, the 60-second evaluator, the quality bands (F4), and
explicitly lists "historical route-health time series" as a Non-Goal (future
work) — this plan delivers that future work via compute-on-read.
- `docs/plans/20260704-1429-packet-breakdown-charts/plan.md` — the cached
aggregation endpoint + `createStackedBarChart` + `BreakdownBucket` schema +
`ChartColors.breakdown` palette patterns this plan mirrors for the fleet
chart and color tokens.
- `docs/plans/20260612-2014-raw-packets-feature/plan.md` — the `raw_packets`
table and `RAW_PACKET_RETENTION_DAYS` retention that bounds the compute-on-read
horizon and cascade-deletes `packet_path_hops`.
- `src/meshcore_hub/collector/routes.py:341-389` — `evaluate_route`, the pure
matching function the day-bounded sibling reimplements.
- `src/meshcore_hub/collector/routes.py:415-450` — `upsert_route_result`, the
in-place overwrite that makes history reconstruction necessary.
- `src/meshcore_hub/common/models/packet_path_hop.py:62-66` — the
`(node_hash, received_at)` composite index that makes the day-bounded range
scan cheap.
- `src/meshcore_hub/collector/cleanup.py:118-121` — raw-packet cleanup that
cascade-deletes hops, confirming `effective_raw_packet_retention_days` is the
sole horizon bound.
- `src/meshcore_hub/api/routes/dashboard.py:442-510` — `get_packet_breakdown`,
the cached-aggregation precedent.
- `src/meshcore_hub/web/static/js/charts.js:36-65,137,243-312` — `ChartColors`,
`formatDateLabels`, and `createStackedBarChart`, the infrastructure reused.
- `src/meshcore_hub/web/static/js/spa/pages/routes.js:51-68` — `renderSummaryStrip`,
the natural neighbor for the fleet chart card.
- Recent git history (`main`): `14fbc45` route health monitoring, `0e202b3`
reversible route matching, `1ee01a6` from/to endpoint labels, `ae22dff` test
determinism — the Routes work this plan builds on.
## Review
**Status**: Approved with Changes
**Reviewed**: 2026-07-15
### Resolutions
- **6-band fleet schema reduced to 5 bands.** The plan originally listed clear /
marginal / failing / `no_coverage` / `unknown` / disabled (6 bands), but the
`RouteQuality` enum (`route_result.py:24-30`) only has 4 values: `clear`,
`marginal`, `failing`, `unknown`. The `no_coverage` is a `RouteState`, not a
`RouteQuality`. The plan now uses 5 bands (clear / marginal / failing /
no_coverage / disabled), where `no_coverage` maps to `quality=unknown` —
matching the existing UI's label scheme at `routes.js:51-68`. The `unknown`
field was removed from `RouteFleetDayPoint`.
- **Quality colors use hardcoded oklch values.** The original TR-6 attempted to
read CSS custom properties (`--color-success`, `--color-warning`,
`--color-error`, `--color-info`) via `getCSSColor()`. Those variables do not
exist in `app.css :root` (which defines only page/section colors like
`--color-nodes`, `--color-routes`, etc.). The `ChartColors.quality` map now
uses hardcoded oklch values — the same approach the existing `breakdown`
palette uses at `charts.js:56-64`, which renders consistently across light and
dark themes.
- **Config attribute corrected to `effective_raw_packet_retention_days`.** The
plan originally referenced `Settings.raw_packet_retention_days` (`Optional[int]`,
can be `None`). The correct attribute is `settings.effective_raw_packet_retention_days`
(`config.py:334-339`), the computed `int` property that falls back to
`data_retention_days` when `raw_packet_retention_days` is `None`.
- **`fetch_candidate_paths` propagation clarified.** TR-1 now explicitly notes
that the `until` parameter must be added to `fetch_candidate_paths` (where the
SQL WHERE clause lives at `routes.py:262`), not only to its wrapper
`_fetch_candidate_paths_maybe_bidirectional`. Both functions need the `until`
parameter for the bounded day query.
- **Chart lifecycle pattern specified.** The routes page has no existing chart
cleanup infrastructure. TR-9 and Phase 4 now explicitly instruct creating a
`chartIds` array and returning a cleanup function that calls
`Chart.getChart(id).destroy()` on page unmount, mirroring the dashboard page's
pattern at `dashboard.js:324-333`.
- **Cache staleness risk acknowledged.** Added as an Open Question: history
endpoints serve stale results for up to `redis_cache_ttl_dashboard` (default
30 s) after route config changes. Same behaviour as the dashboard activity
endpoints. Explicit invalidation on route CRUD endpoints is noted as a
potential follow-up.
- **No plan-plan conflicts.** The observer-area-filters plan
(`20260707-2157-observer-area-filters`) does not touch `routes.js`; no overlap
with this plan's changes.
### Remaining Action Items
- Resolve "Today in the overview" question during Phase 4 implementation —
exclude today (complete days) or include today (partial day) on the fleet
chart.
- Choose status-strip date axis approach during Phase 3 manual check (HTML row
vs. Chart.js category axis).
@@ -0,0 +1,149 @@
# Tasks: Route Health History (7-Day Charts)
> Generated from `plan.md` on 2026-07-15
## Phase 1: Day-Bounded Matching Engine
- [x] Extend `fetch_candidate_paths` with optional `until` param
- [x] Add `until: Optional[datetime] = None` parameter to `fetch_candidate_paths` in `collector/routes.py`
- [x] Append `PacketPathHop.received_at < until` to the existing `received_at >= since` filter (line ~262) when `until` is provided
- [x] Verify the composite index `ix_packet_path_hops_node_hash_received_at` covers the bounded range scan
- [x] Extend `_fetch_candidate_paths_maybe_bidirectional` with `until` param
- [x] Add `until: Optional[datetime] = None` parameter
- [x] Pass `until` through to `fetch_candidate_paths` in both forward and bidirectional call paths
- [x] Extend `_has_any_hops_in_window` with `until` param
- [x] Add `until: Optional[datetime] = None` parameter
- [x] Apply the same `received_at < until` bound to the existence check
- [x] Add `evaluate_route_day(session, route, day_start, day_end)` (TR-1)
- [x] Implement as a sibling of `evaluate_route` (line ~341), mirroring its logic exactly
- [x] Call the bounded fetch helpers (`_fetch_candidate_paths_maybe_bidirectional` with both `since=day_start` and `until=day_end`)
- [x] Reuse `_route_expected_hashes`, `effective_degraded_threshold`, `_match_hops`, `derive_quality` unchanged
- [x] Return `tuple[str, str, int]``(quality, state, matched_count)`
- [x] Do NOT modify `evaluate_route` or `upsert_route_result` (hot path stays untouched)
- [x] Add `evaluate_route_history(session, route, days, *, include_today=False)` (TR-2)
- [x] Compute UTC midnight day boundaries for `days` calendar buckets, oldest first
- [x] When `include_today=True`, add one extra partial-day entry for today
- [x] Call `evaluate_route_day` per day
- [x] For a disabled route, return `(date, RouteQuality.UNKNOWN.value, RouteState.NO_COVERAGE.value, 0)` for every day without a DB hit
- [x] Return `list[tuple[date, str, str, int]]`
- [x] Write unit tests for day-bounded engine (`tests/test_collector/test_routes.py`)
- [x] `evaluate_route_day` returns correct band for clear / marginal / failing / no_coverage
- [x] Seed hops inside vs. outside the day window and assert correct classification
- [x] Day boundaries are strict: hops in adjacent day do not leak across `day_end`
- [x] `evaluate_route_history` returns `days` entries oldest-first
- [x] `include_today=True` adds one extra partial-day entry
- [x] Disabled route returns `unknown`/`no_coverage`/`0` for every day without a DB hit
## Phase 2: Schemas + API Endpoints
- [x] Add Pydantic schemas to `common/schemas/routes.py` (TR-3)
- [x] `RouteDayQuality` with `date: date`, `quality: str`, `state: str`, `matched_count: int`
- [x] `RouteHistory` with `route_id: str`, `days: int`, `data: list[RouteDayQuality]`
- [x] `RouteFleetDayPoint` with `date: date`, `clear: int = 0`, `marginal: int = 0`, `failing: int = 0`, `no_coverage: int = 0`, `disabled: int = 0`
- [x] `RouteFleetHistory` with `days: int`, `data: list[RouteFleetDayPoint]`
- [x] Use `datetime.date` (not `datetime`) for `date` fields so payloads serialize as `YYYY-MM-DD`
- [x] Add fleet history endpoint `GET /history` to `api/routes/routes.py` (TR-4)
- [x] Import `evaluate_route_history` from `collector.routes` and the new schemas
- [x] Read `settings.effective_raw_packet_retention_days` for the `days` clamp
- [x] Decorate with `@cached("routes/history", ttl_setting="redis_cache_ttl_dashboard", key_builder=_routes_key_builder)`
- [x] Load routes visible to caller's role (reuse visibility filter from `get_routes`)
- [x] Clamp `days = min(days, settings.effective_raw_packet_retention_days)`
- [x] For each enabled route, call `evaluate_route_history(session, route, days, include_today=False)`
- [x] Bucket each day's quality into band counters: `quality=unknown``no_coverage` (matching `routes.js:51-68`)
- [x] Disabled routes increment `disabled` for every day
- [x] Return oldest-day-first
- [x] **CRITICAL: Declare this endpoint BEFORE the `/{route_id}` routes** so FastAPI does not match `history` as a `route_id` path parameter
- [x] Add per-route history endpoint `GET /{route_id}/history` to `api/routes/routes.py` (TR-5)
- [x] Decorate with `@cached("routes/{id}/history", ttl_setting="redis_cache_ttl_dashboard", key_builder=_routes_key_builder)`
- [x] Fetch the route, apply visibility check (mirror `get_route` lines 220229 — 404 when not found or above caller's level)
- [x] Clamp `days = min(days, settings.effective_raw_packet_retention_days)`
- [x] Call `evaluate_route_history(session, route, days, include_today=True)`
- [x] Return `RouteHistory` with per-day `RouteDayQuality` entries
- [x] Write API tests (`tests/test_api/test_routes.py`)
- [x] Fleet endpoint: response shape matches `RouteFleetHistory`
- [x] Fleet endpoint: per-day band counts sum to the visible-route count
- [x] Fleet endpoint: disabled routes count only to `disabled`
- [x] Fleet endpoint: visibility filtering — low-role caller does not see admin-only routes
- [x] Fleet endpoint: `days` clamped to `effective_raw_packet_retention_days`
- [x] Fleet endpoint: caching is role-keyed (different roles get different results)
- [x] Per-route endpoint: per-day quality/state/matched_count shape
- [x] Per-route endpoint: 404 for a hidden route (above caller's level)
- [x] Per-route endpoint: 404 for an unknown route_id
- [x] Per-route endpoint: includes today as the final segment
- [x] Route-ordering guard: `GET /history` is not shadowed by `GET /{route_id}`
## Phase 3: Chart Helpers + Quality Palette
- [x] Add `quality` color map to `ChartColors` in `charts.js` (TR-6)
- [x] Add as a sibling of the existing `breakdown` palette (lines 5664)
- [x] Use hardcoded oklch values (not CSS custom properties — `app.css` has no semantic status colors)
- [x] Map: `clear` → green oklch, `marginal` → amber oklch, `failing` → red oklch, `no_coverage` → info-blue oklch, `disabled` → neutral grey oklch
- [x] Add `createRouteOverviewChart(canvasId, fleetData)` to `charts.js` (TR-7)
- [x] `type: 'bar'` (vertical), labels via `formatDateLabels(fleetData.data)` (reuse helper at line 137)
- [x] One dataset per band in fixed semantic order: clear, marginal, failing, no_coverage, disabled
- [x] Each dataset: `data` = per-day count, `backgroundColor` = `ChartColors.quality[band]`
- [x] `scales.x.stacked = true`, `scales.y.stacked = true`, `scales.y.beginAtZero = true`
- [x] Reuse `createChartOptions(true)` for legend/tooltip theming
- [x] Override tooltip `label` callback to list non-zero bands per hovered day
- [x] Return `null` on empty/no-routes data (matching `createLineChart`'s idiom at line 155)
- [x] Add `createRouteDetailStrip(canvasId, routeData)` to `charts.js` (TR-8)
- [x] `type: 'bar'`, `indexAxis: 'y'`, `labels: ['']` (one row)
- [x] One dataset per day: `{ label: <date>, data: [1], backgroundColor: ChartColors.quality[day.quality] }`
- [x] `scales.x.stacked = true`, `scales.y.stacked = true` so seven unit-width datasets render as one bar with seven equal colored segments
- [x] Hide both axes' ticks
- [x] Render a date-axis row beneath the canvas via HTML (not Chart.js) so dates align under each segment — fallback: Chart.js category x-axis with `ticks.maxRotation: 0`, `maxTicksLimit: 7`
- [x] Tooltip per segment: date + quality label + `matched_count`
- [x] Return `null` when `routeData` is empty
- [ ] Manual check: verify both helpers render correctly from browser console with mock data
- [ ] `make up`, then call helpers from console with sample payloads
- [ ] Confirm stacked scales, segment colors, tooltips before SPA wiring
## Phase 4: SPA Wiring + i18n
- [x] Add i18n keys to `en.json` and `nl.json` (TR-11)
- [x] `routes.history_title` — "Health (last 7 days)" / "Gezondheid (laatste 7 dagen)"
- [x] `routes.history_detail_title` — "Last 7 days" / "Laatste 7 dagen"
- [x] `routes.history_today` — "Today" / "Vandaag"
- [x] Verify existing band labels (`routes.quality_clear`, etc.) are reused for legend/tooltips
- [x] Add fleet overview chart to `spa/pages/routes.js` (TR-9)
- [x] Add a chart card with `<canvas id="routeOverviewChart">` adjacent to the existing summary strip (`renderSummaryStrip`, line 51)
- [x] Gate on `features.routes !== false`
- [x] After routes list loads in `renderPage`, fire `apiGet('/api/v1/routes/history', { days: 7 }, { signal })`
- [x] Call `createRouteOverviewChart` with the response
- [x] Create a `chartIds` array and return a cleanup function that destroys each Chart.js instance on page unmount (mirror `dashboard.js:324-333`)
- [x] Include abort support via `{ signal }` for fetch cancellation on page unmount
- [x] Add detail status strip to `spa/pages/routes.js` (TR-10)
- [x] Inside the expanded-card detail content (`renderDetailContent`), add a `<canvas>` element
- [x] Fetch `/api/v1/routes/${route.id}/history?days=7` lazily (only when card is expanded)
- [x] Call `createRouteDetailStrip` with the response
- [x] Destroy the strip instance when the card collapses
- [x] Match the existing lazy-load pattern used for the `detail` payload
## Phase 5: Verification
- [x] Run targeted tests
- [x] `pytest --no-cov tests/test_collector/test_routes.py`
- [x] `pytest --no-cov tests/test_api/test_routes.py`
- [x] `pytest --no-cov tests/test_web/`
- [x] Run quality checks
- [x] `pre-commit run --all-files`
- [ ] Rebuild and start the stack
- [ ] `make build` (SPA bundle rebuild via Docker pipeline)
- [ ] `make up`
- [ ] Visual verification on `/routes` page
- [ ] Fleet chart renders with 7 stacked bars (one per day)
- [ ] Tooltips list non-zero bands per day
- [ ] Legend lists bands in fixed semantic order (clear → marginal → failing → no_coverage → disabled)
- [ ] Expand a route card: status strip renders 7 colored segments oldest → newest
- [ ] Today's segment is labeled on the detail strip
- [ ] Tooltips show date + band label + matched_count
- [ ] Edge case verification
- [ ] Empty state: no visible routes → blank canvas + "no routes" message
- [ ] Route with no hops in window → all-grey (no_coverage) strip
- [ ] Role scoping: log in as a low-role user, confirm fleet counts and per-route 404 reflect visibility
- [ ] Resolve Open Question: "Today" in the overview
- [ ] Evaluate whether excluding today from fleet chart but including it on detail strip reads awkwardly
- [ ] If so, include today in both (partial-day bars acceptable for a status board)
- [ ] Resolve Open Question: Status-strip date axis
- [ ] Confirm HTML date row aligns cleanly under segments
- [ ] If not, switch to Chart.js category x-axis with `ticks.maxRotation: 0`, `maxTicksLimit: 7`
+1 -1
View File
@@ -126,7 +126,7 @@ routes:
match_width: 1
window_hours: 24
packet_count_threshold: 3
# degraded_threshold: 10 # optional; omit/null = 2x threshold
# clear_threshold: 10 # optional; omit/null = 2x threshold
# max_hop_span: 8 # optional; omit/null = unlimited
enabled: true
reversible: true # match both directions (A->B and B->A)
+1 -1
View File
@@ -19,7 +19,7 @@ routes:
match_width: 1
window_hours: 24
packet_count_threshold: 3
# degraded_threshold: 10 # optional; omit/null = 2x threshold
# clear_threshold: 10 # optional; omit/null = 2x threshold
# max_hop_span: 8 # optional; omit/null = unlimited
enabled: true
reversible: true # match both directions (A->B and B->A)
+5 -5
View File
@@ -346,9 +346,9 @@ def collect_metrics(session: Any) -> bytes:
["route"],
registry=registry,
)
route_degraded = Gauge(
"meshcore_route_degraded_threshold",
"Effective degraded threshold (2x threshold when unset)",
route_clear = Gauge(
"meshcore_route_clear_threshold",
"Effective clear threshold (2x threshold when unset)",
["route"],
registry=registry,
)
@@ -366,9 +366,9 @@ def collect_metrics(session: Any) -> bytes:
route_quality.labels(route=name).set(_QUALITY_VALUES.get(quality_str, 3))
route_matched.labels(route=name).set(result.matched_count if result else 0)
route_threshold.labels(route=name).set(route.packet_count_threshold)
from meshcore_hub.collector.routes import effective_degraded_threshold
from meshcore_hub.collector.routes import effective_clear_threshold
route_degraded.labels(route=name).set(effective_degraded_threshold(route))
route_clear.labels(route=name).set(effective_clear_threshold(route))
output: bytes = generate_latest(registry)
return output
+51 -7
View File
@@ -15,9 +15,11 @@ from meshcore_hub.api.channel_visibility import (
from meshcore_hub.api.dependencies import DbSession
from meshcore_hub.collector.routes import (
derive_expected_hash,
evaluate_route_history,
preview_route,
recent_matches,
)
from meshcore_hub.common.config import get_collector_settings
from meshcore_hub.common.models.node import Node
from meshcore_hub.common.models.route import Route
from meshcore_hub.common.models.route_node import RouteNode
@@ -28,6 +30,8 @@ from meshcore_hub.common.schemas.routes import (
RecentMatchPath,
RouteCreate,
RouteDetail,
RouteDayQuality,
RouteHistory,
RouteList,
RouteNodeRead,
RouteObserverRead,
@@ -43,7 +47,7 @@ router = APIRouter()
def _routes_key_builder(request: Request) -> str:
role = resolve_user_role(request) or "anonymous"
return f"routes:role={role}:{sorted_query_string(request)}"
return f"{request.url.path}:role={role}:{sorted_query_string(request)}"
def _route_node_to_read(rn: RouteNode) -> RouteNodeRead:
@@ -72,7 +76,7 @@ def _result_to_summary(result: RouteResult | None) -> RouteResultSummary | None:
quality=result.quality,
matched_count=result.matched_count,
threshold=result.threshold,
effective_degraded=result.effective_degraded,
effective_clear=result.effective_clear,
evaluated_at=result.evaluated_at,
)
@@ -87,7 +91,7 @@ def _route_to_read(route: Route) -> RouteRead:
match_width=route.match_width,
window_hours=route.window_hours,
packet_count_threshold=route.packet_count_threshold,
degraded_threshold=route.degraded_threshold,
clear_threshold=route.clear_threshold,
max_hop_span=route.max_hop_span,
enabled=route.enabled,
reversible=route.reversible,
@@ -195,7 +199,7 @@ def create_route(
match_width=body.match_width,
window_hours=body.window_hours,
packet_count_threshold=body.packet_count_threshold,
degraded_threshold=body.degraded_threshold,
clear_threshold=body.clear_threshold,
max_hop_span=body.max_hop_span,
enabled=body.enabled,
reversible=body.reversible,
@@ -265,6 +269,46 @@ def get_route(
)
@router.get("/{route_id}/history", response_model=RouteHistory)
@cached(
"routes/{id}/history",
ttl_setting="redis_cache_ttl_dashboard",
key_builder=_routes_key_builder,
)
def get_route_history(
_: RequireRead,
session: DbSession,
route_id: str,
request: Request,
days: int = 7,
) -> RouteHistory:
"""Per-route health history over the last *days* (includes today)."""
route = session.execute(
select(Route).where(Route.id == route_id)
).scalar_one_or_none()
if not route:
raise HTTPException(status_code=404, detail="Route not found")
role = resolve_user_role(request)
max_level = get_max_visibility_level(role)
if VISIBILITY_LEVELS.get(route.visibility, 0) > max_level:
raise HTTPException(status_code=404, detail="Route not found")
retention = get_collector_settings().effective_raw_packet_retention_days
days = min(days, retention)
history = evaluate_route_history(session, route, days, include_today=True)
return RouteHistory(
route_id=route.id,
days=len(history),
data=[
RouteDayQuality(date=d, quality=q, state=s, matched_count=c)
for d, q, s, c in history
],
)
@router.put("/{route_id}", response_model=RouteRead)
def update_route(
__: RequireAdmin,
@@ -307,8 +351,8 @@ def update_route(
route.window_hours = body.window_hours
if body.packet_count_threshold is not None:
route.packet_count_threshold = body.packet_count_threshold
if body.degraded_threshold is not None:
route.degraded_threshold = body.degraded_threshold
if body.clear_threshold is not None:
route.clear_threshold = body.clear_threshold
if body.max_hop_span is not None:
route.max_hop_span = body.max_hop_span
if body.enabled is not None:
@@ -381,7 +425,7 @@ def preview(
"observer_ids": [n.id for n in observer_nodes] if observer_nodes else None,
"max_hop_span": body.max_hop_span,
"packet_count_threshold": body.packet_count_threshold,
"degraded_threshold": body.degraded_threshold,
"clear_threshold": body.clear_threshold,
"reversible": body.reversible,
}
result = preview_route(session, config, since)
+2 -2
View File
@@ -808,7 +808,7 @@ def _import_routes(
route.packet_count_threshold = value.get(
"packet_count_threshold", 3
)
route.degraded_threshold = value.get("degraded_threshold")
route.clear_threshold = value.get("clear_threshold")
route.max_hop_span = value.get("max_hop_span")
route.enabled = value.get("enabled", True)
route.reversible = value.get("reversible", True)
@@ -828,7 +828,7 @@ def _import_routes(
match_width=match_width,
window_hours=value.get("window_hours", 24),
packet_count_threshold=value.get("packet_count_threshold", 3),
degraded_threshold=value.get("degraded_threshold"),
clear_threshold=value.get("clear_threshold"),
max_hop_span=value.get("max_hop_span"),
enabled=value.get("enabled", True),
reversible=value.get("reversible", True),
+326 -22
View File
@@ -6,11 +6,11 @@ per reception. Scales with (candidates) only, not (candidates × depth).
"""
import logging
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from typing import Any, Optional
from uuid import uuid4
from sqlalchemy import func, select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import Session
from meshcore_hub.common.models.node import Node
@@ -24,9 +24,9 @@ from meshcore_hub.common.models.route_result import (
logger = logging.getLogger(__name__)
#: Multiplier for the relative default comfort bar (``degraded_threshold = None``
#: means ``effective_degraded = 2 × packet_count_threshold``).
DEGRADED_DEFAULT_MULTIPLIER = 2
#: Multiplier for the relative default comfort bar (``clear_threshold = None``
#: means ``effective_clear = 2 × packet_count_threshold``).
CLEAR_DEFAULT_MULTIPLIER = 2
#: Cap on candidate receptions for preview to bound work per call.
PREVIEW_CANDIDATE_CAP = 5000
@@ -51,10 +51,10 @@ def derive_expected_hash(public_key: str, match_width: int) -> str:
return public_key[: 2 * match_width].upper()
def effective_degraded_threshold(route: Route) -> int:
def effective_clear_threshold(route: Route) -> int:
"""The effective comfort bar: explicit value or ``2 × threshold``."""
return route.degraded_threshold or (
route.packet_count_threshold * DEGRADED_DEFAULT_MULTIPLIER
return route.clear_threshold or (
route.packet_count_threshold * CLEAR_DEFAULT_MULTIPLIER
)
@@ -62,11 +62,11 @@ def derive_quality(
state: str,
matched_count: int,
threshold: int,
effective_degraded: int,
effective_clear: int,
) -> str:
"""Map ``(state, matched_count, thresholds)`` to a quality band."""
if state == RouteState.HEALTHY.value:
if matched_count >= effective_degraded:
if matched_count >= effective_clear:
return RouteQuality.CLEAR.value
return RouteQuality.MARGINAL.value
if state == RouteState.UNHEALTHY.value:
@@ -175,12 +175,15 @@ def _fetch_candidate_paths_maybe_bidirectional(
since: datetime,
observer_ids: Optional[list[str]] = None,
reversible: bool = True,
until: Optional[datetime] = None,
) -> dict[str, list[dict[str, Any]]]:
"""Fetch candidate paths from one or both endpoints of *expected*."""
paths = fetch_candidate_paths(session, expected[0], since, observer_ids)
paths = fetch_candidate_paths(
session, expected[0], since, observer_ids, until=until
)
if reversible and len(expected) > 1 and expected[-1] != expected[0]:
for rp_id, hops in fetch_candidate_paths(
session, expected[-1], since, observer_ids
session, expected[-1], since, observer_ids, until=until
).items():
if rp_id not in paths:
paths[rp_id] = hops
@@ -245,6 +248,7 @@ def fetch_candidate_paths(
since: datetime,
observer_ids: Optional[list[str]] = None,
limit: Optional[int] = None,
until: Optional[datetime] = None,
) -> dict[str, list[dict[str, Any]]]:
"""Fetch all hops for receptions whose path starts with *first_prefix*.
@@ -263,6 +267,8 @@ def fetch_candidate_paths(
)
.group_by(PacketPathHop.raw_packet_id)
)
if until is not None:
subq = subq.where(PacketPathHop.received_at < until)
if observer_ids:
subq = subq.where(PacketPathHop.observer_node_id.in_(observer_ids))
if limit is not None:
@@ -299,6 +305,66 @@ def fetch_candidate_paths(
return paths
def _fetch_matching_hops(
session: Session,
prefixes: list[str],
since: datetime,
until: datetime,
observer_ids: Optional[list[str]] = None,
) -> dict[str, list[dict[str, Any]]]:
"""Fetch only hops whose ``node_hash`` matches any of *prefixes*.
Unlike :func:`fetch_candidate_paths` (which loads **all** hops for
matching raw packets), this returns only the hops whose prefix is in
the set. The subsequence matcher (:func:`_subsequence_indices`) only
inspects hops whose ``node_hash`` starts with an expected prefix, so
filtering at the SQL level is semantically identical but returns far
fewer rows.
"""
prefix_ranges = []
for prefix in dict.fromkeys(prefixes):
prefix_end = _hex_prefix_end(prefix)
prefix_ranges.append(
and_(
PacketPathHop.node_hash >= prefix,
PacketPathHop.node_hash < prefix_end,
)
)
stmt = (
select(
PacketPathHop.raw_packet_id,
PacketPathHop.position,
PacketPathHop.node_hash,
PacketPathHop.packet_hash,
PacketPathHop.received_at,
)
.where(
PacketPathHop.received_at >= since,
PacketPathHop.received_at < until,
or_(*prefix_ranges),
)
.order_by(PacketPathHop.raw_packet_id, PacketPathHop.position)
)
if observer_ids:
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
paths: dict[str, list[dict[str, Any]]] = {}
for row in session.execute(stmt).all():
rp_id = row.raw_packet_id
if rp_id not in paths:
paths[rp_id] = []
paths[rp_id].append(
{
"position": row.position,
"node_hash": row.node_hash,
"packet_hash": row.packet_hash,
"received_at": row.received_at,
}
)
return paths
def _count_candidate_receptions(
session: Session,
first_prefix: str,
@@ -321,6 +387,7 @@ def _has_any_hops_in_window(
session: Session,
since: datetime,
observer_ids: Optional[list[str]] = None,
until: Optional[datetime] = None,
) -> bool:
"""Existence check: are there ANY in-scope hops in the window?"""
stmt = (
@@ -328,11 +395,53 @@ def _has_any_hops_in_window(
.select_from(PacketPathHop)
.where(PacketPathHop.received_at >= since)
)
if until is not None:
stmt = stmt.where(PacketPathHop.received_at < until)
if observer_ids:
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
return (session.execute(stmt).scalar() or 0) > 0
def _has_any_hops_per_day(
session: Session,
day_starts: list[datetime],
day_ends: list[datetime],
observer_ids: Optional[list[str]] = None,
) -> set[date]:
"""Per-day existence check in a single GROUP BY query.
Returns the set of dates that have at least one hop. Replaces N
per-day ``_has_any_hops_in_window`` calls with one query.
"""
if not day_starts:
return set()
window_start = day_starts[0]
window_end = day_ends[-1]
day_expr = func.date(PacketPathHop.received_at)
stmt = (
select(day_expr)
.where(
PacketPathHop.received_at >= window_start,
PacketPathHop.received_at < window_end,
)
.group_by(day_expr)
)
if observer_ids:
stmt = stmt.where(PacketPathHop.observer_node_id.in_(observer_ids))
result: set[date] = set()
for row in session.execute(stmt).all():
day_val = row[0]
if isinstance(day_val, date):
result.add(day_val)
else:
result.add(date.fromisoformat(str(day_val)))
return result
# ---------------------------------------------------------------------------
# Evaluation
# ---------------------------------------------------------------------------
@@ -361,7 +470,7 @@ def evaluate_route(
session, expected, since, observer_ids, reversible
)
eff_degraded = effective_degraded_threshold(route)
eff_clear = effective_clear_threshold(route)
matched_packets: set[str] = set()
for hops in paths.values():
@@ -369,7 +478,7 @@ def evaluate_route(
ph = hops[0]["packet_hash"]
if ph:
matched_packets.add(ph)
if len(matched_packets) >= eff_degraded:
if len(matched_packets) >= eff_clear:
return (
RouteState.HEALTHY.value,
RouteQuality.CLEAR.value,
@@ -385,10 +494,205 @@ def evaluate_route(
exists = _has_any_hops_in_window(session, since, observer_ids)
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
quality = derive_quality(state, matched_count, threshold, eff_degraded)
quality = derive_quality(state, matched_count, threshold, eff_clear)
return state, quality, matched_count
def evaluate_route_day(
session: Session,
route: Route,
day_start: datetime,
day_end: datetime,
) -> tuple[str, str, int]:
"""Evaluate a single route for a bounded day window ``[day_start, day_end)``.
Mirrors :func:`evaluate_route` exactly but bounds the candidate fetch with
an upper ``received_at < day_end``. Returns ``(quality, state,
matched_count)``.
"""
expected = _route_expected_hashes(route)
if len(expected) < 2:
return RouteQuality.UNKNOWN.value, RouteState.NO_COVERAGE.value, 0
observer_ids = (
[ro.node_id for ro in route.route_observers] if route.route_observers else None
)
reversible = getattr(route, "reversible", True)
paths = _fetch_candidate_paths_maybe_bidirectional(
session, expected, day_start, observer_ids, reversible, until=day_end
)
eff_clear = effective_clear_threshold(route)
matched_packets: set[str] = set()
for hops in paths.values():
if _match_hops(hops, expected, route.max_hop_span, reversible):
ph = hops[0]["packet_hash"]
if ph:
matched_packets.add(ph)
if len(matched_packets) >= eff_clear:
return (
RouteQuality.CLEAR.value,
RouteState.HEALTHY.value,
len(matched_packets),
)
matched_count = len(matched_packets)
threshold = route.packet_count_threshold
if matched_count >= threshold:
state = RouteState.HEALTHY.value
else:
exists = _has_any_hops_in_window(
session, day_start, observer_ids, until=day_end
)
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
quality = derive_quality(state, matched_count, threshold, eff_clear)
return quality, state, matched_count
def evaluate_route_history(
session: Session,
route: Route,
days: int,
*,
include_today: bool = False,
now: Optional[datetime] = None,
) -> list[tuple[date, str, str, int]]:
"""Evaluate a route over *days* calendar-day buckets (oldest first).
Returns a list of ``(date, quality, state, matched_count)`` tuples.
When *include_today* is True, an extra partial-day entry for today is
appended. For a disabled route, every day returns ``unknown`` /
``no_coverage`` / ``0`` without hitting the DB.
Performance: fetches only hops matching the route's expected prefixes
(one DB query) plus one GROUP BY existence check, then partitions by
day in Python.
"""
current = now or datetime.now(timezone.utc)
today_midnight = current.replace(hour=0, minute=0, second=0, microsecond=0)
total_days = days + (1 if include_today else 0)
oldest = today_midnight - timedelta(days=days)
day_dates = [(oldest + timedelta(days=i)).date() for i in range(total_days)]
if not route.enabled:
return [
(
day_dates[i],
RouteQuality.UNKNOWN.value,
RouteState.NO_COVERAGE.value,
0,
)
for i in range(total_days)
]
expected = _route_expected_hashes(route)
if len(expected) < 2:
return [
(
day_dates[i],
RouteQuality.UNKNOWN.value,
RouteState.NO_COVERAGE.value,
0,
)
for i in range(total_days)
]
# Compute day boundaries
day_starts: list[datetime] = []
day_ends: list[datetime] = []
for i in range(total_days):
if include_today and i == total_days - 1:
day_starts.append(today_midnight)
day_ends.append(current)
else:
ds = oldest + timedelta(days=i)
day_starts.append(ds)
day_ends.append(ds + timedelta(days=1))
window_start = day_starts[0]
window_end = day_ends[-1]
observer_ids = (
[ro.node_id for ro in route.route_observers] if route.route_observers else None
)
reversible = getattr(route, "reversible", True)
# --- Filtered fetch: only hops matching the route's prefixes ---
# _subsequence_indices only inspects hops whose node_hash starts with
# an expected prefix, so filtering at SQL level is semantically
# identical but returns far fewer rows than loading all hops for
# matching raw packets.
all_paths = _fetch_matching_hops(
session, expected, window_start, window_end, observer_ids
)
# --- Per-day existence (UNHEALTHY vs NO_COVERAGE) ---
day_has_hops = _has_any_hops_per_day(session, day_starts, day_ends, observer_ids)
# --- Partition packets by day in Python ---
# A packet qualifies for a day if it has at least one hop whose
# node_hash matches the route's first or last prefix AND received_at
# falls within that day's [start, end) range.
first_prefix = expected[0]
first_prefix_end = _hex_prefix_end(first_prefix)
last_prefix: Optional[str] = None
last_prefix_end: Optional[str] = None
if reversible and len(expected) > 1 and expected[-1] != expected[0]:
last_prefix = expected[-1]
last_prefix_end = _hex_prefix_end(last_prefix)
day_paths: list[dict[str, list[dict[str, Any]]]] = [{} for _ in range(total_days)]
for rp_id, hops in all_paths.items():
for hop in hops:
h = hop["node_hash"]
ra = hop["received_at"]
if ra.tzinfo is None:
ra = ra.replace(tzinfo=timezone.utc)
if not (
first_prefix <= h < first_prefix_end
or (last_prefix is not None and last_prefix <= h < last_prefix_end)
):
continue
for i in range(total_days):
if day_starts[i] <= ra < day_ends[i]:
if rp_id not in day_paths[i]:
day_paths[i][rp_id] = hops
break
# --- Evaluate each day (CPU-only, no DB) ---
eff_clear = effective_clear_threshold(route)
threshold = route.packet_count_threshold
results: list[tuple[date, str, str, int]] = []
for i in range(total_days):
matched_packets: set[str] = set()
for hops in day_paths[i].values():
if _match_hops(hops, expected, route.max_hop_span, reversible):
ph = hops[0]["packet_hash"]
if ph:
matched_packets.add(ph)
if len(matched_packets) >= eff_clear:
break
matched_count = len(matched_packets)
if matched_count >= threshold:
state = RouteState.HEALTHY.value
elif day_has_hops and day_dates[i] in day_has_hops:
state = RouteState.UNHEALTHY.value
else:
state = RouteState.NO_COVERAGE.value
quality = derive_quality(state, matched_count, threshold, eff_clear)
results.append((day_dates[i], quality, state, matched_count))
return results
def evaluate_all_routes(
session: Session, now: datetime
) -> dict[str, tuple[str, str, int]]:
@@ -421,7 +725,7 @@ def upsert_route_result(
) -> RouteResult:
"""Upsert a route evaluation result (ORM check-then-update/insert)."""
now = datetime.now(timezone.utc)
eff_degraded = effective_degraded_threshold(route)
eff_clear = effective_clear_threshold(route)
existing = session.execute(
select(RouteResult).where(RouteResult.route_id == route.id)
@@ -432,7 +736,7 @@ def upsert_route_result(
existing.quality = quality
existing.matched_count = matched_count
existing.threshold = route.packet_count_threshold
existing.effective_degraded = eff_degraded
existing.effective_clear = eff_clear
existing.evaluated_at = now
return existing
@@ -443,7 +747,7 @@ def upsert_route_result(
quality=quality,
matched_count=matched_count,
threshold=route.packet_count_threshold,
effective_degraded=eff_degraded,
effective_clear=eff_clear,
evaluated_at=now,
)
session.add(result)
@@ -507,7 +811,7 @@ def preview_route(
"""Preview matching for an unsaved route config.
*config* keys: ``node_ids``, ``match_width``, ``observer_ids``,
``max_hop_span``, ``packet_count_threshold``, ``degraded_threshold``,
``max_hop_span``, ``packet_count_threshold``, ``clear_threshold``,
``reversible``.
"""
node_ids: list[str] = config.get("node_ids") or []
@@ -515,7 +819,7 @@ def preview_route(
observer_ids: Optional[list[str]] = config.get("observer_ids") or None
max_hop_span: Optional[int] = config.get("max_hop_span")
threshold: int = config.get("packet_count_threshold") or 3
degraded: Optional[int] = config.get("degraded_threshold")
clear_bar: Optional[int] = config.get("clear_threshold")
reversible: bool = config.get("reversible", True)
if len(node_ids) < 2:
@@ -567,7 +871,7 @@ def preview_route(
session, expected, since, observer_ids, reversible
)
eff_degraded = degraded or (threshold * DEGRADED_DEFAULT_MULTIPLIER)
eff_clear = clear_bar or (threshold * CLEAR_DEFAULT_MULTIPLIER)
matched_packets: set[str] = set()
contributing: dict[str, int] = {}
@@ -589,7 +893,7 @@ def preview_route(
exists = _has_any_hops_in_window(session, since, observer_ids)
state = RouteState.UNHEALTHY.value if exists else RouteState.NO_COVERAGE.value
quality = derive_quality(state, matched_count, threshold, eff_degraded)
quality = derive_quality(state, matched_count, threshold, eff_clear)
collisions_map = prefix_collision_counts(session, match_width)
node_collisions = {
@@ -69,6 +69,10 @@ class PacketPathHop(Base, UUIDMixin, TimestampMixin):
"raw_packet_id",
"position",
),
Index(
"ix_packet_path_hops_received_at",
"received_at",
),
)
def __repr__(self) -> str:
+2 -2
View File
@@ -39,7 +39,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
match_width: Path-hash prefix width in bytes (1/2/3)
window_hours: Evaluation lookback window in hours
packet_count_threshold: Minimum distinct packets for healthy
degraded_threshold: Comfort bar for the clear/marginal split (null = 2x threshold)
clear_threshold: Comfort bar for the clear/marginal split (null = 2x threshold)
max_hop_span: Max hops between first and last configured node (null = unlimited)
enabled: Whether this route is actively evaluated
"""
@@ -75,7 +75,7 @@ class Route(Base, UUIDMixin, TimestampMixin):
default=3,
nullable=False,
)
degraded_threshold: Mapped[Optional[int]] = mapped_column(
clear_threshold: Mapped[Optional[int]] = mapped_column(
Integer,
nullable=True,
)
@@ -34,7 +34,7 @@ class RouteResult(Base, UUIDMixin, TimestampMixin):
"""The latest cached evaluation result for a route (one row per route).
Written by the background evaluator and read by the API/UI. ``threshold``
and ``effective_degraded`` are snapshotted at evaluation time so the display
and ``effective_clear`` are snapshotted at evaluation time so the display
stays self-consistent if thresholds are later changed.
Attributes:
@@ -44,7 +44,7 @@ class RouteResult(Base, UUIDMixin, TimestampMixin):
quality: Display axis (clear / marginal / failing / unknown)
matched_count: Distinct packet count (lower bound when short-circuited)
threshold: Snapshot of route.packet_count_threshold at eval time
effective_degraded: Snapshot of effective_degraded_threshold at eval time
effective_clear: Snapshot of effective_clear_threshold at eval time
evaluated_at: When this evaluation ran
"""
@@ -72,7 +72,7 @@ class RouteResult(Base, UUIDMixin, TimestampMixin):
Integer,
nullable=False,
)
effective_degraded: Mapped[int] = mapped_column(
effective_clear: Mapped[int] = mapped_column(
Integer,
nullable=False,
)
+30 -13
View File
@@ -1,6 +1,6 @@
"""Pydantic schemas for route API endpoints."""
from datetime import datetime
from datetime import date, datetime
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field, model_validator
@@ -35,7 +35,7 @@ class RouteResultSummary(BaseModel):
quality: Optional[str] = None
matched_count: Optional[int] = None
threshold: Optional[int] = None
effective_degraded: Optional[int] = None
effective_clear: Optional[int] = None
evaluated_at: Optional[datetime] = None
model_config = {"from_attributes": True}
@@ -69,7 +69,7 @@ class RouteCreate(BaseModel):
packet_count_threshold: int = Field(
default=3, ge=1, le=10000, description="Minimum distinct packets for healthy"
)
degraded_threshold: Optional[int] = Field(
clear_threshold: Optional[int] = Field(
default=None, description="Comfort bar (null = 2x threshold)"
)
max_hop_span: Optional[int] = Field(
@@ -94,10 +94,10 @@ class RouteCreate(BaseModel):
if len({k.lower() for k in self.node_public_keys}) < len(self.node_public_keys):
raise ValueError("Path nodes must be distinct")
if (
self.degraded_threshold is not None
and self.degraded_threshold <= self.packet_count_threshold
self.clear_threshold is not None
and self.clear_threshold <= self.packet_count_threshold
):
raise ValueError("degraded_threshold must be > packet_count_threshold")
raise ValueError("clear_threshold must be > packet_count_threshold")
return self
@@ -111,7 +111,7 @@ class RouteUpdate(BaseModel):
match_width: Optional[int] = Field(default=None, ge=1, le=3)
window_hours: Optional[int] = Field(default=None, ge=1, le=720)
packet_count_threshold: Optional[int] = Field(default=None, ge=1, le=10000)
degraded_threshold: Optional[int] = None
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
enabled: Optional[bool] = None
reversible: Optional[bool] = None
@@ -128,11 +128,11 @@ class RouteUpdate(BaseModel):
):
raise ValueError("Path nodes must be distinct")
if (
self.degraded_threshold is not None
self.clear_threshold is not None
and self.packet_count_threshold is not None
and self.degraded_threshold <= self.packet_count_threshold
and self.clear_threshold <= self.packet_count_threshold
):
raise ValueError("degraded_threshold must be > packet_count_threshold")
raise ValueError("clear_threshold must be > packet_count_threshold")
return self
@@ -147,7 +147,7 @@ class RouteRead(BaseModel):
match_width: int
window_hours: int
packet_count_threshold: int
degraded_threshold: Optional[int] = None
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
enabled: bool
reversible: bool
@@ -195,7 +195,7 @@ class RouteDetail(BaseModel):
match_width: int
window_hours: int
packet_count_threshold: int
degraded_threshold: Optional[int] = None
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
enabled: bool
reversible: bool
@@ -219,7 +219,7 @@ class RoutePreviewRequest(BaseModel):
match_width: int = Field(default=1, ge=1, le=3)
window_hours: int = Field(default=24, ge=1, le=720)
packet_count_threshold: int = Field(default=3, ge=1, le=10000)
degraded_threshold: Optional[int] = None
clear_threshold: Optional[int] = None
max_hop_span: Optional[int] = None
observer_public_keys: Optional[list[str]] = None
reversible: bool = Field(default=True)
@@ -243,3 +243,20 @@ class RoutePreviewResponse(BaseModel):
collisions: dict[str, int] = {}
truncated: bool = False
candidate_count: Optional[int] = None
class RouteDayQuality(BaseModel):
"""One day of a route's health history."""
date: date
quality: str
state: str
matched_count: int
class RouteHistory(BaseModel):
"""Per-route health history (GET /routes/{id}/history)."""
route_id: str
days: int
data: list[RouteDayQuality]
+75 -1
View File
@@ -61,7 +61,18 @@ const ChartColors = {
'oklch(0.7 0.19 80)', // yellow-green
'oklch(0.65 0.22 25)', // orange
'oklch(0.55 0 0)' // neutral grey (for "other")
]
],
// Semantic quality palette for route health charts. Hardcoded oklch
// values (same approach as `breakdown`) — app.css defines no semantic
// status colors.
quality: {
clear: 'oklch(0.72 0.17 145)',
marginal: 'oklch(0.75 0.18 85)',
failing: 'oklch(0.62 0.24 25)',
no_coverage: 'oklch(0.65 0.15 250)',
disabled: 'oklch(0.55 0 0)'
}
};
/**
@@ -383,3 +394,66 @@ function initDashboardCharts(nodeData, advertData, messageData, packetData, even
);
}
}
/**
* Create a per-route health status strip single horizontal bar of N equal
* colored day-segments.
*
* @param {string} canvasId - ID of the canvas element
* @param {Object} routeData - RouteHistory payload with `data` array
* @returns {Chart|null}
*/
function createRouteDetailStrip(canvasId, routeData) {
var ctx = document.getElementById(canvasId);
if (!ctx || !routeData || !routeData.data || routeData.data.length === 0) {
return null;
}
var existing = Chart.getChart(ctx);
if (existing) existing.destroy();
var datasets = routeData.data.map(function(day) {
return {
label: day.date,
data: [1],
backgroundColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
borderColor: ChartColors.quality[day.quality] || ChartColors.quality.no_coverage,
borderWidth: 1,
_quality: day.quality,
_matched_count: day.matched_count || 0
};
});
return new Chart(ctx, {
type: 'bar',
data: { labels: [''], datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
indexAxis: 'y',
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: ChartColors.tooltipBg,
titleColor: ChartColors.tooltipText,
bodyColor: ChartColors.tooltipText,
borderColor: ChartColors.tooltipBorder,
borderWidth: 1,
callbacks: {
title: function(ctx) { return ctx[0].dataset.label; },
label: function(ctx) {
var q = ctx.dataset._quality || 'unknown';
var label = (window.t && window.t('routes.quality_' + q)) || q;
return label + ' (' + ctx.dataset._matched_count + ')';
}
}
}
},
scales: {
x: { stacked: true, grid: { display: false }, ticks: { display: false } },
y: { stacked: true, grid: { display: false }, ticks: { display: false } }
},
interaction: { mode: 'nearest', intersect: true }
}
});
}
@@ -174,6 +174,10 @@ export function iconRuler(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21.3 8.7 8.7 21.3a1 1 0 0 1-1.4 0L2.7 16.7a1 1 0 0 1 0-1.4L15.3 2.7a1 1 0 0 1 1.4 0l4.6 4.6a1 1 0 0 1 0 1.4Z" /><path d="m7.5 10.5 2 2" /><path d="m10.5 7.5 2 2" /><path d="m13.5 4.5 2 2" /><path d="m4.5 13.5 2 2" /></svg>`;
}
export function iconClock(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>`;
}
export function iconFilter(cls = 'h-5 w-5') {
return html`<svg xmlns="http://www.w3.org/2000/svg" class=${cls} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 3c2.755 0 5.455.232 8.083.678.533.09.917.556.917 1.096v1.044a2.25 2.25 0 01-.659 1.591l-5.432 5.432a2.25 2.25 0 00-.659 1.591v2.927a2.25 2.25 0 01-1.244 2.013L9.75 21v-6.568a2.25 2.25 0 00-.659-1.591L3.659 7.409A2.25 2.25 0 013 5.818V4.774c0-.54.384-1.006.917-1.096A48.32 48.32 0 0112 3z" /></svg>`;
}
@@ -1,6 +1,6 @@
import { apiGet, apiPost, apiPut, apiDelete, isAbortError } from '../api.js';
import { html, litRender, nothing, t, errorAlert, getConfig, hasRole } from '../components.js';
import { iconPath, iconPlus, iconEdit, iconTrash, iconChevronRight } from '../icons.js';
import { iconPath, iconPlus, iconEdit, iconTrash, iconPackets, iconClock, iconRuler, iconNodes, iconSatelliteDish } from '../icons.js';
const VISIBILITY_ORDER = ['community', 'member', 'operator', 'admin'];
@@ -79,21 +79,43 @@ function renderPathChips(route) {
</div>`;
}
function renderNumbersLine(route) {
function renderStatsRow(route) {
const result = route.route_result;
if (!result) return html`<div class="text-xs opacity-50 mt-1">${t('routes.not_evaluated')}</div>`;
const matched = result.matched_count ?? '?';
const threshold = result.threshold ?? '?';
const degraded = result.effective_degraded ?? '?';
const evalTime = result.evaluated_at
? new Date(result.evaluated_at).toLocaleTimeString()
: '?';
return html`<div class="text-xs opacity-60 mt-1">
${matched} / ${threshold} \u2192 ${degraded} \u00B7 ${route.window_hours}h \u00B7 ${evalTime}
const matched = result?.matched_count ?? '?';
const threshold = result?.threshold ?? '?';
const degraded = result?.effective_clear ?? '?';
const nodeCount = (route.route_nodes || []).length;
const obsCount = (route.route_observers || []).length;
return html`<div class="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs opacity-60 mt-1">
<span class="inline-flex items-center gap-1">
${iconPackets('h-3.5 w-3.5')}
<span>${matched}/${threshold}\u2192${degraded}</span>
</span>
<span class="inline-flex items-center gap-1">
${iconClock('h-3.5 w-3.5')}
<span>${route.window_hours}h</span>
</span>
<span class="inline-flex items-center gap-1">
${iconRuler('h-3.5 w-3.5')}
<span>${route.match_width}B</span>
</span>
<span class="inline-flex items-center gap-1">
${iconNodes('h-3.5 w-3.5')}
<span>${nodeCount}</span>
</span>
${route.max_hop_span ? html`<span class="inline-flex items-center gap-1">
${iconPath('h-3.5 w-3.5')}
<span>${route.max_hop_span}</span>
</span>` : nothing}
<span class="inline-flex items-center gap-1">
${iconSatelliteDish('h-3.5 w-3.5')}
<span>${obsCount || '\u221E'}</span>
</span>
</div>`;
}
function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpanded, detail, navigate, packetsEnabled }) {
function renderRouteCard(route, { isAdmin, onDelete, onEdit, detail, navigate, packetsEnabled, history }) {
const q = route.route_result?.quality || 'unknown';
const badgeCls = qualityBadgeClass(q, route.enabled);
const label = qualityLabel(q, route.enabled);
@@ -105,22 +127,24 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
: html`<span class="badge ${badgeCls} badge-sm">${dot} ${label}</span>`;
const adminButtons = isAdmin
? html`<div class="flex gap-2 mt-2">
<button class="btn btn-xs btn-outline" @click=${(e) => { e.stopPropagation(); onEdit(route); }}>
? html`<div class="flex gap-2 mt-auto pt-2">
<button class="btn btn-xs btn-outline" @click=${() => onEdit(route)}>
${iconEdit('h-3 w-3')} ${t('common.edit')}
</button>
<button class="btn btn-xs btn-outline btn-error" @click=${(e) => { e.stopPropagation(); onDelete(route); }}>
<button class="btn btn-xs btn-outline btn-error" @click=${() => onDelete(route)}>
${iconTrash('h-3 w-3')} ${t('common.delete')}
</button>
</div>`
: nothing;
const expandContent = isExpanded && detail ? renderDetailContent(route, detail, { navigate, packetsEnabled }) : nothing;
const expandContent = detail
? renderDetailContent(route, detail, { navigate, packetsEnabled, history })
: html`<div class="mt-4 pt-4 border-t border-base-300 flex justify-center">
<span class="loading loading-spinner loading-sm opacity-50"></span>
</div>`;
return html`<div class="card bg-base-100 shadow-xl">
<div class="card-body cursor-pointer" role="button" tabindex="0"
@click=${() => onExpand(route)}
@keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onExpand(route); } }}>
return html`<div class="card bg-base-100 shadow-xl h-full">
<div class="card-body">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<h2 class="card-title flex items-center gap-2 flex-wrap">
@@ -132,34 +156,36 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande
</div>
<div class="flex items-center gap-2 flex-shrink-0">
${badge}
${iconChevronRight(`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`)}
</div>
</div>
<div class="mt-2">${renderPathChips(route)}</div>
${renderNumbersLine(route)}
<div class="text-xs opacity-50 mt-0.5">
${route.match_width}B \u00B7 ${(route.route_nodes || []).length} ${t('routes.nodes_count')}${route.max_hop_span ? html` \u00B7 ${t('routes.span')}: ${route.max_hop_span}` : nothing}
</div>
${adminButtons}
${renderStatsRow(route)}
${expandContent}
${adminButtons}
</div>
</div>`;
}
function renderDetailContent(route, detail, { navigate, packetsEnabled }) {
const result = detail.route_result || route.route_result;
const observers = detail.contributing_observers || [];
function renderDetailContent(route, detail, { navigate, packetsEnabled, history }) {
const matches = detail.recent_matches || [];
const packetDetailUrl = (packetHash) =>
(packetsEnabled && packetHash) ? `/packets/hash/${packetHash}` : null;
return html`<div class="mt-4 pt-4 border-t border-base-300 space-y-3 text-sm">
${observers.length > 0 ? html`<div>
<strong class="opacity-70">${t('routes.contributing_observers')}:</strong>
${observers.map(o => html`<span class="badge badge-ghost badge-sm ml-1">${o.name || o.node_id.slice(0, 8)} (${o.match_count})</span>`)}
</div>` : html`<div class="opacity-50">${t('routes.no_observers')}</div>`}
const historySection = history
? html`<div class="mb-3">
<div style="height: 40px;">
<canvas id="routeStripChart-${route.id}"></canvas>
</div>
${history.data && history.data.length > 0 ? html`<div class="flex text-xs opacity-50 mt-0.5">
${history.data.map(d => html`<span class="flex-1 text-center">${new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}</span>`)}
</div>` : nothing}
</div>`
: nothing;
return html`<div class="mt-2 space-y-3 text-sm">
${historySection}
${matches.length > 0 ? html`<div>
<strong class="opacity-70">${t('routes.recent_matches')}:</strong>
<strong class="opacity-70">${t('routes.recent_packets')}</strong>
<div class="mt-1 space-y-2">
${matches.map(m => {
const prefixLen = 2 * (route.match_width || 1);
@@ -184,12 +210,6 @@ function renderDetailContent(route, detail, { navigate, packetsEnabled }) {
})}
</div>
</div>` : nothing}
<div class="opacity-50 text-xs">
${t('routes.width')}: ${route.match_width} \u00B7
${t('routes.window')}: ${route.window_hours}h \u00B7
${t('routes.threshold')}: ${route.packet_count_threshold} \u00B7
${route.max_hop_span ? html`${t('routes.span')}: ${route.max_hop_span}` : nothing}
</div>
</div>`;
}
@@ -353,10 +373,10 @@ function renderRouteModal({ modalState, onSave, onCancel }) {
.value=${route.packet_count_threshold || 3} min="1" max="10000" />
</div>
<div>
<label class="text-sm opacity-70">${t('routes.degraded_label')}</label>
<input type="number" id="route-modal-degraded" class="input input-sm w-full"
.value=${route.degraded_threshold || ''}
placeholder="2x" min="1" />
<label class="text-sm opacity-70">${t('routes.clear_label')}</label>
<input type="number" id="route-modal-clear" class="input input-sm w-full"
.value=${route.clear_threshold || ''}
placeholder="${2 * (route.packet_count_threshold || 3)}" min="1" />
</div>
<div>
<label class="text-sm opacity-70">${t('routes.span_label')}</label>
@@ -416,29 +436,44 @@ export async function render(container, params, router) {
const routes = data.items || [];
let modalState = null;
let expandedId = null;
const detailCache = new Map();
const historyCache = new Map();
const chartRegistry = [];
function destroyCharts() {
chartRegistry.forEach(c => { try { c.destroy(); } catch (_) {} });
chartRegistry.length = 0;
}
async function refresh() {
const newData = await apiGet('/api/v1/routes');
renderPage(newData.items || []);
routes.splice(0, routes.length, ...(newData.items || []));
renderPage(routes);
loadAllDetails(routes);
}
async function handleExpand(route) {
if (expandedId === route.id) {
expandedId = null;
} else {
expandedId = route.id;
if (!detailCache.has(route.id)) {
try {
const detail = await apiGet(`/api/v1/routes/${route.id}`);
detailCache.set(route.id, detail);
} catch (e) {
// ignore — card still shows basic info
}
async function loadAllDetails(routesList) {
const promises = [];
for (const r of routesList) {
if (!detailCache.has(r.id)) {
promises.push(
apiGet(`/api/v1/routes/${r.id}`, {}, { signal })
.then(d => detailCache.set(r.id, d))
.catch(() => {})
);
}
if (!historyCache.has(r.id)) {
promises.push(
apiGet(`/api/v1/routes/${r.id}/history`, { days: 6 }, { signal })
.then(h => historyCache.set(r.id, h))
.catch(() => {})
);
}
}
renderPage(routes);
if (promises.length > 0) {
await Promise.allSettled(promises);
renderPage(routes);
}
}
function renderPage(routesList) {
@@ -468,11 +503,10 @@ export async function render(container, params, router) {
isAdmin,
onDelete: handleDeleteClick,
onEdit: handleEditClick,
onExpand: handleExpand,
isExpanded: (r) => expandedId === r.id,
detail: (r) => detailCache.get(r.id),
navigate,
packetsEnabled,
history: (r) => historyCache.get(r.id),
};
const groupedSections = [];
@@ -487,8 +521,8 @@ export async function render(container, params, router) {
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
${group.map(r => renderRouteCard(r, {
...cardOpts,
isExpanded: cardOpts.isExpanded(r),
detail: cardOpts.detail(r),
history: cardOpts.history(r),
}))}
</div>
`);
@@ -509,6 +543,8 @@ export async function render(container, params, router) {
});
}
destroyCharts();
litRender(html`
<div class="mb-6">
<h1 class="text-3xl font-bold flex items-center gap-2">
@@ -522,6 +558,13 @@ export async function render(container, params, router) {
${groupedSections}
${modalHtml}
`, container);
for (const r of routesList) {
if (historyCache.has(r.id)) {
const chart = window.createRouteDetailStrip(`routeStripChart-${r.id}`, historyCache.get(r.id));
if (chart) chartRegistry.push(chart);
}
}
}
function _newModalState(type, route) {
@@ -702,7 +745,7 @@ export async function render(container, params, router) {
const widthEl = document.getElementById('route-modal-width');
const windowEl = document.getElementById('route-modal-window');
const thresholdEl = document.getElementById('route-modal-threshold');
const degradedEl = document.getElementById('route-modal-degraded');
const clearEl = document.getElementById('route-modal-clear');
const spanEl = document.getElementById('route-modal-span');
const enabledEl = document.getElementById('route-modal-enabled');
const reversibleEl = document.getElementById('route-modal-reversible');
@@ -731,14 +774,16 @@ export async function render(container, params, router) {
observer_public_keys: observerPublicKeys.length > 0 ? observerPublicKeys : null,
};
const degradedVal = degradedEl.value.trim();
if (degradedVal) {
body.degraded_threshold = parseInt(degradedVal, 10);
const clearVal = clearEl.value.trim();
if (clearVal) {
body.clear_threshold = parseInt(clearVal, 10);
}
try {
if (isEdit) {
await apiPut(`/api/v1/routes/${modalState.route.id}`, body);
detailCache.delete(modalState.route.id);
historyCache.delete(modalState.route.id);
} else {
await apiPost('/api/v1/routes', body);
}
@@ -760,6 +805,11 @@ export async function render(container, params, router) {
}
renderPage(routes);
loadAllDetails(routes);
return () => {
destroyCharts();
};
} catch (e) {
if (isAbortError(e)) return;
+2 -10
View File
@@ -332,28 +332,20 @@
"observers_placeholder": "Search to add observer nodes",
"window_label": "Window (hours)",
"threshold_label": "Threshold",
"degraded_label": "Degraded",
"clear_label": "Clear Threshold",
"span_label": "Max Span",
"enabled_label": "Enabled",
"reversible_label": "Reversible (match both directions)",
"nodes_count": "nodes",
"disabled": "Disabled",
"not_evaluated": "Not yet evaluated",
"diagnosis_healthy": "Route is healthy — enough packets are traversing the configured path.",
"diagnosis_unhealthy": "Route is unhealthy — in-scope observers are hearing traffic but not enough packets match the configured path.",
"diagnosis_no_coverage": "No coverage — no in-scope observer has heard any packets in the window. The route may be down or no observer is positioned to hear it.",
"contributing_observers": "Contributing Observers",
"no_observers": "No contributing observers in the evaluation window.",
"recent_matches": "Recent Matches",
"width": "Width",
"window": "Window",
"threshold": "Threshold",
"span": "Span",
"quality_clear": "Clear",
"quality_marginal": "Marginal",
"quality_failing": "Failing",
"quality_no_coverage": "No Coverage",
"quality_unknown": "Unknown",
"recent_packets": "Recent Packets",
"min_nodes_error": "At least 2 path nodes are required."
},
"not_found": {
+2 -10
View File
@@ -254,28 +254,20 @@
"observers_placeholder": "Zoek om observer-knooppunten toe te voegen",
"window_label": "Venster (uren)",
"threshold_label": "Drempel",
"degraded_label": "Achteruitgang",
"clear_label": "Heldere drempel",
"span_label": "Max span",
"enabled_label": "Ingeschakeld",
"reversible_label": "Omkeerbaar (beide richtingen)",
"nodes_count": "knooppunten",
"disabled": "Uitgeschakeld",
"not_evaluated": "Nog niet geëvalueerd",
"diagnosis_healthy": "Route is gezond \u2014 voldoende packets volgen het geconfigureerde pad.",
"diagnosis_unhealthy": "Route is ongezond \u2014 observers horen verkeer, maar onvoldoende packets volgen het geconfigureerde pad.",
"diagnosis_no_coverage": "Geen dekking \u2014 geen in-scope observer heeft packets gehoord in het venster. De route ligt mogelijk plat of geen observer is bereikbaar.",
"contributing_observers": "Bijdragende observers",
"no_observers": "Geen bijdragende observers in het evaluatievenster.",
"recent_matches": "Recente overeenkomsten",
"width": "Breedte",
"window": "Venster",
"threshold": "Drempel",
"span": "Span",
"quality_clear": "Goed",
"quality_marginal": "Kritiek",
"quality_failing": "Storing",
"quality_no_coverage": "Geen dekking",
"quality_unknown": "Onbekend",
"recent_packets": "Recente packets",
"min_nodes_error": "Minimaal 2 padknooppunten zijn vereist."
},
"not_found": {
+2 -2
View File
@@ -420,7 +420,7 @@ class TestRouteMetrics:
quality="clear",
matched_count=10,
threshold=3,
effective_degraded=6,
effective_clear=6,
)
)
api_db_session.commit()
@@ -432,7 +432,7 @@ class TestRouteMetrics:
assert "meshcore_route_quality" in text
assert "meshcore_route_matched_packets" in text
assert "meshcore_route_threshold" in text
assert "meshcore_route_degraded_threshold" in text
assert "meshcore_route_clear_threshold" in text
assert 'route="Test -> Route"' in text
def test_disabled_routes_omitted(self, client_no_auth, api_db_session):
+117 -2
View File
@@ -142,7 +142,7 @@ class TestCreateRoute:
)
assert resp.status_code == 422
def test_degraded_threshold_validation(self, client_no_auth, api_db_session):
def test_clear_threshold_validation(self, client_no_auth, api_db_session):
nodes = _sample_nodes(api_db_session)
api_db_session.commit()
@@ -153,7 +153,7 @@ class TestCreateRoute:
"to_label": "B",
"node_public_keys": [n.public_key for n in nodes],
"packet_count_threshold": 5,
"degraded_threshold": 3,
"clear_threshold": 3,
},
headers={"X-User-Roles": "admin"},
)
@@ -311,3 +311,118 @@ class TestPreview:
json={"node_public_keys": [node.public_key]},
)
assert resp.status_code == 422
def _make_route_with_nodes(
session,
from_label: str,
to_label: str,
pubkeys: list[str],
visibility: str = "community",
enabled: bool = True,
match_width: int = 1,
) -> Route:
nodes = [_make_node(session, pk) for pk in pubkeys]
route = Route(
from_label=from_label,
to_label=to_label,
visibility=visibility,
enabled=enabled,
match_width=match_width,
)
session.add(route)
session.flush()
for pos, n in enumerate(nodes):
session.add(
RouteNode(
route_id=route.id,
node_id=n.id,
position=pos,
expected_hash=n.public_key[: 2 * match_width].upper(),
)
)
return route
class TestRouteHistory:
def test_response_shape(self, client_no_auth, api_db_session):
route = _make_route_with_nodes(
api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62]
)
api_db_session.commit()
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=3")
assert resp.status_code == 200
data = resp.json()
assert data["route_id"] == route.id
assert "days" in data
assert "data" in data
assert isinstance(data["data"], list)
for entry in data["data"]:
assert "date" in entry
assert "quality" in entry
assert "state" in entry
assert "matched_count" in entry
def test_includes_today(self, client_no_auth, api_db_session):
route = _make_route_with_nodes(
api_db_session, "A", "B", ["aa" + "0" * 62, "bb" + "0" * 62]
)
api_db_session.commit()
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=7")
assert resp.status_code == 200
data = resp.json()
# days=7 + include_today=True → 8 entries
assert len(data["data"]) == 8
def test_not_found(self, client_no_auth):
resp = client_no_auth.get("/api/v1/routes/nonexistent/history")
assert resp.status_code == 404
def test_hidden_route_404(self, client_no_auth, api_db_session):
route = _make_route_with_nodes(
api_db_session,
"Secret",
"EP",
["aa" + "0" * 62, "bb" + "0" * 62],
visibility="admin",
)
api_db_session.commit()
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history")
assert resp.status_code == 404
def test_admin_sees_hidden_route(self, client_no_auth, api_db_session):
route = _make_route_with_nodes(
api_db_session,
"Secret",
"EP",
["aa" + "0" * 62, "bb" + "0" * 62],
visibility="admin",
)
api_db_session.commit()
resp = client_no_auth.get(
f"/api/v1/routes/{route.id}/history",
headers={"X-User-Roles": "admin"},
)
assert resp.status_code == 200
def test_disabled_route_history(self, client_no_auth, api_db_session):
route = _make_route_with_nodes(
api_db_session,
"Disabled",
"Route",
["aa" + "0" * 62, "bb" + "0" * 62],
enabled=False,
)
api_db_session.commit()
resp = client_no_auth.get(f"/api/v1/routes/{route.id}/history?days=3")
assert resp.status_code == 200
data = resp.json()
for entry in data["data"]:
assert entry["quality"] == "unknown"
assert entry["state"] == "no_coverage"
assert entry["matched_count"] == 0
+1 -1
View File
@@ -112,4 +112,4 @@ class TestRunEvaluation:
assert result.state == RouteState.HEALTHY.value
assert result.quality == RouteQuality.CLEAR.value
assert result.threshold == 3
assert result.effective_degraded == 6
assert result.effective_clear == 6
+284 -12
View File
@@ -9,9 +9,11 @@ from meshcore_hub.collector.routes import (
derive_expected_hash,
derive_quality,
detect_observed_widths,
effective_degraded_threshold,
effective_clear_threshold,
evaluate_all_routes,
evaluate_route,
evaluate_route_day,
evaluate_route_history,
is_subsequence,
preview_route,
prefix_collision_counts,
@@ -79,7 +81,7 @@ def _make_route(
nodes: list[Node],
match_width: int = 1,
threshold: int = 3,
degraded: int | None = None,
clear_bar: int | None = None,
max_hop_span: int | None = None,
observers: list[Node] | None = None,
enabled: bool = True,
@@ -91,7 +93,7 @@ def _make_route(
to_label=name,
match_width=match_width,
packet_count_threshold=threshold,
degraded_threshold=degraded,
clear_threshold=clear_bar,
max_hop_span=max_hop_span,
enabled=enabled,
window_hours=window_hours,
@@ -193,24 +195,24 @@ class TestDeriveQuality:
)
class TestEffectiveDegraded:
class TestEffectiveClear:
def test_explicit(self, db_session):
route = Route(
from_label="t",
to_label="t",
packet_count_threshold=5,
degraded_threshold=20,
clear_threshold=20,
)
assert effective_degraded_threshold(route) == 20
assert effective_clear_threshold(route) == 20
def test_default_2x(self, db_session):
route = Route(
from_label="t",
to_label="t",
packet_count_threshold=5,
degraded_threshold=None,
clear_threshold=None,
)
assert effective_degraded_threshold(route) == 10
assert effective_clear_threshold(route) == 10
class TestDeriveExpectedHash:
@@ -240,7 +242,7 @@ class TestEvaluateRoute:
state, quality, count = evaluate_route(db_session, route, since)
assert state == RouteState.HEALTHY.value
assert quality == RouteQuality.CLEAR.value
assert count >= 6 # short-circuited at effective_degraded = 6
assert count >= 6 # short-circuited at effective_clear = 6
def test_healthy_marginal(self, db_session):
"""Meets threshold but not comfort bar → healthy/marginal."""
@@ -339,11 +341,13 @@ class TestEvaluateRoute:
state, _, count = evaluate_route(db_session, route, since)
assert count == 0
def test_short_circuit_at_effective_degraded(self, db_session):
def test_short_circuit_at_effective_clear(self, db_session):
"""Evaluation stops counting at the comfort bar."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=2, degraded=4)
route = _make_route(
db_session, "R1", [node_a, node_b], threshold=2, clear_bar=4
)
for i in range(20):
_make_reception(db_session, None, f"pkt{i}", ["AA", "BB"])
@@ -352,7 +356,7 @@ class TestEvaluateRoute:
since = _NOW - timedelta(hours=24)
state, quality, count = evaluate_route(db_session, route, since)
assert quality == RouteQuality.CLEAR.value
assert count == 4 # short-circuited at effective_degraded = 4
assert count == 4 # short-circuited at effective_clear = 4
def test_reversible_matches_reverse_direction(self, db_session):
"""Reversible route matches packets travelling the reverse path."""
@@ -556,3 +560,271 @@ class TestPreviewRoute:
widths = detect_observed_widths(db_session, node.public_key)
assert 2 in widths # observed at 2-byte prefix "AABB"
# ---------------------------------------------------------------------------
# Day-bounded evaluation tests
# ---------------------------------------------------------------------------
class TestEvaluateRouteDay:
def test_clear_within_day(self, db_session):
"""Enough matches within the day window → clear."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
day_start = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
day_end = datetime(2026, 7, 11, 0, 0, 0, tzinfo=timezone.utc)
for i in range(10):
_make_reception(
db_session,
None,
f"pkt{i}",
["AA", "BB"],
received_at=day_start + timedelta(hours=2),
)
db_session.commit()
quality, state, count = evaluate_route_day(
db_session, route, day_start, day_end
)
assert quality == RouteQuality.CLEAR.value
assert state == RouteState.HEALTHY.value
assert count >= 6 # short-circuited at effective_clear = 6
def test_marginal_within_day(self, db_session):
"""Meets threshold but not comfort bar → marginal."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
day_start = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
day_end = datetime(2026, 7, 11, 0, 0, 0, tzinfo=timezone.utc)
for i in range(4):
_make_reception(
db_session,
None,
f"pkt{i}",
["AA", "BB"],
received_at=day_start + timedelta(hours=1),
)
db_session.commit()
quality, state, count = evaluate_route_day(
db_session, route, day_start, day_end
)
assert quality == RouteQuality.MARGINAL.value
assert state == RouteState.HEALTHY.value
assert count == 4
def test_failing_within_day(self, db_session):
"""Traffic exists but not enough matches → failing."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
day_start = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
day_end = datetime(2026, 7, 11, 0, 0, 0, tzinfo=timezone.utc)
_make_reception(
db_session,
None,
"pkt1",
["AA", "BB"],
received_at=day_start + timedelta(hours=1),
)
_make_reception(
db_session,
None,
"pkt2",
["CC", "DD"],
received_at=day_start + timedelta(hours=2),
)
db_session.commit()
quality, state, count = evaluate_route_day(
db_session, route, day_start, day_end
)
assert quality == RouteQuality.FAILING.value
assert state == RouteState.UNHEALTHY.value
assert count == 1
def test_no_coverage_within_day(self, db_session):
"""Zero hops in the day window → no_coverage/unknown."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
day_start = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
day_end = datetime(2026, 7, 11, 0, 0, 0, tzinfo=timezone.utc)
db_session.commit()
quality, state, count = evaluate_route_day(
db_session, route, day_start, day_end
)
assert quality == RouteQuality.UNKNOWN.value
assert state == RouteState.NO_COVERAGE.value
assert count == 0
def test_strict_day_boundary(self, db_session):
"""Hops in the adjacent day do not leak across the day_end bound."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
day_start = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
day_end = datetime(2026, 7, 11, 0, 0, 0, tzinfo=timezone.utc)
# 10 matches the day AFTER day_end
for i in range(10):
_make_reception(
db_session,
None,
f"after{i}",
["AA", "BB"],
received_at=day_end + timedelta(hours=2, seconds=i),
)
db_session.commit()
quality, state, count = evaluate_route_day(
db_session, route, day_start, day_end
)
assert state == RouteState.NO_COVERAGE.value
assert count == 0
def test_hops_outside_before_boundary_excluded(self, db_session):
"""Hops before day_start are excluded."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
day_start = datetime(2026, 7, 10, 0, 0, 0, tzinfo=timezone.utc)
day_end = datetime(2026, 7, 11, 0, 0, 0, tzinfo=timezone.utc)
# Matches the day BEFORE day_start
for i in range(10):
_make_reception(
db_session,
None,
f"before{i}",
["AA", "BB"],
received_at=day_start - timedelta(hours=12, seconds=i),
)
db_session.commit()
quality, state, count = evaluate_route_day(
db_session, route, day_start, day_end
)
assert state == RouteState.NO_COVERAGE.value
assert count == 0
class TestEvaluateRouteHistory:
def test_returns_days_entries_oldest_first(self, db_session):
"""History returns *days* entries, oldest first."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=1)
db_session.commit()
now = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
results = evaluate_route_history(db_session, route, days=7, now=now)
assert len(results) == 7
dates = [r[0] for r in results]
assert dates == sorted(dates)
assert dates[0] == (now - timedelta(days=7)).date()
assert dates[-1] == (now - timedelta(days=1)).date()
def test_include_today_adds_extra(self, db_session):
"""include_today=True adds one partial-day entry."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=1)
db_session.commit()
now = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
results = evaluate_route_history(
db_session, route, days=7, include_today=True, now=now
)
assert len(results) == 8
assert results[-1][0] == now.date() # today is the final entry
def test_disabled_route_all_unknown(self, db_session):
"""Disabled route returns unknown/no_coverage/0 for every day."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(
db_session, "R1", [node_a, node_b], threshold=1, enabled=False
)
db_session.commit()
now = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
results = evaluate_route_history(db_session, route, days=7, now=now)
assert len(results) == 7
for _day, quality, state, count in results:
assert quality == RouteQuality.UNKNOWN.value
assert state == RouteState.NO_COVERAGE.value
assert count == 0
def test_disabled_route_include_today(self, db_session):
"""Disabled route with include_today returns days+1 entries."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(
db_session, "R1", [node_a, node_b], threshold=1, enabled=False
)
db_session.commit()
now = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
results = evaluate_route_history(
db_session, route, days=7, include_today=True, now=now
)
assert len(results) == 8
for _day, quality, _state, _count in results:
assert quality == RouteQuality.UNKNOWN.value
def test_correct_quality_per_day(self, db_session):
"""Different days produce different quality bands."""
node_a = _make_node(db_session, "aa" + "0" * 62)
node_b = _make_node(db_session, "bb" + "0" * 62)
route = _make_route(db_session, "R1", [node_a, node_b], threshold=3)
now = datetime(2026, 7, 15, 12, 0, 0, tzinfo=timezone.utc)
# 3 days ago: clear (10 matches)
clear_day = now - timedelta(days=3)
for i in range(10):
_make_reception(
db_session,
None,
f"clear{i}",
["AA", "BB"],
received_at=clear_day.replace(hour=6) + timedelta(seconds=i),
)
# 2 days ago: marginal (4 matches)
marginal_day = now - timedelta(days=2)
for i in range(4):
_make_reception(
db_session,
None,
f"marg{i}",
["AA", "BB"],
received_at=marginal_day.replace(hour=6) + timedelta(seconds=i),
)
db_session.commit()
results = evaluate_route_history(db_session, route, days=7, now=now)
by_date = {r[0]: (r[1], r[2], r[3]) for r in results}
clear_date = (now - timedelta(days=3)).date()
marginal_date = (now - timedelta(days=2)).date()
assert by_date[clear_date][0] == RouteQuality.CLEAR.value
assert by_date[marginal_date][0] == RouteQuality.MARGINAL.value