diff --git a/AGENTS.md b/AGENTS.md index e0b4ef6..d7f24a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_/` then `pre-commit run --all-files`. ## Setup diff --git a/alembic/versions/20260715_2100_76513f4c57e9_add_packet_path_hops_received_at_index.py b/alembic/versions/20260715_2100_76513f4c57e9_add_packet_path_hops_received_at_index.py new file mode 100644 index 0000000..4f7c79a --- /dev/null +++ b/alembic/versions/20260715_2100_76513f4c57e9_add_packet_path_hops_received_at_index.py @@ -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", + ) diff --git a/alembic/versions/20260715_2300_71f6e01e4bf6_rename_degraded_to_clear_threshold.py b/alembic/versions/20260715_2300_71f6e01e4bf6_rename_degraded_to_clear_threshold.py new file mode 100644 index 0000000..82e43bf --- /dev/null +++ b/alembic/versions/20260715_2300_71f6e01e4bf6_rename_degraded_to_clear_threshold.py @@ -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", + ) diff --git a/docs/plans/20260715-2031-route-health-history/plan.md b/docs/plans/20260715-2031-route-health-history/plan.md new file mode 100644 index 0000000..5749418 --- /dev/null +++ b/docs/plans/20260715-2031-route-health-history/plan.md @@ -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 + 220–229 — 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 56–64). 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: , 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 `` 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 `` 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 56–64). + - 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). diff --git a/docs/plans/20260715-2031-route-health-history/tasks.md b/docs/plans/20260715-2031-route-health-history/tasks.md new file mode 100644 index 0000000..f846c9b --- /dev/null +++ b/docs/plans/20260715-2031-route-health-history/tasks.md @@ -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 220–229 — 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 56–64) + - [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: , 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 `` 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 `` 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` diff --git a/docs/seeding.md b/docs/seeding.md index 3230a98..f497587 100644 --- a/docs/seeding.md +++ b/docs/seeding.md @@ -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) diff --git a/example/seed/routes.yaml b/example/seed/routes.yaml index 93f40a0..1ec0d75 100644 --- a/example/seed/routes.yaml +++ b/example/seed/routes.yaml @@ -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) diff --git a/src/meshcore_hub/api/metrics.py b/src/meshcore_hub/api/metrics.py index 842b473..aa04912 100644 --- a/src/meshcore_hub/api/metrics.py +++ b/src/meshcore_hub/api/metrics.py @@ -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 diff --git a/src/meshcore_hub/api/routes/routes.py b/src/meshcore_hub/api/routes/routes.py index 0f60931..8bccde0 100644 --- a/src/meshcore_hub/api/routes/routes.py +++ b/src/meshcore_hub/api/routes/routes.py @@ -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) diff --git a/src/meshcore_hub/collector/cli.py b/src/meshcore_hub/collector/cli.py index f3bd328..3f81a91 100644 --- a/src/meshcore_hub/collector/cli.py +++ b/src/meshcore_hub/collector/cli.py @@ -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), diff --git a/src/meshcore_hub/collector/routes.py b/src/meshcore_hub/collector/routes.py index e43608e..9cdb52d 100644 --- a/src/meshcore_hub/collector/routes.py +++ b/src/meshcore_hub/collector/routes.py @@ -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 = { diff --git a/src/meshcore_hub/common/models/packet_path_hop.py b/src/meshcore_hub/common/models/packet_path_hop.py index ac68e27..52bcd74 100644 --- a/src/meshcore_hub/common/models/packet_path_hop.py +++ b/src/meshcore_hub/common/models/packet_path_hop.py @@ -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: diff --git a/src/meshcore_hub/common/models/route.py b/src/meshcore_hub/common/models/route.py index a2aa48e..d316c89 100644 --- a/src/meshcore_hub/common/models/route.py +++ b/src/meshcore_hub/common/models/route.py @@ -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, ) diff --git a/src/meshcore_hub/common/models/route_result.py b/src/meshcore_hub/common/models/route_result.py index 819fec6..db458a6 100644 --- a/src/meshcore_hub/common/models/route_result.py +++ b/src/meshcore_hub/common/models/route_result.py @@ -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, ) diff --git a/src/meshcore_hub/common/schemas/routes.py b/src/meshcore_hub/common/schemas/routes.py index 9fa64d5..242affc 100644 --- a/src/meshcore_hub/common/schemas/routes.py +++ b/src/meshcore_hub/common/schemas/routes.py @@ -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] diff --git a/src/meshcore_hub/web/static/js/charts.js b/src/meshcore_hub/web/static/js/charts.js index 0baa7e3..71613c9 100644 --- a/src/meshcore_hub/web/static/js/charts.js +++ b/src/meshcore_hub/web/static/js/charts.js @@ -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 } + } + }); +} diff --git a/src/meshcore_hub/web/static/js/spa/icons.js b/src/meshcore_hub/web/static/js/spa/icons.js index 2676b6c..a1f4451 100644 --- a/src/meshcore_hub/web/static/js/spa/icons.js +++ b/src/meshcore_hub/web/static/js/spa/icons.js @@ -174,6 +174,10 @@ export function iconRuler(cls = 'h-5 w-5') { return html``; } +export function iconClock(cls = 'h-5 w-5') { + return html``; +} + export function iconFilter(cls = 'h-5 w-5') { return html``; } diff --git a/src/meshcore_hub/web/static/js/spa/pages/routes.js b/src/meshcore_hub/web/static/js/spa/pages/routes.js index 683ae2d..80b2bde 100644 --- a/src/meshcore_hub/web/static/js/spa/pages/routes.js +++ b/src/meshcore_hub/web/static/js/spa/pages/routes.js @@ -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) { `; } -function renderNumbersLine(route) { +function renderStatsRow(route) { const result = route.route_result; - if (!result) return html`
${t('routes.not_evaluated')}
`; - 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`
- ${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`
+ + ${iconPackets('h-3.5 w-3.5')} + ${matched}/${threshold}\u2192${degraded} + + + ${iconClock('h-3.5 w-3.5')} + ${route.window_hours}h + + + ${iconRuler('h-3.5 w-3.5')} + ${route.match_width}B + + + ${iconNodes('h-3.5 w-3.5')} + ${nodeCount} + + ${route.max_hop_span ? html` + ${iconPath('h-3.5 w-3.5')} + ${route.max_hop_span} + ` : nothing} + + ${iconSatelliteDish('h-3.5 w-3.5')} + ${obsCount || '\u221E'} +
`; } -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`${dot} ${label}`; const adminButtons = isAdmin - ? html`
- -
` : nothing; - const expandContent = isExpanded && detail ? renderDetailContent(route, detail, { navigate, packetsEnabled }) : nothing; + const expandContent = detail + ? renderDetailContent(route, detail, { navigate, packetsEnabled, history }) + : html`
+ +
`; - return html`
-
onExpand(route)} - @keydown=${(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onExpand(route); } }}> + return html`
+

@@ -132,34 +156,36 @@ function renderRouteCard(route, { isAdmin, onDelete, onEdit, onExpand, isExpande

${badge} - ${iconChevronRight(`h-4 w-4 transition-transform ${isExpanded ? 'rotate-90' : ''}`)}
${renderPathChips(route)}
- ${renderNumbersLine(route)} -
- ${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} -
- ${adminButtons} + ${renderStatsRow(route)} ${expandContent} + ${adminButtons}
`; } -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`
- ${observers.length > 0 ? html`
- ${t('routes.contributing_observers')}: - ${observers.map(o => html`${o.name || o.node_id.slice(0, 8)} (${o.match_count})`)} -
` : html`
${t('routes.no_observers')}
`} + const historySection = history + ? html`
+
+ +
+ ${history.data && history.data.length > 0 ? html`
+ ${history.data.map(d => html`${new Date(d.date + 'T00:00:00').toLocaleDateString(undefined, { day: '2-digit', month: '2-digit' })}`)} +
` : nothing} +
` + : nothing; + + return html`
+ ${historySection} ${matches.length > 0 ? html`
- ${t('routes.recent_matches')}: + ${t('routes.recent_packets')}
${matches.map(m => { const prefixLen = 2 * (route.match_width || 1); @@ -184,12 +210,6 @@ function renderDetailContent(route, detail, { navigate, packetsEnabled }) { })}
` : nothing} -
- ${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} -
`; } @@ -353,10 +373,10 @@ function renderRouteModal({ modalState, onSave, onCancel }) { .value=${route.packet_count_threshold || 3} min="1" max="10000" />
- - + +
@@ -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) {
${group.map(r => renderRouteCard(r, { ...cardOpts, - isExpanded: cardOpts.isExpanded(r), detail: cardOpts.detail(r), + history: cardOpts.history(r), }))}
`); @@ -509,6 +543,8 @@ export async function render(container, params, router) { }); } + destroyCharts(); + litRender(html`

@@ -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; diff --git a/src/meshcore_hub/web/static/locales/en.json b/src/meshcore_hub/web/static/locales/en.json index 1b34070..e42b015 100644 --- a/src/meshcore_hub/web/static/locales/en.json +++ b/src/meshcore_hub/web/static/locales/en.json @@ -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": { diff --git a/src/meshcore_hub/web/static/locales/nl.json b/src/meshcore_hub/web/static/locales/nl.json index 82ab63e..9bbdcdd 100644 --- a/src/meshcore_hub/web/static/locales/nl.json +++ b/src/meshcore_hub/web/static/locales/nl.json @@ -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": { diff --git a/tests/test_api/test_metrics.py b/tests/test_api/test_metrics.py index 57b83f7..e4db396 100644 --- a/tests/test_api/test_metrics.py +++ b/tests/test_api/test_metrics.py @@ -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): diff --git a/tests/test_api/test_routes.py b/tests/test_api/test_routes.py index 67ae037..82390c5 100644 --- a/tests/test_api/test_routes.py +++ b/tests/test_api/test_routes.py @@ -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 diff --git a/tests/test_collector/test_route_evaluator.py b/tests/test_collector/test_route_evaluator.py index 762320d..0b5f473 100644 --- a/tests/test_collector/test_route_evaluator.py +++ b/tests/test_collector/test_route_evaluator.py @@ -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 diff --git a/tests/test_collector/test_routes.py b/tests/test_collector/test_routes.py index fb2382c..d6b9481 100644 --- a/tests/test_collector/test_routes.py +++ b/tests/test_collector/test_routes.py @@ -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